@readme/markdown 14.10.1 → 14.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Structural HTML diff for MDX-vs-MDXish render comparison.
3
+ *
4
+ * Algorithm overview:
5
+ * parseDocument(html, { xmlMode: true }) → walk → canonical {tag, attrs, children} tree
6
+ * apply canonicalization pipeline (sort/normalize/collapse)
7
+ * compute bottom-up content hash
8
+ * fast-path match if hashes equal
9
+ * otherwise, recursive walk emitting Change records
10
+ *
11
+ * Tree-walk targets the htmlparser2 / domhandler node shape. The canonicalization
12
+ * rules and severity model are transplanted verbatim from the reference differ.ts
13
+ * (PR #18479 in the readme repo). The tree-walk is re-targeted to htmlparser2/domhandler.
14
+ *
15
+ * The lint disables below are intentional and scoped to this file:
16
+ * - `no-restricted-syntax` + `no-continue`: this is a tree-walker with greedy
17
+ * alignment and lookahead; for-of with continue is the natural shape and
18
+ * array iteration helpers would obscure the control flow.
19
+ * - `@typescript-eslint/no-use-before-define`: helpers are ordered bottom-up
20
+ * (primitives first, public `diff()` last) so the public API anchors the
21
+ * end of the file.
22
+ */
23
+ import type { DiffOptions, DiffResult } from './types';
24
+ /**
25
+ * Diff two rendered HTML strings.
26
+ *
27
+ * The diff tool is engine-agnostic — `leftHtml` and `rightHtml` are simply
28
+ * the two HTML strings to compare in that order. Common use cases:
29
+ * - MDX vs MDXish (Suite B in this repo)
30
+ * - Before vs after a `@readme/markdown` version bump on the same source
31
+ * - Any two HTML strings a consumer wants to compare
32
+ *
33
+ * Both strings are canonicalized (whitespace collapse, class sort, attribute
34
+ * normalization, noise-attr drop, heading-counter strip, void-tag handling,
35
+ * text-equivalent merging) before comparison.
36
+ *
37
+ * A bottom-up content hash provides a fast-path: if both trees hash identically,
38
+ * `{ status: 'match' }` is returned without a full walk.
39
+ *
40
+ * The returned `changes[]` is ordered by document position and is identical
41
+ * across repeated calls on the same input. Each change carries `left` and
42
+ * `right` fields corresponding to the two inputs.
43
+ *
44
+ * @param leftHtml - First HTML string to compare.
45
+ * @param rightHtml - Second HTML string to compare.
46
+ * @param opts - Optional canonicalization / diff configuration.
47
+ * @returns `{ status: 'match' }` or `{ status: 'differ', severity, changes }`.
48
+ */
49
+ export declare function diff(leftHtml: string, rightHtml: string, opts?: DiffOptions): DiffResult;
@@ -0,0 +1,2 @@
1
+ export { diff } from './differ';
2
+ export type { Change, DiffOptions, DiffResult, Preset, Severity } from './types';
@@ -0,0 +1,119 @@
1
+ /**
2
+ * TypeScript types for the render-diff HTML diffing tool.
3
+ *
4
+ * These types form the public contract for `lib/render-diff/differ.ts`.
5
+ */
6
+ /**
7
+ * Severity level of a single diff change or aggregate page diff result.
8
+ *
9
+ * - `cosmetic` — minor presentation differences (e.g. `data-*` / `aria-*` attrs)
10
+ * - `structural` — tag, element, or layout differences
11
+ * - `content` — visible text or meaningful attribute value differences
12
+ */
13
+ export type Severity = 'content' | 'cosmetic' | 'structural';
14
+ /**
15
+ * A single diffed change between two rendered HTML inputs.
16
+ *
17
+ * The diff tool is engine-agnostic — `left` and `right` are simply the two
18
+ * HTML strings passed to `diff()` in that order. Suite B uses them as
19
+ * MDX vs MDXish; other consumers (e.g. before/after a markdown version bump)
20
+ * use them however they like.
21
+ *
22
+ * Each change is emitted at a specific document-position path.
23
+ */
24
+ export interface Change {
25
+ /**
26
+ * Attribute name — populated only when `kind` is `'attr'`.
27
+ */
28
+ attrName?: string;
29
+ /**
30
+ * The type of difference detected.
31
+ *
32
+ * - `tag` — element tag mismatch between `left` and `right`
33
+ * - `attr` — attribute present, missing, or different value
34
+ * - `text` — text node content differs
35
+ * - `missing` — node present in `left` but absent in `right`
36
+ * - `extra` — node present in `right` but absent in `left`
37
+ */
38
+ kind: 'attr' | 'extra' | 'missing' | 'tag' | 'text';
39
+ /**
40
+ * String representation from the `left` input, or `null` when the node is absent.
41
+ */
42
+ left: string | null;
43
+ /**
44
+ * Document-position path of the diffed node (top-to-bottom tree-walk order).
45
+ */
46
+ path: string;
47
+ /**
48
+ * String representation from the `right` input, or `null` when the node is absent.
49
+ */
50
+ right: string | null;
51
+ /**
52
+ * Severity of this individual change.
53
+ */
54
+ severity: Severity;
55
+ }
56
+ /**
57
+ * Result returned by `diff(leftHtml, rightHtml)`.
58
+ *
59
+ * Discriminated union on `status`:
60
+ * - `'match'` arm collapses to `{ status: 'match' }` — no `severity` or `changes` keys.
61
+ * - `'differ'` arm carries the full `{ status, severity, changes }` shape.
62
+ *
63
+ * TypeScript narrowing on `result.status === 'differ'` exposes `severity` and `changes`.
64
+ */
65
+ export type DiffResult = {
66
+ changes: Change[];
67
+ severity: Severity;
68
+ status: 'differ';
69
+ } | {
70
+ status: 'match';
71
+ };
72
+ /**
73
+ * Canonicalization preset for `diff()`.
74
+ *
75
+ * - `'cross-engine'` — full normalization including span-flatten and adjacent-text
76
+ * merge. Designed for MDX↔MDXish comparison (Suite B).
77
+ * - `'minimal'` — lighter normalization; span-flatten and adjacent-text merge are
78
+ * skipped. Suitable for same-engine before/after comparison where those transforms
79
+ * would mask real structural changes.
80
+ */
81
+ export type Preset = 'cross-engine' | 'minimal';
82
+ /**
83
+ * Options accepted by `diff()`.
84
+ *
85
+ * All fields are optional. Default behaviour: hash fast-path enabled, all
86
+ * heading counter suffixes stripped, no additional attributes ignored.
87
+ */
88
+ export interface DiffOptions {
89
+ /**
90
+ * Additional attribute names to drop during canonicalization (lowercased).
91
+ * Added to the built-in noise-attr set (`data-reactroot`, `data-testid`,
92
+ * `suppresshydrationwarning`).
93
+ */
94
+ attrIgnore?: Set<string>;
95
+ /**
96
+ * When `true`, bypass the content-hash fast-path and always perform the full
97
+ * tree walk. Intended for testing only.
98
+ */
99
+ noHashFastPath?: boolean;
100
+ /**
101
+ * When `true`, skip stripping `-N` counter suffixes from heading `id` attrs.
102
+ * Default: `false` (suffixes are stripped so repeated headings compare equal).
103
+ */
104
+ preserveHeadingCounters?: boolean;
105
+ /**
106
+ * Canonicalization preset. Controls which structural-normalization transforms
107
+ * are applied before comparison.
108
+ *
109
+ * - `'cross-engine'` (default) — full normalization including span-flatten
110
+ * and adjacent-text merge. Designed for MDX↔MDXish comparison (Suite B).
111
+ * - `'minimal'` — lighter normalization; span-flatten and adjacent-text merge
112
+ * are skipped. Suitable for same-engine before/after comparison where those
113
+ * transforms would mask real structural changes.
114
+ *
115
+ * Every normalization `'minimal'` performs is also performed by `'cross-engine'`.
116
+ * Default: omitting `preset` is equivalent to `'cross-engine'` (backward compatible).
117
+ */
118
+ preset?: Preset;
119
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Public barrel for `@readme/markdown/render-fixture`.
3
+ *
4
+ * Pairs with the engine-free `@readme/markdown/render-diff` subpath: this
5
+ * bundle DOES include the engine (it has to — its job is to render). A
6
+ * typical consumer that wants regression coverage across @readme/markdown
7
+ * version bumps pairs the two:
8
+ *
9
+ * import { renderFixture } from '@readme/markdown/render-fixture';
10
+ * import { diff } from '@readme/markdown/render-diff';
11
+ *
12
+ * const oldHtml = readFileSync('snapshot.html', 'utf8');
13
+ * const { html: newHtml } = renderFixture(body, ctx, 'mdxish');
14
+ * const result = diff(oldHtml, newHtml, { preset: 'minimal' });
15
+ */
16
+ export { loadFixture } from './loadFixture';
17
+ export type { RenderContext } from './loadFixture';
18
+ export { renderFixture } from './renderFixture';
19
+ export type { Engine, FixtureRenderResult } from './renderFixture';
@@ -0,0 +1,21 @@
1
+ export interface RenderContext {
2
+ components: {
3
+ source: string;
4
+ tag: string;
5
+ }[];
6
+ glossary: {
7
+ definition: string;
8
+ term: string;
9
+ }[];
10
+ variables: {
11
+ defaults: {
12
+ default: string;
13
+ name: string;
14
+ }[];
15
+ user: Record<string, string>;
16
+ };
17
+ }
18
+ export declare function loadFixture(dir: string): {
19
+ body: string;
20
+ ctx: RenderContext;
21
+ };
@@ -0,0 +1,17 @@
1
+ import type { RenderContext } from './loadFixture';
2
+ export type Engine = 'mdx' | 'mdxish';
3
+ export interface FixtureRenderResult {
4
+ error: string | null;
5
+ html: string;
6
+ }
7
+ /**
8
+ * Renders a fixture body string through the specified engine and returns the
9
+ * serialized HTML. Renders are deterministic — Date.now and Math.random are
10
+ * frozen for the duration of each render call.
11
+ *
12
+ * For MDXish, the components map is passed to BOTH mdxish() and renderMdxish()
13
+ * — both call sites need it for custom-block resolution.
14
+ *
15
+ * The loader's `ctx.glossary` is mapped onto the engine's `terms` parameter.
16
+ */
17
+ export declare function renderFixture(body: string, ctx: RenderContext, engine: Engine): FixtureRenderResult;
package/dist/main.js CHANGED
@@ -101750,6 +101750,22 @@ const positionEndingAtConsumed = (nodePosition, value, consumedLength) => {
101750
101750
  return nodePosition;
101751
101751
  return { start: nodePosition.start, end: pointAfter(nodePosition.start, value.slice(0, consumedLength)) };
101752
101752
  };
101753
+ /**
101754
+ * Build a position ending right after the last occurrence of `closingTag` within
101755
+ * this node's span in the original source. Used in the trailing-content path so
101756
+ * the offset is computed against the real source bytes (including blockquote/list
101757
+ * prefixes that were stripped from the html node's value).
101758
+ */
101759
+ const positionEndingAtClosingTagInSource = (nodePosition, closingTag, source) => {
101760
+ if (!nodePosition?.start || !nodePosition.end)
101761
+ return nodePosition;
101762
+ const nodeSource = source.slice(nodePosition.start.offset, nodePosition.end.offset);
101763
+ const closingTagOffset = nodeSource.lastIndexOf(closingTag);
101764
+ if (closingTagOffset === -1)
101765
+ return nodePosition;
101766
+ const consumed = nodeSource.slice(0, closingTagOffset + closingTag.length);
101767
+ return { start: nodePosition.start, end: pointAfter(nodePosition.start, consumed) };
101768
+ };
101753
101769
  /**
101754
101770
  * Create an MdxJsxFlowElement node from component data.
101755
101771
  */
@@ -101793,9 +101809,10 @@ const substituteNodeWithMdxNode = (parent, index, mdxNode) => {
101793
101809
  * The opening tag, content, and closing tag are all captured in one HTML node
101794
101810
  * (guaranteed by the mdx-component tokenizer).
101795
101811
  */
101796
- const mdxishMdxComponentBlocks = (opts = {}) => tree => {
101812
+ const mdxishMdxComponentBlocks = (opts = {}) => (tree, file) => {
101797
101813
  const stack = [tree];
101798
101814
  const safeMode = !!opts.safeMode;
101815
+ const source = file?.value ? String(file.value) : null;
101799
101816
  const parseOpts = { preserveExpressionsAsText: safeMode };
101800
101817
  const processChildNode = (parent, index) => {
101801
101818
  const node = parent.children[index];
@@ -101876,8 +101893,17 @@ const mdxishMdxComponentBlocks = (opts = {}) => tree => {
101876
101893
  attributes,
101877
101894
  children: parsedChildren,
101878
101895
  startPosition: node.position,
101879
- // End at the closing tag, not at trailing content re-parsed as siblings below.
101880
- endPosition: positionEndingAtConsumed(node.position, value, leadingWhitespace + openingTagEnd + closingTagIndex + closingTagStr.length),
101896
+ // When trailing content follows the closing tag, compute the end position precisely
101897
+ // within the html node's value so the component doesn't claim that content.
101898
+ // Prefer source-based positioning when the original source is available: the html
101899
+ // node's value has '> '/space prefixes stripped for blockquotes/list items, so
101900
+ // positionEndingAtConsumed would undercount source offsets. When the entire node
101901
+ // is consumed, use the original node position directly.
101902
+ endPosition: contentAfterClose
101903
+ ? source
101904
+ ? positionEndingAtClosingTagInSource(node.position, closingTagStr, source)
101905
+ : positionEndingAtConsumed(node.position, value, leadingWhitespace + openingTagEnd + closingTagIndex + closingTagStr.length)
101906
+ : node.position,
101881
101907
  });
101882
101908
  substituteNodeWithMdxNode(parent, index, componentNode);
101883
101909
  // After the closing tag, there might be more content to be processed
package/dist/main.node.js CHANGED
@@ -121974,6 +121974,22 @@ const positionEndingAtConsumed = (nodePosition, value, consumedLength) => {
121974
121974
  return nodePosition;
121975
121975
  return { start: nodePosition.start, end: pointAfter(nodePosition.start, value.slice(0, consumedLength)) };
121976
121976
  };
121977
+ /**
121978
+ * Build a position ending right after the last occurrence of `closingTag` within
121979
+ * this node's span in the original source. Used in the trailing-content path so
121980
+ * the offset is computed against the real source bytes (including blockquote/list
121981
+ * prefixes that were stripped from the html node's value).
121982
+ */
121983
+ const positionEndingAtClosingTagInSource = (nodePosition, closingTag, source) => {
121984
+ if (!nodePosition?.start || !nodePosition.end)
121985
+ return nodePosition;
121986
+ const nodeSource = source.slice(nodePosition.start.offset, nodePosition.end.offset);
121987
+ const closingTagOffset = nodeSource.lastIndexOf(closingTag);
121988
+ if (closingTagOffset === -1)
121989
+ return nodePosition;
121990
+ const consumed = nodeSource.slice(0, closingTagOffset + closingTag.length);
121991
+ return { start: nodePosition.start, end: pointAfter(nodePosition.start, consumed) };
121992
+ };
121977
121993
  /**
121978
121994
  * Create an MdxJsxFlowElement node from component data.
121979
121995
  */
@@ -122017,9 +122033,10 @@ const substituteNodeWithMdxNode = (parent, index, mdxNode) => {
122017
122033
  * The opening tag, content, and closing tag are all captured in one HTML node
122018
122034
  * (guaranteed by the mdx-component tokenizer).
122019
122035
  */
122020
- const mdxishMdxComponentBlocks = (opts = {}) => tree => {
122036
+ const mdxishMdxComponentBlocks = (opts = {}) => (tree, file) => {
122021
122037
  const stack = [tree];
122022
122038
  const safeMode = !!opts.safeMode;
122039
+ const source = file?.value ? String(file.value) : null;
122023
122040
  const parseOpts = { preserveExpressionsAsText: safeMode };
122024
122041
  const processChildNode = (parent, index) => {
122025
122042
  const node = parent.children[index];
@@ -122100,8 +122117,17 @@ const mdxishMdxComponentBlocks = (opts = {}) => tree => {
122100
122117
  attributes,
122101
122118
  children: parsedChildren,
122102
122119
  startPosition: node.position,
122103
- // End at the closing tag, not at trailing content re-parsed as siblings below.
122104
- endPosition: positionEndingAtConsumed(node.position, value, leadingWhitespace + openingTagEnd + closingTagIndex + closingTagStr.length),
122120
+ // When trailing content follows the closing tag, compute the end position precisely
122121
+ // within the html node's value so the component doesn't claim that content.
122122
+ // Prefer source-based positioning when the original source is available: the html
122123
+ // node's value has '> '/space prefixes stripped for blockquotes/list items, so
122124
+ // positionEndingAtConsumed would undercount source offsets. When the entire node
122125
+ // is consumed, use the original node position directly.
122126
+ endPosition: contentAfterClose
122127
+ ? source
122128
+ ? positionEndingAtClosingTagInSource(node.position, closingTagStr, source)
122129
+ : positionEndingAtConsumed(node.position, value, leadingWhitespace + openingTagEnd + closingTagIndex + closingTagStr.length)
122130
+ : node.position,
122105
122131
  });
122106
122132
  substituteNodeWithMdxNode(parent, index, componentNode);
122107
122133
  // After the closing tag, there might be more content to be processed