-
Notifications
You must be signed in to change notification settings - Fork 1
Your First Extension
Warning
Under construction. Read Your First Extension ‐ An Easy Start (repo:hello-world
) for more complete and helpful documentation.
To help you get started with building an extension we have created this template. It is an empty shell that can you can configure freely. This page explains what the contents of the template look like, and provides instructions to turn this empty extension into a fully function 'hello world' extension.
Note that besides this template there is also a fully functional 'hello world' extension template available for you to use, along with a Wiki page that explains the contents of this template. If this is your first time building an extension, you may find it helpful to look at the 'hello world' template too.
Another helpful resource might be this recording of a presentation on how to build extensions that was held at the Paratext Summit in September 2023.
As you develop your extension and to understand what some of the capabilities of extensions are you may find this documentation on the PAPI helpful.
The paranext-extension-template
contains only the parts necessary to run the extension, this section will describe the minimal parts. You can see Extension Anatomy for a full description of the parts that make up an extension.
The main directories are:
/src
contains the main entry file and a directory for types that contains the .d.ts file for your extension. Inside of main.ts
we import the PAPI backend service, and from it, the logger service to output logs to the console. Each extension must contain a main entry file, and within it activate()
and deactivate()
functions. In this template, these functions only include a line outputting a message to the console. The types file declares a module which can be filled with types that describe the APIs provided by the extension on the PAPI.
The /webpack
directory contains the webpack configuration files. The only changes required in this directory happen in webpack.config.main.ts
, which is described in the next section.
In this section, we will take the minimal extension template and turn it into the hello world extension template.
The first step is to replace the details of paranext-extension-template
with your extension's details. The list below are all the files that need to be updated. The search & replace action covers everything except the extension manifest files (package.json
and manifest.json
) and the types file name.
README.md
package-lock.json
package.json
public/manifest.json
-
src/types/paranext-extension-template.d.ts
(the file name and contents) -
src/main.ts
(see below starting with Webview provider section)
- Search for: paranext-extension-template Replace with: your-extension-name
- Next manually edit the
package.json
andpublic/manifest.json
Once your details are changed save and npm start
your extension to make sure that it still runs as expected.
The next step is to add a webview so that we can see our extension inside of Platform.Bible. In order to add a webview we need a few things- the content to display in the webview and styles for that content, a webview provider, and to register, get, and resolve the webview inside of our activate()
function.
First, we will create a React webview. Create a new file src/web-views/your-extension.web-view.tsx
. Inside, assign globalThis.webViewComponent
to your function component that returns the JSX that you want to show up in your extension. The code below has a simple div
containing some text and a simple <Button>
from the platform-bible-react
library.
import { Button } from "platform-bible-react";
globalThis.webViewComponent = function ExtensionTemplate() {
return (
<>
<div className="title">
Extension Template Hello World <span className="framework">React</span>
</div>
<Button>Our first button</Button>
</>
);
};
Create a new file src/web-views/your-extension.web-view.css
(or .scss). The code below applies to the <div>
in our React web view.
.title {
color: blue;
.framework {
font-weight: 900;
}
}
In this section and the following one, you will fill out the contents of src/main.ts
. This tutorial will walk through creating a React WebView provider only. Note that you can also create an HTML WebView provider; the only thing that changes between these is the webViewType
constant on the WebViewDefinition
.
First, import the web view definition types from papi/core
. Next, declare an instance of the IWebViewProvider
interface; inside of it we will declare a getWebView()
function that accepts a SavedWebViewDefinition
containing the WebView ID that Platform.Bible created for our WebView and returns a WebViewDefinition
object which is the SavedWebViewDefinition
along with our WebView's function component and styles.
import type {
IWebViewProvider,
SavedWebViewDefinition,
WebViewDefinition,
} from '@papi/core';
import extensionTemplateReact from "./extension-template.web-view?inline";
import extensionTemplateReactStyles from "./extension-template.web-view.scss?inline";
const reactWebViewType = "paranextExtensionTemplate.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,
};
},
};
There are three new imports to add- the PAPI backend service, the PAPI logger, and the ExecutionActivationContext
object that is passed to the activate()
function which should already exist inside the src/main.ts
file, although it will need to be modified. Inside activate()
we use the webview provider service on the PAPI to register the provider we just created with the type of our webview. Next we use the webview service to create or get a webview. We are using { existingId: "?" }
to indicate that we do not want to create a new webview if one already exists. We add the awaited promises to the list of registrations at the end, so that it doesn't hold up everything else.
import papi, { logger } from "papi-backend";
import type {
ExecutionActivationContext,
IWebViewProvider,
SavedWebViewDefinition,
WebViewDefinition,
} from '@papi/core';
export async function activate(context: ExecutionActivationContext) {
logger.info("Extension template is activating!");
const reactWebViewProviderPromise = papi.webViewProviders.register(
reactWebViewType,
reactWebViewProvider
);
papi.webViews.getWebView(reactWebViewType, undefined, { existingId: "?" });
context.registrations.add(await reactWebViewProviderPromise);
logger.info("Extension template is finished activating!");
}
npm start
your extension and you should now see a webview with our basic <Button>
.
Next we can make our basic button do stuff using commands. Let's create a counter that counts the total number of times the user clicks the button on any instance of our WebView. We need to hook up our button to run a command to increment a click counter and hook up an event to announce when our click count changes so all WebViews know about the new click count.
We need to define the event and command that we want to use to count and save the <Button>
clicks. First we declare our event inside of the module that was already made for us. DoStuffEvent
contains a variable count
to track the number of times the extension runs the command. Inside of papi-shared-types
we declare a CommandHandler
so that the PAPI knows about our command and its function type and so other extensions can have access to our command. Our command is going to accept a string
and return a string
and number
.
declare module "paranext-extension-template-hello-world" {
/** Network event that informs subscribers when the command `extensionTemplateHelloWorld.doStuff` is run */
export type DoStuffEvent = {
/** How many times the extension template has run the command `extensionTemplateHelloWorld.doStuff` */
count: number;
};
}
declare module "papi-shared-types" {
export interface CommandHandlers {
"extensionTemplateHelloWorld.doStuff": (message: string) => {
response: string;
occurrence: number;
};
}
}
In activate()
we need to declare a network emitter using the network service and the DoStuffEvent
we declared in the types file. Next we register the command with the command service. At the end we need to await the command registration promise, and add all of our resources to the context registrations
so they will be automatically unregistered when our extension is deactivated.
import type { DoStuffEvent } from 'paranext-extension-template-hello-world';
export async function activate(context: ExecutionActivationContext) {
...
// Emitter to tell subscribers how many times we have done stuff
const onDoStuffEmitter = papi.network.createNetworkEventEmitter<DoStuffEvent>(
'extensionTemplateHelloWorld.doStuff',
);
let doStuffCount = 0;
const doStuffCommandPromise = papi.commands.registerCommand(
'extensionTemplateHelloWorld.doStuff',
(message: string) => {
doStuffCount += 1;
// Inform subscribers of the update
onDoStuffEmitter.emit({ count: doStuffCount });
// Respond to the sender of the command with the news
return {
response: `The template did stuff ${doStuffCount} times! ${message}`,
occurrence: doStuffCount,
};
},
);
context.registrations.add(
await reactWebViewProviderPromise,
onDoStuffEmitter,
await doStuffCommandPromise,
)
...
}
In our webview file src/your-extension.web-view.tsx
we need to update the imports. We are going to use the PAPI frontend service, our DoStuffEvent
, and React hooks from both the React library and PAPI. We can create a state object to hold and manage the amount of <Button>
clicks. When we click the button, we use sendCommand()
to send our created command to the backend. We also send a message to the console using the logger service that shows the response
that comes back from the command and the amount of time (ms) it took to run the command. The useEvent
hook allows us to add a callback to run when our command handler emits the event we made so that the clicks
are updated to the latest click count
whenever we click the button and the command is run.
import papi, { logger } from "papi-frontend";
import { useEvent } from "papi-frontend/react";
import type { DoStuffEvent } from "paranext-extension-template-hello-world";
import { useCallback, useState } from "react";
globalThis.webViewComponent = function ExtensionTemplate() {
const [clicks, setClicks] = useState(0);
useEvent<DoStuffEvent>(
"extensionTemplateHelloWorld.doStuff",
useCallback(({ count }) => setClicks(count), [])
);
return (
<>
<div className="title">
Extension Template Hello World <span className="framework">React</span>
</div>
<Button
onClick={async () => {
const start = performance.now();
const result = await papi.commands.sendCommand(
"extensionTemplateHelloWorld.doStuff",
"Extension Template Hello World React Component"
);
logger.info(
`command:extensionTemplateHelloWorld.doStuff '${
result.response
}' took ${performance.now() - start} ms`
);
}}
>
Hi {clicks}
</Button>
</>
);
};
npm start
the extension and you should now see a button that says 'Hi' and displays the number of clicks that is calculated by using our command and event.
Finally, let's add and use a data provider class in our extension.
Inside of the types file, declare the types of data the provider will use and share. This data provider has three data types:
- Verse: get a portion of Scripture by its reference. You can also change the Scripture at a reference, but you have to clarify that you are heretical because you really shouldn't change published Scriptures like this
- Heresy: get or set Scripture freely. It automatically marks the verse as heretical if changed
- Chapter: get a whole chapter of Scripture by book name and chapter number. Read-only
// Only showing additions. Command, events, etc. that were already added are not included
declare module "paranext-extension-template-hello-world" {
import type { DataProviderDataType } from "shared/models/data-provider.model";
import type IDataProvider from "shared/models/data-provider.interface";
export type ExtensionVerseSetData =
| string
| { text: string; isHeresy: boolean };
export type ExtensionVerseDataTypes = {
Verse: DataProviderDataType<
string,
string | undefined,
ExtensionVerseSetData
>;
Heresy: DataProviderDataType<string, string | undefined, string>;
Chapter: DataProviderDataType<
[book: string, chapter: number],
string | undefined,
never
>;
};
export type ExtensionVerseDataProvider =
IDataProvider<ExtensionVerseDataTypes>;
}
declare module "papi-shared-types" {
import type { ExtensionVerseDataProvider } from "paranext-extension-template-hello-world";
export interface DataProviders {
"paranextExtensionTemplate.quickVerse": ExtensionVerseDataProvider;
}
}
In the main entry file, and outside of activate()
, create your data provider class or object. The data provider class below is copied from paranext-extension-template-hello-world
. It is an example data provider engine that provides easy access to Scripture from another data provider. There are pros and cons to using a class as opposed to an object for a data provider engine. First, update your imports to include the following, including the DataProviderEngine
in the papi-backend
import. Create your data provider, declare any variables, and add a constructor.
import { VerseRef } from '@sillsdev/scripture';
import { logger, DataProviderEngine } from 'papi-backend'
import type IDataProviderEngine from 'shared/models/data-provider-engine.model';
import type {
DoStuffEvent,
ExtensionVerseDataTypes,
ExtensionVerseSetData,
} from 'paranext-extension-template-hello-world';
import type { UsfmDataProvider } from 'usfm-data-provider';
import type { DataProviderUpdateInstructions } from 'shared/models/data-provider.model';
class QuickVerseDataProviderEngine
extends DataProviderEngine<ExtensionVerseDataTypes>
implements IDataProviderEngine<ExtensionVerseDataTypes>
{
verses: { [scrRef: string]: { text: string; isChanged?: boolean } } = {};
/** Latest updated verse reference */
latestVerseRef = 'JHN 11:35';
usfmDataProviderPromise = papi.dataProviders.get<UsfmDataProvider>('usfm');
/** Number of times any verse has been modified by a user this session */
heresyCount = 0;
/** @param heresyWarning string to prefix heretical data */
constructor(public heresyWarning: string) {
// `DataProviderEngine`'s constructor currently does nothing, but TypeScript requires that we
// call it.
super();
this.heresyWarning = this.heresyWarning ?? 'heresyCount =';
}
...
}
Now that the data provider is created, you can begin adding functions. The setInternal()
function is not required, its purpose in this class is to have an internal set method that doesn't send updates, so that the setVerse()
and setHeresy()
functions can use it. The ignore decorator- @papi.dataProviders.decorators.ignore
tells the PAPI to ignore that method. They have also declared a getSelector()
function that is preceded with #
making it a private function that can not be called on the network. See the hello world template for full function descriptions.
@papi.dataProviders.decorators.ignore
async setInternal(
selector: string,
data: ExtensionVerseSetData,
): Promise<DataProviderUpdateInstructions<ExtensionVerseDataTypes>> {
// Just get notifications of updates with the 'notify' selector. Nothing to change
if (selector === 'notify') return false;
// You can't change scripture from just a string. You have to tell us you're a heretic
if (typeof data === 'string' || data instanceof String) return false;
// Only heretics change Scripture, so you have to tell us you're a heretic
if (!data.isHeresy) return false;
// If there is no change in the verse text, don't update
if (data.text === this.verses[this.#getSelector(selector)].text) return false;
// Update the verse text, track the latest change, and send an update
this.verses[this.#getSelector(selector)] = {
text: data.text,
isChanged: true,
};
if (selector !== 'latest') this.latestVerseRef = this.#getSelector(selector);
this.heresyCount += 1;
// Update all data types, so Verse and Heresy in this case
return '*';
}
#getSelector(selector: string) {
const selectorL = selector.toLowerCase().trim();
return selectorL === 'latest' ? this.latestVerseRef : selectorL;
}
For each data type, an engine needs a get<data_type>
and a set<data_type>
.
async setVerse(verseRef: string, data: ExtensionVerseSetData) {
return this.setInternal(verseRef, data);
}
getVerse = async (verseRef: string) => {
// Just get notifications of updates with the 'notify' selector
if (verseRef === 'notify') return undefined;
const selector = this.#getSelector(verseRef);
// Look up the cached data first
let responseVerse = this.verses[selector];
// If we don't already have the verse cached, cache it
if (!responseVerse) {
// Fetch the verse, cache it, and return it
try {
const usfmDataProvider = await this.usfmDataProviderPromise;
if (!usfmDataProvider) throw Error('Unable to get USFM data provider');
const verseData = usfmDataProvider.getVerse(new VerseRef(selector));
responseVerse = { text: (await verseData) ?? `${selector} not found` };
// Cache the verse text, track the latest cached verse, and send an update
this.verses[selector] = responseVerse;
this.latestVerseRef = selector;
this.notifyUpdate();
} catch (e) {
responseVerse = {
text: `Failed to fetch ${selector} from USFM data provider! Reason: ${e}`,
};
}
}
if (responseVerse.isChanged) {
// Remove any previous heresy warning from the beginning of the text so they don't stack
responseVerse.text = responseVerse.text.replace(/^\[.* \d*\] /, '');
return `[${this.heresyWarning} ${this.heresyCount}] ${responseVerse.text}`;
}
return responseVerse.text;
};
async setHeresy(verseRef: string, verseText: string) {
return this.setInternal(verseRef, { text: verseText, isHeresy: true });
}
async getHeresy(verseRef: string) {
return this.getVerse(verseRef);
}
// Does nothing, so we don't need to use `this`
// eslint-disable-next-line class-methods-use-this
async setChapter() {
// We are not supporting setting chapters now, so don't update anything
return false;
}
async getChapter(chapterInfo: [book: string, chapter: number]) {
const [book, chapter] = chapterInfo;
return this.getVerse(`${book} ${chapter}`);
}
Inside of activate()
, we define an instance of our data provider engine and send it a starter string. Then, using the dataProviders
service, register the engine with an id. At the end of activate()
, as to not hold everything else up, await the data provider promise.
const engine = new QuickVerseDataProviderEngine("heresyCount =");
const quickVerseDataProviderPromise = papi.dataProviders.registerEngine(
"paranextExtensionTemplate.quickVerse",
engine
);
context.registrations.add(await quickVerseDataProviderPromise);
Update the imports to include the data types and provider from the types declaration file. Update the papi-frontend/react
import to include the useData
and useDataProvider
hooks. Declare a const for the data provider using the useDataProvider
hook. Next you can declare a variable that will get verse data from the provider using the useData
hook. You can display the data in a <div>
.
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.
import type { DoStuffEvent } from "paranext-extension-template-hello-world";
import { useData, useDataProvider, useEvent } from "papi-frontend/react";
import papi, { logger } from "papi-frontend";
const extensionVerseDataProvider = useDataProvider(
"paranextExtensionTemplate.quickVerse"
);
const [latestExtensionVerseText] =
useData<"paranextExtensionTemplate.quickVerse">(
extensionVerseDataProvider
).Verse("latest", "Loading latest Scripture text from extension template...");
return (
<>
<div className="title">
Extension Template Hello World <span className="framework">React</span>
</div>
<div>{latestExtensionVerseText}</div>
</>
);
npm start
and now we are collecting and displaying data from a provider in the webview.
Note that code style and other such documentation is stored in the Paranext wiki and covers all Paranext repositories.