Add features
There are five ways a plugin can shape your novel. It can:
- Provide commands that do work
- Provide selectors that share data
- Respond to labels in the screenplay
- Respond to changes in state
- Decorate commands and selectors from other plugins
Inkweaver’s bundled plugins are built the same way, so there’s nothing they can do that yours can’t.
Commands
Section titled “Commands”A command is an action any plugin can call, like fetching a beat or drawing a scene. When your plugin provides a command, other plugins can call it and extend it too.
A command is an async function. It takes an object of arguments, and returns a promise that resolves to a result object:
import { getState, setState } from "@inkweaverdev/inkweaver-sdk";import { BuyItemContext, BuyItemResult } from "../plugin-types";import { State } from "../types";
export async function buyItem(ctx: BuyItemContext): Promise<BuyItemResult> { const { itemId, price } = ctx; const current = getState();
const inventory = [...current.inventory, itemId]; const coins = current.coins - price; if (coins < 0) { return { didBuy: false }; }
const newState: State = { ...current, coins, inventory }; setState(newState);
return { didBuy: true };}Selectors
Section titled “Selectors”A selector is how your plugin shares its data, in a safe and predictable way. Like a command, it takes an object of arguments and returns a result object:
import { getState } from "@inkweaverdev/inkweaver-sdk";import { GetInventoryResult } from "../plugin-types";import { State } from "../types";import { getItemName } from "utils";
export function getInventory(): GetInventoryResult { const current = getState<State>(); const inventory = current.inventory.map((itemId) => getItemName(itemId)); return { inventory };}Labels
Section titled “Labels”Labels are how writers call your code from the screenplay. If you know HTML, a label is a bit like a class name: it says what a line is, and your code decides what that means.
Register a handler with onLabel, and it runs whenever the reader reaches a line with a matching label:
onLabel("has-coins", () => { const { coins } = getState(); return coins >= 0;});Your handler’s return value decides what happens to the line:
- Return
true, or nothing, and the line is shown as normal. - Return
falseornull, and the line is skipped.
In this example, if the reader has no coins, the shopkeeper’s dialogue is skipped:
SHOPKEEPER <has-coins>What are you buying?You can also return a new lexia, which replaces the line:
onLabel<DialogLexia>("shouting", (lexia) => ({ ...lexia, content: toUpperCase(lexia.content),}));Handlers can be synchronous or asynchronous.
If you register a label with a regular expression, your handler’s second argument holds its named capture groups. That’s how you give a label parameters. See onLabel for an example.
State changes
Section titled “State changes”onChange watches a selector, and runs your callback whenever its value changes:
const { getCoins } = getSelectors();
onChange(getCoins, (coins) => console.log(`Now holding ${coins} coins`));Decorating
Section titled “Decorating”To change how an existing command or selector behaves, decorate it. Decorators follow the middleware pattern, so if you’ve used a web framework like Express, this will feel familiar.
Wrap a command with decorateCommand, or a selector with decorateSelector. Your function receives the original arguments and a next function that calls the implementation beneath yours. You can do your own work before next, after it, or instead of it.
decorateCommand( "buyItem", async (context, next) => { const result = await next(context); if (result.didBuy) { console.log(`Reader bought item: ${context.itemId}`); } return result; }, PRIORITY,);This decorator calls the original buyItem through next, and logs a message if the purchase went through.
Several plugins can decorate the same command, and each one adds its own behavior. The optional priority argument sets the order: decorators with a higher priority run first, and wrap the ones with a lower priority. If you leave it out, the priority is 0, and decorators with the same priority run in the order their plugins loaded.
Example: replacing a component
Section titled “Example: replacing a component”You’ll often decorate to change the interface that the Web UI plugin draws. Its getComponents selector returns all of the React components it renders with. Decorate it to replace a component, or to add your own for other plugins to use:
decorateSelector("getComponents", (context, next) => { const components = next(context); return { ...components, Scene: MyCustomScene, QuickSaveButton: MyQuickSaveButton, };});Replacing an existing key, like Scene, swaps that component everywhere it’s used through getComponents. Adding a new key, like QuickSaveButton, makes your component available to other plugins.
Type safety
Section titled “Type safety”To decorate another plugin’s commands with type checking, your project needs that plugin’s types. Add the plugin as an npm dependency, then import it for its side effects at the top of your index.ts:
import "shop-plugin";This import brings in the plugin’s declarations, so you can use its commands and selectors in your code. They come from that plugin’s plugin-types.ts, which Create a plugin describes.