// `npm create waybetter -- plugin`: one BetterDiscord-style `*.plugin.js` file (a class with // start/stop and the header the plugin store reads), optionally as a small TypeScript project // whose build writes that file. The example uses the real plugin API: `api.scope()`, storage, // observe, onKey, the UI kit (`scope.ui`: toast with an action, a settings dialog built with // `ui.h`) and the theme API (`api.theme`, `--wb-*` CSS variables). import { UsageError, headerValue, lines, pascalCase, slugify, str, versionValue } from './names.js'; /** Which app the plugin targets: wayBetter for X adds post/account readers to the core API. */ export const PLUGIN_APPS = ['x', 'any']; /** * @typedef {{ name: string, description?: string, author?: string, version?: string, * typescript?: boolean, app?: string }} PluginInput * @typedef {{ name: string, description: string, author: string, version: string, * typescript: boolean, app: 'x' | 'any', id: string, className: string, file: string }} PluginOptions * @typedef {{ path: string, content: string | Uint8Array }} OutputFile */ /** @param {PluginInput} input @returns {PluginOptions} */ export function normalizePluginOptions(input) { const name = headerValue('Plugin name', input.name, { max: 60, required: true }); const id = slugify(name, 48); if (!id) throw new UsageError(`Plugin name "${name}" needs at least one letter or digit.`); const app = String(input.app ?? 'x'); if (!PLUGIN_APPS.includes(app)) throw new UsageError(`--app must be one of: ${PLUGIN_APPS.join(', ')}.`); return { name, description: headerValue('Description', input.description || `${name}, a wayBetter plugin.`, { max: 200 }), author: headerValue('Author', input.author || 'you', { max: 60 }), version: versionValue(input.version), typescript: Boolean(input.typescript), app: /** @type {'x' | 'any'} */ (app), id, className: pascalCase(name), // A bare `name.plugin.js`: letters, digits, `-` only (waybetter-host `valid_file_name`). file: `${id}.plugin.js`, }; } /** @param {PluginOptions} o */ function header(o) { return lines( '/**', ` * @name ${o.name}`, ` * @description ${o.description}`, ` * @version ${o.version}`, ` * @author ${o.author}`, ' */', ); } /** The item a badge is added to, and how its text is read. @param {PluginOptions} o */ function itemParts(o) { if (o.app === 'x') { return { selector: `'article[data-testid="tweet"]'`, selectorNote: '// One post on X.', text: 'this.api.tweet(item)?.text ?? \'\'', anchor: `item.querySelector('[data-testid="User-Name"]') ?? item`, }; } return { selector: `'[data-example-item]'`, selectorNote: '// PLACEHOLDER: a selector for one item of your app\'s site (a post, a message, a video).', text: `item.textContent ?? ''`, anchor: 'item', }; } /** @param {PluginOptions} o */ function jsSource(o) { const item = itemParts(o); return lines( header(o).trimEnd(), '// Made with `npm create waybetter`. The API is documented in PLUGINS.md (wayBetter for X:', '// docs/PLUGINS.md) and in the waybetter package README (plugin UI kit, themes).', '', `const ID = ${str(o.id)}; // storage namespace and CSS id`, `const ITEM = ${item.selector}; ${item.selectorNote}`, '', `module.exports = class ${o.className} {`, ' start() {', ' const api = window.wayBetter.api;', ' this.api = api;', ' // A scope collects everything the plugin adds; stop() undoes it all with scope.dispose().', ' this.scope = api.scope();', ' this.store = api.storage(ID);', ' this.settings = { badges: true, ...this.store.get(\'settings\', {}) };', '', ' // Plugin CSS follows the active theme through the --wb-* variables.', ' this.scope.css(ID, `', ' .${ID}-badge { margin-inline-start: 8px; padding: 1px 8px; border: 1px solid var(--wb-border);', ' border-radius: var(--wb-button-radius); background: var(--wb-surface); color: var(--wb-muted);', ' font: 500 12px/18px var(--wb-font); white-space: nowrap; }', ' html[data-${ID}-off] .${ID}-badge { display: none; }', ' `);', ' this.apply();', ' this.scope.defer(() => document.documentElement.removeAttribute(`data-${ID}-off`));', '', ' // Once for every matching element, now and as the page renders more (batched per frame).', ' this.scope.observe(ITEM, item => this.decorate(item));', '', ' // A single-key shortcut, skipped while typing: Shift+W opens the settings.', ' this.scope.onKey(event => { if (event.key === \'W\') this.openSettings(); });', '', ' if (!this.store.get(\'welcomed\', false)) {', ' this.store.set(\'welcomed\', true);', ` this.scope.ui.toast(${str(`${o.name} is on`)}, { tone: 'success', action: { label: 'Settings', onClick: () => this.openSettings() } });`, ' }', ' }', '', ' decorate(item) {', ` const text = ${item.text};`, ' const words = text.trim() ? text.trim().split(/\\s+/).length : 0;', ' // scope.add: removed again on dispose.', ' const badge = this.scope.add(document.createElement(\'span\'));', ' badge.className = `${ID}-badge`;', ' badge.textContent = `${words} ${words === 1 ? \'word\' : \'words\'}`;', ` ${item.anchor}.append(badge);`, ' }', '', ' apply() {', ' document.documentElement.toggleAttribute(`data-${ID}-off`, !this.settings.badges);', ' }', '', ' openSettings() {', ' const ui = this.scope.ui; // scoped: the dialog closes if the plugin stops', ' const h = ui.h; // safe element builder: strings become text, on* functions listeners', ' const theme = this.api.theme.current();', ' const badges = h(\'input\', { type: \'checkbox\', checked: this.settings.badges });', ' ui.dialog({', ` title: ${str(o.name)},`, ' message: `Drawn in the active theme: ${theme.name || theme.id}, ${theme.mode}.`,', ' content: body => body.append(', ' h(\'label\', { class: \'wb-check\' }, badges, h(\'span\', null, \'Show word counts\', h(\'small\', null, \'A small badge on each item.\'))),', ' ),', ' actions: [', ' { label: \'Cancel\' },', ' { label: \'Save\', tone: \'primary\', default: true, onClick: handle => {', ' const next = { ...this.settings, badges: badges.checked };', ' if (!this.store.set(\'settings\', next)) {', ' handle.setError(\'Could not save.\');', ' return false; // keeps the dialog open', ' }', ' this.settings = next;', ' this.apply();', ' ui.toast(\'Saved\', { tone: \'success\' });', ' } },', ' ],', ' });', ' }', '', ' stop() {', ' this.scope?.dispose();', ' this.scope = null;', ' }', '};', ); } /** @param {PluginOptions} o */ function tsSource(o) { const item = itemParts(o); return lines( header(o).trimEnd(), '// Made with `npm create waybetter`. `npm run build` writes dist/' + o.file + ' (header first).', '', "import type { PluginScope, PluginStorage } from 'waybetter/runtime/pluginApi';", "import type { PluginApi } from './waybetter-env';", '', `const ID = ${str(o.id)}; // storage namespace and CSS id`, `const ITEM = ${item.selector}; ${item.selectorNote}`, '', 'type Settings = { badges: boolean };', '', `export default class ${o.className} {`, ' private api!: PluginApi;', ' private scope: PluginScope | null = null;', ' private store!: PluginStorage;', ' private settings: Settings = { badges: true };', '', ' start(): void {', ' const api = window.wayBetter.api;', ' this.api = api;', ' // A scope collects everything the plugin adds; stop() undoes it all with scope.dispose().', ' const scope = this.scope = api.scope();', ' this.store = api.storage(ID);', ' this.settings = { badges: true, ...this.store.get>(\'settings\', {}) };', '', ' // Plugin CSS follows the active theme through the --wb-* variables.', ' scope.css(ID, `', ' .${ID}-badge { margin-inline-start: 8px; padding: 1px 8px; border: 1px solid var(--wb-border);', ' border-radius: var(--wb-button-radius); background: var(--wb-surface); color: var(--wb-muted);', ' font: 500 12px/18px var(--wb-font); white-space: nowrap; }', ' html[data-${ID}-off] .${ID}-badge { display: none; }', ' `);', ' this.apply();', ' scope.defer(() => document.documentElement.removeAttribute(`data-${ID}-off`));', '', ' // Once for every matching element, now and as the page renders more (batched per frame).', ' scope.observe(ITEM, item => this.decorate(scope, item));', '', ' // A single-key shortcut, skipped while typing: Shift+W opens the settings.', ' scope.onKey(event => { if (event.key === \'W\') this.openSettings(); });', '', ' if (!this.store.get(\'welcomed\', false)) {', ' this.store.set(\'welcomed\', true);', ` scope.ui.toast(${str(`${o.name} is on`)}, { tone: 'success', action: { label: 'Settings', onClick: () => this.openSettings() } });`, ' }', ' }', '', ' private decorate(scope: PluginScope, item: Element): void {', ` const text = ${item.text};`, ' const words = text.trim() ? text.trim().split(/\\s+/).length : 0;', ' // scope.add: removed again on dispose.', ' const badge = scope.add(document.createElement(\'span\'));', ' badge.className = `${ID}-badge`;', ' badge.textContent = `${words} ${words === 1 ? \'word\' : \'words\'}`;', ` ${item.anchor}.append(badge);`, ' }', '', ' private apply(): void {', ' document.documentElement.toggleAttribute(`data-${ID}-off`, !this.settings.badges);', ' }', '', ' private openSettings(): void {', ' if (!this.scope) return;', ' const ui = this.scope.ui; // scoped: the dialog closes if the plugin stops', ' const h = ui.h; // safe element builder: strings become text, on* functions listeners', ' const theme = this.api.theme.current();', ' const badges = h(\'input\', { type: \'checkbox\', checked: this.settings.badges }) as HTMLInputElement;', ' ui.dialog({', ` title: ${str(o.name)},`, ' message: `Drawn in the active theme: ${theme.name || theme.id}, ${theme.mode}.`,', ' content: body => body.append(', ' h(\'label\', { class: \'wb-check\' }, badges, h(\'span\', null, \'Show word counts\', h(\'small\', null, \'A small badge on each item.\'))),', ' ),', ' actions: [', ' { label: \'Cancel\' },', ' { label: \'Save\', tone: \'primary\', default: true, onClick: handle => {', ' const next: Settings = { ...this.settings, badges: badges.checked };', ' if (!this.store.set(\'settings\', next)) {', ' handle.setError(\'Could not save.\');', ' return false; // keeps the dialog open', ' }', ' this.settings = next;', ' this.apply();', ' ui.toast(\'Saved\', { tone: \'success\' });', ' return true;', ' } },', ' ],', ' });', ' }', '', ' stop(): void {', ' this.scope?.dispose();', ' this.scope = null;', ' }', '}', ); } /** Types of `window.wayBetter` for the TypeScript project. @param {PluginOptions} o */ function envTypes(o) { const x = o.app === 'x'; return lines( '// `window.wayBetter`: the page runtime plugins run against. The core API and its types come', '// from the `waybetter` package; an app adds its own readers on top (wayBetter for X: posts,', '// the signed-in account and users, see docs/PLUGINS.md).', "import type { CorePluginApi } from 'waybetter/runtime/pluginApi';", x ? "import type { SeenAccount } from 'waybetter/runtime/accountReport';" : '', '', x ? lines( '/** A rendered post, read by `api.tweet(element)`. */', 'export type TweetInfo = {', ' id: string;', ' url: string;', ' handle: string;', ' name: string;', ' /** Emoji as characters, t.co links as the destination X shows. */', ' text: string;', ' /** ISO timestamp of the post\'s `