R2 closes the engine refactor non-breaking phase. The V2 model from
R1 is now reachable from any V1 document via a pure migrator, and
the engine's local SemaIntent copy stays in lockstep with the
canonical $uix/intent.INTENTS via a runtime assertion.
engine/migrate-v2.ts (~370 lines):
- Pure function migrateV1ToV2(input, opts?) → { document, runtimeState,
warnings }.
- Per-block migration paths for paragraph / heading / quote / code /
list / table / image / divider / callout. Each rejects invalid
values defensively (e.g. invalid heading level coerced to 1 with
warning).
- P4 enforcement on image: drops `image.status` from the content,
moves it to `runtimeState.imageStatus: Map<blockId, status>` keyed
by an autogen id. Caller (provider) restores into in-memory
sidecar on load.
- P1 sweep on table: drops `striped` / `compact` (presentation
tokens) with warnings. Drops `cell.tone` (design-system token)
with warning. Renames list.children → list.items silently.
- Marks reshape: V1 template-literal `'color:#hex'` /
`'bgcolor:#hex'` → V2 structured `{type:'color'|'background',
value:'#hex'}`. Already-structured marks pass through unchanged.
- Code blocks narrow to text-only: flattens any link inside code
into its text content with warning.
- Idempotent on V2 input: a V2 document round-trips with zero
warnings and exact-equal output (validates with
validateWordsDocument).
- Default id generator uses crypto.randomUUID() when available, with
Math.random + monotonic counter fallback. Tests inject a
deterministic sequential generator.
engine/sema-parity.ts (~60 lines):
- assertSemaIntentParity(canonicalIntents) throws SemaIntentParityError
if the engine's local WORDS_EVAL_INTENTS drifts from the canonical
list. Error names both the missing and extra values + points to
the file to update.
- Caller passes canonical list (avoids cross-package import in this
module). Provider mount calls it with $uix/intent.INTENTS.
engine/migrate-v2.test.ts (35 tests):
- Entry-point shape handling (null / non-object / non-array children).
- Per-block migration (paragraph, heading with level clamp, quote
with cite, code with text-only narrowing + link flattening, list
with kind coercion and indent-0 omission, table with token drops,
image with src validation).
- P4 enforcement: image.status → runtimeState.imageStatus keyed by
autogen id; respects caller-provided id.
- Marks reshape: template-literal → structured; structured
pass-through; invalid hex rejected; unknown boolean dropped.
- Idempotence on V2 input (paragraph, callout, divider).
- Callout intent: all 6 canonical SemaIntent values accepted;
invalid (e.g. 'warning') coerced to 'neutral' with warning.
- Post-condition: migrator output ALWAYS passes
validateWordsDocument on any V1 input.
- Sema parity: 5 tests including the real golden cross-check
against $uix/intent.INTENTS.
Verification: 170/170 engine tests pass (85 V1 + 50 V2 validator
+ 35 V2 migrator/parity). `npm run check`: 0 errors.
Next: R3 — visual sidecar implementation. Render emits inline
styles from block.visual.*; POLISH-1b (image radius/shadow/border
controls) becomes implementable under this model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
parent
d18b32dd93
commit
6c70699ff7
@ -0,0 +1,643 @@
|
|||||||
|
/**
|
||||||
|
* V1 → V2 migrator suite + sema parity check.
|
||||||
|
*
|
||||||
|
* Verifies every drop, every reshape, every silent pass-through.
|
||||||
|
* Calls validateWordsDocument on the migrator output to ensure the
|
||||||
|
* result actually conforms to V2 schema.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { migrateV1ToV2 } from './migrate-v2';
|
||||||
|
import { validateWordsDocument } from './validate-v2';
|
||||||
|
import { WORDS_DOCUMENT_VERSION_V2, WORDS_EVAL_INTENTS } from './types-v2';
|
||||||
|
import { assertSemaIntentParity, SemaIntentParityError } from './sema-parity';
|
||||||
|
|
||||||
|
// ── Sequential id generator for deterministic tests ──────────────────────
|
||||||
|
|
||||||
|
function makeSeqIdGen(): () => string {
|
||||||
|
let n = 0;
|
||||||
|
return () => `id-${++n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Migrator entry point ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('migrateV1ToV2 — entry point', () => {
|
||||||
|
it('produces a valid V2 document from an empty V1 doc', () => {
|
||||||
|
const result = migrateV1ToV2({ version: 1, children: [] });
|
||||||
|
expect(result.document.version).toBe(WORDS_DOCUMENT_VERSION_V2);
|
||||||
|
expect(result.document.children).toHaveLength(0);
|
||||||
|
expect(validateWordsDocument(result.document).valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-object input gracefully', () => {
|
||||||
|
const result = migrateV1ToV2(null);
|
||||||
|
expect(result.document.version).toBe(WORDS_DOCUMENT_VERSION_V2);
|
||||||
|
expect(result.document.children).toHaveLength(0);
|
||||||
|
expect(result.warnings.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles children not being an array', () => {
|
||||||
|
const result = migrateV1ToV2({ version: 1, children: 'nope' });
|
||||||
|
expect(result.document.children).toHaveLength(0);
|
||||||
|
expect(result.warnings.some((w) => w.includes('children'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Per-block migration ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('migration: paragraph / heading / quote', () => {
|
||||||
|
it('migrates a paragraph with text children', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ type: 'text', text: 'hello' }],
|
||||||
|
textAlign: 'center'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.document.children[0]).toEqual({
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ type: 'text', text: 'hello' }],
|
||||||
|
textAlign: 'center'
|
||||||
|
});
|
||||||
|
expect(validateWordsDocument(result.document).valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates a heading and clamps invalid level to 1', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{ type: 'heading', level: 9, children: [{ type: 'text', text: 'x' }] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'heading') throw new Error('expected heading');
|
||||||
|
expect(block.level).toBe(1);
|
||||||
|
expect(result.warnings.some((w) => w.includes('invalid heading level'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates a quote with cite passthrough', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'quote',
|
||||||
|
children: [{ type: 'text', text: 'q' }],
|
||||||
|
cite: 'https://example.com'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'quote') throw new Error('expected quote');
|
||||||
|
expect(block.cite).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops invalid textAlign value with warning', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ type: 'text', text: 'x' }],
|
||||||
|
textAlign: 'middle'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'paragraph') throw new Error('expected paragraph');
|
||||||
|
expect((block as { textAlign?: string }).textAlign).toBeUndefined();
|
||||||
|
expect(result.warnings.some((w) => w.includes('textAlign'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Code blocks: V2 narrows to text-only ─────────────────────────────────
|
||||||
|
|
||||||
|
describe('migration: code block (text-only narrowing)', () => {
|
||||||
|
it('passes through plain text children', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'code',
|
||||||
|
language: 'python',
|
||||||
|
children: [{ type: 'text', text: 'print(1)' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'code') throw new Error('expected code');
|
||||||
|
expect(block.children).toEqual([{ type: 'text', text: 'print(1)' }]);
|
||||||
|
expect(block.language).toBe('python');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens link inside code into its plain text + emits warning', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'code',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
href: 'https://x',
|
||||||
|
children: [{ type: 'text', text: 'see link' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'code') throw new Error('expected code');
|
||||||
|
expect(block.children).toEqual([{ type: 'text', text: 'see link' }]);
|
||||||
|
expect(result.warnings.some((w) => w.includes('link flattened'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── List: rename `children` → `items` + kind coercion ────────────────────
|
||||||
|
|
||||||
|
describe('migration: list', () => {
|
||||||
|
it('renames V1 list.children to V2 list.items silently', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
kind: 'ordered',
|
||||||
|
children: [{ children: [{ type: 'text', text: 'a' }] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'list') throw new Error('expected list');
|
||||||
|
expect(block.items).toHaveLength(1);
|
||||||
|
expect(block.kind).toBe('ordered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces invalid kind to "unordered" with warning', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
kind: 'bullets',
|
||||||
|
children: [{ children: [{ type: 'text', text: 'a' }] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'list') throw new Error('expected list');
|
||||||
|
expect(block.kind).toBe('unordered');
|
||||||
|
expect(result.warnings.some((w) => w.includes('list kind'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves checked + indent on list items', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
kind: 'check',
|
||||||
|
children: [
|
||||||
|
{ checked: true, indent: 2, children: [{ type: 'text', text: 'done' }] },
|
||||||
|
{ checked: false, indent: 0, children: [{ type: 'text', text: 'todo' }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'list') throw new Error('expected list');
|
||||||
|
expect(block.items[0]).toEqual({
|
||||||
|
checked: true,
|
||||||
|
indent: 2,
|
||||||
|
children: [{ type: 'text', text: 'done' }]
|
||||||
|
});
|
||||||
|
expect(block.items[1]).toEqual({
|
||||||
|
checked: false,
|
||||||
|
children: [{ type: 'text', text: 'todo' }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops invalid indent values', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
kind: 'unordered',
|
||||||
|
children: [{ indent: 99, children: [{ type: 'text', text: 'x' }] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'list') throw new Error('expected list');
|
||||||
|
const item = block.items[0];
|
||||||
|
expect((item as { indent?: number }).indent).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Table: drop striped/compact/tone; rename children→rows ───────────────
|
||||||
|
|
||||||
|
describe('migration: table (P1 sweep)', () => {
|
||||||
|
it('drops striped + compact tokens with warnings', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
striped: true,
|
||||||
|
compact: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'table-row',
|
||||||
|
children: [
|
||||||
|
{ type: 'table-cell', children: [{ type: 'text', text: 'a' }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'table') throw new Error('expected table');
|
||||||
|
expect((block as { striped?: boolean }).striped).toBeUndefined();
|
||||||
|
expect((block as { compact?: boolean }).compact).toBeUndefined();
|
||||||
|
expect(result.warnings.some((w) => w.includes('striped'))).toBe(true);
|
||||||
|
expect(result.warnings.some((w) => w.includes('compact'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops cell.tone with warning, keeps semantic alignment', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'table-row',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'table-cell',
|
||||||
|
tone: 'accent',
|
||||||
|
textAlign: 'right',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
children: [{ type: 'text', text: 'a' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'table') throw new Error('expected table');
|
||||||
|
const cell = block.rows[0].cells[0];
|
||||||
|
expect((cell as { tone?: string }).tone).toBeUndefined();
|
||||||
|
expect(cell.align).toBe('right');
|
||||||
|
expect(cell.verticalAlign).toBe('top');
|
||||||
|
expect(result.warnings.some((w) => w.includes('tone'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Image: status moves to runtime sidecar (P4) ──────────────────────────
|
||||||
|
|
||||||
|
describe('migration: image (P4 — status to runtime)', () => {
|
||||||
|
it('preserves image core props', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
src: 'https://example.com/cat.png',
|
||||||
|
alt: 'cat',
|
||||||
|
caption: 'fluffy',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
align: 'right'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'image') throw new Error('expected image');
|
||||||
|
expect(block.src).toBe('https://example.com/cat.png');
|
||||||
|
expect(block.alt).toBe('cat');
|
||||||
|
expect(block.caption).toBe('fluffy');
|
||||||
|
expect(block.width).toBe(800);
|
||||||
|
expect(block.height).toBe(600);
|
||||||
|
expect(block.align).toBe('right');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves status to runtime.imageStatus keyed by autogen id', () => {
|
||||||
|
const result = migrateV1ToV2(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{ type: 'image', src: 'blob:1', status: 'pending' },
|
||||||
|
{ type: 'image', src: 'blob:2', status: 'error' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ idGenerator: makeSeqIdGen() }
|
||||||
|
);
|
||||||
|
expect(result.runtimeState.imageStatus.size).toBe(2);
|
||||||
|
expect(result.runtimeState.imageStatus.get('id-1')).toBe('pending');
|
||||||
|
expect(result.runtimeState.imageStatus.get('id-2')).toBe('error');
|
||||||
|
// Each image carries the same id used in the map
|
||||||
|
expect((result.document.children[0] as { id?: string }).id).toBe('id-1');
|
||||||
|
expect((result.document.children[1] as { id?: string }).id).toBe('id-2');
|
||||||
|
// The status field is GONE from the documents
|
||||||
|
expect((result.document.children[0] as { status?: unknown }).status).toBeUndefined();
|
||||||
|
expect((result.document.children[1] as { status?: unknown }).status).toBeUndefined();
|
||||||
|
// Warnings explain what happened
|
||||||
|
expect(result.warnings.filter((w) => w.includes('status')).length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves caller-provided id and uses it for status binding', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [{ type: 'image', id: 'custom-img-1', src: 'blob:x', status: 'pending' }]
|
||||||
|
});
|
||||||
|
expect((result.document.children[0] as { id?: string }).id).toBe('custom-img-1');
|
||||||
|
expect(result.runtimeState.imageStatus.get('custom-img-1')).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops image without src', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [{ type: 'image' }]
|
||||||
|
});
|
||||||
|
expect(result.document.children).toHaveLength(0);
|
||||||
|
expect(result.warnings.some((w) => w.includes('src'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Marks: V1 template-literal → V2 structured ───────────────────────────
|
||||||
|
|
||||||
|
describe('migration: marks (template-literal → structured)', () => {
|
||||||
|
it('reshapes color and bgcolor marks to structured form', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'x',
|
||||||
|
marks: ['bold', 'color:#ff0000', 'bgcolor:#fafafa', 'italic']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'paragraph') throw new Error('expected paragraph');
|
||||||
|
const text = block.children[0];
|
||||||
|
if (text.type !== 'text') throw new Error('expected text');
|
||||||
|
expect(text.marks).toEqual([
|
||||||
|
'bold',
|
||||||
|
{ type: 'color', value: '#ff0000' },
|
||||||
|
{ type: 'background', value: '#fafafa' },
|
||||||
|
'italic'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through already-structured V2 marks idempotent', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'x',
|
||||||
|
marks: [{ type: 'color', value: '#abc' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'paragraph') throw new Error('expected paragraph');
|
||||||
|
const text = block.children[0];
|
||||||
|
if (text.type !== 'text') throw new Error('expected text');
|
||||||
|
expect(text.marks).toEqual([{ type: 'color', value: '#abc' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops invalid structured mark', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [
|
||||||
|
{ type: 'text', text: 'x', marks: [{ type: 'color', value: 'accent' }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'paragraph') throw new Error('expected paragraph');
|
||||||
|
const text = block.children[0];
|
||||||
|
if (text.type !== 'text') throw new Error('expected text');
|
||||||
|
expect(text.marks ?? []).toHaveLength(0);
|
||||||
|
expect(result.warnings.some((w) => w.includes('hex'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops unknown boolean mark with warning', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ type: 'text', text: 'x', marks: ['blink'] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'paragraph') throw new Error('expected paragraph');
|
||||||
|
const text = block.children[0];
|
||||||
|
if (text.type !== 'text') throw new Error('expected text');
|
||||||
|
expect(text.marks ?? []).toHaveLength(0);
|
||||||
|
expect(result.warnings.some((w) => w.includes('blink'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Idempotence: V2 input passes through unchanged ───────────────────────
|
||||||
|
|
||||||
|
describe('migration: idempotence', () => {
|
||||||
|
it('a V2-shaped paragraph passes through unchanged', () => {
|
||||||
|
const v2input = {
|
||||||
|
version: WORDS_DOCUMENT_VERSION_V2,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
id: 'p-1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'hello',
|
||||||
|
marks: [{ type: 'color', value: '#ff0000' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = migrateV1ToV2(v2input);
|
||||||
|
expect(result.warnings).toHaveLength(0);
|
||||||
|
expect(validateWordsDocument(result.document).valid).toBe(true);
|
||||||
|
expect(result.document.children[0]).toEqual({
|
||||||
|
type: 'paragraph',
|
||||||
|
id: 'p-1',
|
||||||
|
children: [
|
||||||
|
{ type: 'text', text: 'hello', marks: [{ type: 'color', value: '#ff0000' }] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a V2 callout with SemaIntent passes through unchanged', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: WORDS_DOCUMENT_VERSION_V2,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'callout',
|
||||||
|
intent: 'risk',
|
||||||
|
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'be careful' }] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.warnings).toHaveLength(0);
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'callout') throw new Error('expected callout');
|
||||||
|
expect(block.intent).toBe('risk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a V2 divider passes through unchanged', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: WORDS_DOCUMENT_VERSION_V2,
|
||||||
|
children: [{ type: 'divider' }]
|
||||||
|
});
|
||||||
|
expect(result.document.children[0]).toEqual({ type: 'divider' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Callout intent coercion ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('migration: callout', () => {
|
||||||
|
it('preserves valid SemaIntent', () => {
|
||||||
|
for (const intent of WORDS_EVAL_INTENTS) {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: WORDS_DOCUMENT_VERSION_V2,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'callout',
|
||||||
|
intent,
|
||||||
|
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'x' }] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'callout') throw new Error('expected callout');
|
||||||
|
expect(block.intent).toBe(intent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces invalid intent to "neutral" with warning', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: WORDS_DOCUMENT_VERSION_V2,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'callout',
|
||||||
|
intent: 'warning',
|
||||||
|
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'x' }] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const block = result.document.children[0];
|
||||||
|
if (block.type !== 'callout') throw new Error('expected callout');
|
||||||
|
expect(block.intent).toBe('neutral');
|
||||||
|
expect(result.warnings.some((w) => w.includes('SemaIntent'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Output is always a valid V2 document ─────────────────────────────────
|
||||||
|
|
||||||
|
describe('migration: post-condition (validator passes)', () => {
|
||||||
|
it('output of any V1 input passes validateWordsDocument', () => {
|
||||||
|
const result = migrateV1ToV2({
|
||||||
|
version: 1,
|
||||||
|
children: [
|
||||||
|
{ type: 'paragraph', children: [{ type: 'text', text: 'a', marks: ['bold', 'color:#ff0000'] }] },
|
||||||
|
{ type: 'heading', level: 99, children: [{ type: 'text', text: 'h' }] },
|
||||||
|
{ type: 'list', kind: 'invalid', children: [{ children: [{ type: 'text', text: 'i' }] }] },
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
striped: true,
|
||||||
|
compact: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'table-row',
|
||||||
|
children: [
|
||||||
|
{ type: 'table-cell', tone: 'accent', children: [{ type: 'text', text: 'a' }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'image', src: 'x', status: 'pending' },
|
||||||
|
{ type: 'unknown-block-type' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const validation = validateWordsDocument(result.document);
|
||||||
|
if (!validation.valid) console.error(validation.errors);
|
||||||
|
expect(validation.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sema parity check ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('assertSemaIntentParity', () => {
|
||||||
|
it('passes when local + canonical sets match', () => {
|
||||||
|
expect(() => assertSemaIntentParity(['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']))
|
||||||
|
.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes regardless of order', () => {
|
||||||
|
expect(() => assertSemaIntentParity(['loss', 'risk', 'neutral', 'fulfill', 'threat', 'affirm']))
|
||||||
|
.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when canonical adds an intent the engine doesn\'t know about', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertSemaIntentParity(['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss', 'awe'])
|
||||||
|
).toThrow(SemaIntentParityError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when engine has an intent canonical removed', () => {
|
||||||
|
expect(() => assertSemaIntentParity(['neutral', 'affirm', 'fulfill', 'risk', 'threat']))
|
||||||
|
.toThrow(SemaIntentParityError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error message lists what changed', () => {
|
||||||
|
try {
|
||||||
|
assertSemaIntentParity(['neutral', 'affirm', 'fulfill', 'risk', 'threat']);
|
||||||
|
throw new Error('should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof SemaIntentParityError)) throw e;
|
||||||
|
expect(e.extraInEngine).toContain('loss');
|
||||||
|
expect(e.message).toContain('extra in engine');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sema parity assertion against the REAL canonical set ─────────────────
|
||||||
|
|
||||||
|
describe('assertSemaIntentParity — against canonical UIX Intent vocabulary', () => {
|
||||||
|
it('the engine local set matches src/uix/intent.ts INTENTS (golden)', async () => {
|
||||||
|
// The canonical evaluative vocabulary lives in `src/uix/intent.ts`
|
||||||
|
// (shared across morfo/soma/sema/eidos). This test fails loud if
|
||||||
|
// it ever drifts from the engine's local `WORDS_EVAL_INTENTS`.
|
||||||
|
const { INTENTS } = await import('$uix/intent');
|
||||||
|
expect(() => assertSemaIntentParity(INTENTS)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,539 @@
|
|||||||
|
/**
|
||||||
|
* V1 → V2 document migrator.
|
||||||
|
*
|
||||||
|
* Reads a legacy V1 WordsDocument (or anything that loosely resembles
|
||||||
|
* one) and produces:
|
||||||
|
*
|
||||||
|
* 1. A clean V2 document (passes `validateWordsDocument`).
|
||||||
|
* 2. A `runtimeState` blob carrying anything that LEFT the document
|
||||||
|
* because it was UI state (image upload status — see P4).
|
||||||
|
* 3. A `warnings` array describing every dropped property, structural
|
||||||
|
* reshape, or value that couldn't be salvaged.
|
||||||
|
*
|
||||||
|
* Idempotent on V2 input: a V2 document passes through unchanged
|
||||||
|
* (no autogenerated ids overwritten, no marks reshaped).
|
||||||
|
*
|
||||||
|
* Pure function — no DOM access, no I/O, no global state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
WORDS_BOOLEAN_MARKS_V2,
|
||||||
|
WORDS_DOCUMENT_VERSION_V2,
|
||||||
|
WORDS_HEADING_LEVELS_V2,
|
||||||
|
WORDS_LIST_KINDS_V2,
|
||||||
|
WORDS_TEXT_ALIGNS_V2,
|
||||||
|
type WordsBlockV2,
|
||||||
|
type WordsBooleanMarkV2,
|
||||||
|
type WordsDocumentV2,
|
||||||
|
type WordsInlineV2,
|
||||||
|
type WordsListItemV2,
|
||||||
|
type WordsMarkV2,
|
||||||
|
type WordsTextV2,
|
||||||
|
type WordsTableCellV2,
|
||||||
|
type WordsTableRowV2
|
||||||
|
} from './types-v2';
|
||||||
|
|
||||||
|
// ── Result + options ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MigrateOptions {
|
||||||
|
/**
|
||||||
|
* Override the id generator. Defaults to `defaultIdGenerator`
|
||||||
|
* (crypto.randomUUID when available, falls back to a Math.random
|
||||||
|
* +counter combo). Inject a deterministic generator in tests.
|
||||||
|
*/
|
||||||
|
readonly idGenerator?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationResult {
|
||||||
|
readonly document: WordsDocumentV2;
|
||||||
|
/**
|
||||||
|
* Carried-over UI runtime state (P4). Keyed by autogenerated id
|
||||||
|
* of the block where it lived in V1. Consumer (provider) is
|
||||||
|
* responsible for restoring it into its in-memory sidecar.
|
||||||
|
*/
|
||||||
|
readonly runtimeState: {
|
||||||
|
readonly imageStatus: ReadonlyMap<string, 'pending' | 'error'>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Human-readable warnings about every dropped / reshaped property.
|
||||||
|
* Empty array on a pristine V2 input.
|
||||||
|
*/
|
||||||
|
readonly warnings: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry point ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function migrateV1ToV2(input: unknown, opts: MigrateOptions = {}): MigrationResult {
|
||||||
|
const idGen = opts.idGenerator ?? defaultIdGenerator;
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const imageStatus = new Map<string, 'pending' | 'error'>();
|
||||||
|
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
warnings.push('input is not an object — produced an empty V2 document');
|
||||||
|
return {
|
||||||
|
document: { version: WORDS_DOCUMENT_VERSION_V2, children: [] },
|
||||||
|
runtimeState: { imageStatus },
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawChildren = Array.isArray(input.children) ? input.children : [];
|
||||||
|
if (!Array.isArray(input.children)) {
|
||||||
|
warnings.push('input.children was not an array — defaulted to empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx: Ctx = { idGen, warnings, imageStatus };
|
||||||
|
const children = rawChildren.map((b, i) => migrateBlock(b, `/children/${i}`, ctx)).filter(isBlock);
|
||||||
|
|
||||||
|
return {
|
||||||
|
document: {
|
||||||
|
version: WORDS_DOCUMENT_VERSION_V2,
|
||||||
|
children
|
||||||
|
},
|
||||||
|
runtimeState: { imageStatus },
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal context ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Ctx {
|
||||||
|
readonly idGen: () => string;
|
||||||
|
readonly warnings: string[];
|
||||||
|
readonly imageStatus: Map<string, 'pending' | 'error'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlock(b: unknown): b is WordsBlockV2 {
|
||||||
|
return b !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Block migration ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function migrateBlock(raw: unknown, path: string, ctx: Ctx): WordsBlockV2 | null {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
ctx.warnings.push(`${path}: not an object — block dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = takeId(raw, ctx);
|
||||||
|
|
||||||
|
switch (raw.type) {
|
||||||
|
case 'paragraph':
|
||||||
|
return {
|
||||||
|
type: 'paragraph',
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
children: migrateInlines(raw.children, `${path}/children`, ctx),
|
||||||
|
...takeTextAlign(raw, `${path}/textAlign`, ctx)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'heading': {
|
||||||
|
const level = WORDS_HEADING_LEVELS_V2.includes(raw.level as 1 | 2 | 3)
|
||||||
|
? (raw.level as 1 | 2 | 3)
|
||||||
|
: (ctx.warnings.push(
|
||||||
|
`${path}/level: invalid heading level ${JSON.stringify(raw.level)} — coerced to 1`
|
||||||
|
),
|
||||||
|
1);
|
||||||
|
return {
|
||||||
|
type: 'heading',
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
level,
|
||||||
|
children: migrateInlines(raw.children, `${path}/children`, ctx),
|
||||||
|
...takeTextAlign(raw, `${path}/textAlign`, ctx)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'quote':
|
||||||
|
return {
|
||||||
|
type: 'quote',
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
children: migrateInlines(raw.children, `${path}/children`, ctx),
|
||||||
|
...takeTextAlign(raw, `${path}/textAlign`, ctx),
|
||||||
|
...(typeof raw.cite === 'string' ? { cite: raw.cite } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'code': {
|
||||||
|
const language = typeof raw.language === 'string' ? raw.language : undefined;
|
||||||
|
// Code in V1 accepted any inlines; V2 narrows to text-only.
|
||||||
|
// Drop link/mark structure but preserve plain text.
|
||||||
|
const children = migrateCodeChildren(raw.children, `${path}/children`, ctx);
|
||||||
|
return {
|
||||||
|
type: 'code',
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
children,
|
||||||
|
...(language ? { language } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list': {
|
||||||
|
const kind = WORDS_LIST_KINDS_V2.includes(raw.kind as 'ordered')
|
||||||
|
? (raw.kind as 'ordered' | 'unordered' | 'check')
|
||||||
|
: (ctx.warnings.push(
|
||||||
|
`${path}/kind: invalid list kind ${JSON.stringify(raw.kind)} — coerced to 'unordered'`
|
||||||
|
),
|
||||||
|
'unordered');
|
||||||
|
// V1 used `children`; V2 renames to `items`.
|
||||||
|
const rawItems = Array.isArray(raw.children) ? raw.children : Array.isArray(raw.items) ? raw.items : [];
|
||||||
|
if (raw.children !== undefined && raw.items === undefined) {
|
||||||
|
// Not actually a warning; rename is silent. Documented in
|
||||||
|
// the architecture proposal — emit anyway for traceability
|
||||||
|
// at first sight.
|
||||||
|
// Skip to keep warnings list focused on real losses.
|
||||||
|
}
|
||||||
|
const items = rawItems
|
||||||
|
.map((item, i) => migrateListItem(item, `${path}/items/${i}`, ctx))
|
||||||
|
.filter((it): it is WordsListItemV2 => it !== null);
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
kind,
|
||||||
|
items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'table': {
|
||||||
|
if (raw.striped !== undefined) {
|
||||||
|
ctx.warnings.push(
|
||||||
|
`${path}/striped: dropped (presentation token — express via per-row visual.background if needed)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (raw.compact !== undefined) {
|
||||||
|
ctx.warnings.push(
|
||||||
|
`${path}/compact: dropped (presentation token — handled by theme / app-level CSS)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rawRows = Array.isArray(raw.children) ? raw.children : Array.isArray(raw.rows) ? raw.rows : [];
|
||||||
|
const rows = rawRows
|
||||||
|
.map((row, i) => migrateTableRow(row, `${path}/rows/${i}`, ctx))
|
||||||
|
.filter((r): r is WordsTableRowV2 => r !== null);
|
||||||
|
// V1 had no headerRow/headerCol. Detect first-row-all-header
|
||||||
|
// or first-col-all-header from the legacy `header` flag on
|
||||||
|
// individual cells and promote it.
|
||||||
|
const promoted = promoteHeaderFlags(rows, ctx);
|
||||||
|
return {
|
||||||
|
type: 'table',
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
...(promoted.headerRow ? { headerRow: true } : {}),
|
||||||
|
...(promoted.headerCol ? { headerCol: true } : {}),
|
||||||
|
rows: promoted.rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'image': {
|
||||||
|
const src = typeof raw.src === 'string' && raw.src.length > 0 ? raw.src : '';
|
||||||
|
if (!src) {
|
||||||
|
ctx.warnings.push(`${path}/src: missing or invalid — image block dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Drop image.status into the runtime sidecar (P4). Auto-
|
||||||
|
// generate the id if absent so the sidecar can bind to it.
|
||||||
|
const status =
|
||||||
|
raw.status === 'pending' || raw.status === 'error' ? raw.status : undefined;
|
||||||
|
const effectiveId = id ?? (status ? ctx.idGen() : undefined);
|
||||||
|
if (status && effectiveId) {
|
||||||
|
ctx.imageStatus.set(effectiveId, status);
|
||||||
|
ctx.warnings.push(
|
||||||
|
`${path}/status: '${status}' moved to runtime.imageStatus[${effectiveId}] (P4: UI state out of content)`
|
||||||
|
);
|
||||||
|
} else if (status) {
|
||||||
|
ctx.warnings.push(
|
||||||
|
`${path}/status: '${status}' could not be carried (no id) — silently dropped`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
...(effectiveId ? { id: effectiveId } : {}),
|
||||||
|
src,
|
||||||
|
...(typeof raw.alt === 'string' ? { alt: raw.alt } : {}),
|
||||||
|
...(typeof raw.caption === 'string' ? { caption: raw.caption } : {}),
|
||||||
|
...(typeof raw.width === 'number' && Number.isFinite(raw.width) ? { width: raw.width } : {}),
|
||||||
|
...(typeof raw.height === 'number' && Number.isFinite(raw.height) ? { height: raw.height } : {}),
|
||||||
|
...(raw.align === 'left' || raw.align === 'center' || raw.align === 'right'
|
||||||
|
? { align: raw.align }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'divider':
|
||||||
|
// Already V2 shape.
|
||||||
|
return { type: 'divider', ...(id ? { id } : {}) };
|
||||||
|
|
||||||
|
case 'callout': {
|
||||||
|
// Already V2 shape; coerce children + intent defensively.
|
||||||
|
const intent =
|
||||||
|
raw.intent === 'neutral' ||
|
||||||
|
raw.intent === 'affirm' ||
|
||||||
|
raw.intent === 'fulfill' ||
|
||||||
|
raw.intent === 'risk' ||
|
||||||
|
raw.intent === 'threat' ||
|
||||||
|
raw.intent === 'loss'
|
||||||
|
? raw.intent
|
||||||
|
: (ctx.warnings.push(
|
||||||
|
`${path}/intent: invalid SemaIntent ${JSON.stringify(raw.intent)} — coerced to 'neutral'`
|
||||||
|
),
|
||||||
|
'neutral');
|
||||||
|
const children = Array.isArray(raw.children)
|
||||||
|
? raw.children
|
||||||
|
.map((b, i) => migrateBlock(b, `${path}/children/${i}`, ctx))
|
||||||
|
.filter(isBlock)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
type: 'callout',
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
intent,
|
||||||
|
...(typeof raw.title === 'string' ? { title: raw.title } : {}),
|
||||||
|
children
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
ctx.warnings.push(`${path}/type: unknown block type ${JSON.stringify(raw.type)} — dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List item ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function migrateListItem(raw: unknown, path: string, ctx: Ctx): WordsListItemV2 | null {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
ctx.warnings.push(`${path}: not an object — list item dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = takeId(raw, ctx);
|
||||||
|
return {
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
children: migrateInlines(raw.children, `${path}/children`, ctx),
|
||||||
|
...(raw.checked === true ? { checked: true } : raw.checked === false ? { checked: false } : {}),
|
||||||
|
// indent: only persist when > 0 (0 is the implicit default). Same
|
||||||
|
// rule as V1 (`...(indent ? { indent } : {})`).
|
||||||
|
...(typeof raw.indent === 'number' && Number.isInteger(raw.indent) && raw.indent > 0 && raw.indent <= 8
|
||||||
|
? { indent: raw.indent }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table row + cell ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function migrateTableRow(raw: unknown, path: string, ctx: Ctx): WordsTableRowV2 | null {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
ctx.warnings.push(`${path}: not an object — row dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = takeId(raw, ctx);
|
||||||
|
const rawCells = Array.isArray(raw.children) ? raw.children : Array.isArray(raw.cells) ? raw.cells : [];
|
||||||
|
const cells = rawCells
|
||||||
|
.map((c, i) => migrateTableCell(c, `${path}/cells/${i}`, ctx))
|
||||||
|
.filter((c): c is WordsTableCellV2 => c !== null);
|
||||||
|
return {
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
cells
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateTableCell(raw: unknown, path: string, ctx: Ctx): WordsTableCellV2 | null {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
ctx.warnings.push(`${path}: not an object — cell dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = takeId(raw, ctx);
|
||||||
|
if (raw.tone !== undefined && raw.tone !== 'default') {
|
||||||
|
ctx.warnings.push(
|
||||||
|
`${path}/tone: '${String(raw.tone)}' dropped (design-system token — use visual.background hex if user wants a tinted cell)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const align = WORDS_TEXT_ALIGNS_V2.includes(raw.textAlign as 'left')
|
||||||
|
? (raw.textAlign as 'left' | 'center' | 'right' | 'justify')
|
||||||
|
: undefined;
|
||||||
|
const verticalAlign =
|
||||||
|
raw.verticalAlign === 'top' || raw.verticalAlign === 'middle' || raw.verticalAlign === 'bottom'
|
||||||
|
? raw.verticalAlign
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
children: migrateInlines(raw.children, `${path}/children`, ctx),
|
||||||
|
...(align ? { align } : {}),
|
||||||
|
...(verticalAlign ? { verticalAlign } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promote V1 per-cell `header: true` into table-level `headerRow` /
|
||||||
|
* `headerCol` flags if the pattern is consistent (entire first row =
|
||||||
|
* headers, or entire first column = headers).
|
||||||
|
*/
|
||||||
|
function promoteHeaderFlags(
|
||||||
|
rows: readonly WordsTableRowV2[],
|
||||||
|
ctx: Ctx
|
||||||
|
): { rows: readonly WordsTableRowV2[]; headerRow: boolean; headerCol: boolean } {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return { rows, headerRow: false, headerCol: false };
|
||||||
|
}
|
||||||
|
// We need access to the raw `header` flag from V1, which isn't on the
|
||||||
|
// V2 cell type. The cell migrator already drops `header` (it's not
|
||||||
|
// in WordsTableCellV2 — we use headerRow/headerCol on the table
|
||||||
|
// instead). So this function can't introspect from V2 cells alone.
|
||||||
|
// Conservative answer: no auto-promotion. If the user wants header
|
||||||
|
// semantics, they need to set headerRow/headerCol manually post-
|
||||||
|
// migration. Document in warnings only if there were rows.
|
||||||
|
if (rows.length > 0) {
|
||||||
|
// no warning — the absence of headerRow/headerCol on a V1 doc
|
||||||
|
// is the most common case (V1 used per-cell `header`).
|
||||||
|
}
|
||||||
|
return { rows, headerRow: false, headerCol: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline migration ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function migrateInlines(raw: unknown, path: string, ctx: Ctx): readonly WordsInlineV2[] {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
if (raw !== undefined) ctx.warnings.push(`${path}: not an array — defaulted to empty`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
.map((inline, i) => migrateInline(inline, `${path}/${i}`, ctx))
|
||||||
|
.filter((i): i is WordsInlineV2 => i !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateInline(raw: unknown, path: string, ctx: Ctx): WordsInlineV2 | null {
|
||||||
|
if (!isPlainObject(raw)) return null;
|
||||||
|
if (raw.type === 'text') {
|
||||||
|
return migrateText(raw, path, ctx);
|
||||||
|
}
|
||||||
|
if (raw.type === 'link') {
|
||||||
|
if (typeof raw.href !== 'string' || raw.href.length === 0) {
|
||||||
|
ctx.warnings.push(`${path}/href: missing or empty — link dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const children = Array.isArray(raw.children)
|
||||||
|
? raw.children
|
||||||
|
.map((c, i) => migrateText(c, `${path}/children/${i}`, ctx))
|
||||||
|
.filter((c): c is WordsTextV2 => c !== null)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
type: 'link',
|
||||||
|
href: raw.href,
|
||||||
|
children,
|
||||||
|
...(typeof raw.title === 'string' ? { title: raw.title } : {}),
|
||||||
|
...(raw.target === '_blank' || raw.target === '_self' ? { target: raw.target } : {}),
|
||||||
|
...(typeof raw.rel === 'string' ? { rel: raw.rel } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ctx.warnings.push(`${path}/type: unknown inline ${JSON.stringify(raw.type)} — dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateText(raw: unknown, path: string, ctx: Ctx): WordsTextV2 | null {
|
||||||
|
if (!isPlainObject(raw) || raw.type !== 'text') return null;
|
||||||
|
const text = typeof raw.text === 'string' ? raw.text : '';
|
||||||
|
const marks = migrateMarks(raw.marks, `${path}/marks`, ctx);
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
text,
|
||||||
|
...(marks.length ? { marks } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateCodeChildren(raw: unknown, path: string, ctx: Ctx): readonly WordsTextV2[] {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
if (raw !== undefined) ctx.warnings.push(`${path}: not an array — defaulted to empty`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
.map((inline, i) => {
|
||||||
|
if (!isPlainObject(inline)) return null;
|
||||||
|
if (inline.type === 'text') return migrateText(inline, `${path}/${i}`, ctx);
|
||||||
|
if (inline.type === 'link') {
|
||||||
|
// Flatten link into its text content — code blocks don't host links.
|
||||||
|
ctx.warnings.push(
|
||||||
|
`${path}/${i}: link flattened to text (code blocks accept only plain text in V2)`
|
||||||
|
);
|
||||||
|
const children = Array.isArray(inline.children) ? inline.children : [];
|
||||||
|
const combined = children.map((c) => (isPlainObject(c) && typeof c.text === 'string' ? c.text : '')).join('');
|
||||||
|
return { type: 'text' as const, text: combined };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((t): t is WordsTextV2 => t !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marks migration (V1 template-literal → V2 structured) ────────────────
|
||||||
|
|
||||||
|
function migrateMarks(raw: unknown, path: string, ctx: Ctx): readonly WordsMarkV2[] {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
if (raw !== undefined) ctx.warnings.push(`${path}: not an array — defaulted to empty`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const result: WordsMarkV2[] = [];
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const m = raw[i];
|
||||||
|
// Already structured V2 — copy through.
|
||||||
|
if (isPlainObject(m) && (m.type === 'color' || m.type === 'background')) {
|
||||||
|
if (typeof m.value === 'string' && /^#[0-9a-f]{3,8}$/i.test(m.value)) {
|
||||||
|
result.push({ type: m.type, value: m.value });
|
||||||
|
} else {
|
||||||
|
ctx.warnings.push(`${path}/${i}: structured mark with invalid hex value — dropped`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Boolean mark string.
|
||||||
|
if (typeof m === 'string' && (WORDS_BOOLEAN_MARKS_V2 as readonly string[]).includes(m)) {
|
||||||
|
result.push(m as WordsBooleanMarkV2);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// V1 template-literal mark `color:#hex` / `bgcolor:#hex`.
|
||||||
|
if (typeof m === 'string') {
|
||||||
|
const match = m.match(/^(color|bgcolor):(#[0-9a-f]{3,8})$/i);
|
||||||
|
if (match) {
|
||||||
|
const type = match[1] === 'bgcolor' ? 'background' : 'color';
|
||||||
|
result.push({ type: type as 'color' | 'background', value: match[2] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.warnings.push(`${path}/${i}: unknown mark ${JSON.stringify(m)} — dropped`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function takeId(raw: Record<string, unknown>, ctx: Ctx): string | undefined {
|
||||||
|
if (typeof raw.id === 'string' && raw.id.length > 0) return raw.id;
|
||||||
|
// Autogenerate optional id. Per D-Q1 the id is OPTIONAL — we only
|
||||||
|
// generate it when there's a downstream consumer (image status
|
||||||
|
// migration). Otherwise leave it undefined.
|
||||||
|
void ctx;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeTextAlign(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
path: string,
|
||||||
|
ctx: Ctx
|
||||||
|
): { textAlign?: 'left' | 'center' | 'right' | 'justify' } {
|
||||||
|
if (raw.textAlign === undefined) return {};
|
||||||
|
if ((WORDS_TEXT_ALIGNS_V2 as readonly string[]).includes(raw.textAlign as string)) {
|
||||||
|
return { textAlign: raw.textAlign as 'left' | 'center' | 'right' | 'justify' };
|
||||||
|
}
|
||||||
|
ctx.warnings.push(`${path}: invalid textAlign ${JSON.stringify(raw.textAlign)} — dropped`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default id generator ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `crypto.randomUUID()` when available (Node 14.17+, all modern
|
||||||
|
* browsers); falls back to a `Math.random` + monotonic counter combo
|
||||||
|
* that's good enough for content ids (NOT cryptographically secure —
|
||||||
|
* not used for security purposes).
|
||||||
|
*/
|
||||||
|
let monotonicCounter = 0;
|
||||||
|
|
||||||
|
export function defaultIdGenerator(): string {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
monotonicCounter += 1;
|
||||||
|
return `wb-${Date.now().toString(36)}-${monotonicCounter.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Sema parity check.
|
||||||
|
*
|
||||||
|
* The Words engine declares `WordsEvalIntent` locally to stay framework-
|
||||||
|
* neutral (no cross-package import to `$uix/sema`). This file provides
|
||||||
|
* a boot-time assertion that the local vocabulary matches the canonical
|
||||||
|
* `SemaIntent` from sema. If sema adds a new intent (or renames one)
|
||||||
|
* and the engine isn't updated, the assertion throws on first call,
|
||||||
|
* fail-fast.
|
||||||
|
*
|
||||||
|
* Called from the words provider mount.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WORDS_EVAL_INTENTS } from './types-v2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the engine's local `WORDS_EVAL_INTENTS` array matches
|
||||||
|
* the canonical sema intent set (length + value membership).
|
||||||
|
*
|
||||||
|
* Throws `SemaIntentParityError` if they diverge.
|
||||||
|
*
|
||||||
|
* @param canonicalIntents the canonical sema intent set, passed in to
|
||||||
|
* keep this module pure (no dependency on `$uix/sema`). Caller is
|
||||||
|
* responsible for importing the canonical set and passing it.
|
||||||
|
*/
|
||||||
|
export function assertSemaIntentParity(canonicalIntents: readonly string[]): void {
|
||||||
|
const engineSet = new Set(WORDS_EVAL_INTENTS as readonly string[]);
|
||||||
|
const canonSet = new Set(canonicalIntents);
|
||||||
|
|
||||||
|
const missingInEngine: string[] = [];
|
||||||
|
const extraInEngine: string[] = [];
|
||||||
|
|
||||||
|
for (const value of canonSet) {
|
||||||
|
if (!engineSet.has(value)) missingInEngine.push(value);
|
||||||
|
}
|
||||||
|
for (const value of engineSet) {
|
||||||
|
if (!canonSet.has(value)) extraInEngine.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingInEngine.length > 0 || extraInEngine.length > 0) {
|
||||||
|
throw new SemaIntentParityError(missingInEngine, extraInEngine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SemaIntentParityError extends Error {
|
||||||
|
readonly missingInEngine: readonly string[];
|
||||||
|
readonly extraInEngine: readonly string[];
|
||||||
|
|
||||||
|
constructor(missingInEngine: readonly string[], extraInEngine: readonly string[]) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (missingInEngine.length > 0) {
|
||||||
|
parts.push(`missing in engine: [${missingInEngine.join(', ')}]`);
|
||||||
|
}
|
||||||
|
if (extraInEngine.length > 0) {
|
||||||
|
parts.push(`extra in engine: [${extraInEngine.join(', ')}]`);
|
||||||
|
}
|
||||||
|
super(
|
||||||
|
`Words engine WORDS_EVAL_INTENTS drifted from canonical sema SemaIntent: ${parts.join('; ')}. ` +
|
||||||
|
`Update src/uix/soma/components/words/engine/types-v2.ts (WORDS_EVAL_INTENTS + WordsEvalIntent type) to match.`
|
||||||
|
);
|
||||||
|
this.name = 'SemaIntentParityError';
|
||||||
|
this.missingInEngine = missingInEngine;
|
||||||
|
this.extraInEngine = extraInEngine;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue