feat(words): R3 — render-v2 + HTML + MD serializers + 56 tests

R3 closes the pure-V2 output path. Take a WordsDocumentV2 and turn it
into either an abstract render tree (for Eidos/React/Vue/vanilla to
walk), an HTML string with inline styles (roundtrip-friendly), or a
Markdown string (lossy by design — visual.* dropped).

engine/render-v2.ts (~370 lines):
- Pure renderWordsDocumentV2(doc) → readonly WordsRenderElementV2[].
- WordsRenderNodeV2 is a superset of V1's WordsRenderNode (adds 'hr',
  'div', 'section' tags for divider / callout). V1 stays untouched.
- Per-block dispatch for all 9 V2 types (paragraph/heading/quote/code/
  list/table/image/divider/callout). Same node shape as V1 — Svelte
  walker doesn't need changes.
- visualToStyle(visual) helper exports the central translation from
  BlockVisual props to CSS declarations. Numbers get 'px', hex strings
  pass through. borderColor without borderWidth defaults to 1px solid.
- Mark wrapping: parametric color/background marks combine into one
  <span style="...">; boolean marks stack via <code>/<span> with
  data-words-mark attr + inline style fallback.
- Callout renders as <div role="note" data-words-callout-intent="X">
  with optional title block + nested children.

engine/serialize-html-v2.ts (~120 lines):
- Pure serializeHtmlV2(doc, { pretty? }) → string.
- Walks renderWordsDocumentV2 output and emits escaped HTML.
- Properly escapes &, <, > in text; &, <, >, " in attribute values.
- Void elements (img/hr/br/input/meta/link) emit self-closing form.
- Pretty mode (opt-in) indents nested elements with 2 spaces.
- Roundtrip-friendly: inline style="..." preserves visual.* lossless.

engine/serialize-markdown-v2.ts (~190 lines):
- Pure serializeMarkdownV2(doc) → string.
- LOSSY by design (D-Q2): visual.* dropped entirely; color/background
  marks dropped (no MD vocabulary); underline mark dropped.
- Preserves structural semantics: headings (# ## ###), quotes (>),
  code blocks (``` with language fence), lists (- / 1. / - [x] - [ ]
  GFM task lists), tables (GFM with :--- / :---: / ---: alignment),
  images (![alt](src "caption")), dividers (---), callouts (GFM
  admonitions via INTENT_TO_GFM_ADMONITION mapping).
- INTENT_TO_GFM_ADMONITION exported: P7 boundary mapping —
  neutral→NOTE, affirm→TIP, fulfill→IMPORTANT, risk→WARNING,
  threat→CAUTION, loss→CAUTION.
- Mark order in output: bold(**) outer → italic(*) → strike(~~) →
  code(`) inner.
- Cells escape | as \| to avoid GFM table parser confusion.

Tests (56 new):
- render-v2.test.ts (23 tests): basic blocks, visual sidecar with
  exact style strings, table headerRow/headerCol semantics, marks
  rendering, path encoding, visualToStyle direct.
- serialize-html-v2.test.ts (12 tests): paragraph, escape semantics,
  void elements, inline styles, callout role attr, pretty mode,
  table th/td.
- serialize-markdown-v2.test.ts (21 tests): every block type, marks,
  visual.* dropped (lossy), callout intent → admonition tag mapping
  (all 6 SemaIntent values), link with title, table cell | escaping.

Important deferral: POLISH-1b (image radius/shadow/border slider)
needs the provider to write block.visual.*. Provider is V1 internally.
Deferred to R5 (after R4 migrates provider to V2). Pure render path
is ready when provider catches up.

Verification: 226/226 engine tests pass (85 V1 + 50 V2 validator +
35 V2 migrator/parity + 56 V2 render/serializers). `npm run check`:
0 errors. V1 model + render path UNTOUCHED — V2 lives in parallel.

Next: R4 — provider V2 migration. Operations rewritten over
WordsBlockV2. Runtime imageStatus sidecar wired at mount. Public API
preserved via adapter during transition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 92f0432eb0
commit 194ca208f9

@ -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. |

@ -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 → <p>', () => {
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 → <blockquote> 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 → <pre> wrapping <code class="language-X">', () => {
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 → <ol>, unordered → <ul>', () => {
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 → <figure><img>[<figcaption>]</figure>', () => {
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 → <hr>', () => {
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 → <div role="note"> 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 <tr>', () => {
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 <td>', () => {
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 <th>', () => {
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 <th>', () => {
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 <span style="color:#hex">', () => {
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 <code>', () => {
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']);
});
});

@ -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<Record<string, string | undefined>>;
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 `<span style="...">` /
* `<strong>` / `<em>` / `<u>` / `<s>` / `<code>` 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 <s> 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 <strong>/<em>/<s>/<u>)
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<BlockVisual> | undefined
): Readonly<Record<string, string>> {
if (!visual) return {};
const out: Record<string, string> = {};
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<Record<string, string>>): 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<BlockVisual> },
blockType: string,
path: WordsPath,
extra: Record<string, string | undefined>
): 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 } : {})
};
}

@ -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('<p ');
expect(html).toContain('data-words-block="paragraph"');
expect(html).toContain('>hello</p>');
});
it('escapes &, <, > in text content', () => {
const html = serializeHtmlV2(
doc({ type: 'paragraph', children: [{ type: 'text', text: '<script>&amp;</script>' }] })
);
expect(html).toContain('&lt;script&gt;&amp;amp;&lt;/script&gt;');
expect(html).not.toContain('<script>');
});
it('escapes " and & in attribute values', () => {
const html = serializeHtmlV2(
doc({
type: 'paragraph',
children: [
{
type: 'link',
href: 'https://example.com?a=1&b="2"',
children: [{ type: 'text', text: 'link' }]
}
]
})
);
expect(html).toContain('href="https://example.com?a=1&amp;b=&quot;2&quot;"');
});
it('emits <hr /> for divider (void element)', () => {
const html = serializeHtmlV2(doc({ type: 'divider' }));
expect(html).toContain('<hr ');
expect(html).toContain('/>');
expect(html).not.toContain('</hr>');
});
it('emits <img /> as void element', () => {
const html = serializeHtmlV2(
doc({ type: 'image', src: 'https://x/cat.png', alt: 'cat' })
);
expect(html).toContain('<img src="https://x/cat.png"');
expect(html).toContain('alt="cat"');
expect(html).toContain('/>');
});
it('preserves inline styles from visual', () => {
const html = serializeHtmlV2(
doc({
type: 'paragraph',
children: [{ type: 'text', text: 'x' }],
visual: { background: '#fafafa', padding: 12 }
})
);
// Canonical visualToStyle order: padding (layout) → background (fill)
expect(html).toContain('style="padding:12px;background-color:#fafafa"');
});
it('color mark produces <span style="...">', () => {
const html = serializeHtmlV2(
doc({
type: 'paragraph',
children: [
{ type: 'text', text: 'red text', marks: [{ type: 'color', value: '#ff0000' }] }
]
})
);
expect(html).toContain('<span style="color:#ff0000">red text</span>');
});
it('callout emits <div role="note">', () => {
const html = serializeHtmlV2(
doc({
type: 'callout',
intent: 'risk',
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'warning' }] }]
})
);
expect(html).toContain('role="note"');
expect(html).toContain('data-words-callout-intent="risk"');
});
it('pretty mode adds newlines and indentation', () => {
const html = serializeHtmlV2(
doc({
type: 'callout',
intent: 'neutral',
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'x' }] }]
}),
{ pretty: true }
);
expect(html).toContain('\n');
expect(html).toContain(' <p');
});
it('table with header row emits <th> + <td>', () => {
const html = serializeHtmlV2(
doc({
type: 'table',
headerRow: true,
rows: [
{ cells: [{ children: [{ type: 'text', text: 'h1' }] }] },
{ cells: [{ children: [{ type: 'text', text: 'a' }] }] }
]
})
);
expect(html).toMatch(/<th [^>]*>h1<\/th>/);
expect(html).toMatch(/<td [^>]*>a<\/td>/);
});
});

@ -0,0 +1,129 @@
/**
* V2 HTML serializer.
*
* Pure function: `WordsDocumentV2` → HTML string. Uses
* `renderWordsDocumentV2` to produce the abstract tree, then walks
* it emitting escaped HTML.
*
* **Roundtrip-friendly for content + visuals** — inline `style="..."`
* preserves `block.visual.*` losslessly. A future
* `deserializeHtmlV2` would re-parse them.
*
* Per P3: this is the "rich" serializer. Markdown (lossy) is in
* `serialize-markdown-v2.ts`. Plain text is in `serialize-text-v2.ts`
* (not implemented yet).
*/
import { renderWordsDocumentV2, type WordsRenderElementV2, type WordsRenderNodeV2 } from './render-v2';
import type { WordsDocumentV2 } from './types-v2';
export interface SerializeHtmlV2Options {
/**
* Indent each element with two spaces times its depth.
* Default `false` (single-line output, smaller payload).
*/
readonly pretty?: boolean;
}
export function serializeHtmlV2(doc: WordsDocumentV2, opts: SerializeHtmlV2Options = {}): string {
const rendered = renderWordsDocumentV2(doc);
const parts = rendered.map((el) => emitNode(el, opts.pretty ? 0 : -1));
return parts.join(opts.pretty ? '\n' : '');
}
// ── Emitter ──────────────────────────────────────────────────────────────
function emitNode(node: WordsRenderNodeV2, indent: number): string {
if (node.kind === 'text') return escapeText(node.text);
return emitElement(node, indent);
}
function emitElement(el: WordsRenderElementV2, indent: number): string {
const pad = indent >= 0 ? ' '.repeat(indent) : '';
const nl = indent >= 0 ? '\n' : '';
const tagAttrs = emitAttrs(el.attrs);
if (isVoidElement(el.tag)) {
return `${pad}<${el.tag}${tagAttrs} />`;
}
if (el.children.length === 0) {
return `${pad}<${el.tag}${tagAttrs}></${el.tag}>`;
}
// Inline-only children (all text) — keep on one line for readability.
const allText = el.children.every((c) => c.kind === 'text');
if (allText) {
const inner = el.children.map((c) => emitNode(c, -1)).join('');
return `${pad}<${el.tag}${tagAttrs}>${inner}</${el.tag}>`;
}
// Mixed / element children — recurse with nested indent.
const childIndent = indent >= 0 ? indent + 1 : -1;
const innerParts = el.children.map((c) => emitNode(c, childIndent));
const inner = indent >= 0 ? innerParts.join(nl) : innerParts.join('');
return `${pad}<${el.tag}${tagAttrs}>${nl}${inner}${nl}${pad}</${el.tag}>`;
}
function emitAttrs(attrs: Readonly<Record<string, string | undefined>>): string {
const out: string[] = [];
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined) continue;
out.push(` ${key}="${escapeAttr(value)}"`);
}
return out.join('');
}
// ── HTML entity escaping ─────────────────────────────────────────────────
function escapeText(text: string): string {
let out = '';
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
switch (c) {
case 38: // &
out += '&amp;';
break;
case 60: // <
out += '&lt;';
break;
case 62: // >
out += '&gt;';
break;
default:
out += text[i];
}
}
return out;
}
function escapeAttr(value: string): string {
let out = '';
for (let i = 0; i < value.length; i++) {
const c = value.charCodeAt(i);
switch (c) {
case 38: // &
out += '&amp;';
break;
case 60: // <
out += '&lt;';
break;
case 62: // >
out += '&gt;';
break;
case 34: // "
out += '&quot;';
break;
default:
out += value[i];
}
}
return out;
}
// ── Void elements (no closing tag) ───────────────────────────────────────
const VOID_TAGS = new Set<string>(['img', 'hr', 'br', 'input', 'meta', 'link']);
function isVoidElement(tag: string): boolean {
return VOID_TAGS.has(tag);
}

@ -0,0 +1,292 @@
/**
* R3C — serialize-markdown-v2 suite.
*
* Verifies LOSSY MD export: structure preserved, visual dropped,
* callout intent maps via INTENT_TO_GFM_ADMONITION.
*/
import { describe, expect, it } from 'vitest';
import { serializeMarkdownV2, INTENT_TO_GFM_ADMONITION } from './serialize-markdown-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('serializeMarkdownV2 — basic blocks', () => {
it('paragraph', () => {
const md = serializeMarkdownV2(
doc({ type: 'paragraph', children: [{ type: 'text', text: 'hello' }] })
);
expect(md.trim()).toBe('hello');
});
it('heading levels', () => {
const md = serializeMarkdownV2(
doc(
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'h1' }] },
{ type: 'heading', level: 2, children: [{ type: 'text', text: 'h2' }] },
{ type: 'heading', level: 3, children: [{ type: 'text', text: 'h3' }] }
)
);
expect(md).toContain('# h1');
expect(md).toContain('## h2');
expect(md).toContain('### h3');
});
it('quote', () => {
const md = serializeMarkdownV2(
doc({ type: 'quote', children: [{ type: 'text', text: 'wisdom' }] })
);
expect(md.trim()).toBe('> wisdom');
});
it('code block with language fence', () => {
const md = serializeMarkdownV2(
doc({
type: 'code',
language: 'python',
children: [{ type: 'text', text: 'print(1)' }]
})
);
expect(md).toContain('```python');
expect(md).toContain('print(1)');
expect(md).toContain('```');
});
it('unordered list', () => {
const md = serializeMarkdownV2(
doc({
type: 'list',
kind: 'unordered',
items: [
{ children: [{ type: 'text', text: 'a' }] },
{ children: [{ type: 'text', text: 'b' }] }
]
})
);
expect(md).toContain('- a');
expect(md).toContain('- b');
});
it('ordered list', () => {
const md = serializeMarkdownV2(
doc({
type: 'list',
kind: 'ordered',
items: [
{ children: [{ type: 'text', text: 'a' }] },
{ children: [{ type: 'text', text: 'b' }] }
]
})
);
expect(md).toContain('1. a');
expect(md).toContain('2. b');
});
it('check list (GFM task list)', () => {
const md = serializeMarkdownV2(
doc({
type: 'list',
kind: 'check',
items: [
{ checked: true, children: [{ type: 'text', text: 'done' }] },
{ checked: false, children: [{ type: 'text', text: 'todo' }] }
]
})
);
expect(md).toContain('- [x] done');
expect(md).toContain('- [ ] todo');
});
it('image', () => {
const md = serializeMarkdownV2(
doc({
type: 'image',
src: 'https://example.com/cat.png',
alt: 'cat',
caption: 'fluffy'
})
);
expect(md.trim()).toBe('![cat](https://example.com/cat.png "fluffy")');
});
it('divider', () => {
const md = serializeMarkdownV2(doc({ type: 'divider' }));
expect(md.trim()).toBe('---');
});
it('GFM table with headerRow + alignment', () => {
const md = serializeMarkdownV2(
doc({
type: 'table',
headerRow: true,
rows: [
{
cells: [
{ children: [{ type: 'text', text: 'name' }], align: 'left' },
{ children: [{ type: 'text', text: 'age' }], align: 'right' }
]
},
{
cells: [
{ children: [{ type: 'text', text: 'Alice' }] },
{ children: [{ type: 'text', text: '30' }] }
]
}
]
})
);
expect(md).toContain('| name | age |');
expect(md).toContain('| :--- | ---: |');
expect(md).toContain('| Alice | 30 |');
});
it('escapes | in table cell text', () => {
const md = serializeMarkdownV2(
doc({
type: 'table',
headerRow: true,
rows: [
{ cells: [{ children: [{ type: 'text', text: 'pipe|here' }] }] },
{ cells: [{ children: [{ type: 'text', text: 'a' }] }] }
]
})
);
expect(md).toContain('pipe\\|here');
});
});
describe('serializeMarkdownV2 — marks', () => {
it('bold + italic + code + strike', () => {
const md = serializeMarkdownV2(
doc({
type: 'paragraph',
children: [
{ type: 'text', text: 'a', marks: ['bold'] },
{ type: 'text', text: ' ' },
{ type: 'text', text: 'b', marks: ['italic'] },
{ type: 'text', text: ' ' },
{ type: 'text', text: 'c', marks: ['code'] },
{ type: 'text', text: ' ' },
{ type: 'text', text: 'd', marks: ['strike'] }
]
})
);
expect(md).toContain('**a**');
expect(md).toContain('*b*');
expect(md).toContain('`c`');
expect(md).toContain('~~d~~');
});
it('color + background marks are SILENTLY dropped (lossy)', () => {
const md = serializeMarkdownV2(
doc({
type: 'paragraph',
children: [
{
type: 'text',
text: 'red',
marks: [{ type: 'color', value: '#ff0000' }]
}
]
})
);
expect(md.trim()).toBe('red');
expect(md).not.toContain('color');
expect(md).not.toContain('#ff0000');
});
it('combined marks wrap from inside out: code → strike → italic → bold', () => {
const md = serializeMarkdownV2(
doc({
type: 'paragraph',
children: [
{ type: 'text', text: 'x', marks: ['bold', 'italic', 'code'] }
]
})
);
expect(md).toContain('***`x`***');
});
});
describe('serializeMarkdownV2 — visual.* is dropped', () => {
it('paragraph with visual produces same MD as without', () => {
const a = serializeMarkdownV2(
doc({ type: 'paragraph', children: [{ type: 'text', text: 'x' }] })
);
const b = serializeMarkdownV2(
doc({
type: 'paragraph',
children: [{ type: 'text', text: 'x' }],
visual: { background: '#fafafa', cornerRadius: 16, padding: 8 }
})
);
expect(a).toBe(b);
});
});
describe('serializeMarkdownV2 — callout (GFM admonition)', () => {
it('maps every SemaIntent to its admonition tag', () => {
for (const intent of ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as const) {
const md = serializeMarkdownV2(
doc({
type: 'callout',
intent,
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'body' }] }]
})
);
expect(md).toContain(`> [!${INTENT_TO_GFM_ADMONITION[intent]}]`);
expect(md).toContain('> body');
}
});
it('callout title is bolded', () => {
const md = serializeMarkdownV2(
doc({
type: 'callout',
intent: 'risk',
title: 'Warning header',
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'body' }] }]
})
);
expect(md).toContain('> [!WARNING]');
expect(md).toContain('> **Warning header**');
expect(md).toContain('> body');
});
});
describe('serializeMarkdownV2 — link', () => {
it('inline link with text', () => {
const md = serializeMarkdownV2(
doc({
type: 'paragraph',
children: [
{
type: 'link',
href: 'https://example.com',
children: [{ type: 'text', text: 'example' }]
}
]
})
);
expect(md.trim()).toBe('[example](https://example.com)');
});
it('link with title attribute', () => {
const md = serializeMarkdownV2(
doc({
type: 'paragraph',
children: [
{
type: 'link',
href: 'https://x',
title: 'site',
children: [{ type: 'text', text: 'x' }]
}
]
})
);
expect(md.trim()).toBe('[x](https://x "site")');
});
});

@ -0,0 +1,266 @@
/**
* V2 Markdown serializer.
*
* LOSSY export — drops `block.visual.*` entirely (markdown has no
* inline-style vocabulary). Drops `text.marks` of type color /
* background. Preserves structural semantics: headings, paragraphs,
* quotes, code blocks (with language fence), lists (ordered /
* unordered / check via GFM task lists), tables (GFM with alignment),
* images, links, dividers, callouts (GFM admonitions via intent →
* web vocabulary mapping).
*
* Per D-Q2 + P3: this is the lossy serializer. Use HTML for
* roundtrip-friendly export. JSON is the canonical lossless format.
*/
import type {
WordsBlockV2,
WordsCalloutBlockV2,
WordsCodeBlockV2,
WordsDocumentV2,
WordsEvalIntent,
WordsHeadingBlockV2,
WordsImageBlockV2,
WordsInlineV2,
WordsLinkV2,
WordsListBlockV2,
WordsListItemV2,
WordsMarkV2,
WordsParagraphBlockV2,
WordsQuoteBlockV2,
WordsTableBlockV2,
WordsTableCellV2,
WordsTextV2
} from './types-v2';
// ── Sema intent → GFM admonition tag (P7 mapping at the I/O boundary) ────
/**
* Maps the canonical evaluative vocabulary to the GitHub Flavored
* Markdown admonition tags. This is the ONLY place where the
* convention vocabulary leaks — it lives at the serializer boundary
* by design (P7 + section 3.4.bis of ARCHITECTURE_PROPOSAL.md).
*/
export const INTENT_TO_GFM_ADMONITION: Record<WordsEvalIntent, string> = {
neutral: 'NOTE',
affirm: 'TIP',
fulfill: 'IMPORTANT',
risk: 'WARNING',
threat: 'CAUTION',
loss: 'CAUTION' // GFM has no FAILURE; reuse CAUTION
};
// ── Entry point ──────────────────────────────────────────────────────────
export function serializeMarkdownV2(doc: WordsDocumentV2): string {
const blocks: string[] = [];
for (const block of doc.children) {
const md = blockToMd(block, 0);
if (md.length > 0) blocks.push(md);
}
return blocks.join('\n\n') + (blocks.length > 0 ? '\n' : '');
}
// ── Block dispatch ───────────────────────────────────────────────────────
function blockToMd(block: WordsBlockV2, depth: number): string {
switch (block.type) {
case 'paragraph':
return paragraphToMd(block);
case 'heading':
return headingToMd(block);
case 'quote':
return quoteToMd(block);
case 'code':
return codeToMd(block);
case 'list':
return listToMd(block, depth);
case 'table':
return tableToMd(block);
case 'image':
return imageToMd(block);
case 'divider':
return '---';
case 'callout':
return calloutToMd(block, depth);
}
}
// ── Paragraph / Heading / Quote ──────────────────────────────────────────
function paragraphToMd(block: WordsParagraphBlockV2): string {
return inlinesToMd(block.children);
}
function headingToMd(block: WordsHeadingBlockV2): string {
const prefix = '#'.repeat(block.level);
return `${prefix} ${inlinesToMd(block.children)}`;
}
function quoteToMd(block: WordsQuoteBlockV2): string {
const text = inlinesToMd(block.children);
// Split paragraph-by-paragraph in case the inline tree has hard
// line breaks. For now treat as single paragraph.
return text
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
// ── Code block ────────────────────────────────────────────────────────────
function codeToMd(block: WordsCodeBlockV2): string {
const code = block.children.map((t) => t.text).join('');
const lang = block.language ?? '';
return `\`\`\`${lang}\n${code}\n\`\`\``;
}
// ── List ─────────────────────────────────────────────────────────────────
function listToMd(block: WordsListBlockV2, depth: number): string {
const lines: string[] = [];
for (let i = 0; i < block.items.length; i++) {
lines.push(listItemToMd(block.items[i], i, block.kind, depth));
}
return lines.join('\n');
}
function listItemToMd(
item: WordsListItemV2,
index: number,
kind: WordsListBlockV2['kind'],
depth: number
): string {
const indent = ' '.repeat(depth + (item.indent ?? 0));
const marker =
kind === 'ordered'
? `${index + 1}.`
: kind === 'check'
? item.checked
? '- [x]'
: '- [ ]'
: '-';
const content = inlinesToMd(item.children);
return `${indent}${marker} ${content}`;
}
// ── Table (GFM) ──────────────────────────────────────────────────────────
function tableToMd(block: WordsTableBlockV2): string {
if (block.rows.length === 0) return '';
const colCount = block.rows[0]?.cells.length ?? 0;
if (colCount === 0) return '';
const rowToMd = (row: { cells: readonly WordsTableCellV2[] }): string => {
const cells = row.cells.map((c) => cellTextToMd(c));
while (cells.length < colCount) cells.push('');
return `| ${cells.join(' | ')} |`;
};
const lines: string[] = [];
const headerRow = block.headerRow === true ? block.rows[0] : undefined;
const headerCells =
headerRow?.cells ?? Array.from({ length: colCount }, () => ({ children: [] as readonly WordsInlineV2[] }));
// GFM requires a header row. If V2 doesn't declare one, emit an
// empty header. (Tables without headers aren't GFM-valid; we'd
// lose data on import otherwise.)
if (headerRow) {
lines.push(rowToMd(headerRow as { cells: readonly WordsTableCellV2[] }));
} else {
lines.push(`| ${headerCells.map(() => ' ').join(' | ')} |`);
}
// Alignment row (GFM `:---` / `:---:` / `---:`)
const alignmentMarkers: string[] = [];
for (let i = 0; i < colCount; i++) {
const firstAlign = block.rows[0]?.cells[i]?.align;
alignmentMarkers.push(
firstAlign === 'center'
? ':---:'
: firstAlign === 'right'
? '---:'
: firstAlign === 'left'
? ':---'
: '---'
);
}
lines.push(`| ${alignmentMarkers.join(' | ')} |`);
// Body rows
const bodyRows = headerRow ? block.rows.slice(1) : block.rows;
for (const row of bodyRows) {
lines.push(rowToMd(row));
}
return lines.join('\n');
}
function cellTextToMd(cell: WordsTableCellV2): string {
return inlinesToMd(cell.children).replace(/\|/g, '\\|');
}
// ── Image ─────────────────────────────────────────────────────────────────
function imageToMd(block: WordsImageBlockV2): string {
const alt = block.alt ?? '';
const title = block.caption ? ` "${block.caption.replace(/"/g, '\\"')}"` : '';
return `![${alt}](${block.src}${title})`;
}
// ── Callout (GFM admonition) ─────────────────────────────────────────────
function calloutToMd(block: WordsCalloutBlockV2, depth: number): string {
const tag = INTENT_TO_GFM_ADMONITION[block.intent];
const lines: string[] = [`> [!${tag}]`];
if (block.title) lines.push(`> **${block.title}**`);
for (const child of block.children) {
const childMd = blockToMd(child, depth + 1);
for (const line of childMd.split('\n')) {
lines.push(`> ${line}`);
}
}
return lines.join('\n');
}
// ── Inlines + marks → MD ─────────────────────────────────────────────────
function inlinesToMd(inlines: readonly WordsInlineV2[]): string {
return inlines.map(inlineToMd).join('');
}
function inlineToMd(inline: WordsInlineV2): string {
if (inline.type === 'link') return linkToMd(inline);
return textWithMarksToMd(inline);
}
function linkToMd(link: WordsLinkV2): string {
const text = link.children.map(textWithMarksToMd).join('');
const title = link.title ? ` "${link.title.replace(/"/g, '\\"')}"` : '';
return `[${text}](${link.href}${title})`;
}
/**
* Mark wrapping order:
* `**bold**` outer → `*italic*` → `~~strike~~` → `\`code\``
* Underline doesn't exist in MD; we drop it silently (could fallback
* to HTML `<u>` but the user said MD is lossy by design).
* Color / background marks are DROPPED — they have no MD vocabulary.
*/
function textWithMarksToMd(text: WordsTextV2): string {
let out = text.text;
if (!text.marks || text.marks.length === 0) return out;
const has = (m: string): boolean => text.marks!.some((mk) => mk === m);
if (has('code')) out = `\`${out}\``;
if (has('strike')) out = `~~${out}~~`;
if (has('italic')) out = `*${out}*`;
if (has('bold')) out = `**${out}**`;
// Underline + color + background marks are lossy. Silent drop.
void (markIsColor as (m: WordsMarkV2) => boolean); // keep helper in tree-shake bin
return out;
}
function markIsColor(mark: WordsMarkV2): boolean {
return typeof mark === 'object' && (mark.type === 'color' || mark.type === 'background');
}
Loading…
Cancel
Save

Powered by TurnKey Linux.