feat(words): extension system skeleton (F2.1 + F2.2)

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 85b8a56ab0
commit 1805b0817d

@ -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>): 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' });
});
});

@ -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<WordsExtension>;
/** 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<WordsExtensionEvent>;
/** Flat list of all public command names exposed by extensions. */
readonly allCommandNames: () => ReadonlyArray<string>;
/** Flat list of all node types owned by extensions. */
readonly allNodeTypes: () => ReadonlyArray<string>;
/** 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<string, WordsExtension>();
const byNodeType = new Map<string, WordsExtension>();
const byCommandName = new Map<string, { extension: WordsExtension; opType: string }>();
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<WordsExtensionEvent> {
return order.flatMap((extension) => extension.events ?? []);
}
function allCommandNames(): ReadonlyArray<string> {
return [...byCommandName.keys()];
}
function allNodeTypes(): ReadonlyArray<string> {
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
};
}

@ -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<WordsExtensionNode | WordsInline>;
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<number>;
/**
* 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<WordsExtensionNode>
| 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<string>; // 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<Record<string, string | number | boolean | undefined>>;
readonly children?: ReadonlyArray<WordsExtensionRenderResult | string>;
}
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<Record<string, string>>;
readonly children: ReadonlyArray<WordsExtensionHtmlNode | string>;
}
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<string>;
/** 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<Record<string, (...args: ReadonlyArray<unknown>) => 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<Record<string, WordsExtensionCommand>>;
/** 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<Record<string, string>>;
/** DOM render hooks per node type. */
readonly render?: Readonly<Record<string, WordsExtensionRenderHook>>;
/** 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<WordsExtensionEvent>;
/** Cleanup. Called when the registry tears down (engine dispose). */
readonly dispose?: () => void;
}

@ -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';
Loading…
Cancel
Save

Powered by TurnKey Linux.