| author | |
| committer | |
| log | 5f937e9fbcf25ac5a81c8e4f9fa3b628197c9325 |
| tree | 7069f977ca63387a9d0be21a28a9373547f03ce5 |
| parent | 8e7f246a0e706c2fcdf48be112d652492568b74d |
| signature |
5 files changed, 144 insertions(+), 139 deletions(-)
README.md+2-3| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | # `@clo/react-markdown` |
| 2 | 2 | |
| 3 | This package exports a React component to render markdown using the [unified] | |
| 4 | ecosystem's markdown tools ([remark], [rehype]). The exported `<Markdown />` | |
| 3 | This package exports a React component to render Markdown using the [unified] | |
| 4 | ecosystem's Markdown tools (remark, rehype). The exported `<Markdown />` | |
| 5 | 5 | component is extremely memoized, making it suitable for streaming situations |
| 6 | 6 | such as LLM chat interfaces. |
| 7 | 7 | |
| ... | ... | @@ -32,7 +32,6 @@ with their library: |
| 32 | 32 | |
| 33 | 33 | [Streamdown]: https://streamdown.ai |
| 34 | 34 | [unified]: https://unifiedjs.com/ |
| 35 | [remark]: https://remark.js.org/ | |
| 36 | 35 | |
| 37 | 36 | ## Getting Started |
| 38 | 37 |
package.json+11-11| ... | ... | @@ -18,16 +18,7 @@ |
| 18 | 18 | "prepublishOnly": "vp run build" |
| 19 | 19 | }, |
| 20 | 20 | "dependencies": { |
| 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" | |
| 21 | "@clo/lib": "jsr:^3.0.0" | |
| 31 | 22 | }, |
| 32 | 23 | "devDependencies": { |
| 33 | 24 | "@tailwindcss/typography": "^0.5.19", |
| ... | ... | @@ -35,11 +26,16 @@ |
| 35 | 26 | "@types/node": "^25.5.0", |
| 36 | 27 | "@types/react": "^19.2.14", |
| 37 | 28 | "@types/react-dom": "^19.2.3", |
| 29 | "@types/unist": "^3.0.3", | |
| 38 | 30 | "@typescript/native-preview": "7.0.0-dev.20260318.1", |
| 39 | 31 | "@vitejs/plugin-react": "^6.0.1", |
| 40 | 32 | "react": "^19.2.4", |
| 41 | 33 | "react-dom": "^19.2.4", |
| 42 | 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 | 39 | "streamdown": "^2.5.0", |
| 44 | 40 | "tailwindcss": "^4.2.2", |
| 45 | 41 | "typescript": "^5.9.3", |
| ... | ... | @@ -47,7 +43,11 @@ |
| 47 | 43 | "vite-plus": "^0.1.11" |
| 48 | 44 | }, |
| 49 | 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 | 52 | "packageManager": "pnpm@10.26.1", |
| 53 | 53 | "pnpm": { |
pnpm-lock.yaml+21-56| ... | ... | @@ -15,36 +15,6 @@ importers: |
| 15 | 15 | '@clo/lib': |
| 16 | 16 | specifier: jsr:^3.0.0 |
| 17 | 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 | 18 | devDependencies: |
| 49 | 19 | '@tailwindcss/typography': |
| 50 | 20 | specifier: ^0.5.19 |
| ... | ... | @@ -61,6 +31,9 @@ importers: |
| 61 | 31 | '@types/react-dom': |
| 62 | 32 | specifier: ^19.2.3 |
| 63 | 33 | version: 19.2.3(@types/react@19.2.14) |
| 34 | '@types/unist': | |
| 35 | specifier: ^3.0.3 | |
| 36 | version: 3.0.3 | |
| 64 | 37 | '@typescript/native-preview': |
| 65 | 38 | specifier: 7.0.0-dev.20260318.1 |
| 66 | 39 | version: 7.0.0-dev.20260318.1 |
| ... | ... | @@ -76,12 +49,30 @@ importers: |
| 76 | 49 | react-scan: |
| 77 | 50 | specifier: ^0.5.3 |
| 78 | 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 | 64 | streamdown: |
| 80 | 65 | specifier: ^2.5.0 |
| 81 | 66 | version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) |
| 82 | 67 | tailwindcss: |
| 83 | 68 | specifier: ^4.2.2 |
| 84 | 69 | 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 | 76 | vite-plus: |
| 86 | 77 | specifier: ^0.1.11 |
| 87 | 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 | 1548 | hast-util-sanitize@5.0.2: |
| 1558 | 1549 | resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} |
| 1559 | 1550 | |
| 1560 | hast-util-to-html@9.0.5: | |
| 1561 | resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} | |
| 1562 | ||
| 1563 | 1551 | hast-util-to-jsx-runtime@2.3.6: |
| 1564 | 1552 | resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} |
| 1565 | 1553 | |
| ... | ... | @@ -2005,9 +1993,6 @@ packages: |
| 2005 | 1993 | rehype-sanitize@6.0.0: |
| 2006 | 1994 | resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} |
| 2007 | 1995 | |
| 2008 | rehype-stringify@10.0.1: | |
| 2009 | resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} | |
| 2010 | ||
| 2011 | 1996 | remark-gfm@4.0.1: |
| 2012 | 1997 | resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} |
| 2013 | 1998 | |
| ... | ... | @@ -3546,20 +3531,6 @@ snapshots: |
| 3546 | 3531 | '@ungap/structured-clone': 1.3.0 |
| 3547 | 3532 | unist-util-position: 5.0.0 |
| 3548 | 3533 | |
| 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 | 3534 | hast-util-to-jsx-runtime@2.3.6: |
| 3564 | 3535 | dependencies: |
| 3565 | 3536 | '@types/estree': 1.0.8 |
| ... | ... | @@ -4286,12 +4257,6 @@ snapshots: |
| 4286 | 4257 | '@types/hast': 3.0.4 |
| 4287 | 4258 | hast-util-sanitize: 5.0.2 |
| 4288 | 4259 | |
| 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 | 4260 | remark-gfm@4.0.1: |
| 4296 | 4261 | dependencies: |
| 4297 | 4262 | '@types/mdast': 4.0.4 |
src/Memoizer.ts+76-69| ... | ... | @@ -1,40 +1,14 @@ |
| 1 | 1 | import { ASSERT, UNWRAP } from "@clo/lib/assert.ts"; |
| 2 | import { fromMarkdown } from "mdast-util-from-markdown"; | |
| 3 | 2 | import { indexOfDiff } from "./strings.ts"; |
| 4 | 3 | import { memo, type JSX, type Key, type ReactNode } from "react"; |
| 5 | 4 | import rehypeReact, { type Components } from "rehype-react"; |
| 6 | 5 | import remarkRehype from "remark-rehype"; |
| 7 | 6 | import { jsx, jsxs, Fragment } from "react/jsx-runtime"; |
| 8 | 7 | import { unified, type Processor } from "unified"; |
| 9 | import type { Node } from "unist"; | |
| 8 | import type { Node, Parent, Literal } from "unist"; | |
| 10 | 9 | import remarkParse from "remark-parse"; |
| 11 | 10 | |
| 12 | 11 | export const defaultProcessor = unified().use(remarkParse); |
| 13 | const 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 | ||
| 34 | function 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 | } | |
| 38 | 12 | |
| 39 | 13 | export type BaseProcessor = Processor< |
| 40 | 14 | Node, |
| ... | ... | @@ -43,27 +17,13 @@ export type BaseProcessor = Processor< |
| 43 | 17 | undefined, |
| 44 | 18 | undefined | JSX.Element |
| 45 | 19 | >; |
| 46 | export type ReactProcessor = Processor<Node, undefined, undefined, undefined, JSX.Element>; | |
| 47 | ||
| 48 | function 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 | ||
| 59 | function setReactKey(element: JSX.Element, key: Key) { | |
| 60 | const { ...props } = element.props; | |
| 61 | return jsx(element.type, props, key); | |
| 62 | } | |
| 20 | type AstProcessor = Processor<Parent, undefined, undefined, Node, Node>; | |
| 21 | type ReactCompiler = Processor<Parent, undefined, undefined, Node, JSX.Element>; | |
| 63 | 22 | |
| 64 | 23 | export class Memoizer { |
| 65 | 24 | #baseProcessor: BaseProcessor = defaultProcessor; |
| 66 | #processor: ReactProcessor | null = null; | |
| 25 | #astProcessor: AstProcessor | null = null; | |
| 26 | #compiler: ReactCompiler | null = null; | |
| 67 | 27 | #previousComponents: Partial<Components> = {}; |
| 68 | 28 | #previous: string = ""; |
| 69 | 29 | #blockPositions: number[] = []; |
| ... | ... | @@ -74,6 +34,7 @@ export class Memoizer { |
| 74 | 34 | reset() { |
| 75 | 35 | this.#previous = ""; |
| 76 | 36 | this.#blockPositions = []; |
| 37 | this.#blockSources = []; | |
| 77 | 38 | this.#reactNodes = []; |
| 78 | 39 | } |
| 79 | 40 | |
| ... | ... | @@ -82,19 +43,22 @@ export class Memoizer { |
| 82 | 43 | components: Partial<Components>, |
| 83 | 44 | ) { |
| 84 | 45 | if ( |
| 85 | this.#processor !== null && | |
| 46 | this.#astProcessor !== null && | |
| 86 | 47 | this.#baseProcessor === processor && |
| 87 | 48 | componentsAreEqual(this.#previousComponents, components) |
| 88 | 49 | ) |
| 89 | 50 | return; |
| 90 | 51 | |
| 91 | 52 | this.#baseProcessor = processor; |
| 92 | if (!processor.attachers.some((plugin) => plugin[0] === rehypeReact)) { | |
| 53 | if (!processor.attachers.some((plugin) => plugin[0] === remarkRehype)) { | |
| 93 | 54 | processor = processor(); |
| 94 | if (!processor.attachers.some((plugin) => plugin[0] === remarkRehype)) { | |
| 95 | processor.use(remarkRehype); | |
| 96 | } | |
| 97 | processor.use(rehypeReact, { | |
| 55 | processor.use(remarkRehype); | |
| 56 | processor.freeze(); | |
| 57 | } | |
| 58 | ||
| 59 | this.#astProcessor = processor as unknown as AstProcessor; | |
| 60 | this.#compiler = unified() | |
| 61 | .use(rehypeReact, { | |
| 98 | 62 | jsx, |
| 99 | 63 | jsxs, |
| 100 | 64 | Fragment, |
| ... | ... | @@ -104,13 +68,9 @@ export class Memoizer { |
| 104 | 68 | components: Object.fromEntries( |
| 105 | 69 | Object.entries(components).map(([k, v]) => [k, typeof v === "function" ? memo(v) : v]), |
| 106 | 70 | ), |
| 107 | }); | |
| 108 | processor.freeze(); | |
| 109 | } else { | |
| 110 | // TODO: troll and force memoize all components | |
| 111 | } | |
| 71 | }) | |
| 72 | .freeze() as ReactCompiler; | |
| 112 | 73 | this.#previousComponents = components; |
| 113 | this.#processor = processor as ReactProcessor; | |
| 114 | 74 | this.reset(); |
| 115 | 75 | } |
| 116 | 76 | |
| ... | ... | @@ -130,12 +90,10 @@ export class Memoizer { |
| 130 | 90 | if (blockIndexToReplace === -1) blockIndexToReplace = 0; |
| 131 | 91 | |
| 132 | 92 | // Compute and replace changed block regions. This behavior relys on the |
| 133 | // fact that this block extraction process can be isolated across blocks. | |
| 134 | // For the basic set of syntax extensions, this is true, for example an | |
| 135 | // unclosed code fence is parsed as a code fence. Anything that isn't | |
| 136 | // compatible will break very loudly since `Memoizer` never re-joins the | |
| 137 | // blocks for rendering. | |
| 138 | const newBlocks = extractBlockPositions(content.slice(preservedLength), preservedLength); | |
| 93 | // fact that blocks generally do not affect each other. | |
| 94 | const processor = UNWRAP(this.#astProcessor); | |
| 95 | const parsedTree = processor.parse(content.slice(preservedLength)); | |
| 96 | const newBlocks = parsedTree.children.map((child) => UNWRAP(UNWRAP(child.position).start.offset) + preservedLength); | |
| 139 | 97 | blockPositions.splice( |
| 140 | 98 | blockIndexToReplace, |
| 141 | 99 | blockPositions.length - blockIndexToReplace, |
| ... | ... | @@ -143,17 +101,26 @@ export class Memoizer { |
| 143 | 101 | ); |
| 144 | 102 | this.#previous = content; |
| 145 | 103 | |
| 146 | // For all touched blocks (newBlocks), find which ones have actually changed | |
| 147 | // their contents and run them through the full pipeline. | |
| 148 | const processor = UNWRAP(this.#processor); | |
| 104 | // For all touched blocks (newBlocks), transform their ASTs. This is done in | |
| 105 | // a group to preserve shared contexts, such as reference links. | |
| 106 | // TODO: need more effort for reference links without reparsing too much code. | |
| 149 | 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 | 115 | let reactNodes: ReactNode[] | null = null; |
| 151 | // If items are removed, the array must be resliced. | |
| 152 | 116 | if (this.#reactNodes.length !== blockPositions.length) { |
| 117 | // If items are removed, the array must be resliced. | |
| 153 | 118 | reactNodes = this.#reactNodes.slice(0, blockPositions.length); |
| 154 | 119 | blockSources.length = blockPositions.length; |
| 155 | 120 | } |
| 156 | 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 | 124 | const source = content |
| 158 | 125 | .slice(UNWRAP(blockPositions[i]), blockPositions[i + 1] ?? content.length) |
| 159 | 126 | .replace(/\s+$/, ""); |
| ... | ... | @@ -164,8 +131,11 @@ export class Memoizer { |
| 164 | 131 | } |
| 165 | 132 | blockSources[i] = source; |
| 166 | 133 | |
| 167 | // Render the block using all plugins. | |
| 168 | let { result } = processor.processSync(source); | |
| 134 | // Render the block using the transformed subtree. | |
| 135 | let result = compiler.stringify({ | |
| 136 | type: "root", | |
| 137 | children: renderedBlocks[i - blockIndexToReplace] ?? [], | |
| 138 | } as Parent); | |
| 169 | 139 | |
| 170 | 140 | // Do a little trolling and unwrap the fragment |
| 171 | 141 | const key = String("m" + i); |
| ... | ... | @@ -186,3 +156,40 @@ export class Memoizer { |
| 186 | 156 | return this.#reactNodes; |
| 187 | 157 | } |
| 188 | 158 | } |
| 159 | ||
| 160 | function 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 | ||
| 171 | function setReactKey(element: JSX.Element, key: Key) { | |
| 172 | const { ...props } = element.props; | |
| 173 | return jsx(element.type, props, key); | |
| 174 | } | |
| 175 | ||
| 176 | function 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 | 1 | import { act, createElement } from "react"; |
| 2 | 2 | import { createRoot } from "react-dom/client"; |
| 3 | 3 | import { expect, test } from "vite-plus/test"; |
| 4 | import remarkGfm from "remark-gfm"; | |
| 5 | import remarkParse from "remark-parse"; | |
| 6 | import { unified } from "unified"; | |
| 4 | 7 | import { Markdown } from "../src/mod.ts"; |
| 5 | 8 | |
| 6 | 9 | Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); |
| 10 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm); | |
| 7 | 11 | |
| 8 | 12 | function getParagraphs(container: HTMLElement) { |
| 9 | 13 | return Array.from(container.querySelectorAll("p")); |
| ... | ... | @@ -74,3 +78,33 @@ test("Markdown reuses rendered nodes across rerenders and preserves them when a |
| 74 | 78 | container.remove(); |
| 75 | 79 | } |
| 76 | 80 | }); |
| 81 | ||
| 82 | test("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 | }); |