From a665b8a7357d546142ee8d12487a1c6f249dfd91 Mon Sep 17 00:00:00 2001 From: James Clark Date: Tue, 26 May 2026 15:14:36 -0700 Subject: [PATCH 01/13] feat(render-diff): add @readme/markdown/render-diff subpath export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine-agnostic HTML diff tool published as its own subpath so consumers can compare two rendered HTML strings without pulling in the engine (compile/run/mdxish/renderMdxish). Public API: import { diff } from '@readme/markdown/render-diff'; import type { Change, DiffOptions, DiffResult, Preset, Severity } from '@readme/markdown/render-diff'; diff(leftHtml, rightHtml, opts?) → { status: 'match' } | { status: 'differ', severity, changes[] } `leftHtml` and `rightHtml` are neutral param names so the same tool serves MDX↔MDXish, before/after a @readme/markdown version bump, or any other two-input comparison. Each `Change` carries `left`/`right` fields matching the inputs. Canonicalization pipeline (transplanted from the reference differ in readmeio/readme PR #18479, re-targeted to htmlparser2/domhandler): whitespace collapse, class sort, attribute normalization, noise-attr drop, heading-counter strip, void-tag handling, text-equivalent merging. Bottom-up SHA-1 content hash fast-path short-circuits to match when both trees hash equal. Two presets: 'cross-engine' (default, full normalization) and 'minimal' (skips span-flatten + adjacent-text- merge — suitable for same-engine before/after). Packaging: - webpack serverConfig multi-entry adds 'render-diff' → dist/render-diff.node.js - package.json exports map adds './render-diff' (types-first ordering) - bundlewatch budget: 185 KB (actual 152 KB) - scripts/verify-render-diff.cjs runs in CI via build:verify: (1) dist-grep — bundle MUST NOT contain any of 5 engine identifiers (2) self-ref probe — require('@readme/markdown/render-diff') resolves - .github/workflows/ci.yml adds 'Verify subpath exports' step Source-level guard (__tests__/lib/render-diff/no-engine-imports.test.ts) prevents any file under lib/render-diff/ from importing engine modules directly. Combined with the bundle-level dist-grep, this gives two layers of defense against engine leakage. Unit tests (__tests__/lib/render-diff/differ.test.ts) cover match arms, differ arms, severity scoring, preset partition, fast-path, and determinism. --- .github/workflows/ci.yml | 3 + __tests__/lib/render-diff/differ.test.ts | 248 ++++++++++ .../lib/render-diff/no-engine-imports.test.ts | 78 +++ lib/render-diff/differ.ts | 466 ++++++++++++++++++ lib/render-diff/index.ts | 2 + lib/render-diff/types.ts | 120 +++++ package.json | 18 + scripts/verify-render-diff.cjs | 54 ++ webpack.config.js | 4 + 9 files changed, 993 insertions(+) create mode 100644 __tests__/lib/render-diff/differ.test.ts create mode 100644 __tests__/lib/render-diff/no-engine-imports.test.ts create mode 100644 lib/render-diff/differ.ts create mode 100644 lib/render-diff/index.ts create mode 100644 lib/render-diff/types.ts create mode 100644 scripts/verify-render-diff.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e6cb9b1..d71411009 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: - name: Run tests run: npm test + - name: Verify subpath exports (build + dist-grep + self-ref probes) + run: npm run build:verify + visual: name: 'Visual Tests' if: "!contains(github.event.head_commit.message, 'SKIP CI')" diff --git a/__tests__/lib/render-diff/differ.test.ts b/__tests__/lib/render-diff/differ.test.ts new file mode 100644 index 000000000..cb9b8d679 --- /dev/null +++ b/__tests__/lib/render-diff/differ.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; + +import { diff } from '../../../lib/render-diff'; + +describe('diff()', () => { + describe('match cases', () => { + it('returns { status: "match" } for identical inputs (content-hash fast-path)', () => { + const html = '

Hello world

'; + const result = diff(html, html); + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('returns { status: "match" } for byte-identical inputs via fast-path', () => { + const html = '

Title

Body

'; + const result = diff(html, html); + expect(result).toStrictEqual({ status: 'match' }); + // Confirm it is exactly { status: 'match' } with no severity or changes keys + expect(Object.keys(result)).toStrictEqual(['status']); + }); + + it('returns { status: "match" } when inputs differ only in collapsible whitespace', () => { + const a = '

hello world

'; + const b = '

hello world

'; + const result = diff(a, b); + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('returns { status: "match" } when inputs differ only in attribute order', () => { + const a = '
'; + const b = '
'; + const result = diff(a, b); + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('returns { status: "match" } when inputs differ only in class sort order', () => { + const a = '
'; + const b = '
'; + const result = diff(a, b); + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('returns { status: "match" } for structurally identical HTML with noise attrs dropped', () => { + const a = '

text

'; + const b = '

text

'; + const result = diff(a, b); + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('match result has exactly one key (no severity/changes on match)', () => { + const html = '

hello

'; + const result = diff(html, html); + expect(result.status).toBe('match'); + expect('severity' in result).toBe(false); + expect('changes' in result).toBe(false); + }); + }); + + describe('differ cases', () => { + it('returns status: "differ" with an "extra"-kind change when the right input has an extra element', () => { + const leftHtml = '
x
'; + const rightHtml = '
xy
'; + const result = diff(leftHtml, rightHtml); + expect(result.status).toBe('differ'); + if (result.status !== 'differ') throw new Error('unreachable'); + expect(result.changes.length).toBeGreaterThan(0); + const extraChange = result.changes.find(c => c.kind === 'extra' || c.kind === 'missing'); + expect(extraChange).toBeDefined(); + }); + + it('returns status: "differ" with a text-kind change of severity "content" for text differences', () => { + const result = diff('

hello

', '

world

'); + expect(result.status).toBe('differ'); + if (result.status !== 'differ') throw new Error('unreachable'); + const textChange = result.changes.find(c => c.kind === 'text'); + expect(textChange).toBeDefined(); + expect(textChange?.severity).toBe('content'); + expect(textChange?.left).toBe('hello'); + expect(textChange?.right).toBe('world'); + }); + + it('returns status: "differ" with a "structural" severity for a tag mismatch', () => { + const result = diff('

content

', '
content
'); + expect(result.status).toBe('differ'); + if (result.status !== 'differ') throw new Error('unreachable'); + const tagChange = result.changes.find(c => c.kind === 'tag'); + expect(tagChange).toBeDefined(); + expect(tagChange?.severity).toBe('structural'); + }); + + it('differ result carries severity as the aggregate maximum', () => { + // text difference → 'content' severity (highest) + const result = diff('

hello

', '

world

'); + expect(result.status).toBe('differ'); + if (result.status !== 'differ') throw new Error('unreachable'); + expect(result.severity).toBe('content'); + }); + + it('returns status: "differ" with non-empty changes when extra element present in the right input', () => { + // This mirrors the known MDXish

wrapper divergence (HTMLBlock safeMode) + const leftHtml = '

text
'; + const rightHtml = '

text

'; + const result = diff(leftHtml, rightHtml); + expect(result.status).toBe('differ'); + if (result.status !== 'differ') throw new Error('unreachable'); + expect(result.changes.length).toBeGreaterThan(0); + }); + }); + + describe('fast-path', () => { + it('noHashFastPath: true still produces the correct result via the full walk', () => { + const html = '

same

'; + const result = diff(html, html, { noHashFastPath: true }); + // Full walk should also find no differences + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('noHashFastPath: true on differing inputs still detects the difference', () => { + const result = diff('

hello

', '

world

', { noHashFastPath: true }); + expect(result.status).toBe('differ'); + }); + }); + + describe('determinism', () => { + it('repeated calls with the same inputs return deeply equal results', () => { + const leftHtml = '

Title

Content

'; + const rightHtml = + '

Title

Content changed

'; + + const result1 = diff(leftHtml, rightHtml); + const result2 = diff(leftHtml, rightHtml); + const result3 = diff(leftHtml, rightHtml); + + expect(result1).toStrictEqual(result2); + expect(result2).toStrictEqual(result3); + }); + + it('changes[] is in document position order', () => { + // Two separate differences — the one that appears first in the document + // should come first in changes[]. + const leftHtml = '

first

second

'; + const rightHtml = '

FIRST

SECOND

'; + const result = diff(leftHtml, rightHtml); + expect(result.status).toBe('differ'); + if (result.status !== 'differ') throw new Error('unreachable'); + + // Both text nodes should appear as changes in order + expect(result.changes.length).toBeGreaterThanOrEqual(2); + const textChanges = result.changes.filter(c => c.kind === 'text'); + expect(textChanges[0].left).toBe('first'); + expect(textChanges[1].left).toBe('second'); + }); + + it('changes[] order is stable across multiple runs', () => { + const leftHtml = '

a

b

c

'; + const rightHtml = '

A

B

C

'; + + const r1 = diff(leftHtml, rightHtml); + const r2 = diff(leftHtml, rightHtml); + + expect(r1).toStrictEqual(r2); + // toStrictEqual already deep-compares changes[] in order — no extra path + // assertion needed. + }); + }); + + describe('canonicalization edge cases', () => { + it('drops aria-hidden="false" attrs', () => { + const a = 'x'; + const b = 'x'; + const result = diff(a, b); + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('strips heading id counter suffixes by default', () => { + const a = '

intro

'; + const b = '

intro

'; + const result = diff(a, b); + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('preserves heading id counter suffixes when preserveHeadingCounters: true', () => { + const a = '

intro

'; + const b = '

intro

'; + const result = diff(a, b, { preserveHeadingCounters: true }); + expect(result.status).toBe('differ'); + }); + + it('respects attrIgnore option', () => { + const a = '
text
'; + const b = '
text
'; + // Without attrIgnore — should differ + const r1 = diff(a, b); + expect(r1.status).toBe('differ'); + // With attrIgnore — should match + const r2 = diff(a, b, { attrIgnore: new Set(['custom-attr']) }); + expect(r2).toStrictEqual({ status: 'match' }); + }); + }); + + describe('preset option', () => { + it('omitting preset is equivalent to cross-engine: same inputs produce identical results', () => { + // diff(a, b) === diff(a, b, { preset: 'cross-engine' }) + const a = '

text more here

'; + const b = '

text more here

'; + const defaultResult = diff(a, b); + const crossEngineResult = diff(a, b, { preset: 'cross-engine' }); + expect(defaultResult).toStrictEqual(crossEngineResult); + }); + + it('cross-engine preset: bare span-flatten fires and reports match', () => { + const a = '

text more here

'; + const b = '

text more here

'; + const result = diff(a, b, { preset: 'cross-engine' }); + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('minimal preset: bare is preserved as a real structural change', () => { + // span-flatten is skipped under minimal; the is a real diff + const a = '

text more here

'; + const b = '

text more here

'; + const result = diff(a, b, { preset: 'minimal' }); + expect(result.status).toBe('differ'); + }); + + it('cross-engine preset: adjacent text nodes from dropped comment merge', () => { + // comment is dropped, adjacent text nodes merge → match + const a = '

foobar

'; + const b = '

foo bar

'; + const result = diff(a, b, { preset: 'cross-engine' }); + expect(result).toStrictEqual({ status: 'match' }); + }); + + it('minimal preset: adjacent text nodes from dropped comment stay separate', () => { + // comment is still dropped, but adjacent-text-merge is skipped → differ + const a = '

foobar

'; + const b = '

foo bar

'; + const result = diff(a, b, { preset: 'minimal' }); + expect(result.status).toBe('differ'); + }); + + it('minimal preset: always-on transforms still apply (whitespace, class sort, noise attrs, heading strip)', () => { + const noisy = '

heading

'; + const clean = '

heading

'; + const result = diff(noisy, clean, { preset: 'minimal' }); + expect(result).toStrictEqual({ status: 'match' }); + }); + }); +}); diff --git a/__tests__/lib/render-diff/no-engine-imports.test.ts b/__tests__/lib/render-diff/no-engine-imports.test.ts new file mode 100644 index 000000000..bc7e4ed5b --- /dev/null +++ b/__tests__/lib/render-diff/no-engine-imports.test.ts @@ -0,0 +1,78 @@ +/** + * Source-level direct forbidden-import guard for lib/render-diff/. + * + * SCOPE: DIRECT imports only — not a transitive proof. + * + * This test regex-scans every .ts file under lib/render-diff/ for direct + * import statements that would couple the render-diff module to engine code + * (lib/compile, lib/mdxish, lib/renderMdxish, lib/run, lib/exports, or + * processor/*). + * + * Intentional limitation: a transitive chain such as + * lib/render-diff/foo.ts → ../shared-helper.ts → ../compile.ts + * would not be caught here because the regex only inspects files under + * lib/render-diff/ directly. + * + * Transitive leaks are defended at the bundle layer by two complementary + * mechanisms: + * - scripts/verify-render-diff.cjs scans dist/render-diff.node.js for + * engine identifiers regardless of how they entered the bundle. + * - the bundlewatch budget catches the ~10x size regression that any + * engine inclusion would produce. + */ +import { readdirSync, readFileSync } from 'fs'; +import path from 'path'; + +// ------------------------------------------------------------------ constants + +const RENDER_DIFF_DIR = path.join(__dirname, '..', '..', '..', 'lib', 'render-diff'); + +/** + * Forbidden direct import patterns. + * + * Each regex matches a TypeScript import statement whose module specifier + * resolves to one of the five engine modules (or the processor/ directory). + * Patterns use single-quote OR double-quote to be robust to code style. + * + * Note: patterns are intentionally anchored to the relative import prefix + * `../` so they only match direct engine imports, not substrings inside + * identifiers or comments. + */ +const FORBIDDEN_PATTERNS: RegExp[] = [ + /from ['"]\.\.\/compile['"]/, + /from ['"]\.\.\/mdxish['"]/, + /from ['"]\.\.\/renderMdxish['"]/, + /from ['"]\.\.\/run['"]/, + /from ['"]\.\.\/exports['"]/, + /from ['"]\.\.\/\.\.\/processor\//, +]; + +// ------------------------------------------------------------------ helpers + +function collectTsFiles(dir: string): string[] { + return readdirSync(dir) + .filter(name => name.endsWith('.ts') || name.endsWith('.tsx')) + .map(name => path.join(dir, name)); +} + +// ------------------------------------------------------------------ tests + +describe('lib/render-diff — direct forbidden import guard', () => { + const files = collectTsFiles(RENDER_DIFF_DIR); + const repoRoot = path.join(__dirname, '..', '..', '..'); + + it('finds TypeScript source files under lib/render-diff/', () => { + expect(files.length).toBeGreaterThan(0); + }); + + it.each(files.map(filePath => [filePath.replace(`${repoRoot}/`, ''), filePath]))( + '%s contains no direct engine imports', + (_relativePath, filePath) => { + const source = readFileSync(filePath, 'utf-8'); + + FORBIDDEN_PATTERNS.forEach(pattern => { + expect(source).not.toMatch(pattern); + }); + }, + ); +}); diff --git a/lib/render-diff/differ.ts b/lib/render-diff/differ.ts new file mode 100644 index 000000000..d5c2105bf --- /dev/null +++ b/lib/render-diff/differ.ts @@ -0,0 +1,466 @@ +/** + * Structural HTML diff for MDX-vs-MDXish render comparison. + * + * Algorithm overview: + * parseDocument(html, { xmlMode: true }) → walk → canonical {tag, attrs, children} tree + * apply canonicalization pipeline (sort/normalize/collapse) + * compute bottom-up content hash + * fast-path match if hashes equal + * otherwise, recursive walk emitting Change records + * + * Tree-walk targets the htmlparser2 / domhandler node shape. The canonicalization + * rules and severity model are transplanted verbatim from the reference differ.ts + * (PR #18479 in the readme repo). The tree-walk is re-targeted to htmlparser2/domhandler. + * + * The lint disables below are intentional and scoped to this file: + * - `no-restricted-syntax` + `no-continue`: this is a tree-walker with greedy + * alignment and lookahead; for-of with continue is the natural shape and + * array iteration helpers would obscure the control flow. + * - `@typescript-eslint/no-use-before-define`: helpers are ordered bottom-up + * (primitives first, public `diff()` last) so the public API anchors the + * end of the file. + */ +/* eslint-disable no-restricted-syntax, no-continue, @typescript-eslint/no-use-before-define */ +import type { Change, DiffOptions, DiffResult, Severity } from './types'; +import type { AnyNode, ChildNode, Element, Text } from 'domhandler'; + +import { createHash } from 'node:crypto'; + +// eslint-disable-next-line import/no-extraneous-dependencies -- transitive via htmlparser2 +import { ElementType, isTag } from 'domelementtype'; +import { parseDocument } from 'htmlparser2'; + + +/* ---------- internal canonical node types ---------- */ + +interface ElementNode { + attrs: Record; + children: CanonNode[]; + hash: string; + tag: string; + type: 'element'; +} + +interface TextNode { + hash: string; + type: 'text'; + value: string; +} + +type CanonNode = ElementNode | TextNode; + +/* ---------- constant sets ---------- */ + +const VOID_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]); + +const NOISE_ATTRS = new Set(['data-reactroot', 'data-testid', 'suppresshydrationwarning']); + +const CONTENT_ATTRS = new Set(['href', 'src', 'id', 'value', 'alt', 'title']); + +/* ---------- canonicalization ---------- */ + +function hashText(value: string): string { + return createHash('sha1').update(`t:${value}`).digest('hex'); +} + +function hashElement(tag: string, attrs: Record, children: CanonNode[]): string { + const sortedAttrKeys = Object.keys(attrs).sort(); + const attrStr = sortedAttrKeys.map(k => `${k}=${attrs[k]}`).join('|'); + const childStr = children.map(c => c.hash).join(','); + return createHash('sha1').update(`e:${tag}|${attrStr}|${childStr}`).digest('hex'); +} + +function walkAndCanonicalize(node: AnyNode, opts: DiffOptions, isRoot: boolean): CanonNode { + // Text node + if (node.type === ElementType.Text) { + const collapsed = (node as Text).data.replace(/\s+/g, ' ').trim(); + return { type: 'text', value: collapsed, hash: hashText(collapsed) }; + } + + // Comment, directive (includes ) — treat as empty text to drop them out of comparison. + // Note: in domhandler's AnyNode union, doctypes are represented as ProcessingInstruction + // nodes with type === ElementType.Directive (not ElementType.Doctype). + if (node.type === ElementType.Comment || node.type === ElementType.Directive) { + return { type: 'text', value: '', hash: hashText('') }; + } + + // Root node (Document type = 'root') or element + const tag = isRoot ? '#root' : (node as Element).name; + + // Attributes: domhandler gives plain Record — not a {name,value}[] array + const rawAttrs: Record = isRoot ? {} : ((node as Element).attribs ?? {}); + const attrs: Record = {}; + + for (const [name, value] of Object.entries(rawAttrs)) { + // oxlint-disable-next-line no-continue + if (NOISE_ATTRS.has(name)) continue; + // oxlint-disable-next-line no-continue + if (opts.attrIgnore?.has(name)) continue; + // oxlint-disable-next-line no-continue + if (name === 'aria-hidden' && value === 'false') continue; + + let normalizedValue = value; + + if (name === 'class') { + normalizedValue = normalizedValue + .split(/\s+/) + .filter(Boolean) + .sort() + .join(' '); + // oxlint-disable-next-line no-continue + if (!normalizedValue) continue; // drop empty class + } + // oxlint-disable-next-line no-continue + if (name === 'style' && normalizedValue.trim() === '') continue; + + // Heading-slug counter normalization + if (name === 'id' && /^h[1-6]$/.test(tag) && !opts.preserveHeadingCounters) { + normalizedValue = normalizedValue.replace(/^(.+)-\d+$/, '$1'); + } + + // Canonicalize whitespace-only non-class/non-style values to '' so that + // (a) two semantically-equivalent whitespace values (`" "` vs `" "`) do + // not diff as unequal, and (b) they flow into the boolean denormalization + // below — matching the existing intent that "empty" attributes are + // equivalent to a boolean-true presence. Class is handled separately + // above (sort+filter); style short-circuits via the `continue` above. + if (name !== 'class' && name !== 'style' && normalizedValue.trim() === '') { + normalizedValue = ''; + } + + // Boolean attribute denormalization: empty → "true" + if (normalizedValue === '') normalizedValue = 'true'; + + attrs[name] = normalizedValue; + } + + // Children + const rawChildren: ChildNode[] = isRoot + ? ((node as unknown as { children: ChildNode[] }).children ?? []) + : isTag(node as AnyNode) + ? (node as Element).children ?? [] + : []; + + const children: CanonNode[] = []; + for (const c of rawChildren) { + const canon = walkAndCanonicalize(c, opts, false); + // oxlint-disable-next-line no-continue + if (canon.type === 'text' && canon.value === '') continue; + children.push(canon); + } + + // Void elements never have children; force-empty for stability. + // For everything else, normalize text-equivalent structure differences. + const finalChildren = VOID_TAGS.has(tag) ? [] : normalizeTextEquivalent(children, opts); + + // Bottom-up hash — recomputed after children are in their final form + const hash = hashElement(tag, attrs, finalChildren); + return { type: 'element', tag, attrs, children: finalChildren, hash }; +} + +function canonicalize(html: string, opts: DiffOptions): CanonNode { + // xmlMode: true — avoids wrapper injection. + // React-rendered HTML is well-formed; xmlMode gives us a flat Document > [content] tree. + const doc = parseDocument(html, { xmlMode: true }); + // Wrap doc in a synthetic root element so the walker has a single entry node. + // We pass the Document node directly and treat it as root (isRoot=true). + return walkAndCanonicalize(doc as unknown as AnyNode, opts, true); +} + +/** + * Make the children sequence forgiving of two specific text-equivalent + * structural differences between MDX and MDXish: + * + * 1. Plain `` wrappers (no attributes) — bare grouping with no semantic + * weight, replaced by their children. + * 2. Adjacent text children — merged into one. MDX inserts `` between + * JSX text fragments which canonicalization drops to empty text, leaving + * runs of adjacent text nodes that MDXish would render as a single text. + * + * Joins on `' '` because in the source HTML there is virtually always + * whitespace surrounding MDX's inline boundary markers; HTML's adjacent-text + * rendering rules collapse that to a single space. The re-collapse + trim + * keeps the merged value canonical. + */ +function normalizeTextEquivalent(children: CanonNode[], opts: DiffOptions): CanonNode[] { + // Span-flatten and adjacent-text-merge are engine-bridging transforms: they + // absorb MDX-specific serialization artifacts (bare wrappers, + // comment-split text nodes). Skip both in 'minimal' preset so same-engine + // before/after comparisons surface real structural changes. + if (opts.preset === 'minimal') return children; + + // Pass 1: flatten plain wrappers. + const flat: CanonNode[] = []; + for (const c of children) { + if (c.type === 'element' && c.tag === 'span' && Object.keys(c.attrs).length === 0) { + flat.push(...c.children); + } else { + flat.push(c); + } + } + + // Pass 2: merge adjacent text children. + const merged: CanonNode[] = []; + for (const c of flat) { + const last = merged[merged.length - 1]; + if (c.type === 'text' && last && last.type === 'text') { + const combined = `${last.value} ${c.value}`.replace(/\s+/g, ' ').trim(); + merged[merged.length - 1] = { type: 'text', value: combined, hash: hashText(combined) }; + } else { + merged.push(c); + } + } + return merged; +} + +/* ---------- diff walker ---------- */ + +function diffNodes(a: CanonNode, b: CanonNode, pathStr: string, changes: Change[]): void { + if (a.hash === b.hash) return; + + if (a.type === 'text' || b.type === 'text') { + if (a.type === 'text' && b.type === 'text') { + if (a.value !== b.value) { + changes.push({ + path: pathStr, + kind: 'text', + severity: 'content', + left: a.value, + right: b.value, + }); + } + } else { + // One side is text, the other is an element — record as tag mismatch. + changes.push({ + path: pathStr, + kind: 'tag', + severity: 'structural', + left: a.type === 'text' ? `#text(${JSON.stringify(a.value)})` : `<${a.tag}>`, + right: b.type === 'text' ? `#text(${JSON.stringify(b.value)})` : `<${b.tag}>`, + }); + } + return; + } + + // Both are elements + if (a.tag !== b.tag) { + changes.push({ + path: pathStr, + kind: 'tag', + severity: 'structural', + left: a.tag, + right: b.tag, + }); + return; // don't descend — children compare unrelated subtrees + } + + // Attribute diff + diffAttrs(a.attrs, b.attrs, pathStr, changes); + + // Children diff via simple greedy alignment with single-step lookahead + alignChildren(a.children, b.children, pathStr, changes); +} + +function diffAttrs( + aAttrs: Record, + bAttrs: Record, + pathStr: string, + changes: Change[], +): void { + const keys = new Set([...Object.keys(aAttrs), ...Object.keys(bAttrs)]); + for (const k of [...keys].sort()) { + const av = aAttrs[k]; + const bv = bAttrs[k]; + // oxlint-disable-next-line no-continue + if (av === bv) continue; + changes.push({ + path: pathStr, + kind: 'attr', + attrName: k, + severity: attrSeverity(k), + left: av ?? null, + right: bv ?? null, + }); + } +} + +function attrSeverity(name: string): Severity { + if (CONTENT_ATTRS.has(name)) return 'content'; + if (name === 'class' || name === 'style') return 'structural'; + if (name.startsWith('data-') || name.startsWith('aria-')) return 'cosmetic'; + return 'structural'; +} + +/** + * Align two child arrays using a single-step lookahead — at each unmatched + * position we peek at `aChildren[i+1]` and `bChildren[j+1]` only, which is + * enough to recover from one isolated insertion or deletion. Handles single + * insertions / deletions / substitutions without cascading. For more complex + * rearrangements (e.g. consecutive double-inserts) we degrade gracefully — a + * long diff may not be optimal but it'll surface as "this page differs a lot" + * which is the right signal. + * + * Children are visited in document position order. + */ +function alignChildren( + aChildren: CanonNode[], + bChildren: CanonNode[], + parentPath: string, + changes: Change[], +): void { + let i = 0; + let j = 0; + while (i < aChildren.length && j < bChildren.length) { + const a = aChildren[i]; + const b = bChildren[j]; + + if (a.hash === b.hash) { + i += 1; + j += 1; + // oxlint-disable-next-line no-continue + continue; + } + + // Lookahead: is a[i] missing in b (b[j] matches a[i+1])? + if (i + 1 < aChildren.length && aChildren[i + 1].hash === b.hash) { + changes.push(missingOrExtra('extra', a, `${parentPath}/${describeNode(a, i)}`)); + i += 1; + // oxlint-disable-next-line no-continue + continue; + } + + // Lookahead: is b[j] extra (a[i] matches b[j+1])? + if (j + 1 < bChildren.length && a.hash === bChildren[j + 1].hash) { + changes.push(missingOrExtra('missing', b, `${parentPath}/${describeNode(b, j)}`)); + j += 1; + // oxlint-disable-next-line no-continue + continue; + } + + // Otherwise substitute and recurse + diffNodes(a, b, `${parentPath}/${describeNode(a, i)}`, changes); + i += 1; + j += 1; + } + + // Trailing extras / missings + while (i < aChildren.length) { + const a = aChildren[i]; + changes.push(missingOrExtra('extra', a, `${parentPath}/${describeNode(a, i)}`)); + i += 1; + } + while (j < bChildren.length) { + const b = bChildren[j]; + changes.push(missingOrExtra('missing', b, `${parentPath}/${describeNode(b, j)}`)); + j += 1; + } +} + +function missingOrExtra(kind: 'extra' | 'missing', node: CanonNode, pathStr: string): Change { + // 'extra' = present in left, missing in right; 'missing' = the reverse. + const isText = node.type === 'text'; + const severity: Severity = isText ? 'content' : 'structural'; + const repr = + node.type === 'text' ? `#text(${JSON.stringify(node.value)})` : serializeShallow(node); + return { + path: pathStr, + kind, + severity, + left: kind === 'extra' ? repr : null, + right: kind === 'missing' ? repr : null, + }; +} + +function describeNode(node: CanonNode, index: number): string { + if (node.type === 'text') return `#text[${index}]`; + return `${node.tag}[${index}]`; +} + +function serializeShallow(node: ElementNode): string { + // Attribute values may contain `"` (e.g. `title='he said "hi"'`); without + // escaping the quote, the rendered diff string becomes malformed and + // misleads during debugging. No security impact — this string is for + // human-readable diff output only — but tightening keeps the output + // unambiguous. Escape `"` to `"` to mirror standard HTML attribute + // escaping. + const attrs = Object.entries(node.attrs) + .map(([k, v]) => `${k}="${v.replace(/"/g, '"')}"`) + .join(' '); + const head = attrs ? `<${node.tag} ${attrs}>` : `<${node.tag}>`; + if (VOID_TAGS.has(node.tag)) return head; + return node.children.length === 0 + ? `${head}` + : `${head}…(${node.children.length} children)…`; +} + +const SEVERITY_ORDER: Record = { cosmetic: 0, structural: 1, content: 2 }; + +function pageSeverity(changes: Change[]): Severity { + let max: Severity = 'cosmetic'; + for (const c of changes) { + if (SEVERITY_ORDER[c.severity] > SEVERITY_ORDER[max]) max = c.severity; + } + return max; +} + +/* ---------- public API ---------- */ + +/** + * Diff two rendered HTML strings. + * + * The diff tool is engine-agnostic — `leftHtml` and `rightHtml` are simply + * the two HTML strings to compare in that order. Common use cases: + * - MDX vs MDXish (Suite B in this repo) + * - Before vs after a `@readme/markdown` version bump on the same source + * - Any two HTML strings a consumer wants to compare + * + * Both strings are canonicalized (whitespace collapse, class sort, attribute + * normalization, noise-attr drop, heading-counter strip, void-tag handling, + * text-equivalent merging) before comparison. + * + * A bottom-up content hash provides a fast-path: if both trees hash identically, + * `{ status: 'match' }` is returned without a full walk. + * + * The returned `changes[]` is ordered by document position and is identical + * across repeated calls on the same input. Each change carries `left` and + * `right` fields corresponding to the two inputs. + * + * @param leftHtml - First HTML string to compare. + * @param rightHtml - Second HTML string to compare. + * @param opts - Optional canonicalization / diff configuration. + * @returns `{ status: 'match' }` or `{ status: 'differ', severity, changes }`. + */ +export function diff(leftHtml: string, rightHtml: string, opts: DiffOptions = {}): DiffResult { + const a = canonicalize(leftHtml, opts); + const b = canonicalize(rightHtml, opts); + + if (!opts.noHashFastPath && a.hash === b.hash) { + return { status: 'match' }; // no severity/changes on match + } + + const changes: Change[] = []; + diffNodes(a, b, '', changes); + + if (changes.length === 0) { + // Hashes differ but the walker found no structural differences — hash-only + // variation (unlikely in practice). Treat as match. + return { status: 'match' }; + } + + return { status: 'differ', severity: pageSeverity(changes), changes }; +} diff --git a/lib/render-diff/index.ts b/lib/render-diff/index.ts new file mode 100644 index 000000000..6313c6ed5 --- /dev/null +++ b/lib/render-diff/index.ts @@ -0,0 +1,2 @@ +export { diff } from './differ'; +export type { Change, DiffOptions, DiffResult, Preset, Severity } from './types'; diff --git a/lib/render-diff/types.ts b/lib/render-diff/types.ts new file mode 100644 index 000000000..35485925f --- /dev/null +++ b/lib/render-diff/types.ts @@ -0,0 +1,120 @@ +/** + * TypeScript types for the render-diff HTML diffing tool. + * + * These types form the public contract for `lib/render-diff/differ.ts`. + */ + +/** + * Severity level of a single diff change or aggregate page diff result. + * + * - `cosmetic` — minor presentation differences (e.g. `data-*` / `aria-*` attrs) + * - `structural` — tag, element, or layout differences + * - `content` — visible text or meaningful attribute value differences + */ +export type Severity = 'content' | 'cosmetic' | 'structural'; + +/** + * A single diffed change between two rendered HTML inputs. + * + * The diff tool is engine-agnostic — `left` and `right` are simply the two + * HTML strings passed to `diff()` in that order. Suite B uses them as + * MDX vs MDXish; other consumers (e.g. before/after a markdown version bump) + * use them however they like. + * + * Each change is emitted at a specific document-position path. + */ +export interface Change { + /** + * Attribute name — populated only when `kind` is `'attr'`. + */ + attrName?: string; + /** + * The type of difference detected. + * + * - `tag` — element tag mismatch between `left` and `right` + * - `attr` — attribute present, missing, or different value + * - `text` — text node content differs + * - `missing` — node present in `left` but absent in `right` + * - `extra` — node present in `right` but absent in `left` + */ + kind: 'attr' | 'extra' | 'missing' | 'tag' | 'text'; + /** + * String representation from the `left` input, or `null` when the node is absent. + */ + left: string | null; + /** + * Document-position path of the diffed node (top-to-bottom tree-walk order). + */ + path: string; + /** + * String representation from the `right` input, or `null` when the node is absent. + */ + right: string | null; + /** + * Severity of this individual change. + */ + severity: Severity; +} + +/** + * Result returned by `diff(leftHtml, rightHtml)`. + * + * Discriminated union on `status`: + * - `'match'` arm collapses to `{ status: 'match' }` — no `severity` or `changes` keys. + * - `'differ'` arm carries the full `{ status, severity, changes }` shape. + * + * TypeScript narrowing on `result.status === 'differ'` exposes `severity` and `changes`. + */ +export type DiffResult = + | { changes: Change[]; severity: Severity; status: 'differ' } + | { status: 'match' }; + +/** + * Canonicalization preset for `diff()`. + * + * - `'cross-engine'` — full normalization including span-flatten and adjacent-text + * merge. Designed for MDX↔MDXish comparison (Suite B). + * - `'minimal'` — lighter normalization; span-flatten and adjacent-text merge are + * skipped. Suitable for same-engine before/after comparison where those transforms + * would mask real structural changes. + */ +export type Preset = 'cross-engine' | 'minimal'; + +/** + * Options accepted by `diff()`. + * + * All fields are optional. Default behaviour: hash fast-path enabled, all + * heading counter suffixes stripped, no additional attributes ignored. + */ +export interface DiffOptions { + /** + * Additional attribute names to drop during canonicalization (lowercased). + * Added to the built-in noise-attr set (`data-reactroot`, `data-testid`, + * `suppresshydrationwarning`). + */ + attrIgnore?: Set; + /** + * When `true`, bypass the content-hash fast-path and always perform the full + * tree walk. Intended for testing only. + */ + noHashFastPath?: boolean; + /** + * When `true`, skip stripping `-N` counter suffixes from heading `id` attrs. + * Default: `false` (suffixes are stripped so repeated headings compare equal). + */ + preserveHeadingCounters?: boolean; + /** + * Canonicalization preset. Controls which structural-normalization transforms + * are applied before comparison. + * + * - `'cross-engine'` (default) — full normalization including span-flatten + * and adjacent-text merge. Designed for MDX↔MDXish comparison (Suite B). + * - `'minimal'` — lighter normalization; span-flatten and adjacent-text merge + * are skipped. Suitable for same-engine before/after comparison where those + * transforms would mask real structural changes. + * + * Every normalization `'minimal'` performs is also performed by `'cross-engine'`. + * Default: omitting `preset` is equivalent to `'cross-engine'` (backward compatible). + */ + preset?: Preset; +} diff --git a/package.json b/package.json index 27bbc9c97..c034ae084 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,18 @@ "main": "dist/main.node.js", "types": "dist/index.d.ts", "browser": "dist/main.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "browser": "./dist/main.js", + "node": "./dist/main.node.js", + "default": "./dist/main.node.js" + }, + "./render-diff": { + "types": "./dist/lib/render-diff/index.d.ts", + "default": "./dist/render-diff.node.js" + } + }, "files": [ "styles", "components", @@ -16,6 +28,7 @@ "scripts": { "build": "webpack --mode production && npm run build-types", "build-types": "npx tsc --declaration --emitDeclarationOnly --declarationDir ./dist --skipLibCheck", + "build:verify": "npm run build && npm run verify-exports", "lint": "eslint . --ext .jsx --ext .js --ext .ts --ext .tsx", "release": "npx semantic-release", "release.dry": "npx semantic-release --dry-run", @@ -23,6 +36,7 @@ "test": "vitest", "test.ui": "vitest --ui", "test.browser": "jest", + "verify-exports": "node scripts/verify-render-diff.cjs", "watch": "webpack --watch --progress --mode development" }, "dependencies": { @@ -178,6 +192,10 @@ { "path": "dist/main.node.js", "maxSize": "947KB" + }, + { + "path": "dist/render-diff.node.js", + "maxSize": "185KB" } ] }, diff --git a/scripts/verify-render-diff.cjs b/scripts/verify-render-diff.cjs new file mode 100644 index 000000000..697e05c9c --- /dev/null +++ b/scripts/verify-render-diff.cjs @@ -0,0 +1,54 @@ +/* eslint-disable no-console -- CLI script: console output is the deliberate UX */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +// --- Dist-grep block: engine-identifier scan --- +// Verifies that dist/render-diff.node.js (or the path given by +// VERIFY_RENDER_DIFF_BUNDLE_PATH) contains none of the five engine identifiers +// that would indicate engine code leaked into the render-diff bundle. +// +// The bundle path is overridable via VERIFY_RENDER_DIFF_BUNDLE_PATH so that +// negative smoke tests can run against a /tmp copy without ever mutating dist/. +// +// ENGINE_TOKENS are verified to appear in dist/main.node.js (positive control) +// and absent from lib/render-diff/**/*.ts (negative control). DO NOT substitute +// generic substrings ('compile', 'mdxish', 'htmlparser2') — those produce +// false positives. +const bundlePath = process.env.VERIFY_RENDER_DIFF_BUNDLE_PATH || path.join(__dirname, '..', 'dist', 'render-diff.node.js'); +const bundle = fs.readFileSync(bundlePath, 'utf-8'); + +const ENGINE_TOKENS = [ + 'mdxishAstProcessor', + 'mdxishMdastToMd', + 'renderMdxish', + 'mdxCompileSync', + 'sanitizeSchema', +]; + +ENGINE_TOKENS.forEach(token => { + assert( + !bundle.includes(token), + `engine-leak violation: identifier '${token}' found in ${bundlePath} — engine code leaked into render-diff bundle`, + ); +}); +console.log(`dist grep: PASS — no engine identifiers found in ${bundlePath}`); + +// --- Self-referencing probe: exports map resolution --- +// Node 22 self-referencing: a .cjs file inside the @readme/markdown package +// can require('@readme/markdown/render-diff') and Node uses the exports map. +// Requires package.json to have an "exports" field — fails with MODULE_NOT_FOUND if absent. +// +// NOTE: this path is intentionally NOT overridable — the purpose of this probe +// is to validate that the real exports map resolves the subpath correctly. +// Making it path-overridable would defeat the resolution check. +const { diff } = require('@readme/markdown/render-diff'); + +assert(typeof diff === 'function', 'diff is not a function after exports map resolution'); + +const result = diff('

hello

', '

hello

'); +assert(result.status === 'match', `Expected status 'match', got '${result.status}'`); +console.log('self-ref probe: PASS — @readme/markdown/render-diff resolves and diff() works'); + +console.log('\nverify-render-diff: ALL CHECKS PASSED'); diff --git a/webpack.config.js b/webpack.config.js index c98666cb2..187151668 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -122,6 +122,10 @@ const browserConfig = merge(getConfig({ target: 'web' }), { }); const serverConfig = merge(getConfig({ target: 'node' }), { + entry: { + main: './index', + 'render-diff': './lib/render-diff', + }, output: { filename: '[name].node.js', }, From 8f43ae6908be03b4a88df739e109c557b7641c24 Mon Sep 17 00:00:00 2001 From: James Clark Date: Tue, 26 May 2026 15:15:00 -0700 Subject: [PATCH 02/13] feat(render-fixture): add @readme/markdown/render-fixture subpath export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the engine-free render-diff subpath. Where render-diff refuses to load engine code (152 KB), render-fixture deliberately includes the full engine (4.04 MB) — its job is to render markdown through MDX or MDXish for fixture-based regression testing. Public API: import { renderFixture, loadFixture } from '@readme/markdown/render-fixture'; import type { RenderContext, Engine, FixtureRenderResult } from '@readme/markdown/render-fixture'; const { body, ctx } = loadFixture('./fixtures/install/'); const { html, error } = renderFixture(body, ctx, 'mdxish'); Motivating consumer: an `owl qa` style CLI that clones customer projects and detects rendering regressions across @readme/markdown version bumps. Pairing with `diff()` from the render-diff subpath gives a complete regression loop in ~10 lines of consumer code. `renderFixture` is deterministic: Date.now is frozen to 2024-01-01 and Math.random is replaced with a seeded LCG for the duration of each render, then restored in a finally block. The helper asserts sync return — if any of compile/run/mdxish/renderMdxish becomes async in the future, the determinism guarantee breaks loudly rather than silently producing flaky output. Packaging: - webpack serverConfig multi-entry adds 'render-fixture' → dist/render-fixture.node.js - package.json exports map adds './render-fixture' (types-first ordering) - bundlewatch budget: 4.5 MB (actual 4.04 MB) - verify-exports script extends to also run scripts/verify-render-fixture.cjs: (1) positive-control: bundle MUST contain all 5 engine identifiers (opposite of render-diff's negative check) (2) self-ref probe: require('@readme/markdown/render-fixture') resolves and both engines successfully render a smoke-test markdown body PKG-04 (render-diff engine-free) constraint preserved unchanged — its check still runs and still passes. render-fixture intentionally violates that constraint because it has to. --- .../lib/render-fixture/loadFixture.test.ts | 138 +++++++++++++++++ .../lib/render-fixture/renderFixture.test.ts | 32 ++++ lib/render-fixture/index.ts | 19 +++ lib/render-fixture/loadFixture.ts | 64 ++++++++ lib/render-fixture/renderFixture.ts | 139 ++++++++++++++++++ package.json | 10 +- scripts/verify-render-fixture.cjs | 54 +++++++ webpack.config.js | 1 + 8 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 __tests__/lib/render-fixture/loadFixture.test.ts create mode 100644 __tests__/lib/render-fixture/renderFixture.test.ts create mode 100644 lib/render-fixture/index.ts create mode 100644 lib/render-fixture/loadFixture.ts create mode 100644 lib/render-fixture/renderFixture.ts create mode 100644 scripts/verify-render-fixture.cjs diff --git a/__tests__/lib/render-fixture/loadFixture.test.ts b/__tests__/lib/render-fixture/loadFixture.test.ts new file mode 100644 index 000000000..54983b159 --- /dev/null +++ b/__tests__/lib/render-fixture/loadFixture.test.ts @@ -0,0 +1,138 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { loadFixture } from '../../../lib/render-fixture/loadFixture'; + +const FIXTURES_DIR = join(__dirname, '..', '..', 'regression', 'fixtures'); + +describe('loadFixture', () => { + describe('convergent fixture', () => { + it('returns a non-empty body string', () => { + const { body } = loadFixture(join(FIXTURES_DIR, 'convergent')); + expect(typeof body).toBe('string'); + expect(body.length).toBeGreaterThan(0); + }); + + it('returns ctx.variables with defaults array and user object', () => { + const { ctx } = loadFixture(join(FIXTURES_DIR, 'convergent')); + expect(ctx.variables).toBeDefined(); + expect(Array.isArray(ctx.variables.defaults)).toBe(true); + expect(typeof ctx.variables.user).toBe('object'); + }); + + it('returns ctx.glossary as an array', () => { + const { ctx } = loadFixture(join(FIXTURES_DIR, 'convergent')); + expect(Array.isArray(ctx.glossary)).toBe(true); + }); + + it('loads components from components/ directory', () => { + const { ctx } = loadFixture(join(FIXTURES_DIR, 'convergent')); + expect(ctx.components.length).toBeGreaterThan(0); + }); + + it('registers Note.mdx as tag "Note" with non-empty source', () => { + const { ctx } = loadFixture(join(FIXTURES_DIR, 'convergent')); + const noteBlock = ctx.components.find(c => c.tag === 'Note'); + expect(noteBlock).toBeDefined(); + expect(typeof noteBlock!.source).toBe('string'); + expect(noteBlock!.source.length).toBeGreaterThan(0); + }); + }); + + describe('divergent fixture', () => { + it('returns a non-empty body string', () => { + const { body } = loadFixture(join(FIXTURES_DIR, 'divergent')); + expect(typeof body).toBe('string'); + expect(body.length).toBeGreaterThan(0); + }); + + it('returns ctx.variables with defaults array and user object', () => { + const { ctx } = loadFixture(join(FIXTURES_DIR, 'divergent')); + expect(ctx.variables).toBeDefined(); + expect(Array.isArray(ctx.variables.defaults)).toBe(true); + expect(typeof ctx.variables.user).toBe('object'); + }); + + it('returns ctx.glossary as an array', () => { + const { ctx } = loadFixture(join(FIXTURES_DIR, 'divergent')); + expect(Array.isArray(ctx.glossary)).toBe(true); + }); + + it('loads with ctx.components as [] when no components/ directory exists', () => { + const { ctx } = loadFixture(join(FIXTURES_DIR, 'divergent')); + expect(ctx.components).toStrictEqual([]); + }); + }); + + describe('context.json defaults', () => { + it('uses { defaults: [], user: {} } when variables key is fully specified as such', () => { + // Both checked-in fixtures specify variables fully — this case exercises + // the "specified-as-empty" path. The truly-absent path is covered below + // via an inline temp dir. + const { ctx } = loadFixture(join(FIXTURES_DIR, 'convergent')); + expect(ctx.variables.defaults).toStrictEqual([]); + expect(ctx.variables.user).toStrictEqual({}); + }); + + it('defaults glossary to [] when absent', () => { + const { ctx } = loadFixture(join(FIXTURES_DIR, 'convergent')); + expect(ctx.glossary).toStrictEqual([]); + }); + + describe('with an inline temp fixture (true absent-key coverage)', () => { + // Create a minimal fixture where context.json OMITS the variables key + // entirely, to exercise the runtime fallback in loadFixture rather than + // the "specified-as-empty" path. Same pattern is used for the partial + // case (variables.defaults present, user omitted). + let tmpRoot: string; + + beforeAll(() => { + tmpRoot = mkdtempSync(join(tmpdir(), 'loadFixture-defaults-')); + + // Variant A: variables key entirely absent. + const absentDir = join(tmpRoot, 'absent'); + mkdirSync(absentDir); + writeFileSync(join(absentDir, 'body.md'), '# absent\n'); + writeFileSync(join(absentDir, 'context.json'), JSON.stringify({})); + + // Variant B: variables.defaults present, variables.user omitted. + const partialDir = join(tmpRoot, 'partial'); + mkdirSync(partialDir); + writeFileSync(join(partialDir, 'body.md'), '# partial\n'); + writeFileSync( + join(partialDir, 'context.json'), + JSON.stringify({ + variables: { defaults: [{ name: 'foo', default: 'bar' }] }, + }), + ); + }); + + afterAll(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('defaults variables to { defaults: [], user: {} } when the key is absent', () => { + const { ctx } = loadFixture(join(tmpRoot, 'absent')); + expect(ctx.variables).toStrictEqual({ defaults: [], user: {} }); + }); + + it('defaults variables.user to {} when only variables.defaults is provided', () => { + const { ctx } = loadFixture(join(tmpRoot, 'partial')); + expect(ctx.variables.defaults).toStrictEqual([{ name: 'foo', default: 'bar' }]); + // user was undefined under the old `??` fallback when `defaults` was + // specified but `user` was omitted; both must always be populated. + expect(ctx.variables.user).toStrictEqual({}); + }); + }); + }); + + describe('components/ filtering', () => { + it('ignores non-.mdx files in components/', () => { + // The convergent fixture only has Note.mdx — exactly 1 component + const { ctx } = loadFixture(join(FIXTURES_DIR, 'convergent')); + expect(ctx.components).toHaveLength(1); + expect(ctx.components[0].tag).toBe('Note'); + }); + }); +}); diff --git a/__tests__/lib/render-fixture/renderFixture.test.ts b/__tests__/lib/render-fixture/renderFixture.test.ts new file mode 100644 index 000000000..ac6735274 --- /dev/null +++ b/__tests__/lib/render-fixture/renderFixture.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { renderFixture } from '../../../lib/render-fixture/renderFixture'; + +describe('renderFixture smoke test', () => { + const body = '# Hello\n\nSmoke test prose.'; + const ctx = { + variables: { defaults: [], user: {} }, + glossary: [], + components: [], + }; + + it('MDX engine returns non-empty HTML containing the heading text', () => { + const result = renderFixture(body, ctx, 'mdx'); + expect(result.error).toBeNull(); + expect(result.html.length).toBeGreaterThan(0); + expect(result.html).toContain('Hello'); + }); + + it('MDXish engine returns non-empty HTML containing the heading text', () => { + const result = renderFixture(body, ctx, 'mdxish'); + expect(result.error).toBeNull(); + expect(result.html.length).toBeGreaterThan(0); + expect(result.html).toContain('Hello'); + }); + + it('determinism: MDX engine returns byte-identical HTML on repeated calls', () => { + const result1 = renderFixture(body, ctx, 'mdx'); + const result2 = renderFixture(body, ctx, 'mdx'); + expect(result1.html).toBe(result2.html); + }); +}); diff --git a/lib/render-fixture/index.ts b/lib/render-fixture/index.ts new file mode 100644 index 000000000..02f984045 --- /dev/null +++ b/lib/render-fixture/index.ts @@ -0,0 +1,19 @@ +/** + * Public barrel for `@readme/markdown/render-fixture`. + * + * Pairs with the engine-free `@readme/markdown/render-diff` subpath: this + * bundle DOES include the engine (it has to — its job is to render). A + * typical consumer that wants regression coverage across @readme/markdown + * version bumps pairs the two: + * + * import { renderFixture } from '@readme/markdown/render-fixture'; + * import { diff } from '@readme/markdown/render-diff'; + * + * const oldHtml = readFileSync('snapshot.html', 'utf8'); + * const { html: newHtml } = renderFixture(body, ctx, 'mdxish'); + * const result = diff(oldHtml, newHtml, { preset: 'minimal' }); + */ +export { loadFixture } from './loadFixture'; +export type { RenderContext } from './loadFixture'; +export { renderFixture } from './renderFixture'; +export type { Engine, FixtureRenderResult } from './renderFixture'; diff --git a/lib/render-fixture/loadFixture.ts b/lib/render-fixture/loadFixture.ts new file mode 100644 index 000000000..970167a91 --- /dev/null +++ b/lib/render-fixture/loadFixture.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, basename, extname } from 'node:path'; + +export interface RenderContext { + components: { source: string, tag: string; }[]; + glossary: { definition: string, term: string; }[]; + variables: { defaults: { default: string, name: string; }[]; user: Record }; +} + +interface RawContextJson { + glossary?: unknown; + variables?: unknown; +} + +export function loadFixture(dir: string): { body: string; ctx: RenderContext } { + const body = readFileSync(join(dir, 'body.md'), 'utf8'); + // `raw` is typed as `unknown`-ish shape so each field below MUST be + // narrowed with Array.isArray / object guards before use. Any unguarded + // property read is a type error. + const raw: RawContextJson = JSON.parse( + readFileSync(join(dir, 'context.json'), 'utf8'), + ); + const components: { source: string, tag: string; }[] = []; + + const compDir = join(dir, 'components'); + // components/ directory is optional — missing dir yields components: []. + // Only an absent directory is silently tolerated; any other I/O failure + // (permissions, malformed reads, etc.) must surface rather than masquerade + // as engine divergence downstream. + if (existsSync(compDir)) { + readdirSync(compDir) + .filter(file => extname(file) === '.mdx') + .forEach(file => { + const tag = basename(file, '.mdx'); + const source = readFileSync(join(compDir, file), 'utf8'); + components.push({ tag, source }); + }); + } + + const rawVars: Record = + typeof raw.variables === 'object' && raw.variables !== null + ? (raw.variables as Record) + : {}; + const variables: RenderContext['variables'] = { + defaults: Array.isArray(rawVars.defaults) + ? (rawVars.defaults as RenderContext['variables']['defaults']) + : [], + user: + typeof rawVars.user === 'object' && rawVars.user !== null + ? (rawVars.user as RenderContext['variables']['user']) + : {}, + }; + + return { + body, + ctx: { + variables, + glossary: Array.isArray(raw.glossary) + ? (raw.glossary as RenderContext['glossary']) + : [], + components, + }, + }; +} diff --git a/lib/render-fixture/renderFixture.ts b/lib/render-fixture/renderFixture.ts new file mode 100644 index 000000000..b62017e96 --- /dev/null +++ b/lib/render-fixture/renderFixture.ts @@ -0,0 +1,139 @@ +import type { RenderContext } from './loadFixture'; +import type { RMDXModule } from '../../types'; + +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; + +import { compile, run, mdxish, renderMdxish } from '..'; + + +export type Engine = 'mdx' | 'mdxish'; + +export interface FixtureRenderResult { + error: string | null; + html: string; +} + +// Frozen timestamp used for deterministic rendering (2024-01-01T00:00:00.000Z) +const FROZEN_NOW = Date.UTC(2024, 0, 1); + +/** + * Wraps a render call with a frozen clock (Date.now and Math.random) so that + * renders are deterministic. + * + * SYNC-ONLY: `fn` must return synchronously. The `finally` block restores the + * real `Date.now` / `Math.random` the moment `fn` returns, which for a + * Promise-returning `fn` happens *before* the async work runs — exposing the + * real clock during render and producing flaky output. If a future engine + * change makes any of `compile`, `run`, `mdxish`, `renderMdxish`, or + * `renderToString` async, this helper must be rewritten to await the result + * before restoring globals. The runtime assertion below fails loudly rather + * than silently producing non-deterministic renders. + */ +function withFrozenClock(fn: () => T): T { + const savedNow = Date.now; + const savedRandom = Math.random; + Date.now = () => FROZEN_NOW; + let seed = 1; + Math.random = () => { + seed = (seed * 16807) % 2147483647; + return seed / 2147483647; + }; + try { + const result = fn(); + if ( + result !== null && + typeof result === 'object' && + typeof (result as { then?: unknown }).then === 'function' + ) { + throw new Error( + 'withFrozenClock: fn returned a Promise. This helper is sync-only — ' + + 'an async render would restore the real clock before the engine ' + + 'work completes, breaking determinism. Rewrite withFrozenClock to ' + + 'await before restoring globals if any engine becomes async.', + ); + } + return result; + } finally { + Date.now = savedNow; + Math.random = savedRandom; + } +} + +/** + * Build the components map for compile() — MDX compile() accepts + * `Record` (raw source strings). + */ +function buildMdxSources( + components: RenderContext['components'], +): Record { + return components.reduce>((acc, { tag, source }) => { + acc[tag] = source; + return acc; + }, {}); +} + +/** + * Compile each custom block through compile+run to produce RMDXModules. + * Used by run()'s `components` option and renderMdxish()'s `components` option. + */ +function buildModules( + components: RenderContext['components'], +): Record { + return components.reduce>((acc, { tag, source }) => { + const code = compile(source, {}); + acc[tag] = run(code, {}) as RMDXModule; + return acc; + }, {}); +} + +/** + * Renders a fixture body string through the specified engine and returns the + * serialized HTML. Renders are deterministic — Date.now and Math.random are + * frozen for the duration of each render call. + * + * For MDXish, the components map is passed to BOTH mdxish() and renderMdxish() + * — both call sites need it for custom-block resolution. + * + * The loader's `ctx.glossary` is mapped onto the engine's `terms` parameter. + */ +export function renderFixture( + body: string, + ctx: RenderContext, + engine: Engine, +): FixtureRenderResult { + return withFrozenClock(() => { + try { + if (engine === 'mdx') { + const code = compile(body, { + useTailwind: true, + components: buildMdxSources(ctx.components), + }); + const mod = run(code, { + components: buildModules(ctx.components), + variables: ctx.variables, + terms: ctx.glossary, + }) as RMDXModule; + return { html: renderToString(createElement(mod.default)), error: null }; + } + + const modules = buildModules(ctx.components); + // components MUST be passed to both mdxish() and renderMdxish() + const tree = mdxish(body, { + useTailwind: true, + components: modules, + }); + const mod = renderMdxish(tree, { + components: modules, + variables: ctx.variables, + terms: ctx.glossary, + }); + return { html: renderToString(createElement(mod.default)), error: null }; + } catch (err) { + return { + html: '', + error: err instanceof Error ? err.message : String(err), + }; + } + }); +} diff --git a/package.json b/package.json index c034ae084..eb5015e5f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "./render-diff": { "types": "./dist/lib/render-diff/index.d.ts", "default": "./dist/render-diff.node.js" + }, + "./render-fixture": { + "types": "./dist/lib/render-fixture/index.d.ts", + "default": "./dist/render-fixture.node.js" } }, "files": [ @@ -36,7 +40,7 @@ "test": "vitest", "test.ui": "vitest --ui", "test.browser": "jest", - "verify-exports": "node scripts/verify-render-diff.cjs", + "verify-exports": "node scripts/verify-render-diff.cjs && node scripts/verify-render-fixture.cjs", "watch": "webpack --watch --progress --mode development" }, "dependencies": { @@ -196,6 +200,10 @@ { "path": "dist/render-diff.node.js", "maxSize": "185KB" + }, + { + "path": "dist/render-fixture.node.js", + "maxSize": "4.5MB" } ] }, diff --git a/scripts/verify-render-fixture.cjs b/scripts/verify-render-fixture.cjs new file mode 100644 index 000000000..19a23e6e9 --- /dev/null +++ b/scripts/verify-render-fixture.cjs @@ -0,0 +1,54 @@ +/* eslint-disable no-console -- CLI script: console output is the deliberate UX */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +// --- Positive control: engine identifiers MUST be present --- +// render-fixture exists to render markdown through the engine; if the +// bundle doesn't contain engine code, the subpath is broken. This is the +// inverse of render-diff's check (which requires the same tokens to be +// absent). +const bundlePath = + process.env.VERIFY_RENDER_FIXTURE_BUNDLE_PATH || + path.join(__dirname, '..', 'dist', 'render-fixture.node.js'); +const bundle = fs.readFileSync(bundlePath, 'utf-8'); + +const ENGINE_TOKENS = [ + 'mdxishAstProcessor', + 'mdxishMdastToMd', + 'renderMdxish', + 'mdxCompileSync', + 'sanitizeSchema', +]; + +const missing = ENGINE_TOKENS.filter(token => !bundle.includes(token)); +assert( + missing.length === 0, + `render-fixture bundle is missing required engine identifiers: ${missing.join(', ')} — the renderer will not work at runtime`, +); +console.log(`engine-presence: PASS — all ${ENGINE_TOKENS.length} engine identifiers present in ${bundlePath}`); + +// --- Self-referencing probe: exports map resolution + smoke render --- +// Same pattern as verify-render-diff.cjs but for the render-fixture subpath. +// NOT path-overridable — this validates the real exports map. +const { renderFixture, loadFixture } = require('@readme/markdown/render-fixture'); + +assert(typeof renderFixture === 'function', 'renderFixture is not a function after exports map resolution'); +assert(typeof loadFixture === 'function', 'loadFixture is not a function after exports map resolution'); + +// Smoke render: plain markdown through both engines, assert non-empty HTML. +const body = '# Hello\n\nSmoke test.'; +const ctx = { variables: { defaults: [], user: {} }, glossary: [], components: [] }; + +const mdxResult = renderFixture(body, ctx, 'mdx'); +assert(mdxResult.error === null, `MDX render failed: ${mdxResult.error}`); +assert(mdxResult.html.includes('Hello'), 'MDX render did not contain heading text'); + +const mdxishResult = renderFixture(body, ctx, 'mdxish'); +assert(mdxishResult.error === null, `MDXish render failed: ${mdxishResult.error}`); +assert(mdxishResult.html.includes('Hello'), 'MDXish render did not contain heading text'); + +console.log('self-ref probe: PASS — @readme/markdown/render-fixture resolves and both engines render'); + +console.log('\nverify-render-fixture: ALL CHECKS PASSED'); diff --git a/webpack.config.js b/webpack.config.js index 187151668..8dd1e4655 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -125,6 +125,7 @@ const serverConfig = merge(getConfig({ target: 'node' }), { entry: { main: './index', 'render-diff': './lib/render-diff', + 'render-fixture': './lib/render-fixture', }, output: { filename: '[name].node.js', From 15692d76efc4f15659ec66cec30920ea10bda76c Mon Sep 17 00:00:00 2001 From: James Clark Date: Tue, 26 May 2026 15:15:48 -0700 Subject: [PATCH 03/13] =?UTF-8?q?test(regression):=20MDX=E2=86=94MDXish=20?= =?UTF-8?q?regression=20suite=20with=2013=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test suites that share a fixture corpus: - Suite A (__tests__/regression/snapshots.test.ts) renders each fixture through both engines separately and toMatchSnapshot()s the raw HTML — one snapshot entry per (fixture, engine), 26 total. Byte-level changes in either engine surface as a failing snapshot on the PR that caused them. - Suite B (__tests__/regression/equivalence.test.ts) renders each fixture through both engines, runs diff() on the two outputs, and toMatchSnapshot()s the structured diff. As MDX↔MDXish parity improves, the snapshot shrinks toward { status: 'match' }. Both suites auto-discover fixture directories via readdirSync — adding a new fixture takes zero code change. Fixture format (documented in __tests__/regression/fixtures/README.md): - body.md — markdown source - context.json — variables + glossary (engine inputs) - components/*.mdx — optional custom components by tag name Fixture corpus (13 directories): Baselines (the two arms of DiffResult + the data-plumbing surface): - convergent — match branch (plain markdown) - divergent — differ branch (HTMLBlock safeMode) - rich — variables + glossary + custom component Regression fixtures grounded in merged bug fixes: - tables-with-html — PR #1466, #1467, #1463, #1469, #1471 - magic-blocks-table — PR #1451, #1452 - unclosed-tags — PR #1474, #1477, #1480, #1482 - callouts-and-glossary — PR #1408, #1421, #1441, #1454 - jsx-attribute-entities — PR #1461, #1462 - variables-everywhere — PR #1423, #1459, #1471 - embeds — PR #1476 - consecutive-emojis-fa — PR #1390, #1416, #1421, #1449 - compact-headings — PR #1428 - htmlblock-with-script — PR #1457 (scoped to non-runScripts path) Each fixture README documents what it covers and what regressing it would mean. Three regression fixtures intentionally render empty on the MDX side (magic-blocks-table, unclosed-tags, variables-everywhere) because they exercise MDXish-flavored syntax strict MDX rejects — the MDXish snapshot is the regression contract for those. 39 snapshot entries committed: 26 in snapshots.test.ts.snap (Suite A) + 13 in equivalence.test.ts.snap (Suite B). --- .../__snapshots__/equivalence.test.ts.snap | 390 ++++++++++++++++++ .../__snapshots__/snapshots.test.ts.snap | 226 ++++++++++ __tests__/regression/equivalence.test.ts | 23 ++ __tests__/regression/fixtures/README.md | 208 ++++++++++ .../fixtures/callouts-and-glossary/README.md | 18 + .../fixtures/callouts-and-glossary/body.md | 19 + .../callouts-and-glossary/context.json | 9 + .../fixtures/compact-headings/README.md | 15 + .../fixtures/compact-headings/body.md | 9 + .../fixtures/compact-headings/context.json | 7 + .../fixtures/consecutive-emojis-fa/README.md | 18 + .../fixtures/consecutive-emojis-fa/body.md | 13 + .../consecutive-emojis-fa/context.json | 7 + .../regression/fixtures/convergent/README.md | 29 ++ .../regression/fixtures/convergent/body.md | 5 + .../fixtures/convergent/components/Note.mdx | 3 + .../fixtures/convergent/context.json | 7 + .../regression/fixtures/divergent/README.md | 35 ++ .../regression/fixtures/divergent/body.md | 3 + .../fixtures/divergent/context.json | 7 + .../regression/fixtures/embeds/README.md | 14 + __tests__/regression/fixtures/embeds/body.md | 9 + .../regression/fixtures/embeds/context.json | 7 + .../fixtures/htmlblock-with-script/README.md | 22 + .../fixtures/htmlblock-with-script/body.md | 10 + .../htmlblock-with-script/context.json | 7 + .../fixtures/jsx-attribute-entities/README.md | 15 + .../fixtures/jsx-attribute-entities/body.md | 11 + .../jsx-attribute-entities/context.json | 7 + .../fixtures/magic-blocks-table/README.md | 24 ++ .../fixtures/magic-blocks-table/body.md | 27 ++ .../fixtures/magic-blocks-table/context.json | 7 + __tests__/regression/fixtures/rich/README.md | 58 +++ __tests__/regression/fixtures/rich/body.md | 12 + .../fixtures/rich/components/Note.mdx | 3 + .../regression/fixtures/rich/context.json | 13 + .../fixtures/tables-with-html/README.md | 20 + .../fixtures/tables-with-html/body.md | 19 + .../fixtures/tables-with-html/context.json | 7 + .../fixtures/unclosed-tags/README.md | 23 ++ .../regression/fixtures/unclosed-tags/body.md | 21 + .../fixtures/unclosed-tags/context.json | 7 + .../fixtures/variables-everywhere/README.md | 32 ++ .../fixtures/variables-everywhere/body.md | 21 + .../variables-everywhere/context.json | 13 + __tests__/regression/snapshots.test.ts | 27 ++ 46 files changed, 1487 insertions(+) create mode 100644 __tests__/regression/__snapshots__/equivalence.test.ts.snap create mode 100644 __tests__/regression/__snapshots__/snapshots.test.ts.snap create mode 100644 __tests__/regression/equivalence.test.ts create mode 100644 __tests__/regression/fixtures/README.md create mode 100644 __tests__/regression/fixtures/callouts-and-glossary/README.md create mode 100644 __tests__/regression/fixtures/callouts-and-glossary/body.md create mode 100644 __tests__/regression/fixtures/callouts-and-glossary/context.json create mode 100644 __tests__/regression/fixtures/compact-headings/README.md create mode 100644 __tests__/regression/fixtures/compact-headings/body.md create mode 100644 __tests__/regression/fixtures/compact-headings/context.json create mode 100644 __tests__/regression/fixtures/consecutive-emojis-fa/README.md create mode 100644 __tests__/regression/fixtures/consecutive-emojis-fa/body.md create mode 100644 __tests__/regression/fixtures/consecutive-emojis-fa/context.json create mode 100644 __tests__/regression/fixtures/convergent/README.md create mode 100644 __tests__/regression/fixtures/convergent/body.md create mode 100644 __tests__/regression/fixtures/convergent/components/Note.mdx create mode 100644 __tests__/regression/fixtures/convergent/context.json create mode 100644 __tests__/regression/fixtures/divergent/README.md create mode 100644 __tests__/regression/fixtures/divergent/body.md create mode 100644 __tests__/regression/fixtures/divergent/context.json create mode 100644 __tests__/regression/fixtures/embeds/README.md create mode 100644 __tests__/regression/fixtures/embeds/body.md create mode 100644 __tests__/regression/fixtures/embeds/context.json create mode 100644 __tests__/regression/fixtures/htmlblock-with-script/README.md create mode 100644 __tests__/regression/fixtures/htmlblock-with-script/body.md create mode 100644 __tests__/regression/fixtures/htmlblock-with-script/context.json create mode 100644 __tests__/regression/fixtures/jsx-attribute-entities/README.md create mode 100644 __tests__/regression/fixtures/jsx-attribute-entities/body.md create mode 100644 __tests__/regression/fixtures/jsx-attribute-entities/context.json create mode 100644 __tests__/regression/fixtures/magic-blocks-table/README.md create mode 100644 __tests__/regression/fixtures/magic-blocks-table/body.md create mode 100644 __tests__/regression/fixtures/magic-blocks-table/context.json create mode 100644 __tests__/regression/fixtures/rich/README.md create mode 100644 __tests__/regression/fixtures/rich/body.md create mode 100644 __tests__/regression/fixtures/rich/components/Note.mdx create mode 100644 __tests__/regression/fixtures/rich/context.json create mode 100644 __tests__/regression/fixtures/tables-with-html/README.md create mode 100644 __tests__/regression/fixtures/tables-with-html/body.md create mode 100644 __tests__/regression/fixtures/tables-with-html/context.json create mode 100644 __tests__/regression/fixtures/unclosed-tags/README.md create mode 100644 __tests__/regression/fixtures/unclosed-tags/body.md create mode 100644 __tests__/regression/fixtures/unclosed-tags/context.json create mode 100644 __tests__/regression/fixtures/variables-everywhere/README.md create mode 100644 __tests__/regression/fixtures/variables-everywhere/body.md create mode 100644 __tests__/regression/fixtures/variables-everywhere/context.json create mode 100644 __tests__/regression/snapshots.test.ts diff --git a/__tests__/regression/__snapshots__/equivalence.test.ts.snap b/__tests__/regression/__snapshots__/equivalence.test.ts.snap new file mode 100644 index 000000000..81dab7e87 --- /dev/null +++ b/__tests__/regression/__snapshots__/equivalence.test.ts.snap @@ -0,0 +1,390 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Suite B: MDX↔MDXish equivalence > callouts-and-glossary 1`] = ` +{ + "changes": [ + { + "attrName": "aria-label", + "kind": "attr", + "left": "Skip link to [object Object], Blockquote callout with FA emoji", + "path": "/blockquote[1]/h3[1]/a[2]", + "right": "Skip link to ,[object Object], Blockquote callout with FA emoji", + "severity": "cosmetic", + }, + { + "kind": "tag", + "left": "blockquote", + "path": "/blockquote[2]", + "right": "div", + "severity": "structural", + }, + { + "kind": "tag", + "left": "blockquote", + "path": "/blockquote[3]", + "right": "div", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/p[4]", + "right": "

", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > compact-headings 1`] = ` +{ + "changes": [ + { + "kind": "tag", + "left": "p", + "path": "/p[0]", + "right": "h1", + "severity": "structural", + }, + { + "kind": "tag", + "left": "h1", + "path": "/h1[1]", + "right": "h2", + "severity": "structural", + }, + { + "kind": "tag", + "left": "h2", + "path": "/h2[2]", + "right": "h3", + "severity": "structural", + }, + { + "kind": "tag", + "left": "h3", + "path": "/h3[3]", + "right": "h1", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/h2[4]", + "right": "

…(3 children)…

", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/h3[5]", + "right": "

…(3 children)…

", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > consecutive-emojis-fa 1`] = ` +{ + "changes": [ + { + "kind": "text", + "left": "Consecutive gemojis: 😁 :grin: should both render side by side.", + "path": "/p[1]/#text[0]", + "right": "Consecutive gemojis: 😁😁 should both render side by side.", + "severity": "content", + }, + { + "attrName": "aria-label", + "kind": "attr", + "left": "Skip link to [object Object], Blockquote callout with FA in header", + "path": "/blockquote[3]/h3[1]/a[2]", + "right": "Skip link to ,[object Object], Blockquote callout with FA in header", + "severity": "cosmetic", + }, + { + "kind": "tag", + "left": "blockquote", + "path": "/blockquote[4]", + "right": "div", + "severity": "structural", + }, + ], + "severity": "content", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > convergent 1`] = ` +{ + "status": "match", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > divergent 1`] = ` +{ + "changes": [ + { + "kind": "tag", + "left": "pre", + "path": "/pre[0]", + "right": "p", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > embeds 1`] = ` +{ + "changes": [ + { + "kind": "tag", + "left": "iframe", + "path": "/iframe[1]", + "right": "div", + "severity": "structural", + }, + { + "kind": "tag", + "left": "iframe", + "path": "/iframe[2]", + "right": "div", + "severity": "structural", + }, + { + "kind": "tag", + "left": "iframe", + "path": "/iframe[3]", + "right": "div", + "severity": "structural", + }, + { + "kind": "tag", + "left": "iframe", + "path": "/iframe[4]", + "right": "div", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > htmlblock-with-script 1`] = ` +{ + "changes": [ + { + "kind": "tag", + "left": "div", + "path": "/div[1]", + "right": "p", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > jsx-attribute-entities 1`] = ` +{ + "changes": [ + { + "kind": "tag", + "left": "blockquote", + "path": "/blockquote[1]", + "right": "div", + "severity": "structural", + }, + { + "kind": "tag", + "left": "blockquote", + "path": "/blockquote[2]", + "right": "div", + "severity": "structural", + }, + { + "kind": "tag", + "left": "figure", + "path": "/figure[3]", + "right": "div", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > magic-blocks-table 1`] = ` +{ + "changes": [ + { + "kind": "missing", + "left": null, + "path": "/h1[0]", + "right": "

…(3 children)…

", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/div[1]", + "right": "
…(1 children)…
", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/div[2]", + "right": "
…(1 children)…
", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > rich 1`] = ` +{ + "changes": [ + { + "kind": "text", + "left": "Bearer <>", + "path": "/p[1]/code[1]/#text[0]", + "right": "Bearer APIKEY", + "severity": "content", + }, + { + "kind": "text", + "left": "<>", + "path": "/p[1]/code[3]/#text[0]", + "right": "REGION", + "severity": "content", + }, + { + "kind": "text", + "left": "<>", + "path": "/div[3]/blockquote[0]/p[1]/code[1]/#text[0]", + "right": "NAME", + "severity": "content", + }, + { + "kind": "text", + "left": "{user.region}", + "path": "/div[3]/blockquote[0]/p[1]/code[3]/#text[0]", + "right": "USER.REGION", + "severity": "content", + }, + { + "kind": "missing", + "left": null, + "path": "/div[3]/blockquote[0]/p[1]/br[4]", + "right": "
", + "severity": "structural", + }, + ], + "severity": "content", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > tables-with-html 1`] = ` +{ + "changes": [ + { + "kind": "tag", + "left": "table", + "path": "/table[1]", + "right": "div", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > unclosed-tags 1`] = ` +{ + "changes": [ + { + "kind": "missing", + "left": null, + "path": "/h1[0]", + "right": "

…(3 children)…

", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; + +exports[`Suite B: MDX↔MDXish equivalence > variables-everywhere 1`] = ` +{ + "changes": [ + { + "kind": "missing", + "left": null, + "path": "/h1[0]", + "right": "

…(3 children)…

", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/p[1]", + "right": "

…(5 children)…

", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/p[2]", + "right": "

…(1 children)…

", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/div[3]", + "right": "
…(1 children)…
", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/p[4]", + "right": "

…(7 children)…

", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/pre[5]", + "right": "
…(1 children)…
", + "severity": "structural", + }, + { + "kind": "missing", + "left": null, + "path": "/p[6]", + "right": "

…(3 children)…

", + "severity": "structural", + }, + ], + "severity": "structural", + "status": "differ", +} +`; diff --git a/__tests__/regression/__snapshots__/snapshots.test.ts.snap b/__tests__/regression/__snapshots__/snapshots.test.ts.snap new file mode 100644 index 000000000..e700c1813 --- /dev/null +++ b/__tests__/regression/__snapshots__/snapshots.test.ts.snap @@ -0,0 +1,226 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Suite A: per-engine > callouts-and-glossary (mdx) 1`] = ` +"

Callouts and glossary

+
📘

Blockquote callout with FA emoji

Body of the blockquote callout.

+
📘

Title with acme term

Callout body content.

+
📘

This headerless callout's first paragraph should not be styled as title.

+ +

A trailing glossary term: acme.

" +`; + +exports[`Suite A: per-engine > callouts-and-glossary (mdxish) 1`] = ` +"

Callouts and glossary

+
📘

Blockquote callout with FA emoji

Body of the blockquote callout.

+
📘

Title with acme term

Callout body content.

+
📘

This headerless callout's first paragraph should not be styled as title.

+

+

A trailing glossary term: acme.

" +`; + +exports[`Suite A: per-engine > compact-headings (mdx) 1`] = ` +"

#Compact h1 with no space after hash +##Compact h2 with no space +###Compact h3 with no space

+

Normal h1 with space

+

Normal h2

+

Normal h3

" +`; + +exports[`Suite A: per-engine > compact-headings (mdxish) 1`] = ` +"

Compact h1 with no space after hash

+

Compact h2 with no space

+

Compact h3 with no space

+

Normal h1 with space

+

Normal h2

+

Normal h3

" +`; + +exports[`Suite A: per-engine > consecutive-emojis-fa (mdx) 1`] = ` +"

Emoji rendering

+

Consecutive gemojis: 😁:grin: should both render side by side.

+

FA standalone: and .

+
📘

Blockquote callout with FA in header

Body content.

+
📘

Callout body containing and 😁 together.

" +`; + +exports[`Suite A: per-engine > consecutive-emojis-fa (mdxish) 1`] = ` +"

Emoji rendering

+

Consecutive gemojis: 😁😁 should both render side by side.

+

FA standalone: and .

+
📘

Blockquote callout with FA in header

Body content.

+
📘

Callout body containing and 😁 together.

" +`; + +exports[`Suite A: per-engine > convergent (mdx) 1`] = ` +"

Convergent Fixture

+

This is a simple convergent fixture with plain Markdown.

+

Both engines should render this identically.

" +`; + +exports[`Suite A: per-engine > convergent (mdxish) 1`] = ` +"

Convergent Fixture

+

This is a simple convergent fixture with plain Markdown.

+

Both engines should render this identically.

" +`; + +exports[`Suite A: per-engine > divergent (mdx) 1`] = ` +"
<div class="example">Hello</div>
+

Some prose after the HTML block.

" +`; + +exports[`Suite A: per-engine > divergent (mdxish) 1`] = ` +"

<div class="example">Hello</div>

+

Some prose after the HTML block.

" +`; + +exports[`Suite A: per-engine > embeds (mdx) 1`] = ` +"

Embeds

+ + + +" +`; + +exports[`Suite A: per-engine > embeds (mdxish) 1`] = ` +"

Embeds

+
+
+
+
" +`; + +exports[`Suite A: per-engine > htmlblock-with-script (mdx) 1`] = ` +"

HTMLBlock (no script execution)

+
+
+

Plain HTML inside HTMLBlock

+

Content with nested tags and an entity: &.

+
+
+

Trailing paragraph after the HTMLBlock.

" +`; + +exports[`Suite A: per-engine > htmlblock-with-script (mdxish) 1`] = ` +"

HTMLBlock (no script execution)

+

+

Plain HTML inside HTMLBlock

+

Content with nested tags and an entity: &.

+

+

Trailing paragraph after the HTMLBlock.

" +`; + +exports[`Suite A: per-engine > jsx-attribute-entities (mdx) 1`] = ` +"

JSX attribute entities

+
🚧

Numeric-entity emoji in icon should decode to the construction sign.

+
📘

Hex-entity emoji in icon should decode to the blue book.

+

Caption with & ampersand entity

" +`; + +exports[`Suite A: per-engine > jsx-attribute-entities (mdxish) 1`] = ` +"

JSX attribute entities

+
🚧

Numeric-entity emoji in icon should decode to the construction sign.

+
📘

Hex-entity emoji in icon should decode to the blue book.

+

Caption with & ampersand entity

" +`; + +exports[`Suite A: per-engine > magic-blocks-table (mdx) 1`] = `""`; + +exports[`Suite A: per-engine > magic-blocks-table (mdxish) 1`] = ` +"

Magic-block tables

+ + + + + + + + + + + + + +
FieldType
name
  • String
  • + + + + + + + + + + + + + +
    FieldType
    id

    Number

    " +`; + +exports[`Suite A: per-engine > rich (mdx) 1`] = ` +"

    Rich Fixture

    +

    Authentication uses your API key. Use Bearer <<apiKey>> for requests originating from the <<region>> region.

    +

    A short glossary term: acme.

    +
    📘

    Both legacy <<name>> substitution and MDX-style {user.region} +interpolation should resolve from context.json.

    +

    Closing prose to anchor a trailing paragraph in the rendered tree.

    " +`; + +exports[`Suite A: per-engine > rich (mdxish) 1`] = ` +"

    Rich Fixture

    +

    Authentication uses your API key. Use Bearer APIKEY for requests originating from the REGION region.

    +

    A short glossary term: acme.

    +
    📘

    Both legacy NAME substitution and MDX-style USER.REGION
    +interpolation should resolve from context.json.

    +

    Closing prose to anchor a trailing paragraph in the rendered tree.

    " +`; + +exports[`Suite A: per-engine > tables-with-html (mdx) 1`] = ` +"

    Tables with inline HTML

    +
    AttributeDescription
    action_name
    catinline imagebold after a nested tag
    " +`; + +exports[`Suite A: per-engine > tables-with-html (mdxish) 1`] = ` +"

    Tables with inline HTML

    +
    AttributeDescription
    action_name
    catinline imagebold after a nested tag
    " +`; + +exports[`Suite A: per-engine > unclosed-tags (mdx) 1`] = `""`; + +exports[`Suite A: per-engine > unclosed-tags (mdxish) 1`] = ` +"

    Unclosed and orphan tags

    +" +`; + +exports[`Suite A: per-engine > variables-everywhere (mdx) 1`] = `""`; + +exports[`Suite A: per-engine > variables-everywhere (mdxish) 1`] = ` +"

    Variables in many contexts

    +

    Inline code: Bearer APIKEY and USER.REGION.

    +

    Standalone variable in a table cell:

    + + + + + + + + + + + + + + + + + +
    FieldValue
    API keysk_test_abc123
    Regionus-east-1
    +

    Mermaid sequence diagram — -- and - must NOT be substituted
    +as legacy variables:

    +
    sequenceDiagram
    +  Client <<-->> Server: Bidirectional dotted
    +  Client <<->> Server: Bidirectional solid
    +

    Closing prose with a glossary term: acme.

    " +`; diff --git a/__tests__/regression/equivalence.test.ts b/__tests__/regression/equivalence.test.ts new file mode 100644 index 000000000..d430bb4e4 --- /dev/null +++ b/__tests__/regression/equivalence.test.ts @@ -0,0 +1,23 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, it, expect } from 'vitest'; + +import { diff } from '../../lib/render-diff'; +import { loadFixture, renderFixture } from '../../lib/render-fixture'; + +const FIXTURES_DIR = join(__dirname, 'fixtures'); + +describe('Suite B: MDX↔MDXish equivalence', () => { + const fixtures = readdirSync(FIXTURES_DIR, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); + + it.each(fixtures)('%s', fixtureName => { + const { body, ctx } = loadFixture(join(FIXTURES_DIR, fixtureName)); + const { html: mdxHtml } = renderFixture(body, ctx, 'mdx'); + const { html: mdxishHtml } = renderFixture(body, ctx, 'mdxish'); + const result = diff(mdxHtml, mdxishHtml); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/__tests__/regression/fixtures/README.md b/__tests__/regression/fixtures/README.md new file mode 100644 index 000000000..f4ee5d846 --- /dev/null +++ b/__tests__/regression/fixtures/README.md @@ -0,0 +1,208 @@ +# Regression Fixture Format + +Each fixture is a self-contained directory that both regression suites pick up +automatically. This README is the single source of truth for hand-authoring a +new fixture — you should not need to read any TypeScript to get started. + +--- + +## 1. Directory Convention & File Roles + +A fixture lives directly under `__tests__/regression/fixtures/`. The directory +name becomes the fixture name shown in test output and snapshot keys. + +``` +__tests__/regression/fixtures/ +│ +│ ── Baseline fixtures (engine behavior baselines) ── +├── convergent/ ← match branch of DiffResult (plain markdown) +│ ├── body.md ← REQUIRED +│ ├── context.json ← REQUIRED +│ └── components/ ← optional +│ └── Note.mdx ← registers +├── divergent/ ← differ branch of DiffResult (HTMLBlock safeMode) +├── rich/ ← exercises components + variables + glossary +│ +│ ── Regression fixtures (each grounded in a real merged bug fix) ── +├── tables-with-html/ ← raw with , attrs, inline content +├── magic-blocks-table/ ← consecutive [block:parameters] with HTML +├── unclosed-tags/ ← orphan/unclosed JSX openers and closers +├── callouts-and-glossary/ ← , , FA emoji in headers +├── jsx-attribute-entities/ ← HTML entities in JSX attribute values +├── variables-everywhere/ ← <>, {user.x}, Mermaid arrow preservation +├── embeds/ ← variants +├── consecutive-emojis-fa/ ← :grin::grin:, FA in callouts and components +├── compact-headings/ ← #Heading-without-space migration coverage +└── htmlblock-with-script/ ← static-content parse path +``` + +See each fixture's own `README.md` for what it specifically covers and which +merged PRs / Linear tickets motivated it. Regression fixtures intentionally +exercise inputs strict MDX may not accept (`[block:...]`, `<<>>` variables, +orphan tags) — those fixtures lock in the MDXish-side render and accept +`""` on the MDX side. Each fixture's README calls this out where it applies. + +### File roles + +| File | Required? | Purpose | +|------|-----------|---------| +| `body.md` | Yes | The Markdown source rendered by both engines | +| `context.json` | Yes | Supplies `variables` and `glossary` for the render | +| `components/` | No | Optional subdirectory; each `*.mdx` file registers a custom component | + +**`body.md`** — The Markdown body that gets rendered. Plain Markdown, GFM, and +any ReadMe custom syntax are all accepted. + +**`context.json`** — Required JSON file parsed by the loader. It provides +`variables` (default values and per-render overrides) and `glossary` entries. +See section 2 for the exact schema. + +**`components/` subdirectory** — Optional. If present, the loader reads every +`.mdx` file inside and registers it as a custom component. The filename +(without `.mdx`) becomes the tag name. For example, +`components/Note.mdx` registers `` — exactly as in `convergent/`. +If the `components/` directory is absent the fixture still loads normally; +`components` defaults to an empty array. + +--- + +## 2. `context.json` Schema + +`context.json` contains exactly three top-level fields. Do not add any others — +unrecognised fields are silently ignored by the loader. + +### Fields + +**`variables.defaults`** — `Array<{ name: string; default: string }>` + +Fixture-scoped default values for `{user.}` variable references in +`body.md`. If a variable is referenced in `body.md` but not overridden by +`variables.user`, this default value is used. + +```json +"defaults": [{ "name": "userName", "default": "Anonymous" }] +``` + +**`variables.user`** — `Record` + +Per-render override values keyed by variable name. Values here take precedence +over `variables.defaults`. + +```json +"user": { "userName": "Ada Lovelace" } +``` + +**`glossary`** — `Array<{ term: string; definition: string }>` + +Glossary entries available to the render context. + +```json +"glossary": [{ "term": "MDX", "definition": "Markdown with JSX" }] +``` + +### Complete minimal example + +```json +{ + "variables": { + "defaults": [{ "name": "userName", "default": "Anonymous" }], + "user": { "userName": "Ada Lovelace" } + }, + "glossary": [{ "term": "MDX", "definition": "Markdown with JSX" }] +} +``` + +### Empty context is valid + +Both `convergent/` and `divergent/` ship with the minimal empty form: + +```json +{ + "variables": { + "defaults": [], + "user": {} + }, + "glossary": [] +} +``` + +This is a perfectly valid `context.json`. Start here and add fields only as +needed. + +> **Note:** `components` does **not** appear in `context.json`. Custom +> components are registered by placing `.mdx` files in the `components/` +> subdirectory. The loader builds the `components` array from those files, not +> from the JSON. + +--- + +## 3. Adding a New Fixture (Step-by-Step) + +1. **Create a new directory** under `__tests__/regression/fixtures/`. The + directory name is the fixture name — choose something descriptive + (e.g. `callout-basic`, `table-alignment`). + +2. **Write `body.md`** — the Markdown source to render. A minimal example: + + ```md + # Hello + + World + ``` + + Use the existing `convergent/body.md` or `divergent/body.md` as copy-paste + starting points. + +3. **Write `context.json`** — copy `convergent/context.json` (a valid minimal + example) and populate as needed: + + ```bash + cp __tests__/regression/fixtures/convergent/context.json \ + __tests__/regression/fixtures//context.json + ``` + + If your `body.md` has no variable references or glossary terms, leave the + empty form unchanged. + +4. **(Optional) Add a `components/` subdirectory** with one `.mdx` file per + custom component. Follow the filename-equals-tag convention from section 1. + See `convergent/components/Note.mdx` as a living example. + +5. **Run `npm test`**. The new fixture is auto-discovered by both suites — no + code change required. On first run you will see: + + - **Suite A** (`snapshots.test.ts`): 2 new snapshot entries created — one + per engine (`mdx` and `mdxish`). + - **Suite B** (`equivalence.test.ts`): 1 new snapshot entry created — the + `diff()` result comparing the two engines. + +6. **Review the generated `.snap` entries** under + `__tests__/regression/__snapshots__/`. If they look correct, commit the + fixture directory together with the new `.snap` entries. + +--- + +## 4. How Each Suite Consumes Fixtures + +**Suite A** (`snapshots.test.ts`) renders the fixture through both engines +(MDX and MDXish) separately and calls `toMatchSnapshot()` on the raw HTML +string each engine emits, producing **two `.snap` entries per fixture**. Snapshot +keys follow the shape `Suite A: per-engine > () 1` (e.g. +`Suite A: per-engine > convergent (mdx) 1`). Any byte-level change to the +engine output — attribute order, whitespace, structure — surfaces as a failing +snapshot on the PR that caused it. + +**Suite B** (`equivalence.test.ts`) renders the fixture through both engines, +calls `diff()` on the two HTML strings, and snapshots the diff result, producing +**one `.snap` entry per fixture**. Keys follow `Suite B: MDX↔MDXish equivalence > 1`. +As MDX↔MDXish parity improves, the diff result shrinks toward an empty change +list. + +Both suites discover fixture directories with the same pattern — no code change +is needed when you add a new directory: + +```typescript +readdirSync(FIXTURES_DIR, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) +``` diff --git a/__tests__/regression/fixtures/callouts-and-glossary/README.md b/__tests__/regression/fixtures/callouts-and-glossary/README.md new file mode 100644 index 000000000..f2bc7bb3b --- /dev/null +++ b/__tests__/regression/fixtures/callouts-and-glossary/README.md @@ -0,0 +1,18 @@ +# `callouts-and-glossary` Fixture + +Crash-prone combinations of ``, ``, and FA emoji inside +callout headers. Each of these patterns crashed view-mode rendering at some +point in the last two months. + +## Source bugs + +- PR #1421 — FA emoji shortcodes (`:fa-rss-square:`) silently dropped inside blockquote callout headers +- PR #1441 — `<>` inside a callout title crashed the whole page +- PR #1408 — empty `` / `` crashed both MDX and MDXish +- PR #1454 — headerless callouts incorrectly applied title styling to first body paragraph + +## What flips this fixture + +Changes to the callout transformer's `toMarkdownExtensions`, the +`extractCalloutTitle` regex, glossary lookup paths, or the headerless +callout detection logic. diff --git a/__tests__/regression/fixtures/callouts-and-glossary/body.md b/__tests__/regression/fixtures/callouts-and-glossary/body.md new file mode 100644 index 000000000..4bf20e74f --- /dev/null +++ b/__tests__/regression/fixtures/callouts-and-glossary/body.md @@ -0,0 +1,19 @@ +# Callouts and glossary + +> 📘 :fa-rss-square: Blockquote callout with FA emoji +> +> Body of the blockquote callout. + + + ## Title with acme term + + Callout body content. + + + + This headerless callout's first paragraph should not be styled as title. + + + + +A trailing glossary term: acme. diff --git a/__tests__/regression/fixtures/callouts-and-glossary/context.json b/__tests__/regression/fixtures/callouts-and-glossary/context.json new file mode 100644 index 000000000..760ef6a03 --- /dev/null +++ b/__tests__/regression/fixtures/callouts-and-glossary/context.json @@ -0,0 +1,9 @@ +{ + "variables": { + "defaults": [], + "user": {} + }, + "glossary": [ + { "term": "acme", "definition": "A placeholder company name used in examples." } + ] +} diff --git a/__tests__/regression/fixtures/compact-headings/README.md b/__tests__/regression/fixtures/compact-headings/README.md new file mode 100644 index 000000000..efaed2a16 --- /dev/null +++ b/__tests__/regression/fixtures/compact-headings/README.md @@ -0,0 +1,15 @@ +# `compact-headings` Fixture + +Markdown headings without a space after the hash — legacy rdmd accepted +this form, MDX/MDXish did not until PR #1428's preprocessor was added. +This was a persistent migration pain point for customers moving from +legacy to MDX/MDXish. + +## Source bugs + +- PR #1428 — `#Heading` without space was not treated as a heading; customers migrating from legacy rdmd hit this constantly + +## What flips this fixture + +Changes to the `normalizeCompactHeadings()` preprocessor or any +heading-tokenizer change that affects the leading-hash detection regex. diff --git a/__tests__/regression/fixtures/compact-headings/body.md b/__tests__/regression/fixtures/compact-headings/body.md new file mode 100644 index 000000000..574ff6e6b --- /dev/null +++ b/__tests__/regression/fixtures/compact-headings/body.md @@ -0,0 +1,9 @@ +#Compact h1 with no space after hash +##Compact h2 with no space +###Compact h3 with no space + +# Normal h1 with space + +## Normal h2 + +### Normal h3 diff --git a/__tests__/regression/fixtures/compact-headings/context.json b/__tests__/regression/fixtures/compact-headings/context.json new file mode 100644 index 000000000..7db183bbb --- /dev/null +++ b/__tests__/regression/fixtures/compact-headings/context.json @@ -0,0 +1,7 @@ +{ + "variables": { + "defaults": [], + "user": {} + }, + "glossary": [] +} diff --git a/__tests__/regression/fixtures/consecutive-emojis-fa/README.md b/__tests__/regression/fixtures/consecutive-emojis-fa/README.md new file mode 100644 index 000000000..439652419 --- /dev/null +++ b/__tests__/regression/fixtures/consecutive-emojis-fa/README.md @@ -0,0 +1,18 @@ +# `consecutive-emojis-fa` Fixture + +Four emoji patterns that each surfaced their own fix in recent months: +consecutive gemojis, standalone FA shortcodes, FA inside a blockquote +callout header, and emoji combinations inside a JSX `` body. + +## Source bugs + +- PR #1390 — consecutive gemojis (`:grin::grin:`) only rendered the first one +- PR #1421 — FA emoji shortcodes silently dropped inside blockquote callout headers +- PR #1416 — gemoji not rendering in api-header magic block titles +- PR #1449 — gemoji and expressions not rendering inside custom components + +## What flips this fixture + +Changes to the gemoji tokenizer's adjacent-emoji detection, the FA-emoji +handler in the callout transformer's `toMarkdownExtensions`, or the +component-children expression evaluation. diff --git a/__tests__/regression/fixtures/consecutive-emojis-fa/body.md b/__tests__/regression/fixtures/consecutive-emojis-fa/body.md new file mode 100644 index 000000000..a90b26b6b --- /dev/null +++ b/__tests__/regression/fixtures/consecutive-emojis-fa/body.md @@ -0,0 +1,13 @@ +# Emoji rendering + +Consecutive gemojis: :grin::grin: should both render side by side. + +FA standalone: :fa-rss-square: and :fa-coffee:. + +> 📘 :fa-coffee: Blockquote callout with FA in header +> +> Body content. + + + Callout body containing :fa-rss-square: and :grin: together. + diff --git a/__tests__/regression/fixtures/consecutive-emojis-fa/context.json b/__tests__/regression/fixtures/consecutive-emojis-fa/context.json new file mode 100644 index 000000000..7db183bbb --- /dev/null +++ b/__tests__/regression/fixtures/consecutive-emojis-fa/context.json @@ -0,0 +1,7 @@ +{ + "variables": { + "defaults": [], + "user": {} + }, + "glossary": [] +} diff --git a/__tests__/regression/fixtures/convergent/README.md b/__tests__/regression/fixtures/convergent/README.md new file mode 100644 index 000000000..1840f5fb6 --- /dev/null +++ b/__tests__/regression/fixtures/convergent/README.md @@ -0,0 +1,29 @@ +# `convergent` Fixture + +Demonstrates the **match** branch of `DiffResult`: input markdown that MDX and +MDXish render to **identical** HTML. + +## What it proves + +- Suite B (`equivalence.test.ts`) gets `{ status: 'match' }` from `diff()`. +- The bottom-up content-hash fast-path in `differ.ts` short-circuits when both + trees hash equal. +- The convergent committed snapshot is the smallest possible diff result — any + future regression that perturbs MDX↔MDXish parity on plain Markdown will + flip this fixture from `match` to `differ` and surface on the PR. + +## Why it's plain Markdown + +The body deliberately avoids ReadMe custom syntax (no ``, no +``, no glossary terms). Plain Markdown is the one input shape where +MDX and MDXish are expected to converge exactly. The `components/Note.mdx` +file exists to exercise the loader's optional `components/` directory branch +— it isn't referenced from `body.md`. + +## Files + +| File | Role | +|------|------| +| `body.md` | The rendered markdown (kept intentionally trivial) | +| `context.json` | Empty `variables` and `glossary` — nothing to substitute | +| `components/Note.mdx` | Registers `` for loader-coverage; unused in body | diff --git a/__tests__/regression/fixtures/convergent/body.md b/__tests__/regression/fixtures/convergent/body.md new file mode 100644 index 000000000..b92ac9623 --- /dev/null +++ b/__tests__/regression/fixtures/convergent/body.md @@ -0,0 +1,5 @@ +# Convergent Fixture + +This is a simple convergent fixture with plain Markdown. + +Both engines should render this identically. diff --git a/__tests__/regression/fixtures/convergent/components/Note.mdx b/__tests__/regression/fixtures/convergent/components/Note.mdx new file mode 100644 index 000000000..3fc05b50f --- /dev/null +++ b/__tests__/regression/fixtures/convergent/components/Note.mdx @@ -0,0 +1,3 @@ + + {props.children} + diff --git a/__tests__/regression/fixtures/convergent/context.json b/__tests__/regression/fixtures/convergent/context.json new file mode 100644 index 000000000..7db183bbb --- /dev/null +++ b/__tests__/regression/fixtures/convergent/context.json @@ -0,0 +1,7 @@ +{ + "variables": { + "defaults": [], + "user": {} + }, + "glossary": [] +} diff --git a/__tests__/regression/fixtures/divergent/README.md b/__tests__/regression/fixtures/divergent/README.md new file mode 100644 index 000000000..2f2f03c1c --- /dev/null +++ b/__tests__/regression/fixtures/divergent/README.md @@ -0,0 +1,35 @@ +# `divergent` Fixture + +Demonstrates the **differ** branch of `DiffResult`: input markdown that MDX +and MDXish render to **different** HTML on purpose. + +## What it proves + +- Suite B (`equivalence.test.ts`) gets + `{ status: 'differ', severity: 'structural', changes: [...] }` from `diff()`. +- The change list is deterministic and document-ordered. +- Severity scoring fires. +- Suite A (`snapshots.test.ts`) commits two distinct per-engine snapshots, + proving the engines really do produce different HTML for this input. + +## Why it diverges + +The body uses `{`...`}`. The two engines +handle that custom block differently: + +- **MDX** wraps the raw HTML in its safe-mode container (a `
    `-style envelope).
    +- **MDXish** processes the HTML body more directly.
    +
    +The result is a structural divergence that's small, stable, and easy to read
    +in snapshot diffs. If a future engine change accidentally aligns these two
    +outputs, the divergent snapshot collapses — that's a meaningful signal worth
    +reviewing, not a flake.
    +
    +## Files
    +
    +| File | Role |
    +|------|------|
    +| `body.md` | One `` plus a trailing paragraph |
    +| `context.json` | Empty `variables` and `glossary` — divergence is engine-driven, not data-driven |
    +
    +No `components/` directory — this fixture doesn't need custom blocks.
    diff --git a/__tests__/regression/fixtures/divergent/body.md b/__tests__/regression/fixtures/divergent/body.md
    new file mode 100644
    index 000000000..a15a48310
    --- /dev/null
    +++ b/__tests__/regression/fixtures/divergent/body.md
    @@ -0,0 +1,3 @@
    +{`
    Hello
    `}
    + +Some prose after the HTML block. diff --git a/__tests__/regression/fixtures/divergent/context.json b/__tests__/regression/fixtures/divergent/context.json new file mode 100644 index 000000000..7db183bbb --- /dev/null +++ b/__tests__/regression/fixtures/divergent/context.json @@ -0,0 +1,7 @@ +{ + "variables": { + "defaults": [], + "user": {} + }, + "glossary": [] +} diff --git a/__tests__/regression/fixtures/embeds/README.md b/__tests__/regression/fixtures/embeds/README.md new file mode 100644 index 000000000..5c5fc3525 --- /dev/null +++ b/__tests__/regression/fixtures/embeds/README.md @@ -0,0 +1,14 @@ +# `embeds` Fixture + +Four `` variants without prerendered `html` fields — the shape +MdxishEditor emits. Without the PR #1476 fix, all four rendered as link +cards in view mode despite working correctly in the editor. + +## Source bugs + +- PR #1476 — iframe / YouTube / PDF / jsfiddle embeds rendered as link cards instead of iframes when `html` was absent + +## What flips this fixture + +Changes to the `Embed` view-mode renderer's branch logic, the `typeOfEmbed` +discriminator handling, or the `html` / `iframe` fallback chain. diff --git a/__tests__/regression/fixtures/embeds/body.md b/__tests__/regression/fixtures/embeds/body.md new file mode 100644 index 000000000..7c480a001 --- /dev/null +++ b/__tests__/regression/fixtures/embeds/body.md @@ -0,0 +1,9 @@ +# Embeds + + + + + + + + diff --git a/__tests__/regression/fixtures/embeds/context.json b/__tests__/regression/fixtures/embeds/context.json new file mode 100644 index 000000000..7db183bbb --- /dev/null +++ b/__tests__/regression/fixtures/embeds/context.json @@ -0,0 +1,7 @@ +{ + "variables": { + "defaults": [], + "user": {} + }, + "glossary": [] +} diff --git a/__tests__/regression/fixtures/htmlblock-with-script/README.md b/__tests__/regression/fixtures/htmlblock-with-script/README.md new file mode 100644 index 000000000..746840705 --- /dev/null +++ b/__tests__/regression/fixtures/htmlblock-with-script/README.md @@ -0,0 +1,22 @@ +# `htmlblock-with-script` Fixture + +Static HTML content inside an `` template-literal — exercises +the HTMLBlock parse path without enabling `runScripts`. + +## Source bugs + +- PR #1457 — `\n` escape inside a `runScripts=true` template literal was mangled by `formatHtmlForMdxish`, crashing the page with a JavaScript `SyntaxError` + +## Scope note + +The original bug specifically required `runScripts=true` plus a `\n` inside +a `