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 (), 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
parent
92f0432eb0
commit
194ca208f9
@ -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>&</script>' }] })
|
||||
);
|
||||
expect(html).toContain('<script>&amp;</script>');
|
||||
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&b="2""');
|
||||
});
|
||||
|
||||
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 += '&';
|
||||
break;
|
||||
case 60: // <
|
||||
out += '<';
|
||||
break;
|
||||
case 62: // >
|
||||
out += '>';
|
||||
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 += '&';
|
||||
break;
|
||||
case 60: // <
|
||||
out += '<';
|
||||
break;
|
||||
case 62: // >
|
||||
out += '>';
|
||||
break;
|
||||
case 34: // "
|
||||
out += '"';
|
||||
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('');
|
||||
});
|
||||
|
||||
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 ``;
|
||||
}
|
||||
|
||||
// ── 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…
Reference in new issue