Skip to content

Your First Extension ‐ An Easy Start

Jolie Rabideau edited this page Dec 7, 2023 · 10 revisions

Introduction

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.

Build the environment

From scratch

  1. Create a development folder to work in. mkdir dev
  2. Clone and configure the paranext-core repo (inside of the development folder) using directions given here.

Using the extension template

  1. Navigate to the paranext-extension-template-hello-world repo here.
  2. Select the “Use this template” option. It will ask you to create a new repo for your extension.
  3. Clone your extension repo into the same parent directory as paranext-core. This means you will not have to reconfigure paths to paranext-core.
  4. Follow the instructions in the paranext-extension-template-hello-world README.md to install dependencies and run the extension. npm install and then npm 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.

Development Environment Structure

/dev
	/paranext-core
	/your-extension

From an existing codebase

  • 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.

Turning the template into your extension

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.

Add extension details

Search and Replace

  • 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)

Filenames

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.

Manifest

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.

Webpack

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.

Add implementation

See the types and components that your extension can use from the PAPI.

Backend

The backend of the sample extension is contained inside of the main.ts file.

Add provider and type definitions

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.

  1. 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
    }
  2. 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";
    }
  3. Define data types

    Inside of the .d.ts file, you can separate the different data types you create by declaring different modules. See main.ts for how to import types from your .d.ts file. In the paranext-extension-template-hello-world it defines a module for the template itself (and declares papi-shared-types to add types that the papi will then know about). Inside of that module it defines and exports three DataProviderDataType’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
       >;
     };
    
     ...
    }
  4. 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>;
  5. Add your data provider to papi-shared-types DataProviders shared interface

    Once you have defined your data provider type, you need to add it to the DataProviders shared interface in papi-shared-types for TypeScript to understand that your data provider is available on the papi:

    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;
    	}
    }
Implement data provider via object

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 },
  },

  ...
Getter example

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;
},
Setter example

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'];
},
Custom Method

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;
},
Ignored method

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);
Implement data provider via class

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.

Getter Example

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}`);
}
Setter Example

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 method example

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;
}
Ignored method

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>> { }

Frontend

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.

Web view - HTML

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.

  1. Create web view file

    Add the file into extension/lib

    extension - template - hello - world - html.web - view.ejs;
  2. 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>
  3. Add JS

    ...
    <body>
       <script>
          const greetingsButton = document.getElementById('greetings-button');
          greetingsButton.addEventListener(...);
       </script>
    </body>
    ...
  4. 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";
  5. 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,
    		};
    	},
    };
  6. 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
             ])
          );
       ...
    }
Add Styles

The example below from paranext-extension-template-hello-world shows how to add a css stylesheet.

  1. Create stylesheet

    Add the file into extension/lib

    extension - template - hello - world - html.web - view.scss;
  2. Import stylesheet

    To read about the use and importance of ?inline, see the paranext-extension-template-hello-world README.md Special Imports section.

    import extensionTemplateReactStyles from "./extension-template-hello-world.web-view.scss?inline";
  3. Fill stylesheet

    .title {
    	color: blue;
    
    	.framework {
    		font-weight: 900;
    	}
    }
  4. 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,
          };
       },
    };
    
    ...

Build your extension

Inside of your extension main directory:

  1. npm run build

This will build without running, the most recent build is stored in /dist

Run your extension

Inside of your extension main directory:

  1. npm start

This will start paranext-core with your extension.