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
';
+ 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('
';
+
+ 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 = '
';
+ 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 = '
"
+`;
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 `