Skip to content

Your First Extension

Alex Mercado edited this page Oct 16, 2024 · 27 revisions

Warning

Under construction. Read Your First Extension ‐ An Easy Start (repo:hello-world) for more complete and helpful documentation.


Introduction

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.

Template Contents

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

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

/webpack

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.

Turn the Minimal into hello world

In this section, we will take the minimal extension template and turn it into the hello world extension template.

Details

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.

Files to be edited:

  • 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 and replace:

  • Search for: paranext-extension-template Replace with: your-extension-name
  • Next manually edit the package.json and public/manifest.json

Once your details are changed save and npm start your extension to make sure that it still runs as expected.

Web Views

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.

Webview content

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>
		</>
	);
};

Webview styles

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

Webview provider

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

Register, get, and await

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

PAPI Commands and Using Events

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.

Types (.d.ts) file

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

Activate commands

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

Using commands in webviews

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.

Data provider

Finally, let's add and use a data provider class in our extension.

Types file

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

Create data provider

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

Getters and setters

For each data type, an engine needs a get<data_type> and a set<data_type>.

Verse get and set
  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;
  };
Heresy get and set
  async setHeresy(verseRef: string, verseText: string) {
    return this.setInternal(verseRef, { text: verseText, isHeresy: true });
  }

  async getHeresy(verseRef: string) {
    return this.getVerse(verseRef);
  }
Chapter get and set
  // 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}`);
  }

Activate data provider

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

Use data provider in webview

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.

Further Reading