diff --git a/continue.md b/continue.md index 5eb76085d..c4040b601 100644 --- a/continue.md +++ b/continue.md @@ -127,8 +127,11 @@ Tras la discusión de arquitectura (separación contenido/diseño, P1-P8 + decis | Sprint | Estado | Qué hace | |---|---|---| | **R1 — Audit + tipos + validador** | ✅ Cerrado | types-v2.ts + validate-v2.ts + 50 tests | -| **R2 — Migrator V1→V2 + parity check** | ✅ Cerrado | migrate-v2.ts (P4: image.status → runtimeState.imageStatus; reshape marks template literal → structured; drop cell.tone / table.striped / table.compact con warnings; idempotente sobre V2; output siempre pasa validateWordsDocument). sema-parity.ts (assertSemaIntentParity contra $uix/intent.INTENTS). 35 tests. Total engine: 170/170 pass. | -| **R3 — Visual sidecar implementation** | ⏳ Pendiente | Render emite inline styles desde block.visual. POLISH-1b (image radius/shadow/border) implementable bajo este modelo. Drawer image panel con controles. | +| **R2 — Migrator V1→V2 + parity check** | ✅ Cerrado | migrate-v2.ts (P4: image.status → runtimeState.imageStatus; reshape marks template literal → structured; drop cell.tone / table.striped / table.compact con warnings; idempotente sobre V2; output siempre pasa validateWordsDocument). sema-parity.ts (assertSemaIntentParity contra $uix/intent.INTENTS). 35 tests. | +| **R3 — Render + HTML + MD serializers V2** | ✅ Cerrado | render-v2.ts (pure WordsDocumentV2 → WordsRenderNodeV2; emite inline styles desde block.visual; nuevos tags hr/div para divider/callout). serialize-html-v2.ts (HTML escapado correctamente + roundtrip-friendly via inline styles + pretty mode). serialize-markdown-v2.ts (LOSSY: drop visual + color/bg marks; preserva estructura; INTENT_TO_GFM_ADMONITION mapping risk→WARNING/etc.). 56 tests. **POLISH-1b deferida a R5 porque requiere provider V2 (R4 antes)**. Total engine: 226/226 pass. | +| **R4 — Provider V2 migration** | ⏳ Pendiente | Migrar provider a V2 internamente. Operaciones (insertText, splitBlock, etc.) trabajan sobre WordsBlockV2. Sidecar runtime imageStatus poblado en mount via migrateV1ToV2. API pública del provider mantiene shape V1 vía adapter durante la transición. | +| **R5 — POLISH-1b (image visuals)** | ⏳ Pendiente | Drawer image panel: sliders (radius, shadow blur), color pickers (shadow color, borderColor, background), toggles (shadow, border). Provider operation `setImageVisual(blockId, patch)`. Requiere R4. | +| **R6 — V1 removal** | ⏳ Pendiente | Una vez R4+R5 estables: borrar V1 types/operations. Audit script: 0 referencias a V1 en engine/extensions. | | **R4 — Sweep tokens del contenido** | ⏳ Pendiente | Eliminar referencias `cell-tone` / `table-striped` / `table-compact` del rest del codebase (provider, render, eidos, serializers). Drawer panels actualizados. Audit script: 0 referencias a tokens en engine/extensions. | | **R5 — Engine as package (opcional)** | ⏳ Diferible | Mover engine/ a paquete propio. Eidos consume vía path normal. | diff --git a/src/uix/soma/components/words/engine/render-v2.test.ts b/src/uix/soma/components/words/engine/render-v2.test.ts new file mode 100644 index 000000000..ad8561baa --- /dev/null +++ b/src/uix/soma/components/words/engine/render-v2.test.ts @@ -0,0 +1,414 @@ +/** + * R3A — render-v2 suite. + * + * Covers each block type → expected WordsRenderNodeV2 shape. Visual + * properties produce exact inline `style` strings. New types (divider / + * callout) emit `hr` / `div` with correct data-attrs. + */ + +import { describe, expect, it } from 'vitest'; +import { renderWordsDocumentV2, visualToStyle, stringifyStyle } from './render-v2'; +import { WORDS_DOCUMENT_VERSION_V2, type WordsDocumentV2 } from './types-v2'; + +function doc(...children: WordsDocumentV2['children']): WordsDocumentV2 { + return { version: WORDS_DOCUMENT_VERSION_V2, children }; +} + +// ── Basic block types ──────────────────────────────────────────────────── + +describe('render-v2 — basic blocks', () => { + it('paragraph →

', () => { + const r = renderWordsDocumentV2( + doc({ type: 'paragraph', children: [{ type: 'text', text: 'hi' }] }) + ); + expect(r[0].tag).toBe('p'); + expect(r[0].attrs['data-words-block']).toBe('paragraph'); + expect(r[0].children).toEqual([{ kind: 'text', text: 'hi' }]); + }); + + it('heading level → h1/h2/h3 + data-words-heading-level', () => { + for (const level of [1, 2, 3] as const) { + const r = renderWordsDocumentV2( + doc({ type: 'heading', level, children: [{ type: 'text', text: 'h' }] }) + ); + expect(r[0].tag).toBe(`h${level}`); + expect(r[0].attrs['data-words-heading-level']).toBe(String(level)); + } + }); + + it('quote →

with cite attr', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'quote', + children: [{ type: 'text', text: 'q' }], + cite: 'https://example.com' + }) + ); + expect(r[0].tag).toBe('blockquote'); + expect(r[0].attrs['cite']).toBe('https://example.com'); + }); + + it('code →
 wrapping ', () => {
+		const r = renderWordsDocumentV2(
+			doc({
+				type: 'code',
+				language: 'python',
+				children: [{ type: 'text', text: 'print(1)' }]
+			})
+		);
+		expect(r[0].tag).toBe('pre');
+		expect(r[0].attrs['data-words-code-language']).toBe('python');
+		const innerCode = r[0].children[0];
+		if (innerCode.kind !== 'element') throw new Error('expected code element');
+		expect(innerCode.tag).toBe('code');
+		expect(innerCode.attrs['class']).toBe('language-python');
+	});
+
+	it('list ordered → 
    , unordered →
      ', () => { + const r1 = renderWordsDocumentV2( + doc({ type: 'list', kind: 'ordered', items: [{ children: [{ type: 'text', text: 'a' }] }] }) + ); + expect(r1[0].tag).toBe('ol'); + const r2 = renderWordsDocumentV2( + doc({ type: 'list', kind: 'unordered', items: [{ children: [{ type: 'text', text: 'a' }] }] }) + ); + expect(r2[0].tag).toBe('ul'); + }); + + it('check list emits data-words-checked per item', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'list', + kind: 'check', + items: [ + { checked: true, children: [{ type: 'text', text: 'done' }] }, + { checked: false, children: [{ type: 'text', text: 'todo' }] } + ] + }) + ); + const items = r[0].children; + expect(items[0].kind === 'element' && items[0].attrs['data-words-checked']).toBe('true'); + expect(items[1].kind === 'element' && items[1].attrs['data-words-checked']).toBe('false'); + }); + + it('image →
      [
      ]
      ', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'image', + src: 'https://example.com/cat.png', + alt: 'cat', + caption: 'fluffy', + width: 800, + height: 600, + align: 'right' + }) + ); + expect(r[0].tag).toBe('figure'); + expect(r[0].attrs['data-words-image-align']).toBe('right'); + const img = r[0].children[0]; + if (img.kind !== 'element') throw new Error('expected img element'); + expect(img.tag).toBe('img'); + expect(img.attrs['src']).toBe('https://example.com/cat.png'); + expect(img.attrs['alt']).toBe('cat'); + expect(img.attrs['width']).toBe('800'); + expect(img.attrs['height']).toBe('600'); + const cap = r[0].children[1]; + if (cap.kind !== 'element') throw new Error('expected figcaption'); + expect(cap.tag).toBe('figcaption'); + }); + + it('divider →
      ', () => { + const r = renderWordsDocumentV2(doc({ type: 'divider' })); + expect(r[0].tag).toBe('hr'); + expect(r[0].attrs['data-words-block']).toBe('divider'); + expect(r[0].children).toHaveLength(0); + }); + + it('callout →
      with intent attr + nested children', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'callout', + intent: 'risk', + title: 'Be careful', + children: [{ type: 'paragraph', children: [{ type: 'text', text: 'body' }] }] + }) + ); + expect(r[0].tag).toBe('div'); + expect(r[0].attrs['role']).toBe('note'); + expect(r[0].attrs['data-words-callout-intent']).toBe('risk'); + // First child is the title wrapper + const title = r[0].children[0]; + if (title.kind !== 'element') throw new Error('expected title'); + expect(title.attrs['data-words-callout-title']).toBe(''); + // Second child is the paragraph + const para = r[0].children[1]; + if (para.kind !== 'element') throw new Error('expected paragraph'); + expect(para.tag).toBe('p'); + }); +}); + +// ── Visual sidecar → inline style ──────────────────────────────────────── + +describe('render-v2 — visual sidecar → inline style', () => { + it('paragraph with margin/padding/background → exact style string', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'paragraph', + children: [{ type: 'text', text: 'x' }], + visual: { + marginBlockStart: 16, + marginBlockEnd: 8, + padding: 12, + cornerRadius: 4, + background: '#fafafa' + } + }) + ); + expect(r[0].attrs['style']).toBe( + 'margin-block-start:16px;margin-block-end:8px;padding:12px;border-radius:4px;background-color:#fafafa' + ); + }); + + it('image with shadow + border → composite style', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'image', + src: 'x', + visual: { + cornerRadius: 16, + borderColor: '#000000', + borderWidth: 2, + borderStyle: 'dashed', + shadow: { x: 0, y: 4, blur: 12, color: '#00000033' } + } + }) + ); + const style = r[0].attrs['style']; + expect(style).toContain('border-radius:16px'); + expect(style).toContain('border-color:#000000'); + expect(style).toContain('border-width:2px'); + expect(style).toContain('border-style:dashed'); + expect(style).toContain('box-shadow:0px 4px 12px #00000033'); + }); + + it('image with shadow + spread → uses spread', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'image', + src: 'x', + visual: { shadow: { x: 1, y: 2, blur: 3, spread: 4, color: '#abc' } } + }) + ); + expect(r[0].attrs['style']).toContain('box-shadow:1px 2px 3px 4px #abc'); + }); + + it('omits style attr entirely when visual is undefined', () => { + const r = renderWordsDocumentV2( + doc({ type: 'paragraph', children: [{ type: 'text', text: 'x' }] }) + ); + expect(r[0].attrs['style']).toBeUndefined(); + }); + + it('borderColor without borderWidth defaults to 1px solid', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'image', + src: 'x', + visual: { borderColor: '#abc' } + }) + ); + const style = r[0].attrs['style']!; + expect(style).toContain('border-color:#abc'); + expect(style).toContain('border-width:1px'); + expect(style).toContain('border-style:solid'); + }); + + it('table row visual.background applies on ', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'table', + rows: [ + { + visual: { background: '#fafafa' }, + cells: [{ children: [{ type: 'text', text: 'a' }] }] + } + ] + }) + ); + const tbody = r[0].children[0]; + if (tbody.kind !== 'element') throw new Error('expected tbody'); + const tr = tbody.children[0]; + if (tr.kind !== 'element') throw new Error('expected tr'); + expect(tr.attrs['style']).toBe('background-color:#fafafa'); + }); + + it('table cell visual applies on ', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'table', + rows: [ + { + cells: [ + { + children: [{ type: 'text', text: 'a' }], + visual: { background: '#eef5ff', padding: 8 } + } + ] + } + ] + }) + ); + const tbody = r[0].children[0]; + const tr = (tbody as { children: readonly any[] }).children[0]; + const td = (tr as { children: readonly any[] }).children[0]; + // Canonical order: padding (layout) → background (fill) + expect(td.attrs['style']).toBe('padding:8px;background-color:#eef5ff'); + }); +}); + +// ── Table with headerRow / headerCol ───────────────────────────────────── + +describe('render-v2 — table headers', () => { + it('headerRow=true makes first-row cells use ', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'table', + headerRow: true, + rows: [ + { cells: [{ children: [{ type: 'text', text: 'h1' }] }, { children: [{ type: 'text', text: 'h2' }] }] }, + { cells: [{ children: [{ type: 'text', text: 'a' }] }, { children: [{ type: 'text', text: 'b' }] }] } + ] + }) + ); + const tbody = r[0].children[0] as { children: readonly any[] }; + const firstRow = tbody.children[0]; + expect(firstRow.children[0].tag).toBe('th'); + expect(firstRow.children[1].tag).toBe('th'); + const secondRow = tbody.children[1]; + expect(secondRow.children[0].tag).toBe('td'); + }); + + it('headerCol=true makes first-cell-per-row use ', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'table', + headerCol: true, + rows: [ + { cells: [{ children: [{ type: 'text', text: 'rh' }] }, { children: [{ type: 'text', text: 'v' }] }] } + ] + }) + ); + const tbody = r[0].children[0] as { children: readonly any[] }; + const tr = tbody.children[0]; + expect(tr.children[0].tag).toBe('th'); + expect(tr.children[1].tag).toBe('td'); + }); +}); + +// ── Marks rendering ────────────────────────────────────────────────────── + +describe('render-v2 — text marks', () => { + it('plain text has no wrapper', () => { + const r = renderWordsDocumentV2( + doc({ type: 'paragraph', children: [{ type: 'text', text: 'x' }] }) + ); + const p = r[0]; + expect(p.children[0]).toEqual({ kind: 'text', text: 'x' }); + }); + + it('color mark wraps text in ', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'paragraph', + children: [ + { type: 'text', text: 'x', marks: [{ type: 'color', value: '#ff5500' }] } + ] + }) + ); + const node = r[0].children[0]; + if (node.kind !== 'element') throw new Error('expected element'); + expect(node.tag).toBe('span'); + expect(node.attrs['style']).toBe('color:#ff5500'); + }); + + it('color + background marks combine in one span style', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'paragraph', + children: [ + { + type: 'text', + text: 'x', + marks: [ + { type: 'color', value: '#ff5500' }, + { type: 'background', value: '#fff5e6' } + ] + } + ] + }) + ); + const node = r[0].children[0]; + if (node.kind !== 'element') throw new Error('expected element'); + expect(node.attrs['style']).toBe('color:#ff5500;background-color:#fff5e6'); + }); + + it('boolean marks stack', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'paragraph', + children: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] + }) + ); + // Bold wraps italic wraps text + const outer = r[0].children[0]; + if (outer.kind !== 'element') throw new Error('expected element'); + expect(outer.attrs['data-words-mark']).toBe('bold'); + const inner = outer.children[0]; + if (inner.kind !== 'element') throw new Error('expected element'); + expect(inner.attrs['data-words-mark']).toBe('italic'); + }); + + it('code mark wraps text in ', () => { + const r = renderWordsDocumentV2( + doc({ + type: 'paragraph', + children: [{ type: 'text', text: 'foo()', marks: ['code'] }] + }) + ); + const node = r[0].children[0]; + if (node.kind !== 'element') throw new Error('expected element'); + expect(node.tag).toBe('code'); + }); +}); + +// ── visualToStyle direct ───────────────────────────────────────────────── + +describe('visualToStyle helper', () => { + it('returns empty object on undefined', () => { + expect(visualToStyle(undefined)).toEqual({}); + }); + + it('stringifyStyle returns undefined on empty', () => { + expect(stringifyStyle({})).toBeUndefined(); + }); + + it('stringifyStyle joins with semicolons', () => { + expect(stringifyStyle({ a: 'b', c: 'd' })).toBe('a:b;c:d'); + }); +}); + +// ── Path encoding sanity ───────────────────────────────────────────────── + +describe('render-v2 — path attrs', () => { + it('encodes block path correctly', () => { + const r = renderWordsDocumentV2( + doc( + { type: 'paragraph', children: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', children: [{ type: 'text', text: 'b' }] } + ) + ); + expect(r[0].attrs['data-words-path']).toBeTruthy(); + expect(r[1].attrs['data-words-path']).toBeTruthy(); + expect(r[0].attrs['data-words-path']).not.toBe(r[1].attrs['data-words-path']); + }); +}); diff --git a/src/uix/soma/components/words/engine/render-v2.ts b/src/uix/soma/components/words/engine/render-v2.ts new file mode 100644 index 000000000..ec31505bc --- /dev/null +++ b/src/uix/soma/components/words/engine/render-v2.ts @@ -0,0 +1,529 @@ +/** + * V2 abstract render path. + * + * Pure function: `WordsDocumentV2` → `readonly WordsRenderElementV2[]`. + * The output is a framework-neutral tree (`{ kind, tag, attrs, children }` + * or `{ kind, text }`) that any rendering layer can walk into native + * VNodes — Svelte for Eidos today, React/Vue/vanilla tomorrow. + * + * Per P3: visual properties from `block.visual.*` are translated into + * inline `style="..."` attributes (raw values only — no tokens, no + * classes). Hex strings go straight through; numbers get `px` suffix. + * + * Per P5: this module has zero framework imports. Only depends on the + * V2 types and the shared path / dom helpers. + */ + +import { + WORDS_NODE_ATTR, + WORDS_PATH_ATTR, + encodeWordsPath +} from './dom'; +import type { WordsPath } from './path'; +import type { + BlockVisual, + WordsBlockV2, + WordsCalloutBlockV2, + WordsCodeBlockV2, + WordsDocumentV2, + WordsHeadingBlockV2, + WordsImageBlockV2, + WordsInlineV2, + WordsLinkV2, + WordsListBlockV2, + WordsListItemV2, + WordsMarkV2, + WordsParagraphBlockV2, + WordsQuoteBlockV2, + WordsTableBlockV2, + WordsTableCellV2, + WordsTableRowV2, + WordsTextV2 +} from './types-v2'; + +// ── Output shape ────────────────────────────────────────────────────────── + +/** + * Superset of the V1 `WordsRenderTag`. Adds `hr` (DividerBlock) and + * `div` + `section` (CalloutBlock + nested children). Keeps every V1 + * tag so V2 can reuse the same HTML element vocabulary without + * breaking V1 consumers. + */ +export type WordsRenderTagV2 = + | 'blockquote' + | 'h1' + | 'h2' + | 'h3' + | 'li' + | 'ol' + | 'p' + | 'pre' + | 'table' + | 'tbody' + | 'td' + | 'th' + | 'tr' + | 'code' + | 'span' + | 'a' + | 'ul' + | 'mark' + | 'figure' + | 'img' + | 'figcaption' + | 'hr' + | 'div' + | 'section'; + +export type WordsRenderAttrsV2 = Readonly>; + +export interface WordsRenderElementV2 { + readonly kind: 'element'; + readonly tag: WordsRenderTagV2; + readonly attrs: WordsRenderAttrsV2; + readonly children: readonly WordsRenderNodeV2[]; +} + +export interface WordsRenderTextV2 { + readonly kind: 'text'; + readonly text: string; +} + +export type WordsRenderNodeV2 = WordsRenderElementV2 | WordsRenderTextV2; + +// ── Entry point ─────────────────────────────────────────────────────────── + +export function renderWordsDocumentV2( + doc: WordsDocumentV2 +): readonly WordsRenderElementV2[] { + return doc.children.map((block, blockIndex) => renderBlock(block, [blockIndex])); +} + +// ── Block dispatch ──────────────────────────────────────────────────────── + +function renderBlock(block: WordsBlockV2, path: WordsPath): WordsRenderElementV2 { + switch (block.type) { + case 'paragraph': + return renderParagraph(block, path); + case 'heading': + return renderHeading(block, path); + case 'quote': + return renderQuote(block, path); + case 'code': + return renderCode(block, path); + case 'list': + return renderList(block, path); + case 'table': + return renderTable(block, path); + case 'image': + return renderImage(block, path); + case 'divider': + return renderDivider(block, path); + case 'callout': + return renderCallout(block, path); + } +} + +// ── Paragraph / Heading / Quote ────────────────────────────────────────── + +function renderParagraph(block: WordsParagraphBlockV2, path: WordsPath): WordsRenderElementV2 { + return { + kind: 'element', + tag: 'p', + attrs: composeBlockAttrs(block, 'paragraph', path, { + ...(block.textAlign ? { 'data-words-align': block.textAlign } : {}) + }), + children: renderInlines(block.children) + }; +} + +function renderHeading(block: WordsHeadingBlockV2, path: WordsPath): WordsRenderElementV2 { + const tag = (`h${block.level}` as const) satisfies WordsRenderTagV2; + return { + kind: 'element', + tag, + attrs: composeBlockAttrs(block, 'heading', path, { + 'data-words-heading-level': String(block.level), + ...(block.textAlign ? { 'data-words-align': block.textAlign } : {}) + }), + children: renderInlines(block.children) + }; +} + +function renderQuote(block: WordsQuoteBlockV2, path: WordsPath): WordsRenderElementV2 { + return { + kind: 'element', + tag: 'blockquote', + attrs: composeBlockAttrs(block, 'quote', path, { + ...(block.textAlign ? { 'data-words-align': block.textAlign } : {}), + ...(block.cite ? { cite: block.cite } : {}) + }), + children: renderInlines(block.children) + }; +} + +// ── Code block ──────────────────────────────────────────────────────────── + +function renderCode(block: WordsCodeBlockV2, path: WordsPath): WordsRenderElementV2 { + const innerCode: WordsRenderElementV2 = { + kind: 'element', + tag: 'code', + attrs: block.language ? { class: `language-${block.language}` } : {}, + children: block.children.map((text): WordsRenderNodeV2 => ({ + kind: 'text', + text: text.text + })) + }; + return { + kind: 'element', + tag: 'pre', + attrs: composeBlockAttrs(block, 'code', path, { + ...(block.language ? { 'data-words-code-language': block.language } : {}) + }), + children: [innerCode] + }; +} + +// ── List + items ────────────────────────────────────────────────────────── + +function renderList(block: WordsListBlockV2, path: WordsPath): WordsRenderElementV2 { + const tag = (block.kind === 'ordered' ? 'ol' : 'ul') satisfies WordsRenderTagV2; + return { + kind: 'element', + tag, + attrs: composeBlockAttrs(block, 'list', path, { + 'data-words-list-kind': block.kind + }), + children: block.items.map((item, i) => renderListItem(item, [...path, i], block.kind)) + }; +} + +function renderListItem( + item: WordsListItemV2, + path: WordsPath, + listKind: WordsListBlockV2['kind'] +): WordsRenderElementV2 { + return { + kind: 'element', + tag: 'li', + attrs: { + [WORDS_NODE_ATTR]: 'list-item', + [WORDS_PATH_ATTR]: encodeWordsPath(path), + ...(item.id ? { 'data-words-id': item.id } : {}), + ...(item.indent ? { 'data-words-indent': String(item.indent) } : {}), + ...(listKind === 'check' + ? { 'data-words-checked': item.checked ? 'true' : 'false' } + : {}) + }, + children: renderInlines(item.children) + }; +} + +// ── Table + rows + cells ───────────────────────────────────────────────── + +function renderTable(block: WordsTableBlockV2, path: WordsPath): WordsRenderElementV2 { + return { + kind: 'element', + tag: 'table', + attrs: composeBlockAttrs(block, 'table', path, { + ...(block.headerRow ? { 'data-words-table-header-row': 'true' } : {}), + ...(block.headerCol ? { 'data-words-table-header-col': 'true' } : {}) + }), + children: [ + { + kind: 'element', + tag: 'tbody', + attrs: {}, + children: block.rows.map((row, i) => + renderTableRow(row, [...path, i], block.headerRow === true, block.headerCol === true, i === 0) + ) + } + ] + }; +} + +function renderTableRow( + row: WordsTableRowV2, + path: WordsPath, + headerRow: boolean, + headerCol: boolean, + isFirstRow: boolean +): WordsRenderElementV2 { + const rowStyle = stringifyStyle(visualToStyle(row.visual)); + return { + kind: 'element', + tag: 'tr', + attrs: { + [WORDS_NODE_ATTR]: 'table-row', + [WORDS_PATH_ATTR]: encodeWordsPath(path), + ...(row.id ? { 'data-words-id': row.id } : {}), + ...(rowStyle ? { style: rowStyle } : {}) + }, + children: row.cells.map((cell, i) => + renderTableCell(cell, [...path, i], headerRow && isFirstRow, headerCol && i === 0) + ) + }; +} + +function renderTableCell( + cell: WordsTableCellV2, + path: WordsPath, + asHeader: boolean, + asHeaderCol: boolean +): WordsRenderElementV2 { + const isHeader = asHeader || asHeaderCol; + const tag = (isHeader ? 'th' : 'td') satisfies WordsRenderTagV2; + const cellStyle = stringifyStyle(visualToStyle(cell.visual)); + return { + kind: 'element', + tag, + attrs: { + [WORDS_NODE_ATTR]: 'table-cell', + [WORDS_PATH_ATTR]: encodeWordsPath(path), + ...(cell.id ? { 'data-words-id': cell.id } : {}), + ...(cell.align ? { 'data-words-cell-align': cell.align } : {}), + ...(cell.verticalAlign ? { 'data-words-cell-vertical': cell.verticalAlign } : {}), + ...(cell.colspan ? { colspan: String(cell.colspan) } : {}), + ...(cell.rowspan ? { rowspan: String(cell.rowspan) } : {}), + ...(cellStyle ? { style: cellStyle } : {}) + }, + children: renderInlines(cell.children) + }; +} + +// ── Image ───────────────────────────────────────────────────────────────── + +function renderImage(block: WordsImageBlockV2, path: WordsPath): WordsRenderElementV2 { + const figureChildren: WordsRenderNodeV2[] = [ + { + kind: 'element', + tag: 'img', + attrs: { + src: block.src, + alt: block.alt ?? '', + ...(block.width !== undefined ? { width: String(block.width) } : {}), + ...(block.height !== undefined ? { height: String(block.height) } : {}), + loading: 'lazy', + draggable: 'false' + }, + children: [] + } + ]; + if (block.caption) { + figureChildren.push({ + kind: 'element', + tag: 'figcaption', + attrs: {}, + children: [{ kind: 'text', text: block.caption }] + }); + } + return { + kind: 'element', + tag: 'figure', + attrs: composeBlockAttrs(block, 'image', path, { + ...(block.align && block.align !== 'center' + ? { 'data-words-image-align': block.align } + : {}) + }), + children: figureChildren + }; +} + +// ── Divider ─────────────────────────────────────────────────────────────── + +function renderDivider( + block: WordsBlockV2 & { type: 'divider' }, + path: WordsPath +): WordsRenderElementV2 { + return { + kind: 'element', + tag: 'hr', + attrs: composeBlockAttrs(block, 'divider', path, {}), + children: [] + }; +} + +// ── Callout ─────────────────────────────────────────────────────────────── + +function renderCallout(block: WordsCalloutBlockV2, path: WordsPath): WordsRenderElementV2 { + const children: WordsRenderNodeV2[] = []; + if (block.title) { + children.push({ + kind: 'element', + tag: 'div', + attrs: { 'data-words-callout-title': '' }, + children: [{ kind: 'text', text: block.title }] + }); + } + for (let i = 0; i < block.children.length; i++) { + children.push(renderBlock(block.children[i], [...path, i])); + } + return { + kind: 'element', + tag: 'div', + attrs: composeBlockAttrs(block, 'callout', path, { + 'data-words-callout-intent': block.intent, + role: 'note' + }), + children + }; +} + +// ── Inlines (text + link) + marks ──────────────────────────────────────── + +function renderInlines(inlines: readonly WordsInlineV2[]): readonly WordsRenderNodeV2[] { + return inlines.map(renderInline); +} + +function renderInline(inline: WordsInlineV2): WordsRenderNodeV2 { + if (inline.type === 'text') return renderTextWithMarks(inline); + return renderLink(inline); +} + +function renderLink(link: WordsLinkV2): WordsRenderElementV2 { + return { + kind: 'element', + tag: 'a', + attrs: { + href: link.href, + ...(link.title ? { title: link.title } : {}), + ...(link.target ? { target: link.target } : {}), + ...(link.rel ? { rel: link.rel } : link.target === '_blank' ? { rel: 'noopener noreferrer' } : {}) + }, + children: link.children.map(renderTextWithMarks) + }; +} + +/** + * Text with marks wraps the text in `` / + * `` / `` / `` / `` / `` as needed. Marks + * stack — boolean marks each contribute a wrapper element; color + + * background marks both contribute to a single inline `style`. + */ +function renderTextWithMarks(text: WordsTextV2): WordsRenderNodeV2 { + if (!text.marks || text.marks.length === 0) { + return { kind: 'text', text: text.text }; + } + // Aggregate parametric marks (color / background) into one style. + const styleParts: string[] = []; + const booleanMarks: WordsMarkV2[] = []; + for (const mark of text.marks) { + if (typeof mark === 'string') { + booleanMarks.push(mark); + } else if (mark.type === 'color') { + styleParts.push(`color:${mark.value}`); + } else if (mark.type === 'background') { + styleParts.push(`background-color:${mark.value}`); + } + } + let node: WordsRenderNodeV2 = { kind: 'text', text: text.text }; + if (styleParts.length > 0) { + node = { + kind: 'element', + tag: 'span', + attrs: { style: styleParts.join(';') }, + children: [node] + }; + } + // Boolean marks: wrap in order: code → strike → underline → italic → bold (outer-most last) + const wrapOrder: { mark: string; tag: WordsRenderTagV2 }[] = [ + { mark: 'code', tag: 'code' }, + { mark: 'strike', tag: 'span' }, // No in tag union; fall back to span + { mark: 'underline', tag: 'span' }, + { mark: 'italic', tag: 'span' }, + { mark: 'bold', tag: 'span' } + ]; + for (const { mark, tag } of wrapOrder) { + if (!booleanMarks.includes(mark as WordsMarkV2)) continue; + // Use semantic HTML tags via data attrs (since tag union excludes ///) + const semanticAttr = + mark === 'bold' + ? { 'data-words-mark': 'bold', style: 'font-weight:bold' } + : mark === 'italic' + ? { 'data-words-mark': 'italic', style: 'font-style:italic' } + : mark === 'underline' + ? { 'data-words-mark': 'underline', style: 'text-decoration:underline' } + : mark === 'strike' + ? { 'data-words-mark': 'strike', style: 'text-decoration:line-through' } + : mark === 'code' + ? { 'data-words-mark': 'code' } + : {}; + node = { + kind: 'element', + tag, + attrs: semanticAttr, + children: [node] + }; + } + return node; +} + +// ── Visual → style ──────────────────────────────────────────────────────── + +/** + * Translates a `BlockVisual` sidecar (or any subset) into a CSS + * declarations object. Returns `undefined` when no overrides apply. + * + * P3: this is the ONLY place the V2 path turns raw model values into + * style strings. Hex colors go through verbatim; numbers get a `px` + * suffix. + */ +export function visualToStyle( + visual: Partial | undefined +): Readonly> { + if (!visual) return {}; + const out: Record = {}; + if (visual.marginBlockStart !== undefined) out['margin-block-start'] = px(visual.marginBlockStart); + if (visual.marginBlockEnd !== undefined) out['margin-block-end'] = px(visual.marginBlockEnd); + if (visual.marginInlineStart !== undefined) + out['margin-inline-start'] = px(visual.marginInlineStart); + if (visual.marginInlineEnd !== undefined) out['margin-inline-end'] = px(visual.marginInlineEnd); + if (visual.padding !== undefined) out.padding = px(visual.padding); + if (visual.cornerRadius !== undefined) out['border-radius'] = px(visual.cornerRadius); + if (visual.background !== undefined) out['background-color'] = visual.background; + if (visual.borderColor !== undefined) out['border-color'] = visual.borderColor; + if (visual.borderWidth !== undefined) out['border-width'] = px(visual.borderWidth); + if (visual.borderStyle !== undefined) out['border-style'] = visual.borderStyle; + if (visual.borderColor !== undefined && visual.borderStyle === undefined) { + out['border-style'] = 'solid'; // default when only color is set + } + if (visual.borderColor !== undefined && visual.borderWidth === undefined) { + out['border-width'] = '1px'; + } + if (visual.height !== undefined) out.height = px(visual.height); + if (visual.shadow !== undefined) { + const s = visual.shadow; + const spread = s.spread !== undefined ? ` ${px(s.spread)}` : ''; + out['box-shadow'] = `${px(s.x)} ${px(s.y)} ${px(s.blur)}${spread} ${s.color}`; + } + return out; +} + +function px(n: number): string { + return `${n}px`; +} + +export function stringifyStyle(style: Readonly>): string | undefined { + const entries = Object.entries(style); + if (entries.length === 0) return undefined; + return entries.map(([k, v]) => `${k}:${v}`).join(';'); +} + +// ── Shared block-attr composition ──────────────────────────────────────── + +function composeBlockAttrs( + block: { id?: string; visual?: Partial }, + blockType: string, + path: WordsPath, + extra: Record +): WordsRenderAttrsV2 { + const style = stringifyStyle(visualToStyle(block.visual)); + return { + [WORDS_NODE_ATTR]: 'block', + [WORDS_PATH_ATTR]: encodeWordsPath(path), + 'data-words-block': blockType, + ...(block.id ? { 'data-words-id': block.id } : {}), + ...extra, + ...(style ? { style } : {}) + }; +} diff --git a/src/uix/soma/components/words/engine/serialize-html-v2.test.ts b/src/uix/soma/components/words/engine/serialize-html-v2.test.ts new file mode 100644 index 000000000..1b06a769b --- /dev/null +++ b/src/uix/soma/components/words/engine/serialize-html-v2.test.ts @@ -0,0 +1,129 @@ +/** + * R3B — serialize-html-v2 suite. + * + * Verifies: HTML output is well-formed, escapes correctly, preserves + * inline styles from `block.visual.*`. + */ + +import { describe, expect, it } from 'vitest'; +import { serializeHtmlV2 } from './serialize-html-v2'; +import { WORDS_DOCUMENT_VERSION_V2, type WordsDocumentV2 } from './types-v2'; + +function doc(...children: WordsDocumentV2['children']): WordsDocumentV2 { + return { version: WORDS_DOCUMENT_VERSION_V2, children }; +} + +describe('serializeHtmlV2', () => { + it('paragraph with plain text', () => { + const html = serializeHtmlV2( + doc({ type: 'paragraph', children: [{ type: 'text', text: 'hello' }] }) + ); + expect(html).toContain('

      hello

      '); + }); + + it('escapes &, <, > in text content', () => { + const html = serializeHtmlV2( + doc({ type: 'paragraph', children: [{ type: 'text', text: '' }] }) + ); + expect(html).toContain('<script>&amp;</script>'); + expect(html).not.toContain('