-
Notifications
You must be signed in to change notification settings - Fork 1
Extension Anatomy
In this section, you will learn more about specific parts of the extension and how they interact with each other.
/paranext-extension-template
/public
/assets
manifest.json
/src
/types
paranext-extension-template.d.ts
main.ts
web-view.tsx
web-view.scss
/webpack
package.json
The public
folder contains static files that are transferred to the build folder (dist
). Included here are the files that define the extension manifest (described below) and a folder for assets.
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 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.
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
.
The webpack
folder contains all the files to configure web pack for your extension.
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.
An extension's manifest.json
contains information about the extension and its interaction with Platform.Bible.
{
"name": "paranext-extension-template",
"version": "0.0.1",
"description": "Extension template for Paranext. Powered by webpack",
"author": "Paranext",
"license": "MIT",
"main": "paranext-extension-template.ts"
}
Name of the extension
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.
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.
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:
<extension_name>.d.ts
<extension_name><other_stuff>.d.ts
index.d.ts
An extension's package.json
contains information about the extension and its interaction with NPM.
{
"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": { ... }
...
}
You can find the definition of package.json
fields here.
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
.
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 byrequire
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
.- Note: Dynamic imports currently work, but they will be removed soon. Do not use them, or your extension will break.
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:
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
);
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: "?" });
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.
There are two built-in commands that are registered when the CommandService
is initialized:
- Accepts three numbers and returns the three numbers added together.
- Accepts a number and a string and returns the number squared and concat it to the front of the string.
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!");
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.
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> {}
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.
Read the selector and determine what data to return based off of it
Read the selector to determine what to do with the data provided
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 theWithNotifyUpdate
type and provide a placeholdernotifyUpdate
method
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
#
.
- 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 anotifyUpdate
function
- This will enable TypeScript to understand
- 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.
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.
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
andallow-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 byrequire
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 userequire
.
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.
-
Import hooks
PAPI hooks are available through
papi-frontend/react
.import papi, { logger } from "papi-frontend"; import { useData, useDataProvider, ... } from "papi-frontend/react";
-
Use hooks
The
useDataProvider
hook gets a data provider with a specified provider name. TheuseData
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 useuseDataProvider
unless you want to use the data provider directly. You can simply pass in the data provider ID as the first argument inuseData
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..." );
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() { ... }
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
papi-components
is a library that provides some React components that follow the theming of Platform.Bible to help you to make your extension look and feel like the rest of Platform.Bible. You can see more information about the PAPI components in PAPI Docs.
To import any PAPI components use this format:
import { Component1-Name-Here,
Component2-Name-Here,
...,
} from "papi-components";
See the next section to see how to use the Button component.
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>
</>
);
Note that code style and other such documentation is stored in the Paranext wiki and covers all Paranext repositories.