426 lines
18 KiB
JavaScript
426 lines
18 KiB
JavaScript
// `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<Partial<Settings>>(\'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 `<time datetime>`. */',
|
|
' time: string | null;',
|
|
' /** Full-size image URLs of the post itself. */',
|
|
' media: string[];',
|
|
' element: Element;',
|
|
'};',
|
|
'',
|
|
'/** What X\'s own responses said about a user this session; null = not seen (never guessed). */',
|
|
'export type UserInfo = {',
|
|
' xUserId: string;',
|
|
' handle: string;',
|
|
' blue: boolean | null;',
|
|
' verifiedType: string | null;',
|
|
' following: boolean | null;',
|
|
'};',
|
|
'',
|
|
'export type PluginApi = CorePluginApi & {',
|
|
' tweet(element: Element | null | undefined): TweetInfo | null;',
|
|
' account(): SeenAccount | null;',
|
|
' users: { get(handle: string): UserInfo | null; onUpdate(callback: (handles: string[]) => void): () => void };',
|
|
' isTypingTarget(target: EventTarget | null): boolean;',
|
|
'};',
|
|
).trimEnd() : 'export type PluginApi = CorePluginApi;',
|
|
'',
|
|
'declare global {',
|
|
' interface Window {',
|
|
' wayBetter: {',
|
|
' api: PluginApi;',
|
|
' /** Load errors by plugin file. */',
|
|
' errors: Record<string, string>;',
|
|
' };',
|
|
' }',
|
|
'}',
|
|
).replace(/\n\n\n+/g, '\n\n');
|
|
}
|
|
|
|
/** Builds dist/<file> from src/<file-ts> with the TypeScript compiler; the header stays first. */
|
|
function buildScript(o) {
|
|
const tsFile = o.file.replace(/\.js$/, '.ts');
|
|
return lines(
|
|
'// Compiles the plugin to one CommonJS file the plugin store can load. The `/** @name … */`',
|
|
'// header must be the very first thing in the file, so it is cut from the source and put back',
|
|
'// on top of the compiled output.',
|
|
"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';",
|
|
"import ts from 'typescript';",
|
|
'',
|
|
`const source = readFileSync(new URL('./src/${tsFile}', import.meta.url), 'utf8');`,
|
|
'const header = /^\\uFEFF?\\s*(\\/\\*\\*[\\s\\S]*?\\*\\/)/.exec(source);',
|
|
"if (!header) throw new Error('The plugin must start with its /** @name … */ header.');",
|
|
'const { outputText, diagnostics } = ts.transpileModule(source.slice(header[0].length), {',
|
|
` fileName: ${str(tsFile)},`,
|
|
' reportDiagnostics: true,',
|
|
' compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, removeComments: false },',
|
|
'});',
|
|
'if (diagnostics?.length) {',
|
|
" for (const d of diagnostics) console.error(ts.flattenDiagnosticMessageText(d.messageText, '\\n'));",
|
|
' process.exit(1);',
|
|
'}',
|
|
"mkdirSync(new URL('./dist/', import.meta.url), { recursive: true });",
|
|
`writeFileSync(new URL('./dist/${o.file}', import.meta.url), \`\${header[1]}\\n\${outputText}\`);`,
|
|
`console.log('dist/${o.file}');`,
|
|
);
|
|
}
|
|
|
|
/** @param {PluginOptions} o */
|
|
function readme(o) {
|
|
const x = o.app === 'x';
|
|
const target = x ? 'wayBetter for X' : 'your wayBetter app';
|
|
const file = o.typescript ? `dist/${o.file}` : o.file;
|
|
return lines(
|
|
`# ${o.name}`,
|
|
'',
|
|
o.description,
|
|
'',
|
|
`A plugin for ${target}: one \`*.plugin.js\` file with a class that has \`start()\` and \`stop()\`.`,
|
|
'',
|
|
'## Install',
|
|
'',
|
|
...(o.typescript ? ['```sh', 'npm install', 'npm run build # writes ' + file, 'npm run typecheck', '```', ''] : []),
|
|
`1. Open **wayBetter → Plugins → Open folder** and copy \`${file}\` into it.`,
|
|
'2. Press **Rescan** and turn the plugin on. It starts and stops live on every page.',
|
|
'',
|
|
'The header comment at the top (`@name`, `@description`, `@version`, `@author`) is what the',
|
|
'Plugins list shows; keep it the first thing in the file.',
|
|
'',
|
|
'## What the example shows',
|
|
'',
|
|
'- `api.scope()`: everything the plugin adds (CSS, observers, key handlers, nodes, dialogs) is',
|
|
' undone by one `scope.dispose()` in `stop()`.',
|
|
'- `api.storage(namespace)`: settings kept in the tab\'s local storage.',
|
|
'- `scope.observe(selector, fn)`: once per matching element, now and as the page renders more.',
|
|
'- `scope.onKey(fn)`: a single-key shortcut (Shift+W) that never fires while you type.',
|
|
'- `scope.ui`: a toast with an action and a settings dialog built with `ui.h`; they use the',
|
|
' active theme and close when the plugin stops.',
|
|
'- `api.theme.current()` and the `--wb-*` CSS variables: the badge matches every theme.',
|
|
x ? '- `api.tweet(element)`: reads a rendered post (X only).' : '- Replace the `ITEM` placeholder selector with one item of your app\'s site.',
|
|
'',
|
|
'## Rules',
|
|
'',
|
|
'- No network requests and no account actions (like, follow, post, delete).',
|
|
'- `stop()` must undo everything; use the scope instead of your own observers and timers.',
|
|
'- A plugin runs inside the page with the page\'s own access: share only code you would trust.',
|
|
'',
|
|
x ? 'Reference: `docs/PLUGINS.md` in the wayBetter for X repository, and **wayBetter → Make** in the app.' : 'Reference: the `waybetter` package README (plugin UI kit and themes).',
|
|
);
|
|
}
|
|
|
|
/** @param {PluginOptions} o @returns {OutputFile[]} */
|
|
export function pluginFiles(o) {
|
|
const readmeFile = { path: 'README.md', content: readme(o) };
|
|
if (!o.typescript) return [{ path: o.file, content: jsSource(o) }, readmeFile];
|
|
const pkg = {
|
|
name: o.id,
|
|
version: o.version,
|
|
private: true,
|
|
type: 'module',
|
|
description: o.description,
|
|
scripts: { build: 'node build.mjs', typecheck: 'tsc -p tsconfig.json' },
|
|
devDependencies: { typescript: '^5.9.0', waybetter: '^0.7.0' },
|
|
};
|
|
const tsconfig = {
|
|
compilerOptions: {
|
|
target: 'ES2022',
|
|
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
|
|
module: 'ESNext',
|
|
moduleResolution: 'Bundler',
|
|
noEmit: true,
|
|
strict: true,
|
|
skipLibCheck: true,
|
|
isolatedModules: true,
|
|
types: [],
|
|
},
|
|
include: ['src'],
|
|
};
|
|
return [
|
|
{ path: `src/${o.file.replace(/\.js$/, '.ts')}`, content: tsSource(o) },
|
|
{ path: 'src/waybetter-env.d.ts', content: envTypes(o) },
|
|
{ path: 'build.mjs', content: buildScript(o) },
|
|
{ path: 'package.json', content: `${JSON.stringify(pkg, null, 2)}\n` },
|
|
{ path: 'tsconfig.json', content: `${JSON.stringify(tsconfig, null, 2)}\n` },
|
|
{ path: '.gitignore', content: lines('node_modules/', 'dist/') },
|
|
readmeFile,
|
|
];
|
|
}
|