-
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
/assets
/contributions
/src
/types
paranext-extension-template.d.ts
main.ts
web-view.tsx
web-view.scss
/webpack
package.json
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.
The contributions
folder contains files of various predetermined types (referenced in the manifest.json
n file) that indicate menus items, settings, localized strings, etc. that this extension contributes. More details.
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. 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
.
The webpack
folder contains all the files to configure webpack 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",
"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": []
}
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 JSON file that contains data shown to users about this extension in the extension marketplace.
Name of the person, people, or team who wrote this extension.
SPDX license identifier that represents the type of license that covers this extension.
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.
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.
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.
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
See Extension Anatomy - Type Declaration Files for more information about extension type declaration files.
Path to the JSON file containing data about menus that should be added to the application for this extension.
Path to the JSON file containing data about settings that should be added to the application for this extension.
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.
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.
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
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.
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>
</>
);
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.
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:
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.
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 havetw-
at the beginning. For example, to set a background color to purple, instead of usingbg-purple-500
, you must usetw-bg-purple-500
. This prefix is important because this Tailwind setup's prefix must match the prefix of the Tailwind setup inplatform-bible-react
. Read more about the significance of the prefix in theplatform-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 classtw-bg-primary
.
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 theC:\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
Note that code style and other such documentation is stored in the Paranext wiki and covers all Paranext repositories.