// Text checkers that plugins register (api.composer.registerChecker). // // A checker is a plugin function that gets the text the user writes and // answers with things to underline (spelling, grammar, style). Unlike every // other api.composer method, the host calls it for each open composer where // checking is on, without the user pointing the plugin at that composer. // That is what the compose:check permission grants. // // The functions stay in the plugin's background frame; the host keeps only // the data below and calls them through a dispatcher the loader sets // (`checker:` and `checker-action:` hooks). Composers that check // register a target here, so a plugin can also push results for a composer // it was handed (on-demand checkers such as an assistant's "check" button). import { CHECK_KINDS, type CheckKind } from '@/lib/proofreading/annotations'; export const MAX_CHECKERS_PER_PLUGIN = 4; export const MIN_DEBOUNCE_MS = 300; export const MAX_DEBOUNCE_MS = 5000; export const DEFAULT_DEBOUNCE_MS = 800; const MAX_LANGUAGES = 100; export type CheckerMode = 'typing' | 'on-demand'; export interface CheckerData { id: string; label: string; kinds: CheckKind[]; debounceMs: number; languages: string[]; mode: CheckerMode; /** Ask the user before sending while this checker has open issues. */ warnBeforeSend: boolean; } export interface CheckerSpec extends CheckerData { pluginId: string; pluginName: string; /** Bumped by recheck(): composers send their whole text again. */ epoch: number; /** Registration order; the first checker wins where underlines overlap. */ order: number; } const ID = /^[a-z0-9][a-z0-9-]{0,39}$/; const LANG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8}){0,3}$/; function fail(msg: string): never { throw new Error(`composer.registerChecker: ${msg}`); } function readKinds(v: unknown, method: string): CheckKind[] { if (!Array.isArray(v) || v.length === 0) throw new Error(`${method}: kinds must list one or more of ${CHECK_KINDS.join(', ')}`); const out: CheckKind[] = []; for (const k of v) { if (typeof k !== 'string' || !(CHECK_KINDS as readonly string[]).includes(k)) throw new Error(`${method}: unknown kind "${String(k)}"`); if (!out.includes(k as CheckKind)) out.push(k as CheckKind); } return out; } function readLabel(v: unknown, method: string): string { if (typeof v !== 'string' || !v.trim() || v.length > 60) throw new Error(`${method}: label must be 1 to 60 characters`); return v.trim(); } function readDebounce(v: unknown, method: string): number { if (v === undefined) return DEFAULT_DEBOUNCE_MS; if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`${method}: debounceMs must be a number`); return Math.min(MAX_DEBOUNCE_MS, Math.max(MIN_DEBOUNCE_MS, Math.round(v))); } /** A registration as the plugin sent it, checked. */ export function checkCheckerData(raw: unknown): CheckerData { const method = 'composer.registerChecker'; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('needs an object'); const r = raw as Record; const known = new Set(['id', 'label', 'kinds', 'debounceMs', 'languages', 'mode', 'warnBeforeSend']); for (const key of Object.keys(r)) if (!known.has(key)) fail(`unknown field "${key}"`); if (typeof r.id !== 'string' || !ID.test(r.id)) fail('id must be 1 to 40 lowercase letters, digits or dashes'); const languages: string[] = []; if (r.languages !== undefined) { if (!Array.isArray(r.languages) || r.languages.length > MAX_LANGUAGES) fail(`languages must be a list of at most ${MAX_LANGUAGES} language tags`); for (const l of r.languages) { if (typeof l !== 'string' || !LANG.test(l)) fail(`"${String(l)}" is not a language tag`); if (!languages.includes(l)) languages.push(l); } } if (r.mode !== undefined && r.mode !== 'typing' && r.mode !== 'on-demand') fail('mode must be "typing" or "on-demand"'); if (r.warnBeforeSend !== undefined && typeof r.warnBeforeSend !== 'boolean') fail('warnBeforeSend must be true or false'); return { id: r.id as string, label: readLabel(r.label, method), kinds: readKinds(r.kinds, method), debounceMs: readDebounce(r.debounceMs, method), languages, mode: (r.mode as CheckerMode | undefined) ?? 'typing', warnBeforeSend: r.warnBeforeSend === true, }; } /** The fields update() may change, checked. */ export function checkCheckerPatch(raw: unknown): Partial> { const method = 'composer.updateChecker'; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${method}: needs an object`); const r = raw as Record; const out: Partial> = {}; for (const key of Object.keys(r)) { switch (key) { case 'label': out.label = readLabel(r.label, method); break; case 'kinds': out.kinds = readKinds(r.kinds, method); break; case 'debounceMs': out.debounceMs = readDebounce(r.debounceMs, method); break; case 'warnBeforeSend': if (typeof r.warnBeforeSend !== 'boolean') throw new Error(`${method}: warnBeforeSend must be true or false`); out.warnBeforeSend = r.warnBeforeSend; break; default: throw new Error(`${method}: "${key}" can't be changed`); } } return out; } // pluginId → checkerId → spec const registry = new Map>(); const listeners = new Set<() => void>(); let snapshot: CheckerSpec[] | null = null; let nextOrder = 0; function changed(): void { snapshot = null; for (const l of listeners) { try { l(); } catch { /* ignore */ } } } export function subscribeCheckers(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } /** Every registered checker, in registration order; the same array until something changes. */ export function allCheckers(): CheckerSpec[] { if (snapshot) return snapshot; const out: CheckerSpec[] = []; for (const checkers of registry.values()) out.push(...checkers.values()); out.sort((a, b) => a.order - b.order); snapshot = out; return out; } export function getChecker(pluginId: string, checkerId: unknown): CheckerSpec | null { return typeof checkerId === 'string' ? registry.get(pluginId)?.get(checkerId) ?? null : null; } /** Register a checker, or replace one with the same id (keeping its place). */ export function setPluginChecker(pluginId: string, pluginName: string, data: CheckerData): void { let checkers = registry.get(pluginId); if (!checkers) { checkers = new Map(); registry.set(pluginId, checkers); } const old = checkers.get(data.id); if (!old && checkers.size >= MAX_CHECKERS_PER_PLUGIN) fail(`at most ${MAX_CHECKERS_PER_PLUGIN} checkers per plugin`); checkers.set(data.id, { ...data, pluginId, pluginName, epoch: (old?.epoch ?? 0) + 1, order: old?.order ?? nextOrder++ }); changed(); } export function updatePluginChecker(pluginId: string, checkerId: string, patch: ReturnType): void { const spec = getChecker(pluginId, checkerId); if (!spec) throw new Error('composer.updateChecker: no checker with that id'); registry.get(pluginId)!.set(checkerId, { ...spec, ...patch }); changed(); } /** Forget what composers sent: each sends its whole text again. */ export function recheckPluginChecker(pluginId: string, checkerId: string): void { const spec = getChecker(pluginId, checkerId); if (!spec) throw new Error('composer.recheck: no checker with that id'); registry.get(pluginId)!.set(checkerId, { ...spec, epoch: spec.epoch + 1 }); changed(); } export function removePluginChecker(pluginId: string, checkerId: string): void { const checkers = registry.get(pluginId); if (checkers?.delete(checkerId)) { if (checkers.size === 0) registry.delete(pluginId); changed(); } } /** Drop everything a plugin registered (it was turned off or removed). */ export function clearPluginCheckers(pluginId: string): void { if (registry.delete(pluginId)) changed(); } /** Test helper. */ export function resetCheckers(): void { registry.clear(); targets.clear(); nextOrder = 0; changed(); } // ─── Calls into the plugin ──────────────────────────────────── export interface CheckerDispatcher { check(pluginId: string, checkerId: string, request: unknown): Promise; action(pluginId: string, checkerId: string, payload: unknown): Promise; } let dispatcher: CheckerDispatcher | null = null; /** Set by the loader, which owns the running plugins. */ export function setCheckerDispatcher(next: CheckerDispatcher | null): void { dispatcher = next; } export function dispatchCheck(spec: CheckerSpec, request: unknown): Promise { if (!dispatcher) return Promise.reject(new Error('plugins are not running')); return dispatcher.check(spec.pluginId, spec.id, request); } export function dispatchCheckerAction(spec: CheckerSpec, payload: unknown): Promise { if (!dispatcher) return Promise.reject(new Error('plugins are not running')); return dispatcher.action(spec.pluginId, spec.id, payload); } // ─── Composers that check ───────────────────────────────────── export interface CheckTarget { /** The whole text for an on-demand check, or null when checking is off for this checker here. */ getCheckText(spec: CheckerSpec): unknown | null; /** Results a plugin pushed; returns how many annotations were kept. */ setAnnotations(spec: CheckerSpec, revision: string, annotations: unknown): number; } const targets = new Map(); export function registerCheckTarget(composerId: string, target: CheckTarget): () => void { targets.set(composerId, target); return () => { if (targets.get(composerId) === target) targets.delete(composerId); }; } export function checkTarget(composerId: unknown): CheckTarget | null { return typeof composerId === 'string' ? targets.get(composerId) ?? null : null; }