authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-19 03:04:11-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 01:17:10-07:00
log5f937e9fbcf25ac5a81c8e4f9fa3b628197c9325
tree7069f977ca63387a9d0be21a28a9373547f03ce5
parent8e7f246a0e706c2fcdf48be112d652492568b74d
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

fix: re-use parsing pass


5 files changed, 144 insertions(+), 139 deletions(-)

README.md+2-3
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1# `@clo/react-markdown`1# `@clo/react-markdown`
22
3This package exports a React component to render markdown using the [unified]3This package exports a React component to render Markdown using the [unified]
4ecosystem's markdown tools ([remark], [rehype]). The exported `<Markdown />`4ecosystem's Markdown tools (remark, rehype). The exported `<Markdown />`
5component is extremely memoized, making it suitable for streaming situations5component is extremely memoized, making it suitable for streaming situations
6such as LLM chat interfaces.6such as LLM chat interfaces.
77
...@@ -32,7 +32,6 @@ with their library:...@@ -32,7 +32,6 @@ with their library:
3232
33[Streamdown]: https://streamdown.ai33[Streamdown]: https://streamdown.ai
34[unified]: https://unifiedjs.com/34[unified]: https://unifiedjs.com/
35[remark]: https://remark.js.org/
3635
37## Getting Started36## Getting Started
3837
package.json+11-11
...@@ -18,16 +18,7 @@...@@ -18,16 +18,7 @@
18 "prepublishOnly": "vp run build"18 "prepublishOnly": "vp run build"
19 },19 },
20 "dependencies": {20 "dependencies": {
21 "@clo/lib": "jsr:^3.0.0",21 "@clo/lib": "jsr:^3.0.0"
22 "@types/unist": "^3.0.3",
23 "mdast-util-from-markdown": "^2.0.3",
24 "rehype-react": "^8.0.0",
25 "rehype-stringify": "^10.0.1",
26 "remark-gfm": "^4.0.1",
27 "remark-parse": "^11.0.0",
28 "remark-rehype": "^11.1.2",
29 "remark-stringify": "^11.0.0",
30 "unified": "^11.0.5"
31 },22 },
32 "devDependencies": {23 "devDependencies": {
33 "@tailwindcss/typography": "^0.5.19",24 "@tailwindcss/typography": "^0.5.19",
...@@ -35,11 +26,16 @@...@@ -35,11 +26,16 @@
35 "@types/node": "^25.5.0",26 "@types/node": "^25.5.0",
36 "@types/react": "^19.2.14",27 "@types/react": "^19.2.14",
37 "@types/react-dom": "^19.2.3",28 "@types/react-dom": "^19.2.3",
29 "@types/unist": "^3.0.3",
38 "@typescript/native-preview": "7.0.0-dev.20260318.1",30 "@typescript/native-preview": "7.0.0-dev.20260318.1",
39 "@vitejs/plugin-react": "^6.0.1",31 "@vitejs/plugin-react": "^6.0.1",
40 "react": "^19.2.4",32 "react": "^19.2.4",
41 "react-dom": "^19.2.4",33 "react-dom": "^19.2.4",
42 "react-scan": "^0.5.3",34 "react-scan": "^0.5.3",
35 "rehype-react": "^8.0.0",
36 "remark-gfm": "^4.0.1",
37 "remark-parse": "^11.0.0",
38 "remark-rehype": "^11.1.2",
43 "streamdown": "^2.5.0",39 "streamdown": "^2.5.0",
44 "tailwindcss": "^4.2.2",40 "tailwindcss": "^4.2.2",
45 "typescript": "^5.9.3",41 "typescript": "^5.9.3",
...@@ -47,7 +43,11 @@...@@ -47,7 +43,11 @@
47 "vite-plus": "^0.1.11"43 "vite-plus": "^0.1.11"
48 },44 },
49 "peerDependencies": {45 "peerDependencies": {
50 "react": "*"46 "react": "*",
47 "rehype-react": "^8.0.0",
48 "remark-parse": "^11.0.0",
49 "remark-rehype": "^11.0.0",
50 "unified": "^11.0.0"
51 },51 },
52 "packageManager": "pnpm@10.26.1",52 "packageManager": "pnpm@10.26.1",
53 "pnpm": {53 "pnpm": {
pnpm-lock.yaml+21-56
...@@ -15,36 +15,6 @@ importers:...@@ -15,36 +15,6 @@ importers:
15 '@clo/lib':15 '@clo/lib':
16 specifier: jsr:^3.0.016 specifier: jsr:^3.0.0
17 version: '@jsr/clo__lib@3.0.0'17 version: '@jsr/clo__lib@3.0.0'
18 '@types/unist':
19 specifier: ^3.0.3
20 version: 3.0.3
21 mdast-util-from-markdown:
22 specifier: ^2.0.3
23 version: 2.0.3
24 rehype-react:
25 specifier: ^8.0.0
26 version: 8.0.0
27 rehype-stringify:
28 specifier: ^10.0.1
29 version: 10.0.1
30 remark-gfm:
31 specifier: ^4.0.1
32 version: 4.0.1
33 remark-parse:
34 specifier: ^11.0.0
35 version: 11.0.0
36 remark-rehype:
37 specifier: ^11.1.2
38 version: 11.1.2
39 remark-stringify:
40 specifier: ^11.0.0
41 version: 11.0.0
42 typescript:
43 specifier: ^5.9.3
44 version: 5.9.3
45 unified:
46 specifier: ^11.0.5
47 version: 11.0.5
48 devDependencies:18 devDependencies:
49 '@tailwindcss/typography':19 '@tailwindcss/typography':
50 specifier: ^0.5.1920 specifier: ^0.5.19
...@@ -61,6 +31,9 @@ importers:...@@ -61,6 +31,9 @@ importers:
61 '@types/react-dom':31 '@types/react-dom':
62 specifier: ^19.2.332 specifier: ^19.2.3
63 version: 19.2.3(@types/react@19.2.14)33 version: 19.2.3(@types/react@19.2.14)
34 '@types/unist':
35 specifier: ^3.0.3
36 version: 3.0.3
64 '@typescript/native-preview':37 '@typescript/native-preview':
65 specifier: 7.0.0-dev.20260318.138 specifier: 7.0.0-dev.20260318.1
66 version: 7.0.0-dev.20260318.139 version: 7.0.0-dev.20260318.1
...@@ -76,12 +49,30 @@ importers:...@@ -76,12 +49,30 @@ importers:
76 react-scan:49 react-scan:
77 specifier: ^0.5.350 specifier: ^0.5.3
78 version: 0.5.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)51 version: 0.5.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
52 rehype-react:
53 specifier: ^8.0.0
54 version: 8.0.0
55 remark-gfm:
56 specifier: ^4.0.1
57 version: 4.0.1
58 remark-parse:
59 specifier: ^11.0.0
60 version: 11.0.0
61 remark-rehype:
62 specifier: ^11.1.2
63 version: 11.1.2
79 streamdown:64 streamdown:
80 specifier: ^2.5.065 specifier: ^2.5.0
81 version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)66 version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
82 tailwindcss:67 tailwindcss:
83 specifier: ^4.2.268 specifier: ^4.2.2
84 version: 4.2.269 version: 4.2.2
70 typescript:
71 specifier: ^5.9.3
72 version: 5.9.3
73 unified:
74 specifier: ^11.0.5
75 version: 11.0.5
85 vite-plus:76 vite-plus:
86 specifier: ^0.1.1177 specifier: ^0.1.11
87 version: 0.1.12(@types/node@25.5.0)(happy-dom@20.8.4)(jiti@2.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.2))(yaml@2.8.2)78 version: 0.1.12(@types/node@25.5.0)(happy-dom@20.8.4)(jiti@2.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.2))(yaml@2.8.2)
...@@ -1557,9 +1548,6 @@ packages:...@@ -1557,9 +1548,6 @@ packages:
1557 hast-util-sanitize@5.0.2:1548 hast-util-sanitize@5.0.2:
1558 resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}1549 resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
15591550
1560 hast-util-to-html@9.0.5:
1561 resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
1562
1563 hast-util-to-jsx-runtime@2.3.6:1551 hast-util-to-jsx-runtime@2.3.6:
1564 resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}1552 resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
15651553
...@@ -2005,9 +1993,6 @@ packages:...@@ -2005,9 +1993,6 @@ packages:
2005 rehype-sanitize@6.0.0:1993 rehype-sanitize@6.0.0:
2006 resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}1994 resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
20071995
2008 rehype-stringify@10.0.1:
2009 resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
2010
2011 remark-gfm@4.0.1:1996 remark-gfm@4.0.1:
2012 resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}1997 resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
20131998
...@@ -3546,20 +3531,6 @@ snapshots:...@@ -3546,20 +3531,6 @@ snapshots:
3546 '@ungap/structured-clone': 1.3.03531 '@ungap/structured-clone': 1.3.0
3547 unist-util-position: 5.0.03532 unist-util-position: 5.0.0
35483533
3549 hast-util-to-html@9.0.5:
3550 dependencies:
3551 '@types/hast': 3.0.4
3552 '@types/unist': 3.0.3
3553 ccount: 2.0.1
3554 comma-separated-tokens: 2.0.3
3555 hast-util-whitespace: 3.0.0
3556 html-void-elements: 3.0.0
3557 mdast-util-to-hast: 13.2.1
3558 property-information: 7.1.0
3559 space-separated-tokens: 2.0.2
3560 stringify-entities: 4.0.4
3561 zwitch: 2.0.4
3562
3563 hast-util-to-jsx-runtime@2.3.6:3534 hast-util-to-jsx-runtime@2.3.6:
3564 dependencies:3535 dependencies:
3565 '@types/estree': 1.0.83536 '@types/estree': 1.0.8
...@@ -4286,12 +4257,6 @@ snapshots:...@@ -4286,12 +4257,6 @@ snapshots:
4286 '@types/hast': 3.0.44257 '@types/hast': 3.0.4
4287 hast-util-sanitize: 5.0.24258 hast-util-sanitize: 5.0.2
42884259
4289 rehype-stringify@10.0.1:
4290 dependencies:
4291 '@types/hast': 3.0.4
4292 hast-util-to-html: 9.0.5
4293 unified: 11.0.5
4294
4295 remark-gfm@4.0.1:4260 remark-gfm@4.0.1:
4296 dependencies:4261 dependencies:
4297 '@types/mdast': 4.0.44262 '@types/mdast': 4.0.4
src/Memoizer.ts+76-69
...@@ -1,40 +1,14 @@...@@ -1,40 +1,14 @@
1import { ASSERT, UNWRAP } from "@clo/lib/assert.ts";1import { ASSERT, UNWRAP } from "@clo/lib/assert.ts";
2import { fromMarkdown } from "mdast-util-from-markdown";
3import { indexOfDiff } from "./strings.ts";2import { indexOfDiff } from "./strings.ts";
4import { memo, type JSX, type Key, type ReactNode } from "react";3import { memo, type JSX, type Key, type ReactNode } from "react";
5import rehypeReact, { type Components } from "rehype-react";4import rehypeReact, { type Components } from "rehype-react";
6import remarkRehype from "remark-rehype";5import remarkRehype from "remark-rehype";
7import { jsx, jsxs, Fragment } from "react/jsx-runtime";6import { jsx, jsxs, Fragment } from "react/jsx-runtime";
8import { unified, type Processor } from "unified";7import { unified, type Processor } from "unified";
9import type { Node } from "unist";8import type { Node, Parent, Literal } from "unist";
10import remarkParse from "remark-parse";9import remarkParse from "remark-parse";
1110
12export const defaultProcessor = unified().use(remarkParse);11export const defaultProcessor = unified().use(remarkParse);
13const extractBlockOptions = {
14 extensions: [
15 {
16 disable: {
17 null: [
18 "attention",
19 "autolink",
20 "characterEscape",
21 "characterReference",
22 "codeText",
23 "hardBreakEscape",
24 "htmlText",
25 "labelStartImage",
26 "labelStartLink",
27 "labelEnd",
28 ],
29 },
30 },
31 ],
32};
33
34function extractBlockPositions(source: string, offset: number) {
35 const tree = fromMarkdown(source, extractBlockOptions);
36 return tree.children.map((child) => UNWRAP(UNWRAP(child.position).start.offset) + offset);
37}
3812
39export type BaseProcessor = Processor<13export type BaseProcessor = Processor<
40 Node,14 Node,
...@@ -43,27 +17,13 @@ export type BaseProcessor = Processor<...@@ -43,27 +17,13 @@ export type BaseProcessor = Processor<
43 undefined,17 undefined,
44 undefined | JSX.Element18 undefined | JSX.Element
45>;19>;
46export type ReactProcessor = Processor<Node, undefined, undefined, undefined, JSX.Element>;20type AstProcessor = Processor<Parent, undefined, undefined, Node, Node>;
4721type ReactCompiler = Processor<Parent, undefined, undefined, Node, JSX.Element>;
48function componentsAreEqual(left: Partial<Components>, right: Partial<Components>) {
49 const leftRecord = left as Record<string, unknown>;
50 const rightRecord = right as Record<string, unknown>;
51 const leftKeys = Object.keys(leftRecord);
52 if (leftKeys.length !== Object.keys(rightRecord).length) return false;
53 for (const key of leftKeys) {
54 if (leftRecord[key] !== rightRecord[key]) return false;
55 }
56 return true;
57}
58
59function setReactKey(element: JSX.Element, key: Key) {
60 const { ...props } = element.props;
61 return jsx(element.type, props, key);
62}
6322
64export class Memoizer {23export class Memoizer {
65 #baseProcessor: BaseProcessor = defaultProcessor;24 #baseProcessor: BaseProcessor = defaultProcessor;
66 #processor: ReactProcessor | null = null;25 #astProcessor: AstProcessor | null = null;
26 #compiler: ReactCompiler | null = null;
67 #previousComponents: Partial<Components> = {};27 #previousComponents: Partial<Components> = {};
68 #previous: string = "";28 #previous: string = "";
69 #blockPositions: number[] = [];29 #blockPositions: number[] = [];
...@@ -74,6 +34,7 @@ export class Memoizer {...@@ -74,6 +34,7 @@ export class Memoizer {
74 reset() {34 reset() {
75 this.#previous = "";35 this.#previous = "";
76 this.#blockPositions = [];36 this.#blockPositions = [];
37 this.#blockSources = [];
77 this.#reactNodes = [];38 this.#reactNodes = [];
78 }39 }
7940
...@@ -82,19 +43,22 @@ export class Memoizer {...@@ -82,19 +43,22 @@ export class Memoizer {
82 components: Partial<Components>,43 components: Partial<Components>,
83 ) {44 ) {
84 if (45 if (
85 this.#processor !== null &&46 this.#astProcessor !== null &&
86 this.#baseProcessor === processor &&47 this.#baseProcessor === processor &&
87 componentsAreEqual(this.#previousComponents, components)48 componentsAreEqual(this.#previousComponents, components)
88 )49 )
89 return;50 return;
9051
91 this.#baseProcessor = processor;52 this.#baseProcessor = processor;
92 if (!processor.attachers.some((plugin) => plugin[0] === rehypeReact)) {53 if (!processor.attachers.some((plugin) => plugin[0] === remarkRehype)) {
93 processor = processor();54 processor = processor();
94 if (!processor.attachers.some((plugin) => plugin[0] === remarkRehype)) {55 processor.use(remarkRehype);
95 processor.use(remarkRehype);56 processor.freeze();
96 }57 }
97 processor.use(rehypeReact, {58
59 this.#astProcessor = processor as unknown as AstProcessor;
60 this.#compiler = unified()
61 .use(rehypeReact, {
98 jsx,62 jsx,
99 jsxs,63 jsxs,
100 Fragment,64 Fragment,
...@@ -104,13 +68,9 @@ export class Memoizer {...@@ -104,13 +68,9 @@ export class Memoizer {
104 components: Object.fromEntries(68 components: Object.fromEntries(
105 Object.entries(components).map(([k, v]) => [k, typeof v === "function" ? memo(v) : v]),69 Object.entries(components).map(([k, v]) => [k, typeof v === "function" ? memo(v) : v]),
106 ),70 ),
107 });71 })
108 processor.freeze();72 .freeze() as ReactCompiler;
109 } else {
110 // TODO: troll and force memoize all components
111 }
112 this.#previousComponents = components;73 this.#previousComponents = components;
113 this.#processor = processor as ReactProcessor;
114 this.reset();74 this.reset();
115 }75 }
11676
...@@ -130,12 +90,10 @@ export class Memoizer {...@@ -130,12 +90,10 @@ export class Memoizer {
130 if (blockIndexToReplace === -1) blockIndexToReplace = 0;90 if (blockIndexToReplace === -1) blockIndexToReplace = 0;
13191
132 // Compute and replace changed block regions. This behavior relys on the92 // Compute and replace changed block regions. This behavior relys on the
133 // fact that this block extraction process can be isolated across blocks.93 // fact that blocks generally do not affect each other.
134 // For the basic set of syntax extensions, this is true, for example an94 const processor = UNWRAP(this.#astProcessor);
135 // unclosed code fence is parsed as a code fence. Anything that isn't95 const parsedTree = processor.parse(content.slice(preservedLength));
136 // compatible will break very loudly since `Memoizer` never re-joins the96 const newBlocks = parsedTree.children.map((child) => UNWRAP(UNWRAP(child.position).start.offset) + preservedLength);
137 // blocks for rendering.
138 const newBlocks = extractBlockPositions(content.slice(preservedLength), preservedLength);
139 blockPositions.splice(97 blockPositions.splice(
140 blockIndexToReplace,98 blockIndexToReplace,
141 blockPositions.length - blockIndexToReplace,99 blockPositions.length - blockIndexToReplace,
...@@ -143,17 +101,26 @@ export class Memoizer {...@@ -143,17 +101,26 @@ export class Memoizer {
143 );101 );
144 this.#previous = content;102 this.#previous = content;
145103
146 // For all touched blocks (newBlocks), find which ones have actually changed104 // For all touched blocks (newBlocks), transform their ASTs. This is done in
147 // their contents and run them through the full pipeline.105 // a group to preserve shared contexts, such as reference links.
148 const processor = UNWRAP(this.#processor);106 // TODO: need more effort for reference links without reparsing too much code.
149 const blockSources = this.#blockSources;107 const blockSources = this.#blockSources;
108 const compiler = UNWRAP(this.#compiler);
109 const renderedBlocks = extractRenderedBlocks(
110 processor.runSync(parsedTree) as Parent,
111 newBlocks,
112 preservedLength,
113 );
114
150 let reactNodes: ReactNode[] | null = null;115 let reactNodes: ReactNode[] | null = null;
151 // If items are removed, the array must be resliced.
152 if (this.#reactNodes.length !== blockPositions.length) {116 if (this.#reactNodes.length !== blockPositions.length) {
117 // If items are removed, the array must be resliced.
153 reactNodes = this.#reactNodes.slice(0, blockPositions.length);118 reactNodes = this.#reactNodes.slice(0, blockPositions.length);
154 blockSources.length = blockPositions.length;119 blockSources.length = blockPositions.length;
155 }120 }
156 for (let i = blockIndexToReplace, len = blockPositions.length; i < len; i += 1) {121 for (let i = blockIndexToReplace, len = blockPositions.length; i < len; i += 1) {
122 // The decision on if to recreate the React element is derived simply from if the
123 // souce code changes. TODO: perform a light AST-diff? use a mix of approaches?
157 const source = content124 const source = content
158 .slice(UNWRAP(blockPositions[i]), blockPositions[i + 1] ?? content.length)125 .slice(UNWRAP(blockPositions[i]), blockPositions[i + 1] ?? content.length)
159 .replace(/\s+$/, "");126 .replace(/\s+$/, "");
...@@ -164,8 +131,11 @@ export class Memoizer {...@@ -164,8 +131,11 @@ export class Memoizer {
164 }131 }
165 blockSources[i] = source;132 blockSources[i] = source;
166133
167 // Render the block using all plugins.134 // Render the block using the transformed subtree.
168 let { result } = processor.processSync(source);135 let result = compiler.stringify({
136 type: "root",
137 children: renderedBlocks[i - blockIndexToReplace] ?? [],
138 } as Parent);
169139
170 // Do a little trolling and unwrap the fragment140 // Do a little trolling and unwrap the fragment
171 const key = String("m" + i);141 const key = String("m" + i);
...@@ -186,3 +156,40 @@ export class Memoizer {...@@ -186,3 +156,40 @@ export class Memoizer {
186 return this.#reactNodes;156 return this.#reactNodes;
187 }157 }
188}158}
159
160function componentsAreEqual(left: Partial<Components>, right: Partial<Components>) {
161 const leftRecord = left as Record<string, unknown>;
162 const rightRecord = right as Record<string, unknown>;
163 const leftKeys = Object.keys(leftRecord);
164 if (leftKeys.length !== Object.keys(rightRecord).length) return false;
165 for (const key of leftKeys) {
166 if (leftRecord[key] !== rightRecord[key]) return false;
167 }
168 return true;
169}
170
171function setReactKey(element: JSX.Element, key: Key) {
172 const { ...props } = element.props;
173 return jsx(element.type, props, key);
174}
175
176function extractRenderedBlocks(tree: Parent, blockPositions: number[], offset: number) {
177 const blocks = blockPositions.map(() => [] as Node[]);
178
179 let blockIndex = 0;
180 for (const child of tree.children) {
181 const startOffset = child.position?.start.offset;
182 if (startOffset !== undefined) {
183 for (; blockIndex + 1 < blockPositions.length; blockIndex += 1) {
184 if (startOffset + offset < blockPositions[blockIndex + 1]!) break;
185 }
186 blocks[blockIndex]!.push(child);
187 continue;
188 }
189
190 if (child.type === "text" && /^\s*$/.test(String((child as Literal).value))) continue;
191 blocks[blockIndex]!.push(child);
192 }
193
194 return blocks;
195}
tests/index.test.ts+34
...@@ -1,9 +1,13 @@...@@ -1,9 +1,13 @@
1import { act, createElement } from "react";1import { act, createElement } from "react";
2import { createRoot } from "react-dom/client";2import { createRoot } from "react-dom/client";
3import { expect, test } from "vite-plus/test";3import { expect, test } from "vite-plus/test";
4import remarkGfm from "remark-gfm";
5import remarkParse from "remark-parse";
6import { unified } from "unified";
4import { Markdown } from "../src/mod.ts";7import { Markdown } from "../src/mod.ts";
58
6Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });9Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10const gfmProcessor = unified().use(remarkParse).use(remarkGfm);
711
8function getParagraphs(container: HTMLElement) {12function getParagraphs(container: HTMLElement) {
9 return Array.from(container.querySelectorAll("p"));13 return Array.from(container.querySelectorAll("p"));
...@@ -74,3 +78,33 @@ test("Markdown reuses rendered nodes across rerenders and preserves them when a...@@ -74,3 +78,33 @@ test("Markdown reuses rendered nodes across rerenders and preserves them when a
74 container.remove();78 container.remove();
75 }79 }
76});80});
81
82test("Markdown can render reference definitions and footnotes when they are in the same changed suffix", async () => {
83 const container = document.createElement("div");
84 document.body.append(container);
85
86 const root = createRoot(container);
87 const content = "[ref][a] and note[^1]\n\n[a]: https://example.com\n[^1]: hi there";
88
89 try {
90 await act(async () => {
91 root.render(createElement(Markdown, { content, processor: gfmProcessor }));
92 });
93
94 const link = container.querySelector("a[href='https://example.com']");
95 const footnoteRef = container.querySelector("[data-footnote-ref]");
96 const footnotes = container.querySelector("section[data-footnotes]");
97 const bodyParagraph = container.querySelector("p");
98
99 expect(bodyParagraph?.textContent).toBe("ref and note1");
100 expect(link?.textContent).toBe("ref");
101 expect(footnoteRef?.textContent).toBe("1");
102 expect(footnotes?.textContent).toContain("hi there");
103 } finally {
104 await act(async () => {
105 root.unmount();
106 });
107
108 container.remove();
109 }
110});