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