From 1805b0817d4e4427543e6d1c176ebecd08a08997 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 15:04:49 +0200 Subject: [PATCH] feat(words): extension system skeleton (F2.1 + F2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the words rich-text editor extension system. Skeleton + contract only — engine is NOT yet wired to consult the registry. That happens in F2.3 (migrate table) and F2.4 (migrate code-block). New module `src/uix/soma/components/words/extensions/`: - extension-types.ts: WordsExtension interface + sub-types. Every hook is optional. Hooks cover document semantics (nodeTypes, factories), normalization, commands (reducer pattern), rendering, serialization (HTML + Markdown, in + out), path navigation, keyboard, sema events, dispose. Each hook documented inline with rationale. - extension-registry.ts: createWordsExtensionRegistry() returns the runtime container. API: register / unregister / dispose extensions / findByNodeType / findByCommandName getRender / getNormalize / getCommand / getSerialize / getPath / getKeyboard allEvents / allCommandNames / allNodeTypes tryDeserializeHtml / tryDeserializeMarkdown Duplicate-name and duplicate-nodeType detection at register-time with rollback-safe semantics. dispose() tears down extensions in reverse-registration order. - extension-registry.test.ts: 14 tests covering register, conflict rejection (duplicate name, duplicate nodeType, duplicate command name), all-or-nothing rollback, sema event aggregation, dispose ordering, unregister, getRender/getNormalize/getCommand walks, tryDeserializeHtml walk-until-non-null. - index.ts: barrel. Verification: - 14/14 registry tests pass - 82/82 existing engine tests still pass (no engine touched) - Total 96/96 in src/uix/soma/components/words F2.1 (audit) + F2.2 (skeleton) done. F2.3 (migrate table) and F2.4 (migrate code-block) are separate sessions per the planned rollback-safe sub-task structure — each one will move the ~990 LoC of table and ~790 LoC of code-block from the engine core into extensions/{table,code-block}/* and wire them into the registry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../extensions/extension-registry.test.ts | 198 +++++++++++++ .../words/extensions/extension-registry.ts | 253 +++++++++++++++++ .../words/extensions/extension-types.ts | 259 ++++++++++++++++++ .../soma/components/words/extensions/index.ts | 37 +++ 4 files changed, 747 insertions(+) create mode 100644 src/uix/soma/components/words/extensions/extension-registry.test.ts create mode 100644 src/uix/soma/components/words/extensions/extension-registry.ts create mode 100644 src/uix/soma/components/words/extensions/extension-types.ts create mode 100644 src/uix/soma/components/words/extensions/index.ts diff --git a/src/uix/soma/components/words/extensions/extension-registry.test.ts b/src/uix/soma/components/words/extensions/extension-registry.test.ts new file mode 100644 index 000000000..2fca62488 --- /dev/null +++ b/src/uix/soma/components/words/extensions/extension-registry.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { + createWordsExtensionRegistry, + WordsExtensionRegistryError +} from './extension-registry'; +import type { WordsExtension } from './extension-types'; + +function makeExt(overrides: Partial): WordsExtension { + return { + name: 'test-ext', + nodeTypes: ['test-node'], + ...overrides + } as WordsExtension; +} + +describe('WordsExtensionRegistry', () => { + it('starts empty', () => { + const reg = createWordsExtensionRegistry(); + expect(reg.extensions()).toEqual([]); + expect(reg.allEvents()).toEqual([]); + expect(reg.allCommandNames()).toEqual([]); + expect(reg.allNodeTypes()).toEqual([]); + }); + + it('registers an extension and exposes it via every lookup', () => { + const reg = createWordsExtensionRegistry(); + const ext = makeExt({ name: 'alpha', nodeTypes: ['alpha-node', 'alpha-child'] }); + reg.register(ext); + expect(reg.extensions()).toEqual([ext]); + expect(reg.findByNodeType('alpha-node')).toBe(ext); + expect(reg.findByNodeType('alpha-child')).toBe(ext); + expect(reg.findByNodeType('unknown')).toBeUndefined(); + expect(reg.allNodeTypes()).toEqual(['alpha-node', 'alpha-child']); + }); + + it('rejects duplicate extension name', () => { + const reg = createWordsExtensionRegistry(); + reg.register(makeExt({ name: 'alpha' })); + expect(() => reg.register(makeExt({ name: 'alpha', nodeTypes: ['other'] }))).toThrow( + WordsExtensionRegistryError + ); + }); + + it('rejects node type already owned', () => { + const reg = createWordsExtensionRegistry(); + reg.register(makeExt({ name: 'alpha', nodeTypes: ['shared'] })); + expect(() => reg.register(makeExt({ name: 'beta', nodeTypes: ['shared'] }))).toThrow( + WordsExtensionRegistryError + ); + }); + + it('rolls back registration on conflict (all-or-nothing)', () => { + const reg = createWordsExtensionRegistry(); + reg.register(makeExt({ name: 'alpha', nodeTypes: ['shared'] })); + expect(() => + reg.register(makeExt({ name: 'beta', nodeTypes: ['ok-node', 'shared'] })) + ).toThrow(WordsExtensionRegistryError); + // 'beta' must not have been partially registered; 'ok-node' should NOT + // be present in the registry after the failed attempt. + expect(reg.findByNodeType('ok-node')).toBeUndefined(); + expect(reg.extensions().map((e) => e.name)).toEqual(['alpha']); + }); + + it('routes public command names through findByCommandName', () => { + const reg = createWordsExtensionRegistry(); + const ext = makeExt({ + name: 'alpha', + nodeTypes: ['alpha-node'], + commandNames: { 'public-insert': 'internal-insert-op' } + }); + reg.register(ext); + expect(reg.findByCommandName('public-insert')).toBe(ext); + expect(reg.findByCommandName('unknown')).toBeUndefined(); + expect(reg.allCommandNames()).toEqual(['public-insert']); + }); + + it('rejects duplicate public command names across extensions', () => { + const reg = createWordsExtensionRegistry(); + reg.register( + makeExt({ name: 'alpha', nodeTypes: ['a'], commandNames: { 'shared-cmd': 'op-a' } }) + ); + expect(() => + reg.register( + makeExt({ name: 'beta', nodeTypes: ['b'], commandNames: { 'shared-cmd': 'op-b' } }) + ) + ).toThrow(WordsExtensionRegistryError); + }); + + it('aggregates sema events across registered extensions', () => { + const reg = createWordsExtensionRegistry(); + reg.register( + makeExt({ + name: 'alpha', + nodeTypes: ['a'], + events: [ + { name: 'commit-set-alpha', family: 'commit', verb: 'set', target: 'a' } + ] + }) + ); + reg.register( + makeExt({ + name: 'beta', + nodeTypes: ['b'], + events: [ + { name: 'signal-warn-beta', family: 'signal', verb: 'warn', target: 'b', intent: 'risk' } + ] + }) + ); + expect(reg.allEvents()).toHaveLength(2); + expect(reg.allEvents().map((e) => e.name)).toEqual([ + 'commit-set-alpha', + 'signal-warn-beta' + ]); + }); + + it('dispose tears down extensions in reverse order and clears state', () => { + const reg = createWordsExtensionRegistry(); + const disposed: string[] = []; + reg.register( + makeExt({ name: 'alpha', nodeTypes: ['a'], dispose: () => disposed.push('alpha') }) + ); + reg.register( + makeExt({ name: 'beta', nodeTypes: ['b'], dispose: () => disposed.push('beta') }) + ); + reg.dispose(); + expect(disposed).toEqual(['beta', 'alpha']); // reverse order + expect(reg.extensions()).toEqual([]); + expect(reg.findByNodeType('a')).toBeUndefined(); + }); + + it('unregister removes the extension and calls dispose', () => { + const reg = createWordsExtensionRegistry(); + let disposed = false; + reg.register( + makeExt({ name: 'alpha', nodeTypes: ['a'], dispose: () => (disposed = true) }) + ); + reg.unregister('alpha'); + expect(disposed).toBe(true); + expect(reg.extensions()).toEqual([]); + }); + + it('unregister of unknown extension is a no-op', () => { + const reg = createWordsExtensionRegistry(); + expect(() => reg.unregister('nope')).not.toThrow(); + }); + + it('getRender / getNormalize / getCommand return undefined when absent', () => { + const reg = createWordsExtensionRegistry(); + reg.register(makeExt({ name: 'alpha', nodeTypes: ['a'] })); + expect(reg.getRender('a')).toBeUndefined(); + expect(reg.getNormalize('a')).toBeUndefined(); + expect(reg.getCommand('any-op')).toBeUndefined(); + }); + + it('getCommand walks extensions and returns the first match', () => { + const reg = createWordsExtensionRegistry(); + const aCmd = () => null; + const bCmd = () => null; + reg.register( + makeExt({ name: 'alpha', nodeTypes: ['a'], commands: { 'op-x': aCmd } }) + ); + reg.register( + makeExt({ name: 'beta', nodeTypes: ['b'], commands: { 'op-y': bCmd } }) + ); + expect(reg.getCommand('op-x')).toBe(aCmd); + expect(reg.getCommand('op-y')).toBe(bCmd); + expect(reg.getCommand('op-z')).toBeUndefined(); + }); + + it('tryDeserializeHtml walks extensions until one returns non-null', () => { + const reg = createWordsExtensionRegistry(); + const alphaResult = { type: 'alpha-node' }; + reg.register( + makeExt({ + name: 'alpha', + nodeTypes: ['alpha-node'], + serialize: { + htmlIn: (el) => (el.tag === 'alpha' ? alphaResult : null) + } + }) + ); + reg.register( + makeExt({ + name: 'beta', + nodeTypes: ['beta-node'], + serialize: { htmlIn: () => ({ type: 'beta-node' }) } + }) + ); + const ctx = { document: { version: 1, children: [] } } as any; + // Element matched by alpha → alpha wins. + expect(reg.tryDeserializeHtml?.({ tag: 'alpha', attrs: {}, children: [] }, ctx)).toBe( + alphaResult + ); + // Element not matched by alpha → beta wins (its htmlIn never returns null). + const out = reg.tryDeserializeHtml?.({ tag: 'other', attrs: {}, children: [] }, ctx); + expect(out).toEqual({ type: 'beta-node' }); + }); +}); diff --git a/src/uix/soma/components/words/extensions/extension-registry.ts b/src/uix/soma/components/words/extensions/extension-registry.ts new file mode 100644 index 000000000..ccfd9ad3f --- /dev/null +++ b/src/uix/soma/components/words/extensions/extension-registry.ts @@ -0,0 +1,253 @@ +/** + * WordsExtensionRegistry — the runtime container that holds the set of + * extensions registered against a single Words engine instance, plus + * dispatch helpers used by the engine to route node-typed operations + * (render / normalize / serialize / path / keyboard) to the owning + * extension. + * + * Lifecycle: + * const registry = createWordsExtensionRegistry(); + * registry.register(tableExtension); + * registry.register(codeBlockExtension); + * // ... engine consumes registry across phases ... + * registry.dispose(); + * + * The registry is **deliberately immutable in production use**: + * extensions are added once at engine construction and never removed. + * The `unregister` method exists for tests + dev hot-reload scenarios + * only — production code should not depend on it. + */ + +import type { + WordsExtension, + WordsExtensionCommand, + WordsExtensionEvent, + WordsExtensionKeyboardHook, + WordsExtensionNormalizeHook, + WordsExtensionPathHooks, + WordsExtensionRenderHook, + WordsExtensionSerializeHooks +} from './extension-types'; + +export class WordsExtensionRegistryError extends Error { + constructor(message: string) { + super(`[WordsExtensionRegistry] ${message}`); + this.name = 'WordsExtensionRegistryError'; + } +} + +export interface WordsExtensionRegistry { + /** Register an extension. Throws if `name` is duplicate or if a node + * type is already owned by another registered extension. */ + readonly register: (extension: WordsExtension) => void; + + /** Remove an extension by name. Test / dev only. */ + readonly unregister: (name: string) => void; + + /** All registered extensions, in registration order. */ + readonly extensions: () => ReadonlyArray; + + /** Find the extension that owns a given `node.type`, or `undefined`. */ + readonly findByNodeType: (nodeType: string) => WordsExtension | undefined; + + /** Find the extension that exposes a given public command name. */ + readonly findByCommandName: (commandName: string) => WordsExtension | undefined; + + /** Get the render hook for a node type, or `undefined` if none. */ + readonly getRender: (nodeType: string) => WordsExtensionRenderHook | undefined; + + /** Get the normalize hook for a node type, or `undefined` if none. */ + readonly getNormalize: (nodeType: string) => WordsExtensionNormalizeHook | undefined; + + /** Get the command reducer for an operation type. The lookup walks + * all extensions; the first match wins. Returns `undefined` if no + * extension owns the operation. */ + readonly getCommand: (operationType: string) => WordsExtensionCommand | undefined; + + /** Get the serialize hooks for a node type. */ + readonly getSerialize: (nodeType: string) => WordsExtensionSerializeHooks | undefined; + + /** Get the path hooks for a node type. */ + readonly getPath: (nodeType: string) => WordsExtensionPathHooks | undefined; + + /** Get the keyboard hook for a node type. */ + readonly getKeyboard: (nodeType: string) => WordsExtensionKeyboardHook | undefined; + + /** Flat list of all sema events contributed by registered extensions. */ + readonly allEvents: () => ReadonlyArray; + + /** Flat list of all public command names exposed by extensions. */ + readonly allCommandNames: () => ReadonlyArray; + + /** Flat list of all node types owned by extensions. */ + readonly allNodeTypes: () => ReadonlyArray; + + /** Iterate extensions in HTML deserialization order. Caller-facing + * helper for serializer — first extension whose `htmlIn` returns + * non-null wins. */ + readonly tryDeserializeHtml: WordsExtensionSerializeHooks['htmlIn']; + + /** Iterate extensions in Markdown deserialization order. Same logic + * as `tryDeserializeHtml`. */ + readonly tryDeserializeMarkdown: WordsExtensionSerializeHooks['markdownIn']; + + /** Tear down all extensions (calls each one's `dispose()` if set). */ + readonly dispose: () => void; +} + +export function createWordsExtensionRegistry(): WordsExtensionRegistry { + const byName = new Map(); + const byNodeType = new Map(); + const byCommandName = new Map(); + const order: WordsExtension[] = []; + + function register(extension: WordsExtension): void { + if (byName.has(extension.name)) { + throw new WordsExtensionRegistryError( + `extension '${extension.name}' is already registered` + ); + } + for (const nodeType of extension.nodeTypes) { + const owner = byNodeType.get(nodeType); + if (owner) { + throw new WordsExtensionRegistryError( + `node type '${nodeType}' is already owned by extension ` + + `'${owner.name}' (attempted by '${extension.name}')` + ); + } + } + // All-or-nothing: only after duplicate checks pass do we mutate. + byName.set(extension.name, extension); + for (const nodeType of extension.nodeTypes) { + byNodeType.set(nodeType, extension); + } + if (extension.commandNames) { + for (const [publicName, opType] of Object.entries(extension.commandNames)) { + if (byCommandName.has(publicName)) { + throw new WordsExtensionRegistryError( + `command '${publicName}' is already registered` + ); + } + byCommandName.set(publicName, { extension, opType }); + } + } + order.push(extension); + } + + function unregister(name: string): void { + const extension = byName.get(name); + if (!extension) return; + byName.delete(name); + for (const nodeType of extension.nodeTypes) { + if (byNodeType.get(nodeType) === extension) byNodeType.delete(nodeType); + } + if (extension.commandNames) { + for (const publicName of Object.keys(extension.commandNames)) { + const entry = byCommandName.get(publicName); + if (entry?.extension === extension) byCommandName.delete(publicName); + } + } + const idx = order.indexOf(extension); + if (idx >= 0) order.splice(idx, 1); + extension.dispose?.(); + } + + function findByNodeType(nodeType: string): WordsExtension | undefined { + return byNodeType.get(nodeType); + } + + function findByCommandName(commandName: string): WordsExtension | undefined { + return byCommandName.get(commandName)?.extension; + } + + function getRender(nodeType: string): WordsExtensionRenderHook | undefined { + return byNodeType.get(nodeType)?.render?.[nodeType]; + } + + function getNormalize(nodeType: string): WordsExtensionNormalizeHook | undefined { + return byNodeType.get(nodeType)?.normalize; + } + + function getCommand(operationType: string) { + // Operations are routed by direct command-key lookup across all + // extensions. The first extension that has `commands[operationType]` + // wins. Since each extension owns its own command keys (validated + // at register time), there is no collision in practice. + for (const extension of order) { + const reducer = extension.commands?.[operationType]; + if (reducer) return reducer; + } + return undefined; + } + + function getSerialize(nodeType: string): WordsExtensionSerializeHooks | undefined { + return byNodeType.get(nodeType)?.serialize; + } + + function getPath(nodeType: string): WordsExtensionPathHooks | undefined { + return byNodeType.get(nodeType)?.path; + } + + function getKeyboard(nodeType: string): WordsExtensionKeyboardHook | undefined { + return byNodeType.get(nodeType)?.keyboard; + } + + function allEvents(): ReadonlyArray { + return order.flatMap((extension) => extension.events ?? []); + } + + function allCommandNames(): ReadonlyArray { + return [...byCommandName.keys()]; + } + + function allNodeTypes(): ReadonlyArray { + return [...byNodeType.keys()]; + } + + const tryDeserializeHtml: WordsExtensionSerializeHooks['htmlIn'] = (element, ctx) => { + for (const extension of order) { + const out = extension.serialize?.htmlIn?.(element, ctx); + if (out) return out; + } + return null; + }; + + const tryDeserializeMarkdown: WordsExtensionSerializeHooks['markdownIn'] = (block, ctx) => { + for (const extension of order) { + const out = extension.serialize?.markdownIn?.(block, ctx); + if (out) return out; + } + return null; + }; + + function dispose(): void { + // Reverse-order disposal so later registrations tear down first. + for (let i = order.length - 1; i >= 0; i--) { + order[i].dispose?.(); + } + byName.clear(); + byNodeType.clear(); + byCommandName.clear(); + order.length = 0; + } + + return { + register, + unregister, + extensions: () => order.slice(), + findByNodeType, + findByCommandName, + getRender, + getNormalize, + getCommand, + getSerialize, + getPath, + getKeyboard, + allEvents, + allCommandNames, + allNodeTypes, + tryDeserializeHtml, + tryDeserializeMarkdown, + dispose + }; +} diff --git a/src/uix/soma/components/words/extensions/extension-types.ts b/src/uix/soma/components/words/extensions/extension-types.ts new file mode 100644 index 000000000..5b0649112 --- /dev/null +++ b/src/uix/soma/components/words/extensions/extension-types.ts @@ -0,0 +1,259 @@ +/** + * WordsExtension contract (FASE 2 of the words rich-text editor). + * + * **What this is**: the formal interface that lets a node type (table / + * code-block / image / mention / future packs) plug into the engine + * through documented hooks instead of being hardcoded across 10+ files. + * + * **What this is NOT**: + * - It is NOT a render-only plugin. Extensions own document semantics, + * normalization, commands, serialization, navigation, keyboard. The + * visual layer (Eidos) does NOT participate here — it consumes the + * `data-words-node` attribute the engine emits. + * - It is NOT an async / server-aware plugin. All hooks are synchronous + * pure functions over the document model. Async needs (e.g. image + * upload to storage) sit OUTSIDE the engine, in the provider that + * dispatches commands. + * - It is NOT a runtime-replaceable plugin. Extensions are registered + * at construction time of the engine and stay for the lifetime of the + * editor. Hot-reload of extensions is out of scope. + * + * **The contract** — every hook is optional. A minimal extension that + * only contributes a node type with default rendering needs `name` + + * `nodeTypes` + `factories` + `render`. A full-featured extension + * (like `table`) uses every hook. + */ + +import type { WordsDocument, WordsInline } from '../engine/document'; + +/** + * The base shape a node introduced by an extension must satisfy. Every + * extension node has a `type: string` tag (matching one of the + * `nodeTypes` declared) plus arbitrary readonly fields. Children are + * optional and untyped at the contract level because extensions can + * compose recursively (table → row → cell → inline). + */ +export interface WordsExtensionNode { + readonly type: string; + readonly children?: ReadonlyArray; + readonly [field: string]: unknown; +} + +/** + * A path that addresses a position in the document tree. Engine path + * representation — left here as an opaque numeric tuple to avoid + * cross-importing `engine/path.ts` types from the contract module. + */ +export type WordsExtensionPath = ReadonlyArray; + +/** + * Context passed to every extension hook. Provides read access to the + * current document + dispatch hooks back into the engine. The engine + * fills this when invoking the extension; extensions never construct it. + */ +export interface WordsExtensionContext { + /** Read-only snapshot of the document at hook invocation. */ + readonly document: WordsDocument; + /** Logger inherited from ActiveApp — extensions log via this, not console. */ + readonly logger?: { + readonly debug?: (msg: string, data?: unknown) => void; + readonly warn?: (msg: string, data?: unknown) => void; + readonly error?: (msg: string, data?: unknown) => void; + }; +} + +// ── Normalization ───────────────────────────────────────────────────── + +/** + * Result of normalizing a node: + * - `null` → delete the node (return removes it from parent). + * - single node → replace. + * - array → replace with multiple nodes (split / expand). + */ +export type WordsExtensionNormalizeResult = + | WordsExtensionNode + | ReadonlyArray + | null; + +export type WordsExtensionNormalizeHook = ( + node: WordsExtensionNode, + ctx: WordsExtensionContext +) => WordsExtensionNormalizeResult; + +// ── Commands ────────────────────────────────────────────────────────── + +/** + * Result of a command reducer. Returns the new document (immutable + * snapshot) plus optional selection-after / emit flags. `null` means + * "no-op — the command did not apply". + */ +export interface WordsExtensionCommandResult { + readonly document: WordsDocument; + readonly selection?: unknown; // engine selection type; opaque here + readonly emit?: ReadonlyArray; // sema event names to fire after commit +} + +/** + * Command reducer. Receives the operation payload (extension-specific + * shape) and the context, returns the new document state or null + * (no-op). + */ +export type WordsExtensionCommand = ( + operation: { readonly type: string; readonly [field: string]: unknown }, + ctx: WordsExtensionContext +) => WordsExtensionCommandResult | null; + +// ── Render ──────────────────────────────────────────────────────────── + +/** + * Render output: an opaque object the engine's render pass converts to + * actual DOM nodes. The contract specifies tag + attrs + children at the + * shape level; extensions can extend with extra fields (e.g. data-*). + */ +export interface WordsExtensionRenderResult { + readonly tag: string; + readonly attrs?: Readonly>; + readonly children?: ReadonlyArray; +} + +export type WordsExtensionRenderHook = ( + node: WordsExtensionNode, + ctx: WordsExtensionContext +) => WordsExtensionRenderResult; + +// ── Serialization ───────────────────────────────────────────────────── + +/** + * HTML element node shape the engine's parser produces. Kept opaque + * here to avoid importing the parser from the contract module. + */ +export interface WordsExtensionHtmlNode { + readonly tag: string; + readonly attrs: Readonly>; + readonly children: ReadonlyArray; +} + +export interface WordsExtensionSerializeHooks { + /** Convert one of this extension's nodes to an HTML string fragment. */ + readonly htmlOut?: ( + node: WordsExtensionNode, + ctx: WordsExtensionContext + ) => string | null; + /** Try to deserialize an HTML element into one of this extension's + * nodes. Returns null if this extension does not own the tag. */ + readonly htmlIn?: ( + element: WordsExtensionHtmlNode, + ctx: WordsExtensionContext + ) => WordsExtensionNode | null; + /** Convert one of this extension's nodes to a Markdown string fragment. */ + readonly markdownOut?: ( + node: WordsExtensionNode, + ctx: WordsExtensionContext + ) => string | null; + /** Try to parse a Markdown block into one of this extension's nodes. + * Returns null if this extension does not own the block. */ + readonly markdownIn?: ( + block: string, + ctx: WordsExtensionContext + ) => WordsExtensionNode | null; +} + +// ── Path / navigation ───────────────────────────────────────────────── + +export type WordsExtensionPathDirection = 'next' | 'prev' | 'first' | 'last'; + +export interface WordsExtensionPathHooks { + /** Resolve a path to a node inside this extension's tree. Returns + * null if the path falls outside this extension's domain. */ + readonly resolve?: ( + path: WordsExtensionPath, + doc: WordsDocument + ) => WordsExtensionNode | null; + /** Compute the next selectable path inside this extension (e.g. Tab + * between table cells). Returns null if no further selection. */ + readonly nextSelectablePath?: ( + currentPath: WordsExtensionPath, + doc: WordsDocument, + direction: WordsExtensionPathDirection + ) => WordsExtensionPath | null; +} + +// ── Keyboard ───────────────────────────────────────────────────────── + +/** + * Keyboard hook. Invoked by the provider when selection is inside one of + * this extension's nodes (the engine resolves "inside" via the + * extension's `nodeTypes`). Return `true` to claim the event and stop + * propagation; `false` to let the engine handle it. + */ +export type WordsExtensionKeyboardHook = ( + event: KeyboardEvent, + ctx: WordsExtensionContext +) => boolean; + +// ── Sema events ─────────────────────────────────────────────────────── + +/** + * Sema event contributed by the extension. These get merged into the + * morfo event list at registration time so the engine + sema cascade + * see them as first-class events. Shape mirrors `MorfoEvent.semantic`. + */ +export interface WordsExtensionEvent { + readonly name: string; + readonly family: 'commit' | 'signal' | 'contact' | 'handle' | 'emerge' | 'shift' | 'sustain' | 'delegate'; + readonly verb: string; + readonly target: string; // part kebab name + readonly intent?: 'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'; + readonly sequence?: 'pre' | 'coincident' | 'post'; +} + +// ── The interface itself ────────────────────────────────────────────── + +export interface WordsExtension { + /** Unique extension identifier (kebab-case). Used as registry key + * and as namespace for telemetry. */ + readonly name: string; + + /** Optional version for tooling/debug. Not enforced by the engine. */ + readonly version?: string; + + /** Node `type` strings this extension contributes. Used to route + * hooks (render / normalize / serialize) to the right extension. */ + readonly nodeTypes: ReadonlyArray; + + /** Typed factories for the node types — `extension.factories.cell()` + * returns a fresh empty cell node. Optional but recommended for + * consumers that want to construct nodes outside commands. */ + readonly factories?: Readonly) => WordsExtensionNode>>; + + /** Per-node normalization. Engine calls this for each node whose + * `type` is in `nodeTypes`. */ + readonly normalize?: WordsExtensionNormalizeHook; + + /** Reducer commands. Key = command operation type, value = reducer. */ + readonly commands?: Readonly>; + + /** Public command name strings (`'insert-table'`, `'insert-table-row'`, + * etc.). Used by the toolbar and command dispatcher to know what + * this extension exposes externally. Maps the public name to the + * internal command operation type — same string if no rename needed. */ + readonly commandNames?: Readonly>; + + /** DOM render hooks per node type. */ + readonly render?: Readonly>; + + /** Serialization hooks per format (HTML / Markdown / in / out). */ + readonly serialize?: WordsExtensionSerializeHooks; + + /** Path / navigation hooks for extension-internal selection. */ + readonly path?: WordsExtensionPathHooks; + + /** Keyboard hook (called when selection lives inside this extension). */ + readonly keyboard?: WordsExtensionKeyboardHook; + + /** Sema events the extension contributes to the morfo. */ + readonly events?: ReadonlyArray; + + /** Cleanup. Called when the registry tears down (engine dispose). */ + readonly dispose?: () => void; +} diff --git a/src/uix/soma/components/words/extensions/index.ts b/src/uix/soma/components/words/extensions/index.ts new file mode 100644 index 000000000..311573452 --- /dev/null +++ b/src/uix/soma/components/words/extensions/index.ts @@ -0,0 +1,37 @@ +/** + * Words engine extensions — formal contract for plugging node types + * (table / code-block / future images / mentions / etc.) into the + * engine without hardcoding them across 10+ files. + * + * Phase 1 (this skeleton): interface + registry exported. Engine is + * NOT yet wired to consult the registry — that happens in F2.3 + * (migrate table) and F2.4 (migrate code-block). + * + * See `src/uix/words/EXTENSIONS.md` for the migration plan + how to + * author an extension. + */ + +export type { + WordsExtension, + WordsExtensionCommand, + WordsExtensionCommandResult, + WordsExtensionContext, + WordsExtensionEvent, + WordsExtensionHtmlNode, + WordsExtensionKeyboardHook, + WordsExtensionNode, + WordsExtensionNormalizeHook, + WordsExtensionNormalizeResult, + WordsExtensionPath, + WordsExtensionPathDirection, + WordsExtensionPathHooks, + WordsExtensionRenderHook, + WordsExtensionRenderResult, + WordsExtensionSerializeHooks +} from './extension-types'; + +export type { WordsExtensionRegistry } from './extension-registry'; +export { + createWordsExtensionRegistry, + WordsExtensionRegistryError +} from './extension-registry';