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
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…
Reference in new issue