-
Notifications
You must be signed in to change notification settings - Fork 0
Your First Extension ‐ An Easy Start
Building extension for Platform.Bible using the minimal extension template may seem like a daunting task at first. To make developing your first extension even easier we also created a fully functional hello world template. This Wiki page describes how to configure our repositories on your local machine, and explains the contents of the fully functional 'hello world' extension template.
If you are already familiar with building extensions for Platform.Bible, you may find the minimal extension template and the Wiki page that explains its contents to better suit your needs in starting your extension.
The Wiki of the minimal extension template contains a lot of additional documentation on extensions for Platform.Bible.
- Create a development folder to work in.
mkdir dev
- Clone and configure the
paranext-core
repo (inside of the development folder) using directions given here.
- Navigate to the
paranext-extension-template-hello-world
repo here. - Select the “Use this template” option. It will ask you to create a new repo for your extension.
- Clone your extension repo into the same parent directory as
paranext-core
. This means you will not have to reconfigure paths toparanext-core
. - Follow the instructions in the
paranext-extension-template-hello-world README.md
to install dependencies and run the extension.npm install
and thennpm start
will install dependencies, start Platform.Bible core, and load your extension. After this step the extension template should run without error, next you can begin changing the template details to your own extension's details.
/dev
/paranext-core
/your-extension
- With the current state of Platform.Bible, it is easiest to clone the template and then add your code into it. You can create a
/src
folder to add code that you want to separate from the main entry file. There will be support for this in the future.
The paranext-extension-template-hello-world
contains one HTML and two React webview's that are registered and activated in main.ts
. First, you need to add your extension details, and then you can begin implementing.
-
Search for: paranext-extension-template-hello-world
Replace with: your-extension-name -
Search for: extensionTemplateHelloWorld
Replace with: yourExtensionName -
Search for: extension-template-hello-world
Replace with: your-extension -
Search for: Extension Template Hello World
Replace with: Your Extension
(Be sure to match case)
You need to change the filenames of the web view and .d.ts
files. The web views are found in /src
and referenced inside of main.ts
. The .d.ts
file is found in /src/types
and referenced in the package.json
“types” field. See more information on the web view and .d.ts files in Extension Anatomy.
The manifest.json
and package.json
files makeup your extension manifest. Add your details in these two files based on your extension name and what you renamed the files described in 1 and 2. See more information on the manifest.json
and package.json
files in Extension Anatomy.
You will need to add your extension's name into webpack.config.main.ts
. The search and replace actions listed above will correct this for you.
See the types and components that your extension can use from the PAPI.
The backend of the sample extension is contained inside of the main.ts
file.
Before you use your data provider, you need to define the types of data and the type of data provider that you are going to be using. You also need to add your data provider to the papi-shared-types
DataProviders
shared interface so TypeScript knows about your data provider. You do this inside of the .d.ts
file. The code snippets below are excerpts from paranext-extension-template-hello-world.d.ts
.
-
Declare your extension's module
Inside your
.d.ts
file, you must declare a module with your extension's name so TypeScript and other extensions can import your extension's types:declare module "paranext-extension-template-hello-world" { // Add extension types exposed on the papi for other extensions to use here }
-
Import the data provider models
See Extension Anatomy for more information on the data provider.
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"; }
-
Define data types
Inside of the
.d.ts
file, you can separate the different data types you create by declaring different modules. Seemain.ts
for how to import types from your.d.ts
file. In theparanext-extension-template-hello-world
it defines a module for the template itself (and declarespapi-shared-types
to add types that thepapi
will then know about). Inside of that module it defines and exports threeDataProviderDataType
’s.declare module 'paranext-extension-template-hello-world' { 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 >; }; ... }
-
Define data provider
Once you have defined your type(s), you can use them to define your data provider.
// Inside the 'paranext-extension-template-hello-world' module export type ExtensionVerseDataProvider = IDataProvider<ExtensionVerseDataTypes>;
-
Add your data provider to
papi-shared-types
DataProviders
shared interfaceOnce you have defined your data provider type, you need to add it to the
DataProviders
shared interface inpapi-shared-types
for TypeScript to understand that your data provider is available on thepapi
:declare module "papi-shared-types" { import type { ExtensionVerseDataProvider } from "paranext-extension-template-hello-world"; export interface CommandHandlers { "extensionTemplateHelloWorld.doStuff": (message: string) => { response: string; occurrence: number; }; } export interface DataProviders { "paranextExtensionTemplate.quickVerse": ExtensionVerseDataProvider; } }
There are different ways that you can implement the data provider. The hello-someone
data provider is built with an object. See Extension Anatomy for pros and cons of implementing a data provider as an object.
The object initialized below is an example data provider engine that provides information about people. It has three data types:
- Greeting: a person's greeting
- Age: a person's age
- People: info about all people associated with this engine
const peopleDataProviderEngine: IDataProviderEngine<PeopleDataTypes> &
withNotifyUpdate<PeopleDataTypes> &
PeopleDataMethods & {
people: PeopleData;
getPerson<T extends boolean = true>(
name: string,
createIfDoesNotExist?: T,
): T extends true ? Person : Person | undefined;
} = {
people: {
bill: { greeting: 'Hi, my name is Bill!', age: 43 },
kathy: { greeting: 'Hello. My name is Kathy.', age: 35 },
},
...
This method is used when someone uses the useData('helloSomeone.people').Greeting
hook or the subscribeGreeting
method on the data provider PAPI creates for this engine.
async getGreeting(name: string) {
return this.getPerson(name, false)?.greeting;
},
This method gets layered over so that you can run this.setGreeting
inside this data provider engine, and it will send updates after returning. This method is used when someone uses the useData('helloSomeone.people').Greeting
hook on the data provider PAPI creates for this engine.
async setGreeting(
name: string,
greeting: string,
): Promise<DataProviderUpdateInstructions<PeopleDataTypes>> {
const person = this.getPerson(name);
// If there is no change in the greeting, don't update
if (greeting === person.greeting) return false;
// Update the greeting and send an update
person.greeting = greeting;
// Update greetings and People because People needs to know about all changes to people
return ['Greeting', 'People'];
},
You can create a custom method in your engine. If you do not use the “get” or “set” keywords then you do not have to use the ignore
decorator. It uses notifyUpdate
to inform subscribers that the data has changed because it is not in a set<data_type>
.
async deletePerson(name: string) {
const person = this.getPerson(name, false);
if (person) {
logger.info(`RIP ${name}, who died tragically young at age ${person.age}. ;(`);
delete this.people[name.toLowerCase()];
this.notifyUpdate();
return true;
}
return false;
},
This method gets a person's name, and creates that person if they do not exist yet. This method is named using the keyword “get”. This means that PAPI expects there to be a setPerson
method as well. We can use the ignore decorator to tell PAPI to ignore this method so it will not pick it up.
...
getPerson<T extends boolean = true>(
name: string,
createIfDoesNotExist: T = true as T,
): T extends true ? Person : Person | undefined {
const nameLower = name.toLowerCase();
if (createIfDoesNotExist && !this.people[nameLower]) this.people[nameLower] = {};
// Type assert because we know this person exists
return this.people[nameLower] as T extends true ? Person : Person | undefined;
},
...
};
papi.dataProviders.decorators.ignore(peopleDataProviderEngine.getPerson);
The paranext-extension-template-hello-world
provider is built with a class. See Extension Anatomy for pros and cons of implementing a data provider as a class.
The class initialized below is an example data provider engine that provides easy access to Scripture from an internet API.
class QuickVerseDataProviderEngine
extends DataProviderEngine<ExtensionVerseDataTypes>
implements IDataProviderEngine<ExtensionVerseDataTypes> {
constructor(public heresyWarning: string) {
super();
this.heresyWarning = this.heresyWarning ?? 'heresyCount =';
}
...
}
Currently, the constructor of DataProviderEngine
does nothing, but as it is the parent class, TypeScript requires that we call it.
For each of the three data types defined in ExtensionVerseDataTypes
you need to implement both a getter and setter. You may also define any other functions that would be helpful to you.
This method demonstrates one way to make a get<data_type>
function that feels more like a normal method in that it has “multiple” parameters in its selector, which is an array of parameters. To use it, you have to wrap the parameters in an array. This method is used when someone uses the useData('paranextExtensionTemplate.quickVerse').Chapter
hook or the subscribeChapter
method on the data provider PAPI creates for this engine.
async getChapter(chapterInfo: [book: string, chapter: number]) {
const [book, chapter] = chapterInfo;
return this.getVerse(`${book} ${chapter}`);
}
This method gets layered over so that you can run this.setVerse
inside of this data provider engine, and it will send updates after returning. This method is used when someone uses the useData('paranextExtensionTemplate.quickVerse').Verse
hook on the data provider PAPI creates for this engine.
async setVerse(verseRef: string, data: ExtensionVerseSetData) {
return this.setInternal(verseRef, data);
Private methods cannot be called on the network. They can only be used locally. What makes this method private is that it is prefixed with #
.
#getSelector(selector: string) {
const selectorL = selector.toLowerCase().trim();
return selectorL = 'latest' ? this.latestVerseRef : selectorL;
}
This method name begins with the key phrase “set”, that would cause PAPI to assume that it is one of our setters and require that it has a getter. By using the ignore
decorator we can tell the PAPI it does not need to pick up this function. This is a helpful method to use when you want to use a function on the network but do not want the PAPI to pick it up as a data type method. You could also name it anything that does not start with the “get” and “set” key phrases.
@papi.dataProviders.decorators.ignore
async setInternal(selector: string, data: ExtensionVerseSetData,) :
Promise<DataProviderUpdateInstructions<ExtensionVerseDataTypes>> { }
The web view file(s) hold the front end of your extension. It is not a requirement that you have a web view, but it is available if you need a user interface.
The template contains one HTML and two React web views. The example below shows how to add an HTML web view. To see a full example look at paranext-extension-template-hello-world
here.
-
Create web view file
Add the file into
extension/lib
extension - template - hello - world - html.web - view.ejs;
-
Add HTML
<!DOCTYPE html> <html> <head> <style> .title { color: red; } </style> </head> <body> <div class="title">Extension Template HTMl</div> <div> <input id="name-input" value="Bill" /> <button id="greetings-button" type="button">Greet</button> </div> </body> </html>
-
Add JS
... <body> <script> const greetingsButton = document.getElementById('greetings-button'); greetingsButton.addEventListener(...); </script> </body> ...
-
Import file
The
@ts-expect-error
comment prevents TypeScript from throwing an error for the import statement by telling TypeScript that the file is actually producing what it wants to except, it just can’t tell. The importance of this line is explained further in Extension Anatomy.// @ts-expect-error ts(1192) this file has no default export; the text is exported by rollup import extensionTemplateHtml from "./extension-template-hello-world-html.web-view.ejs?inline";
-
Define web view provider
The htmlWebViewType matches the title of your extension and the framework of your web view. The next piece is a simple web view provider that provides sample HTML web views when PAPI requests them.
const htmlWebViewType = "paranextExtensionTemplate.html"; const htmlWebViewProvider: IWebViewProvider = { async getWebView( savedWebView: SavedWebViewDefinition ): Promise<WebViewDefinition | undefined> { if (savedWebView.webViewType !== htmlWebViewType) throw new Error( `${htmlWebViewType} provider received request to provide a ${savedWebView.webViewType} web view` ); return { ...savedWebView, title: "Extension Template HTML", contentType: "html" as WebViewContentType.HTML, content: extensionTemplateHtml, }; }, };
-
Inside of activate
To start you need to register the web view provider. The register function takes a web view provider to serve web views for the specified web view type. The
getWebView
function creates web views or gets an existing web view if one already exists for this type and with this type -htmlWebViewType
. See Extension Anatomy for more information on registering web views and handling cleanup.export async function activate(context: ExecutionActivationContext) { ... // register web view const htmlWebViewProviderPromise = papi.webViewProviders.register( htmlWebViewType, htmlWebViewProvider ); // get web view papi.webViews.getWebView(htmlWebViewType, undefined, { existingId: "?" }); // data provider promise const htmlWebViewProviderResolved = await htmlWebViewProviderPromise; // cleanup, dispose resources const combinedUnsubscriber: UnsubscriberAsync = papi.utils.aggregateUnsubscriberAsyncs( (await Promise.all(unsubPromise)).concat([ ... htmlWebViewProviderResolved.dispose ]) ); ... }
The example below from paranext-extension-template-hello-world
shows how to add a css stylesheet.
-
Create stylesheet
Add the file into
extension/lib
extension - template - hello - world - html.web - view.scss;
-
Import stylesheet
To read about the use and importance of
?inline
, see theparanext-extension-template-hello-world
README.md
Special Imports section.import extensionTemplateReactStyles from "./extension-template-hello-world.web-view.scss?inline";
-
Fill stylesheet
.title { color: blue; .framework { font-weight: 900; } }
-
Use stylesheet
You can easily attach your stylesheet to your web view provider by adding it to the
styles
attribute.... const reactWebViewProvider: IWebViewProvider = { async getWebView( savedWebView: SavedWebViewDefinition ): Promise<WebViewDefinition | undefined> { return { ...savedWebView, title: "Extension Template React", content: extensionTemplateReact, styles: extensionTemplateReactStyles, }; }, }; ...
Inside of your extension main directory:
npm run build
This will build without running, the most recent build is stored in /dist
Inside of your extension main directory:
npm start
This will start paranext-core
with your extension.