Skip to content

Extension Anatomy

Ira Hopkinson edited this page Dec 13, 2024 · 27 revisions

In this section, you will learn more about specific parts of the extension and how they interact with each other.

Extension File Structure

/paranext-extension-template
  /assets
  /contributions
  /src
    /types
      paranext-extension-template.d.ts
    main.ts
    web-view.tsx
    web-view.scss
  /webpack
  package.json

/assets

The assets folder contains static asset files (js, css, images, etc.) that need to be accessible for the extension to work correctly when deployed in Platform.Bible. This includes minimally a json file (referenced in the displayData key in the manifest.json file) with information that will be shown in the extension marketplace.

/contributions

The contributions folder contains files of various predetermined types (referenced in the manifest.jsonn file) that indicate menus items, settings, localized strings, etc. that this extension contributes. More details.

/src

The src folder contains source code for the extension. Included here are the main entry file for the extension and the files that define the web view displayed by Platform.Bible, both of which are described below. It also includes a /types folder that holds the .d.ts type declaration file.

Type Declaration Files (*.d.ts)

Type declaration files describe the types of a module and only contain type information. In our extensions, this file allows you to declare your extension's API so that other extensions can see and interact with its types using Intellisense. Each extension may have only one type declaration file that Platform.Bible shares with other extensions; Platform.Bible ignores other type declaration files.

If the extension's type declaration file contains a module with extension types to share with other extensions, that module's name must match the module name Platform.Bible determines for the extension, which is either the extension name or the extension's type declaration file name if the type declaration file is named the extension name with a suffix like paranext-extension-template-v1.d.ts. Extensions can import types from other extensions using this module name.

For example, if an extension is named my-extension, its declaration file may be named my-extension-v2.d.ts. It could contain the following module for other extensions to import and from which to share types (note that the module name must match the file name):

// In `my-extension-v2.d.ts`

declare module 'my-extension-v2' {
  export type MySharedType = { thing: number };
}

Another extension could use types shared from my-extension by importing from my-extension-v2 as in the following:

import type { MySharedType } from 'my-extension-v2';

However, if my-extension's declaration file is named index.d.ts or some other name that does not start with my-extension, its module must be named my-extension:

// In `index.d.ts` for `my-extension`

declare module `my-extension` {
  export type MySharedType = { thing: number };
}

When Platform.Bible is running in development, each installed extension's type declaration file is automatically cached and shared so you can see other extensions' types in Intellisense. When Platform.Bible is not running, however, changes to type declaration files in installed extensions are not applied to the cache, so the latest type declaration files from the last time Platform.Bible ran in development will be used. If you need latest type declaration files even when Platform.Bible is not running, like when developing multiple extensions in different repos, for example, you need to add the other extensions' types in your extension's tsconfig.json's typeRoots. More instructions can be found inside tsconfig.json.

/webpack

The webpack folder contains all the files to configure webpack for your extension.

Extension Manifest

One of the first changes to be made to the extension template are the manifest.json and package.json files. These files describe the identity of your extension to Platform.Bible and NPM. They are similar except the package.json has fields that pertain to NPM such as scripts and dependencies that list the other NPM packages it depends on. The package.json file is required for Platform.Bible to use your extension appropriately.

manifest.json

An extension's manifest.json contains information about the extension and its interaction with Platform.Bible.

Example from extension template

{
  "name": "paranext-extension-template",
  "version": "0.0.1",
  "displayData": "assets/displayData.json",
  "author": "Paranext",
  "license": "MIT",
  "main": "src/main.ts",
  "extensionDependencies": {},
  "elevatedPrivileges": [],
  "types": "src/types/paranext-extension-template.d.ts",
  "menus": "contributions/menus.json",
  "settings": "contributions/settings.json",
  "projectSettings": "contributions/projectSettings.json",
  "localizedStrings": "contributions/localizedStrings.json",
  "activationEvents": []
}

Definition of fields

name

Name of the extension

version

Version of the extension - expected to be semver like "0.1.3".

Note: semver may become a hard requirement in the future, so we recommend using it now.

displayData

Path to JSON file that contains data shown to users about this extension in the extension marketplace.

author

Name of the person, people, or team who wrote this extension.

license

SPDX license identifier that represents the type of license that covers this extension.

main

Path to the JavaScript file to run in the extension host. Relative to the extension's root folder. Must be specified or null if the extension has no JavaScript to run. The extension template has webpack installed with it. Webpack is configured to take your main.ts file and transform it into the appropriately-named final extension file in the dist folder. That's the file that the manifest.json is pointing to.

extensionDependencies

Object whose keys are the names of other extensions this extension depends on. The values of the keys provide the version requirements for the extension.

elevatedPrivileges

List of strings representing special permissions required by this extension. For each permission required, an object will be provided in the ExecutionActivationContext object passed into the extension in the activate function.

types

Path to the TypeScript type declaration file that describes this extension and its interactions on the PAPI. Relative to the extension's root folder.

If not provided, Platform.Bible will look in the following locations:

  1. <extension_name>.d.ts
  2. <extension_name><other_stuff>.d.ts
  3. index.d.ts

See Extension Anatomy - Type Declaration Files for more information about extension type declaration files.

menus

Path to the JSON file containing data about menus that should be added to the application for this extension.

settings

Path to the JSON file containing data about settings that should be added to the application for this extension.

projectSettings

Path to the JSON file containing data about settings that should be added for this extension to all projects that are opened by the application.

localizedStrings

Path to the JSON file containing localization strings used by the application.

Note that an extension could contain only localization data if it was just trying to provide localized UI information for users who want to use Platform.Bible in a language other than English. This includes, but is not limited to, UI elements like labels, buttons, menu items, and dialogs.

package.json

An extension's package.json contains information about the extension and its interaction with NPM.

Example from extension template

{
  "name": "paranext-extension-template",
  "private": true,
  "version": "0.0.1",
  "main": "paranext-extension-template.js",
  "types": "paranext-extension-template.d.ts",
  "author": "Paranext",
  "license": "MIT",
  "scripts": { ... }
  ...
}

Definition of fields

You can find the definition of package.json fields here.

Extension Entry File

The extension entry file holds the “back-end” of your extension. One of the requirements of building extensions for Platform.Bible is that this file must remain named main.ts. This file is where you can define your data provider engine. It also exports two important functions: activate and deactivate.

Restrictions

The extension entry file is limited by Platform.Bible in a few important ways:

  • The require function is monkey-patched to serve only a few specific modules. This means you must bundle the entry file and any imported code other than the modules served by require together with a bundler like webpack.
  • Static and dynamic imports are forbidden, so you cannot use ES Modules. You can use a bundler like webpack to transpile ES Module code to CommonJS to use require.

Extension activate function

activate is executed when your extension is loaded and activated. An object of type ExecutionActivationContext is passed into activate from core for each extension during initialization. The object contains name, the name of the extension; executionToken, a unique token the extension can use to access storage; and registrations, a helper object on which you can run registrations.add with your PAPI registrations to set them up to unregister automatically when your extension is deactivated. Inside of activate, you can set up your extension's features and APIs:

Create and Register your Data Provider Engine

When you create and register your data provider engine, you must use a specific id. This will be used by the front end when it accesses the engine. You can create and register the data engine from the provider class you initialized.

// Example of web view using the data engine with an id

const engine = new QuickVerseDataProviderEngine(warning.trim());

const quickVerseDataProviderPromise = papi.dataProviders.registerEngine(
	"paranextExtensionTemplate.quickVerse",
	engine
);

Register Web Views

Web views can be HTML or React. The paranext-extension-template-hello-world shows you how to do both.

You can import your <>.web-view.html/tsx file into the entry file. All web view related imports into the main file need to have ?inline as the suffix, see the paranext-extension-template README.md Special Imports section for more information on this.

import extensionTemplateReact from "./extension-template.web-view?inline";
import extensionTemplateReactStyles from "./extension-template.web-view.scss?inline";

Outside of activate is where you create your web view type and provider. The example is a simple provider that contains a getWebView() and provides the React web views when the PAPI requests them.

const reactWebViewType = "paranext-extension-template.react";

/**
* Simple web view provider that provides React web views when 
papi requests them
*/
const reactWebViewProvider: IWebViewProvider = {
	async getWebView(
		savedWebView: SavedWebViewDefinition
	): Promise<WebViewDefinition | undefined> {
		if (savedWebView.webViewType !== reactWebViewType)
			throw new Error(
				`${reactWebViewType} provider received request to provide a ${savedWebView.webViewType} web view`
			);
		return {
			...savedWebView,
			title: "Extension Template Hello World React",
			content: extensionTemplateReact,
			styles: extensionTemplateReactStyles,
		};
	},
};

Inside of activate you register and call the provider to load the webviews. The code below creates the webviews or gets an existing webview if one already exists. In the second line, we are using existingId: "?" to indicate that we do not want to create a new webview if one already exists. The webview that already exists could have been created by anyone anywhere; it just has to match webViewType. See paranext-core's hello-someone.ts for an example of keeping an existing webview that was specifically created by paranext-core's hello-someone.

const reactWebViewProviderPromise = papi.webViewProviders.register(
	reactWebViewType,
	reactWebViewProvider
);

papi.webViews.getWebView(reactWebViewType, undefined, { existingId: "?" });

Register Commands

Commands are exposed functions that can be used in other places. You are not required to register commands, but they can be useful tools. When you register a command it essentially binds the id you set to a handler function in your extension. Whenever your command id is executed, the handler function will be invoked.

Built-in Commands

There are two built-in commands that are registered when the CommandService is initialized:

addThree(a: number, b: number, c: number) { }
  • Accepts three numbers and returns the three numbers added together.
squareAndConcat(a: number, b: string) { }
  • Accepts a number and a string and returns the number squared and concat it to the front of the string.

Handle Cleanup

At the end of the activate function, we need to perform some cleanup actions. In all of the sample extensions currently, we add all of the resources to the context object that is passed in. Subscribing is essentially adding an event listener. Then, unsubscribing is removing the event listener. Adding the resources to the context object's registrations will allow them to be unsubscribed automatically when your extension deactivates. The code excerpt below is from paranext-extension-template-hello-world main.ts activate function. It is generally recommended to resolve all promises at the end of activation, so as not to hold up the rest of the activation.

context.registrations.add(
	await quickVerseDataProviderPromise,
	await htmlWebViewProviderPromise,
	await reactWebViewProviderPromise,
	await reactWebViewProvider2Promise,
	onDoStuffEmitter,
	await doStuffCommandPromise
);

logger.info("Extension template is finished activating!");

Extension deactivate function

deactivate is optional, but allows you to perform cleanup actions before the extension is deactivated or Platform.Bible is shutting down (e.g. saving files). In the paranext-extension-template-hello-world, deactivate produces a logger message that the template is deactivating.

Data Provider Engine

The data provider acts as a mini API for your extension that defines how information flows between the front and back ends. It should implement IDataProviderEngine which will allow PAPI to create a data provider that internally uses this engine. The provider layers over this engine and adds functionality such as subscribe<data_type> functions that automatically update. For each data type defined, the engine needs a get<data_type> and a set<data_type>. The Selector is the information that is sent with the get or set so that the functions know what information they need to return or set. You have the ability to define the type of selector, two examples include: * for everything, or a number to get one instance of information using an id.

class QuickVerseDataProviderEngine
	extends DataProviderEngine<ExtensionVerseDataTypes>
	implements IDataProviderEngine<ExtensionVerseDataTypes> {}

Data Provider TypeScript Support

You must add your data provider to the papi-shared-types shared interface DataProviders to enable TypeScript support for your data provider. See Your First Extension for an example.

Get

Read the selector and determine what data to return based off of it

Set

Read the selector to determine what to do with the data provided

Ways to Implement Data Provider

Object

It is recommended that you define your data provider engine by an object when you’re first starting out, like in the hello-someone (hello-someone.ts) internal extension.

If the engine is defined by an object:

  • You can use intellisense to see which get and set functions you need. Ctrl + space lists the methods that are still to be implemented
  • The function and parameter types are inferred, so you will not have to specify them.

Some cons to this method are that:

  • You must specify all properties and methods in the object type
  • It becomes difficult to apply papi.dataProviders.decorators.ignore to tell the PAPI to ignore methods
  • When using this.notifyUpdate, you must include the WithNotifyUpdate type and provide a placeholder notifyUpdate method

Class

Once you have an understanding of the Data Provider api then you may want to try to implement the provider using a class, like in the quick-verse internal extension and paranext-extension-template-hello-world.

If the engine is defined by a class you can:

  • Freely add properties and methods without specifying them in an extra type
  • Use private methods
    • These are automatically ignored by PAPI, but you must prefix them with #.
  • Use @papi.dataProviders.decorators.ignore to tell PAPI to ignore certain methods
  • Extend DataProviderEngine
    • This will enable TypeScript to understand this.notifyUpdate without specifying a notifyUpdate function
  • Easily create multiple data providers from the same engine
    • This is useful in cases where you may have two independent sets of data.

Though there are some cons to this method as well, two are:

  • Intellisense does not tell you all of the set and get methods you need to provide, it will only show an error if you do not have the right methods.
  • You must specify parameters and return types because they are not inferred.

Frontend WebView File

WebViews are iframes in the Platform.Bible UI into which extensions load frontend code, either HTML or React components. Your extension's frontend code can be either an HTML document or a React component. The following sections will mainly talk about TypeScript/React.

Restrictions

WebView files are limited by Platform.Bible in a few important ways:

  • WebView iframes' Content Security Policy is fairly strict to attempt to prevent unintended code execution.
  • WebView iframes' sandbox includes allow-same-origin and allow-scripts at most.
  • The require function is added as a global variable to serve a few modules. This means you must bundle the entry file and any imported code other than the modules served by require together with a bundler like webpack.
  • Static and dynamic imports are not monkey-patched to match the functionality of require, so you practically cannot use ES Modules. You can use a bundler like webpack to transpile ES Module code to CommonJS to use require.

Import and use PAPI hooks

There are a number of React hooks exposed on the PAPI that make interacting with the PAPI easier in React. You can utilize them inside of your React web view. The excerpt below is from paranext-extension-template-hello-world extension-template.web-view.tsx. You can see what hooks are exposed and read more about them in PAPI Docs.

  1. Import hooks

    PAPI hooks are available through papi-frontend/react.

    import papi, { logger } from "papi-frontend";
    import { useData, useDataProvider, ... } from "papi-frontend/react";
  2. Use hooks

    The useDataProvider hook gets a data provider with a specified provider name. The useData hook is a special React hook that subscribes to run a callback on a data provider’s data with a specified selector on any type of data that the data provider serves. In this case we are subscribing to a verse. To read more about these hooks see the PAPI Docs.

    Note: to get data from a data provider with useData, you do not have to use useDataProvider unless you want to use the data provider directly. You can simply pass in the data provider ID as the first argument in useData instead of passing in the data provider as in this example.

    const extensionVerseDataProvider = useDataProvider(
    	"paranextExtensionTemplate.quickVerse"
    );
    
    const [latestExtensionVerseText] =
    	useData<"paranextExtensionTemplate.quickVerse">(
    		extensionVerseDataProvider
    	).Verse(
    		"latest",
    		"Loading latest Scripture text from extension template..."
    	);

Web view component

Each React WebView extension must provide a function component for Platform.Bible to display it. You must provide your React function component to Platform.Bible by assigning it to globalThis.webViewComponent. This function is a normal React function component; inside of this function is where you use hooks and PAPI components and return the JSX that shows up in your webview. The code excerpts below are from paranext-extension-template-hello-world extension-template.web-view.tsx.

globalThis.webViewComponent = function ExtensionTemplate() { ... }

Props

React WebView components have a few props available to them that are provided by the application. The props available to WebView components are of type WebViewProps

Import and Use platform-bible-react Components

platform-bible-react is a library that provides some React hooks and components that follow the theming of Platform.Bible to help you to make your extension look and feel like the rest of Platform.Bible.

To import any component use this format:

import { Component1-Name-Here,
         Component2-Name-Here,
         ...,
} from "platform-bible-react";

See the next section to see how to use the Button component.

Return JSX

From the webViewComponent function you return the JSX that you want to appear in the Platform.Bible web view. In the following excerpt, you can see one use case for a PAPI button. This button declares an onClick method that sends a command, counts clicks, and prints a logger message in the log console.

...
return (
    <>
      <div className="title">
        Extension Template Hello World <span className="framework">React</span>
      </div>
      <div>{latestExtensionVerseText}</div>
      <div>{latestQuickVerseText}</div>
      <div>
        <Button
          onClick={async () => {
            const start = performance.now();
            const result = await papi.commands.sendCommand(
              'extensionTemplateHelloWorld.doStuff',
              'Extension Template Hello World React Component',
            );
            setClicks(clicks + 1);
            logger.info(
              `command:extensionTemplateHelloWorld.doStuff '${result.response}' took ${
                performance.now() - start
              } ms`,
            );
          }}
        >
          Hi {clicks}
        </Button>
      </div>
    </>
  );

Styling Web Views

You can style your web view by creating a .scss file and adding CSS or SCSS to it like you would in any other frontend project. However, importing your styles is different in Platform.Bible extensions than in typical frontend projects. See Register Web Views for information on importing your styles into your web view.

Matching Application Theme

Users may apply various themes (e.g. dark or light theme) in Platform.Bible. In order to match the user's theme, please use color variables set up by platform-bible-react appropriately. For example, you can apply the following CSS property in a class to match the background color for a primary action like a primary button to click: background-color: hsl(var(--primary)). See platform-bible-react's index.css for a list of available colors.

Note: Tailwind CSS makes applying theme colors much easier. If you plan to use Tailwind CSS to style your web views, please see the Tailwind CSS configuration notes for information on how to apply theme colors in Tailwind.

platform-bible-react's preview page has a tab displaying all theme colors. Following is a screenshot of the dark theme displayed in the preview page's Guide & Colors -> Current Theme Colors tab as of 3 October 2024: image

Tailwind CSS in Web Views

The extension template is equipped with Tailwind CSS configured the same way it is configured in Platform.Bible's React component library platform-bible-react to enable web views to match Platform.Bible's look and feel. To add Tailwind CSS to your web view, simply use your extension's ./src/tailwind.css file into your web view's style .scss file (note that you should not add the .css extension when using local CSS files into .scss files):

@use './path/to/src/tailwind';

For example, if your web view style file is located at ./src/web-views/my-first.web-view.scss, you could add the following line to enable Tailwind in your web view:

@use '../tailwind';

Adding this @use to your WebView's styles enables Tailwind CSS in the WebView. This means you can use Tailwind CSS like you would in any other frontend project. Alternatively, you can directly use ./src/tailwind.css as your WebView's style file if you do not need any additional CSS.

Tailwind CSS configuration notes

Please note the following important points about the way Tailwind CSS is configured in the extension template:

  • The extension template's Tailwind's configuration is set up with the prefix tw-, so all tailwind classes must have tw- at the beginning. For example, to set a background color to purple, instead of using bg-purple-500, you must use tw-bg-purple-500. This prefix is important because this Tailwind setup's prefix must match the prefix of the Tailwind setup in platform-bible-react. Read more about the significance of the prefix in the platform-bible-react cn function documentation.
  • Tailwind's preflight is enabled by default, meaning some default HTML tag styles are significantly modified. You can disable it or restrict its scope if desired. However, we generally recommend instead using @tailwindcss/typography, included in the extension template's Tailwind configuration by default, when displaying flowing content.
  • The extension template's Tailwind's configuration has additional colors set up to apply the colors of the currently enabled theme in Platform.Bible (e.g. dark or light theme). You can match to these colors without using Tailwind by applying the color variables manually in your CSS, but Tailwind makes it easy. Simply apply the class corresponding to the appropriate CSS property and theme color variable name. For example, instead of creating your own class that applies the property background-color: hsl(var(--primary)), you can use the class tw-bg-primary.

Running Platform.Bible with your extension

Working with extensions in ZIP files

To have Platform.Bible recognize your extension ZIP files, run it with the --extensions or --extensionDirs command line argument. For example:

  • --extensionDirs C:\my-extensions if you have your packaged extension ZIP files inside of the C:\my-extensions directory on your local computer
  • --extensions C:\path\to\my\extension.zip if you want to load a specific extension from a ZIP file in a known location on your local computer
Clone this wiki locally