waybetter/packages/create-waybetter/src/plugin.js
CRT 74c14554c4 waybetter 0.7.0, create-waybetter 0.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:57:36 +07:00

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