| author | |
| committer | |
| log | b7690cda634c38b30367307cc546917adbda1064 |
| tree | fa56b4f51193f053d2d1839d753c441cefa7fc56 |
| parent | 80fb1789cd75eb917d402b010325e5d446b4b997 |
| signature |
3 files changed, 115 insertions(+), 114 deletions(-)
README.md deleted-114| ... | @@ -1,114 +0,0 @@ | ||
| 1 | # `@clo/react-markdown` | ||
| 2 | |||
| 3 | This package exports a React component to render Markdown using the [unified] | ||
| 4 | ecosystem's Markdown tools (remark, rehype). The exported `<Markdown />` | ||
| 5 | component is extremely memoized, making it suitable for streaming situations | ||
| 6 | such as LLM chat interfaces. | ||
| 7 | |||
| 8 | - **Bring your Existing Pipeline**: The primary option for configuration is | ||
| 9 | providing a `unified.Processor` with remark and rehype plugins configured. | ||
| 10 | Additionally, you can override the element renderers with the `components` | ||
| 11 | attribute. By default, `@clo/react-markdown` applies a bare bones config so | ||
| 12 | you can get started with just `<Markdown content="hi" />`. Configuration is | ||
| 13 | done either with a provider, or right at the component level. | ||
| 14 | - **Handle Partial Markdown**: When the `predict` prop is set, the document | ||
| 15 | will be treated as an incomplete stream at the end of a document. | ||
| 16 | `hello **world` will be emitted as `hello <strong>world</strong>`, perfect for | ||
| 17 | the LLM case. (standalone predictor exported as `@clo/react-markdown/Predict`) | ||
| 18 | |||
| 19 | The motivation for this package is to have an easy to understand version of | ||
| 20 | [Streamdown]. With me banning all Vercel software at the company I work at, | ||
| 21 | we needed a simple, trustable solution for this problem. Some differences | ||
| 22 | to Streamdown: | ||
| 23 | |||
| 24 | - **Better Memoization**: All inline components will preserve their state, even | ||
| 25 | as adjacent content changes. This is done to preserve remounts for things like | ||
| 26 | custom `<a>` tags or other components. (For example, if a custom `<a>` fetches | ||
| 27 | previewing data and provides a hover card, that card won't flicker). | ||
| 28 | - **Better Prediction**: While optimizing edge cases, the prediction system became | ||
| 29 | better than the Streamdown one on accident. | ||
| 30 | - **Headless UI**: No built in styles or components, bring your own CSS to blend | ||
| 31 | your markdown with your existing theme. `@clo/react-markdown` simply takes in | ||
| 32 | your existing `unified` pipeline and works off of that. | ||
| 33 | - **Easy to Audit**: Only ~1000 lines of modern TypeScript. 4 total files. | ||
| 34 | |||
| 35 | [Streamdown]: https://streamdown.ai | ||
| 36 | [unified]: https://unifiedjs.com/ | ||
| 37 | |||
| 38 | ## Getting Started | ||
| 39 | |||
| 40 | Install off of the [JSR package](https://jsr.io/clo/react-markdown): | ||
| 41 | |||
| 42 | ```sh | ||
| 43 | npx jsr add @clo/react-markdown | ||
| 44 | pnpm add jsr:@clo/react-markdown | ||
| 45 | ``` | ||
| 46 | |||
| 47 | ```tsx | ||
| 48 | import { Markdown } from "@clo/react-markdown"; | ||
| 49 | import remarkGfm from "remark-gfm"; | ||
| 50 | import remarkParse from "remark-parse"; | ||
| 51 | import { unified } from "unified"; | ||
| 52 | |||
| 53 | // Define any unified processing pipeline. We expect that your | ||
| 54 | // app already has one of these. @clo/react-markdown works with | ||
| 55 | // most pipelines, but some important things to note: | ||
| 56 | // - During parsing, all nodes have to have a source location. | ||
| 57 | // - The pipeline must output a remark (Markdown) AST. | ||
| 58 | // - You can also add `remarkRehype` and some rehype plugins. | ||
| 59 | // - Do not include the `rehypeReact` plugin in here. It is added for you. | ||
| 60 | const processor = unified().use(remarkParse).use(remarkGfm); | ||
| 61 | |||
| 62 | // Define custom components. (maps to rehypeReact's option) | ||
| 63 | const components = { | ||
| 64 | a: MarkdownLink, | ||
| 65 | code: InlineCode, | ||
| 66 | }; | ||
| 67 | |||
| 68 | export function HelloWorld() { | ||
| 69 | return ( | ||
| 70 | <Markdown | ||
| 71 | content="hello **world" | ||
| 72 | // Predict closing tags for streaming use case | ||
| 73 | predict | ||
| 74 | // These are optional, and can be set via `MarkdownOptionsProvider` | ||
| 75 | processor={processor} | ||
| 76 | components={components} | ||
| 77 | /> | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | ``` | ||
| 81 | |||
| 82 | If you want to play with the example project, which offers a comparison to | ||
| 83 | Streamdown and the non-memoized `react-markdown` package. | ||
| 84 | |||
| 85 | ``` | ||
| 86 | git clone https://git.paperclover.net/clo/react-markdown | ||
| 87 | cd react-markdown | ||
| 88 | pnpm demo # auto-install | ||
| 89 | ``` | ||
| 90 | |||
| 91 | ## Architecture | ||
| 92 | |||
| 93 | The `Markdown` component is built out of a memoizer which uses the following tricks to improve performance: | ||
| 94 | |||
| 95 | - Using proper `React.memo()` calls. Obviously. | ||
| 96 | - Prediction is implemented in a stateful way that only parses the tail end of | ||
| 97 | the document, marking how much of the document is stable and where possible | ||
| 98 | incomplete syntax may live. Since prediction only applies at the end, changing | ||
| 99 | text midway through can invalidate the whole predictor. | ||
| 100 | - Separate the parsed document into "blocks", noting the source location of | ||
| 101 | where each block lives. | ||
| 102 | - Only start parsing after the first changed character, rounded to the nearest | ||
| 103 | block. In the append-only stream, this essentially means the last two blocks | ||
| 104 | are the only things being re-parsed. | ||
| 105 | - Similarly, run AST transforms only on the changed data. This step has a couple | ||
| 106 | of slow paths for when reference link definitions are added or edited, since | ||
| 107 | it means any places that might have used a reference link may now have to | ||
| 108 | reflect it. | ||
| 109 | - After all that, a special AST -> React node transform is used that diffs the | ||
| 110 | new ast with the last ast, reusing React nodes whenever possible. It supports | ||
| 111 | nested children as well as re-ordering top level blocks. This is what prevents | ||
| 112 | most rerenders and is the "secret sauce". | ||
| 113 | |||
| 114 | All put together, basically nothing rerenders except what actually changed, and the document is beautiful. | ||
readme.changes.md created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | # notable changes in React Markdown | ||
readme.md created+114| ... | @@ -0,0 +1,114 @@ | ||
| 1 | # `@clo/react-markdown` | ||
| 2 | |||
| 3 | This package exports a React component to render Markdown using the [unified] | ||
| 4 | ecosystem's Markdown tools (remark, rehype). The exported `<Markdown />` | ||
| 5 | component is extremely memoized, making it suitable for streaming situations | ||
| 6 | such as LLM chat interfaces. | ||
| 7 | |||
| 8 | - **Bring your Existing Pipeline**: The primary option for configuration is | ||
| 9 | providing a `unified.Processor` with remark and rehype plugins configured. | ||
| 10 | Additionally, you can override the element renderers with the `components` | ||
| 11 | attribute. By default, `@clo/react-markdown` applies a bare bones config so | ||
| 12 | you can get started with just `<Markdown content="hi" />`. Configuration is | ||
| 13 | done either with a provider, or right at the component level. | ||
| 14 | - **Handle Partial Markdown**: When the `predict` prop is set, the document | ||
| 15 | will be treated as an incomplete stream at the end of a document. | ||
| 16 | `hello **world` will be emitted as `hello <strong>world</strong>`, perfect for | ||
| 17 | the LLM case. (standalone predictor exported as `@clo/react-markdown/Predict`) | ||
| 18 | |||
| 19 | The motivation for this package is to have an easy to understand version of | ||
| 20 | [Streamdown]. With me banning all Vercel software at the company I work at, | ||
| 21 | we needed a simple, trustable solution for this problem. Some differences | ||
| 22 | to Streamdown: | ||
| 23 | |||
| 24 | - **Better Memoization**: All inline components will preserve their state, even | ||
| 25 | as adjacent content changes. This is done to preserve remounts for things like | ||
| 26 | custom `<a>` tags or other components. (For example, if a custom `<a>` fetches | ||
| 27 | previewing data and provides a hover card, that card won't flicker). | ||
| 28 | - **Better Prediction**: While optimizing edge cases, the prediction system became | ||
| 29 | better than the Streamdown one on accident. | ||
| 30 | - **Headless UI**: No built in styles or components, bring your own CSS to blend | ||
| 31 | your markdown with your existing theme. `@clo/react-markdown` simply takes in | ||
| 32 | your existing `unified` pipeline and works off of that. | ||
| 33 | - **Easy to Audit**: Only ~1000 lines of modern TypeScript. 4 total files. | ||
| 34 | |||
| 35 | [Streamdown]: https://streamdown.ai | ||
| 36 | [unified]: https://unifiedjs.com/ | ||
| 37 | |||
| 38 | ## Getting Started | ||
| 39 | |||
| 40 | Install off of the [JSR package](https://jsr.io/clo/react-markdown): | ||
| 41 | |||
| 42 | ```sh | ||
| 43 | npx jsr add @clo/react-markdown | ||
| 44 | pnpm add jsr:@clo/react-markdown | ||
| 45 | ``` | ||
| 46 | |||
| 47 | ```tsx | ||
| 48 | import { Markdown } from "@clo/react-markdown"; | ||
| 49 | import remarkGfm from "remark-gfm"; | ||
| 50 | import remarkParse from "remark-parse"; | ||
| 51 | import { unified } from "unified"; | ||
| 52 | |||
| 53 | // Define any unified processing pipeline. We expect that your | ||
| 54 | // app already has one of these. @clo/react-markdown works with | ||
| 55 | // most pipelines, but some important things to note: | ||
| 56 | // - During parsing, all nodes have to have a source location. | ||
| 57 | // - The pipeline must output a remark (Markdown) AST. | ||
| 58 | // - You can also add `remarkRehype` and some rehype plugins. | ||
| 59 | // - Do not include the `rehypeReact` plugin in here. It is added for you. | ||
| 60 | const processor = unified().use(remarkParse).use(remarkGfm); | ||
| 61 | |||
| 62 | // Define custom components. (maps to rehypeReact's option) | ||
| 63 | const components = { | ||
| 64 | a: MarkdownLink, | ||
| 65 | code: InlineCode, | ||
| 66 | }; | ||
| 67 | |||
| 68 | export function HelloWorld() { | ||
| 69 | return ( | ||
| 70 | <Markdown | ||
| 71 | content="hello **world" | ||
| 72 | // Predict closing tags for streaming use case | ||
| 73 | predict | ||
| 74 | // These are optional, and can be set via `MarkdownOptionsProvider` | ||
| 75 | processor={processor} | ||
| 76 | components={components} | ||
| 77 | /> | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | ``` | ||
| 81 | |||
| 82 | If you want to play with the example project, which offers a comparison to | ||
| 83 | Streamdown and the non-memoized `react-markdown` package. | ||
| 84 | |||
| 85 | ``` | ||
| 86 | git clone https://git.paperclover.net/clo/react-markdown | ||
| 87 | cd react-markdown | ||
| 88 | pnpm demo # auto-install | ||
| 89 | ``` | ||
| 90 | |||
| 91 | ## Architecture | ||
| 92 | |||
| 93 | The `Markdown` component is built out of a memoizer which uses the following tricks to improve performance: | ||
| 94 | |||
| 95 | - Using proper `React.memo()` calls. Obviously. | ||
| 96 | - Prediction is implemented in a stateful way that only parses the tail end of | ||
| 97 | the document, marking how much of the document is stable and where possible | ||
| 98 | incomplete syntax may live. Since prediction only applies at the end, changing | ||
| 99 | text midway through can invalidate the whole predictor. | ||
| 100 | - Separate the parsed document into "blocks", noting the source location of | ||
| 101 | where each block lives. | ||
| 102 | - Only start parsing after the first changed character, rounded to the nearest | ||
| 103 | block. In the append-only stream, this essentially means the last two blocks | ||
| 104 | are the only things being re-parsed. | ||
| 105 | - Similarly, run AST transforms only on the changed data. This step has a couple | ||
| 106 | of slow paths for when reference link definitions are added or edited, since | ||
| 107 | it means any places that might have used a reference link may now have to | ||
| 108 | reflect it. | ||
| 109 | - After all that, a special AST -> React node transform is used that diffs the | ||
| 110 | new ast with the last ast, reusing React nodes whenever possible. It supports | ||
| 111 | nested children as well as re-ordering top level blocks. This is what prevents | ||
| 112 | most rerenders and is the "secret sauce". | ||
| 113 | |||
| 114 | All put together, basically nothing rerenders except what actually changed, and the document is beautiful. | ||