/** * -------------------------------------------------------------------- * docmd : the zero-config documentation engine. * * Phase 2 container normaliser — edge-case test fixture * * These tests cover the five reported F1–F5 failure modes plus the * 50+ edge cases that derive from the shim's own warning surface * (battle-test-reports/robust-parser-shim/index.js) and the * `normaliseContainers` algorithm. * * Test categories (in order): * 1. classifyLine — open / close / other classification * 2. indentOf — leading-space counting * 3. normaliseContainers — core algorithm (no warnings) * 4. normaliseContainers — warning surface * 5. F1–F5 reported failure modes * 6. processContentAsync integration — end-to-end HTML * 7. Determinism — identical output across threads / replays * * Run: `pnpm --filter @docmd/parser test` * -------------------------------------------------------------------- */ import test from 'node:test'; import assert from 'node:assert/strict'; import { normaliseContainers, classifyLine, indentOf, SELF_CLOSING_CONTAINER_NAMES, createMarkdownProcessor, processContentAsync } from '../dist/index.js'; // ───────────────────────────────────────────────────────────────────── // 1. classifyLine // ───────────────────────────────────────────────────────────────────── test('classifyLine: `::: callout` is open with name `callout`', () => { assert.deepEqual(classifyLine('::: callout'), { kind: 'open', name: 'callout' }); }); test('classifyLine: `::: callout info "Title"` is open, name is the first word', () => { assert.deepEqual( classifyLine('::: callout info "Title"'), { kind: 'open', name: 'callout' } ); }); test('classifyLine: `:::card` (no space) is still open (zero-or-more whitespace)', () => { assert.deepEqual(classifyLine(':::card'), { kind: 'open', name: 'card' }); }); test('classifyLine: `:::` is close', () => { assert.deepEqual(classifyLine(':::'), { kind: 'close' }); }); test('classifyLine: `::: ` (trailing whitespace) is close', () => { assert.deepEqual(classifyLine('::: '), { kind: 'close' }); }); test('classifyLine: ` ::: callout` (leading indent) is still open', () => { assert.deepEqual( classifyLine(' ::: callout'), { kind: 'open', name: 'callout' } ); }); test('classifyLine: `:::: callout` (four colons) is NOT matched as open or close', () => { // Three colons then a fourth colon is not whitespace, so `[a-zA-Z]` fails. // The line passes through to the parser unchanged. assert.equal(classifyLine(':::: callout').kind, 'other'); }); test('classifyLine: `::: 123abc` (leading digit) is NOT open', () => { // Name must start with a letter. assert.equal(classifyLine('::: 123abc').kind, 'other'); }); test('classifyLine: `:::-foo` (hyphen, not letter) is NOT open', () => { assert.equal(classifyLine(':::-foo').kind, 'other'); }); test('classifyLine: `plain text` is other', () => { assert.equal(classifyLine('plain text').kind, 'other'); }); test('classifyLine: empty string is other', () => { assert.equal(classifyLine('').kind, 'other'); }); // ───────────────────────────────────────────────────────────────────── // 2. indentOf // ───────────────────────────────────────────────────────────────────── test('indentOf: empty line is 0', () => { assert.equal(indentOf(''), 0); }); test('indentOf: line with no leading spaces is 0', () => { assert.equal(indentOf('::: callout'), 0); }); test('indentOf: 4 leading spaces is 4', () => { assert.equal(indentOf(' ::: callout'), 4); }); test('indentOf: 8 leading spaces is 8', () => { assert.equal(indentOf(' ::: card'), 8); }); // ───────────────────────────────────────────────────────────────────── // 3. normaliseContainers — core algorithm (no warnings expected) // ───────────────────────────────────────────────────────────────────── test('normaliseContainers: empty input is unchanged', () => { const r = normaliseContainers(''); assert.equal(r.source, ''); assert.deepEqual(r.warnings, []); }); test('normaliseContainers: input with no `:::` is unchanged', () => { const src = '# Title\n\nSome **bold** text.\n\n- list item\n'; const r = normaliseContainers(src); assert.equal(r.source, src); assert.deepEqual(r.warnings, []); }); test('normaliseContainers: balanced single callout is unchanged', () => { const src = '::: callout info "Title"\nbody\n:::\n'; const r = normaliseContainers(src); assert.equal(r.source, src); assert.deepEqual(r.warnings, []); }); test('normaliseContainers: self-closing `::: button` passes through with no stack push', () => { const src = '::: button "Click me"\n'; const r = normaliseContainers(src); assert.equal(r.source, src); assert.deepEqual(r.warnings, []); }); test('normaliseContainers: SELF_CLOSING_CONTAINER_NAMES has exactly button/tag/embed', () => { // The whitelist is the source of truth for the depth counter's behaviour. // Adding or removing names here is a parser-semantics change. assert.deepEqual( [...SELF_CLOSING_CONTAINER_NAMES].sort(), ['button', 'embed', 'tag'] ); }); test('normaliseContainers: three self-closing tags then a stray `:::` removes the stray', () => { // F2 — orphan `:::` after self-closing tags must be dropped. const src = '::: tag "a"\n::: tag "b"\n::: tag "c"\n:::\n'; const r = normaliseContainers(src); assert.equal(r.source, '::: tag "a"\n::: tag "b"\n::: tag "c"\n'); assert.equal(r.warnings.length, 1); assert.equal(r.warnings[0].severity, 'warning'); assert.match(r.warnings[0].message, /Stray `:::`/); }); test('normaliseContainers: nested callout/card with one implicit close emits 2 closes', () => { // Inner `::: card` is at indent 4; user's `:::` is at indent 0 → the // close at indent 0 matches the OUTER callout, and the inner card is // closed implicitly. Algorithm emits 2 closes at indent 0 (one replaces // the user's `:::`, one is added for the card). const src = '::: callout\n ::: card "x"\n body\n:::\n'; const r = normaliseContainers(src); const lines = r.source.split('\n'); const closeCount = lines.filter((l) => /^:::\s*$/.test(l)).length; assert.equal(closeCount, 2, `expected 2 closes at indent 0, got ${closeCount}`); assert.equal(r.warnings.length, 1); assert.equal(r.warnings[0].severity, 'info'); }); test('normaliseContainers: indented open matched by greater-indent close', () => { // Open at indent 4, close at indent 8 (deeper indent). The shim's // matching rule is `open.indent <= close.indent` so 4 <= 8 matches. // The original `:::` is replaced with one at the open's indent. const src = ' ::: card "x"\nbody\n :::\n'; const r = normaliseContainers(src); // The original close is rewritten at the open's indent (4 spaces). assert.match(r.source, / {4}::: card "x"\n/); assert.match(r.source, /\n {4}:::/); // No warnings — this is a balanced container. assert.deepEqual(r.warnings, []); }); test('normaliseContainers: close LESS indented than open is stray, container auto-closes at EOF', () => { // Open at indent 4, close at indent 0. The shim's matching rule is // `open.indent <= close.indent` so 4 <= 0 fails → stray close. The // card then auto-closes at EOF with an ERROR (at the card's indent). const src = ' ::: card "x"\nbody\n:::\n'; const r = normaliseContainers(src); // The stray close at indent 0 is removed; the card is auto-closed at // EOF at the card's original indent (4 spaces). assert.doesNotMatch(r.source, /\n:::\s*$/); assert.match(r.source, /\n {4}:::\s*$/); // Warnings: 1 stray (warning) + 1 unclosed (error). const stray = r.warnings.find((w) => w.severity === 'warning'); const unclosed = r.warnings.find((w) => w.severity === 'error'); assert.ok(stray, 'expected a stray-close warning'); assert.ok(unclosed, 'expected an unclosed-at-EOF error'); assert.match(unclosed.message, /Unclosed ``/); }); test('normaliseContainers: over-indented close is normalised, not stray', () => { // Open at indent 0, close at indent 4 → close's indent (4) >= open's // indent (0), so they match. The over-indented close is rewritten to // the open's indent. This is the normaliser's signature behaviour: // it lets users get the indent wrong without breaking the page. const src = '::: callout\nbody\n :::\n'; const r = normaliseContainers(src); // The callout is closed cleanly with no warnings. assert.equal(r.source, '::: callout\nbody\n:::\n'); assert.deepEqual(r.warnings, []); }); test('normaliseContainers: unclosed at EOF is auto-closed with an error', () => { // F4 / F3 class — file ends with an open container still on the stack. const src = '::: callout\nbody\n'; const r = normaliseContainers(src); // Source ends with the auto-close at indent 0 (callout's indent). The // original body line ended with `\n`, so there are two newlines // between `body` and the inserted `:::`. assert.match(r.source, /\n:::\s*$/); assert.equal(r.warnings.length, 1); assert.equal(r.warnings[0].severity, 'error'); assert.match(r.warnings[0].message, /Unclosed ``/); }); test('normaliseContainers: multiple unclosed at EOF each emit an error', () => { const src = '::: callout\n::: card "x"\n'; const r = normaliseContainers(src); // Two auto-closes are appended, one per open frame. assert.match(r.source, /\n:::\n:::\s*$/); const errors = r.warnings.filter((w) => w.severity === 'error'); assert.equal(errors.length, 2); // Inner card is reported first (LIFO from the stack). assert.match(errors[0].message, //); assert.match(errors[1].message, //); }); // ───────────────────────────────────────────────────────────────────── // 4. normaliseContainers — warning surface // ───────────────────────────────────────────────────────────────────── test('normaliseContainers: stray `:::` at top level produces a warning with the line number', () => { const r = normaliseContainers(':::\n'); assert.equal(r.warnings.length, 1); assert.equal(r.warnings[0].line, 1); assert.equal(r.warnings[0].severity, 'warning'); }); test('normaliseContainers: sourcePath is reflected in warnings', () => { const r = normaliseContainers(':::\n', { sourcePath: 'docs/page.md' }); assert.equal(r.warnings[0].path, 'docs/page.md'); }); test('normaliseContainers: sourcePath defaults to ``', () => { const r = normaliseContainers(':::\n'); assert.equal(r.warnings[0].path, ''); }); test('normaliseContainers: onWarning callback fires alongside the returned array', () => { const collected = []; const r = normaliseContainers(':::\n', { onWarning: (w) => collected.push(w) }); assert.equal(collected.length, 1); assert.equal(collected[0].line, 1); assert.equal(r.warnings.length, 1); assert.equal(collected[0], r.warnings[0]); }); test('normaliseContainers: legacy 2-arg signature `normaliseContainers(src, path)` works', () => { // Used by the legacy shim-style plugin descriptor. const r = normaliseContainers(':::\n', 'legacy/path.md'); assert.equal(r.warnings[0].path, 'legacy/path.md'); }); test('normaliseContainers: implicit multi-close info warning lists all closed containers', () => { // Inner `::: card` at indent 4 is closed by the user's `:::` at indent 0. // The outer `::: callout` is ALSO closed by the same `:::` because its // indent (0) matches the close's indent (0). One user gesture closes two // containers — the algorithm emits an INFO warning naming both. const src = '::: callout\n ::: card "x"\n body\n:::\n'; const r = normaliseContainers(src); const info = r.warnings.find((w) => w.severity === 'info'); assert.ok(info, 'expected an info warning for implicit multi-close'); assert.match(info.message, //); assert.match(info.message, //); }); // ───────────────────────────────────────────────────────────────────── // 5. F1–F5 reported failure modes // ───────────────────────────────────────────────────────────────────── test('F1: nested grids with one close per card → 3 outer closes at indent 0', () => { // The user-reported "grids don't work" pattern. Cards are closed // individually (one `:::` per card body) at indent 8; only the outer // grids has its own `:::` at indent 0. The user's final `:::` closes // the OUTER grids, and the inner grids (×2) are closed implicitly. const src = [ '::: grids', ' ::: grid', ' ::: card "Fast" icon:zap', ' body', ' :::', ' ::: grid', ' ::: card "Slow"', ' body', ' :::', ':::' ].join('\n'); const r = normaliseContainers(src); // Algorithm emits 3 closes at indent 0: 1 replaces the user's final // `:::`, 2 are added to close the 2 inner grids. const lines = r.source.split('\n'); const outerCloses = lines.filter((l) => /^:::\s*$/.test(l)); assert.equal(outerCloses.length, 3, `expected 3 outer closes, got ${outerCloses.length}`); // Top-level normaliser produces an info warning naming all 3 containers // that the user's one `:::` closed implicitly. const info = r.warnings.find((w) => w.severity === 'info'); assert.ok(info, 'expected an info warning for implicit multi-close'); assert.match(info.message, //); assert.match(info.message, //); }); test('F2: three `::: tag` lines plus an orphan `:::` → orphan removed, no stack pushes', () => { const src = '::: tag "v0.8" color:blue\n::: tag "Experimental"\n::: tag "Live"\n:::\n'; const r = normaliseContainers(src); // All three tag lines preserved, orphan `:::` removed with a warning. assert.match(r.source, /::: tag "v0.8"/); assert.match(r.source, /::: tag "Experimental"/); assert.match(r.source, /::: tag "Live"/); assert.equal(r.source.split('\n').filter((l) => l === ':::').length, 0); assert.equal(r.warnings.length, 1); assert.equal(r.warnings[0].severity, 'warning'); }); test('F3: `::: callout ... ::: card ... :::` mismatched close → auto-close callout', () => { const src = '::: callout info "x"\nbody\n::: card "wrong close"\noops\n:::\n'; const r = normaliseContainers(src); // The callout opener stays; the card opener stays; the user's `:::` closes // the card; the callout is then auto-closed at EOF with an error. assert.match(r.source, /::: callout info "x"/); assert.match(r.source, /::: card "wrong close"/); const errors = r.warnings.filter((w) => w.severity === 'error'); assert.equal(errors.length, 1); assert.match(errors[0].message, /Unclosed ``/); }); test('F4: triple `:::` after a balanced callout → two are removed, one closes callout', () => { const src = '::: callout info "x"\nbody\n:::\n:::\n:::\n'; const r = normaliseContainers(src); // First `:::` closes callout (matching); second and third are stray. const warnings = r.warnings.filter((w) => w.severity === 'warning'); assert.equal(warnings.length, 2); // Source retains exactly one `:::` (the callout close). const closes = r.source.split('\n').filter((l) => /^:::\s*$/.test(l)); assert.equal(closes.length, 1); }); test('F5: 5-level nested callouts → already balanced, normalisation is a no-op', () => { const src = [ '::: callout info "l1"', '::: callout tip "l2"', '::: callout warning "l3"', '::: callout danger "l4"', '::: callout success "l5"', 'deep', ':::', ':::', ':::', ':::', ':::' ].join('\n'); const r = normaliseContainers(src); assert.equal(r.source, src); assert.deepEqual(r.warnings, []); }); // ───────────────────────────────────────────────────────────────────── // 6. processContentAsync integration — end-to-end HTML // ───────────────────────────────────────────────────────────────────── // Build a fresh processor per test so module-level caches don't leak. function freshProcessor() { return createMarkdownProcessor({}, () => {}); } test('integration: plain markdown renders unchanged', async () => { const md = freshProcessor(); const r = await processContentAsync('# Hello\n\nBody text.\n', md, {}, {}); // headingIdPlugin adds id + class + permalink anchor; just check the // h1 exists and contains the heading text. assert.match(r.htmlContent, /]*>[\s\S]*?Hello[\s\S]*?<\/h1>/); assert.match(r.htmlContent, /

Body text\.<\/p>/); }); test('integration: balanced single callout renders as `.callout` div', async () => { const md = freshProcessor(); const r = await processContentAsync( '::: callout info "Title"\nbody\n:::\n', md, {}, { filePath: 'test.md' } ); assert.match(r.htmlContent, /docmd-container callout callout-info/); assert.match(r.htmlContent, /callout-title/); assert.match(r.htmlContent, /

body<\/p>/); }); test('integration: F1 grids render correctly (no raw `

::: grids
` text)', async () => { const md = freshProcessor(); const src = [ '::: grids', ' ::: grid', ' ::: card "Fast"', ' body1', ' :::', ' ::: grid', ' ::: card "Slow"', ' body2', ' :::', ':::' ].join('\n'); const r = await processContentAsync(src, md, {}, { filePath: 'f1.md' }); assert.match(r.htmlContent, /docmd-container grids/); assert.match(r.htmlContent, /grid-item/); assert.match(r.htmlContent, /card-title/); // No leaked raw container text. assert.doesNotMatch(r.htmlContent, /

::: grids
{ const md = freshProcessor(); const r = await processContentAsync( '::: tag "v0.8" color:blue\n::: tag "Experimental"\n::: tag "Live"\n:::\n', md, {}, { filePath: 'f2.md' } ); assert.match(r.htmlContent, /docmd-tag/); // No leaked `

:::

` orphan. assert.doesNotMatch(r.htmlContent, /

:::<\/p>/); }); test('integration: F3 mismatched close renders callout + card correctly', async () => { const md = freshProcessor(); const r = await processContentAsync( '::: callout info "x"\nbody\n::: card "wrong close"\noops\n:::\n', md, {}, { filePath: 'f3.md' } ); assert.match(r.htmlContent, /callout-info/); assert.match(r.htmlContent, /card-title/); assert.match(r.htmlContent, /

oops<\/p>/); }); test('integration: F4 triple close renders callout only, no leaked `

:::

`', async () => { const md = freshProcessor(); const r = await processContentAsync( '::: callout info "x"\nbody\n:::\n:::\n:::\n', md, {}, { filePath: 'f4.md' } ); assert.match(r.htmlContent, /callout-info/); assert.match(r.htmlContent, /

body<\/p>/); // No leaked `

:::

` orphan paragraphs. assert.doesNotMatch(r.htmlContent, /

:::<\/p>/); }); test('integration: F5 5-level nested callouts render five `

` levels', async () => { const md = freshProcessor(); const src = [ '::: callout info "l1"', '::: callout tip "l2"', '::: callout warning "l3"', '::: callout danger "l4"', '::: callout success "l5"', 'deep', ':::', ':::', ':::', ':::', ':::' ].join('\n'); const r = await processContentAsync(src, md, {}, { filePath: 'f5.md' }); for (const cls of ['callout-info', 'callout-tip', 'callout-warning', 'callout-danger', 'callout-success']) { assert.match(r.htmlContent, new RegExp(cls), `expected ${cls} in HTML`); } assert.match(r.htmlContent, /

deep<\/p>/); }); // ───────────────────────────────────────────────────────────────────── // 7. Determinism — identical output across replays / concurrent calls // ───────────────────────────────────────────────────────────────────── test('determinism: normaliseContainers returns byte-identical output on replay', () => { const src = [ '::: grids', ' ::: grid', ' ::: card "x"', ' body', ' :::', ':::' ].join('\n'); const a = normaliseContainers(src); const b = normaliseContainers(src); assert.equal(a.source, b.source); assert.deepEqual(a.warnings, b.warnings); }); test('determinism: 100 concurrent parses of the same source produce identical HTML', async () => { const md = freshProcessor(); const src = [ '::: grids', ' ::: grid', ' ::: card "Fast"', ' body1', ' :::', ' ::: grid', ' ::: card "Slow"', ' body2', ' :::', ':::' ].join('\n'); const N = 100; const results = await Promise.all( Array.from({ length: N }, () => processContentAsync(src, md, {}, { filePath: 'det.md' })) ); const first = results[0].htmlContent; for (let i = 1; i < N; i++) { assert.equal(results[i].htmlContent, first, `divergence at index ${i}`); } }); test('determinism: parser output is independent of `Date.now` / randomness', async () => { // No Date.now / Math.random in the parser pipeline. Two parses made at // very different times must produce identical HTML. const md = freshProcessor(); const src = '# Heading\n\n::: callout\nbody\n:::\n'; const a = await processContentAsync(src, md, {}, { filePath: 'a.md' }); await new Promise((r) => setTimeout(r, 10)); const b = await processContentAsync(src, md, {}, { filePath: 'a.md' }); assert.equal(a.htmlContent, b.htmlContent); }); // Cross-thread determinism (Phase 2 commit 3). Run the same parse in a // real worker_threads worker — a different module instance, a different // microtask queue — and assert the HTML is byte-identical to the main // thread's result. test('determinism: parse in a worker_threads worker produces identical HTML', async () => { const { Worker } = await import('node:worker_threads'); const md = freshProcessor(); const src = [ '::: grids', ' ::: grid', ' ::: card "Fast"', ' body1', ' :::', ' ::: grid', ' ::: card "Slow"', ' body2', ' :::', ':::' ].join('\n') + '\n:::\n'; // trailing stray exercises the warning path // Main-thread parse. const mainResult = await processContentAsync(src, md, {}, { filePath: 'det.md' }); // Worker-thread parse. Inline worker that imports the same dist build. const workerCode = ` import { parentPort, workerData } from 'node:worker_threads'; import { createMarkdownProcessor, processContentAsync } from '${import.meta.dirname}/../dist/index.js'; (async () => { const md = createMarkdownProcessor({}, () => {}); const r = await processContentAsync(workerData.src, md, {}, { filePath: workerData.filePath }); parentPort.postMessage(r.htmlContent); })().catch((e) => { parentPort.postMessage({ __err: e.message }); }); `; const workerResult = await new Promise((resolve, reject) => { const w = new Worker(workerCode, { eval: true, workerData: { src, filePath: 'det.md' } }); w.once('message', (msg) => { if (msg && typeof msg === 'object' && msg.__err) reject(new Error(msg.__err)); else resolve(msg); }); w.once('error', reject); }); assert.equal(workerResult, mainResult.htmlContent); }); // ───────────────────────────────────────────────────────────────────── // 8. Edge cases that round out the fixture to 50+ assertions // ───────────────────────────────────────────────────────────────────── test('edge case: empty `info` after `::: callout` is preserved through normalisation', () => { // `::: callout` with no extra info → passes through, no warnings. const src = '::: callout\nbody\n:::\n'; const r = normaliseContainers(src); assert.equal(r.source, src); assert.deepEqual(r.warnings, []); }); test('edge case: `::: callout` followed immediately by another container (no body)', () => { // Two opens in a row — the inner one is "body" of the outer. Both // push to the stack; one close closes the outer. const src = '::: callout\n::: card "x"\nbody\n:::\n'; const r = normaliseContainers(src); // The card is closed by the user's `:::`, the callout is auto-closed // at EOF with an ERROR. assert.equal(r.warnings.filter((w) => w.severity === 'error').length, 1); }); test('edge case: CRLF line endings preserve `\r` on pass-through lines (documented behaviour)', () => { // The algorithm splits on `\n` so `\r` ends up at the end of each // pass-through line and is kept verbatim. The close regex `\s*$` // still matches because `\r` is `\s`. The output replaces close lines // with a freshly-emitted `:::` (no `\r`), so the trailing `\r` on the // user's close line is dropped — but everything else keeps its `\r`. // This matches the legacy shim's behaviour. const src = '::: callout\r\nbody\r\n:::\r\n'; const r = normaliseContainers(src); // Pass-through lines retain `\r\n`; only the emitted `:::` is clean. assert.equal(r.source, '::: callout\r\nbody\r\n:::\n'); assert.deepEqual(r.warnings, []); }); test('edge case: multiple spaces between `:::` and the container name', () => { const src = '::: callout info "Title"\nbody\n:::\n'; const r = normaliseContainers(src); assert.equal(r.source, src); assert.deepEqual(r.warnings, []); }); test('edge case: deeply nested self-closing tags do not corrupt the stack', () => { // Many self-closing tag lines; no closes, no opens, no pushes. const lines = Array.from({ length: 50 }, (_, i) => `::: tag "v${i}"`).join('\n'); const r = normaliseContainers(lines + '\n'); assert.equal(r.source, lines + '\n'); assert.deepEqual(r.warnings, []); }); test('edge case: pre-existing normaliser output is idempotent', () => { // Running the normaliser twice must produce the same result as once // (for already-balanced input). const src = [ '::: grids', ' ::: grid', ' ::: card "Fast"', ' body1', ' :::', ' ::: grid', ' ::: card "Slow"', ' body2', ' :::', ':::' ].join('\n'); const once = normaliseContainers(src); const twice = normaliseContainers(once.source); assert.equal(twice.source, once.source); }); test('edge case: warning `line` numbers are 1-indexed and stable across edits', () => { // Insert a line at the top; the warning for the stray `:::` should // shift from line 1 to line 2. const r1 = normaliseContainers(':::\n'); assert.equal(r1.warnings[0].line, 1); const r2 = normaliseContainers('# heading\n:::\n'); assert.equal(r2.warnings[0].line, 2); }); test('edge case: `NormaliserWarning` shape is exactly { line, severity, path, message }', () => { const r = normaliseContainers(':::\n', { sourcePath: 'p.md' }); const w = r.warnings[0]; assert.deepEqual(Object.keys(w).sort(), ['line', 'message', 'path', 'severity']); assert.equal(typeof w.line, 'number'); assert.equal(typeof w.severity, 'string'); assert.equal(typeof w.path, 'string'); assert.equal(typeof w.message, 'string'); }); test('edge case: container aliases (`tip`, `warning`, `info`, `note`, `danger`, `caution`) are NOT self-closing', () => { // The parser maps `tip`, `warning`, `danger`, `info`, `note`, `caution` // to callout types. The normaliser must NOT treat them as self-closing // even though the names match a generic pattern. Only the literal set // { button, tag, embed } is self-closing. for (const name of ['tip', 'warning', 'danger', 'info', 'note', 'caution']) { assert.ok( !SELF_CLOSING_CONTAINER_NAMES.has(name), `${name} must NOT be self-closing (it's a callout alias)` ); } }); test('edge case: debug mode logs self-close lines to stdout', () => { // Capture console.log to verify the debug path fires. const originalLog = console.log; const captured = []; console.log = (...args) => captured.push(args.join(' ')); try { normaliseContainers('::: tag "x"\n', { debug: true }); } finally { console.log = originalLog; } assert.equal(captured.length, 1); assert.match(captured[0], /self-close /); }); test('edge case: warnings preserve insertion order across severity', () => { // Three different issues in one source, each in source order: // L1 — stray top-level `:::` (warning) // L2–L6 — callout + card with one close at indent 0 → implicit multi-close (info) // L8 — `::: unclosed` never closed (error) const src = [ ':::', // 1: stray (warning) '::: callout', // 2 ' ::: card "x"', // 3 ' body', // 4 ':::', // 5: closes callout + card (info) '', // 6 '::: unclosed' // 7: unclosed at EOF (error) ].join('\n') + '\n'; const r = normaliseContainers(src); const sevs = r.warnings.map((w) => w.severity); // All three severities should appear, in source order. assert.deepEqual(sevs, ['warning', 'info', 'error']); assert.equal(r.warnings[0].line, 1); assert.equal(r.warnings[1].line, 5); assert.equal(r.warnings[2].line, 7); }); // ───────────────────────────────────────────────────────────────────── // 8. Fenced code block tracking (F6 — normaliser must not interpret // `:::` lines that appear inside a ``` or ~~~ fence) // ───────────────────────────────────────────────────────────────────── test('F6: ::: lines inside a ``` fence are not classified as opens/closes', () => { // The docs site has pages that show `::: card ... :::` examples inside // markdown code fences. Without fence tracking the normaliser treats the // fenced `::: card` as a real open, pushes it on the stack, and reports // a spurious "Unclosed " error when EOF arrives. const src = [ '# Buttons', // 1 '', // 2 '```markdown', // 3: fence open '::: card "Setup"', // 4: literal text in fence ' ::: button "Begin" ../../x', // 5: literal text in fence ':::', // 6: literal text in fence '```', // 7: fence close '', // 8 '::: callout', // 9: real open 'body', // 10 ':::' // 11: real close ].join('\n') + '\n'; const r = normaliseContainers(src); // No warnings: the fenced ::: lines are not counted, the real callout // at L9–L11 is balanced. assert.equal(r.warnings.length, 0); // Output is unchanged — the fence contents pass through verbatim. assert.equal(r.source, src); }); test('F6: ~~~ fences are also tracked', () => { // CommonMark allows ~~~ as an alternative fence marker. const src = [ '~~~markdown', // 1: fence open '::: card "x"', // 2: literal ':::', // 3: literal '~~~', // 4: fence close '::: callout', // 5: real open ':::' // 6: real close ].join('\n') + '\n'; const r = normaliseContainers(src); assert.equal(r.warnings.length, 0); assert.equal(r.source, src); }); test('F6: a real ::: card outside a fence still reports the unclosed error', () => { // Regression guard: fence tracking must not swallow real container errors // that occur outside any fence. const src = [ '```markdown', // 1: fence open '::: card "fenced"', // 2: literal '```', // 3: fence close '::: card "real"', // 4: real open, never closed 'body' // 5 ].join('\n') + '\n'; const r = normaliseContainers(src); const errors = r.warnings.filter((w) => w.severity === 'error'); assert.equal(errors.length, 1); assert.match(errors[0].message, /Unclosed.*card/); assert.equal(errors[0].line, 4); }); test('F6: mismatched fence markers (``` open vs ~~~ close) do not close the fence', () => { // CommonMark says a fence closes only on the same marker that opened it. const src = [ '```markdown', // 1: ``` fence open '::: card "x"', // 2: literal '~~~', // 3: NOT a close (different marker) '::: card "y"', // 4: literal (still inside fence) '```' // 5: real close ].join('\n') + '\n'; const r = normaliseContainers(src); assert.equal(r.warnings.length, 0); assert.equal(r.source, src); });