feat: replace react-markdown with streamdown

This commit is contained in:
ibelick
2026-02-13 08:42:23 +01:00
parent 350121aec9
commit e6bfa78e92
4 changed files with 166 additions and 108 deletions
+1 -4
View File
@@ -24,15 +24,12 @@
"@tanstack/router-plugin": "^1.132.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"marked": "^17.0.1",
"motion": "^12.29.2",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.21.0",
"streamdown": "^2.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"use-stick-to-bottom": "^1.1.2",
@@ -1,22 +1,13 @@
import { marked } from 'marked'
import { memo, useId, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { memo } from 'react'
import { Streamdown } from 'streamdown'
import { CodeBlock } from './code-block'
import type { Components } from 'react-markdown'
import { cn } from '@/lib/utils'
export type MarkdownProps = {
children: string
id?: string
className?: string
components?: Partial<Components>
}
function parseMarkdownIntoBlocks(markdown: string): Array<string> {
const tokens = marked.lexer(markdown)
return tokens.map((token) => token.raw)
components?: Record<string, React.ComponentType<any>>
}
function extractLanguage(className?: string): string {
@@ -25,7 +16,7 @@ function extractLanguage(className?: string): string {
return match ? match[1] : 'text'
}
const INITIAL_COMPONENTS: Partial<Components> = {
const INITIAL_COMPONENTS: Record<string, React.ComponentType<any>> = {
code: function CodeComponent({ className, children }) {
const isInline = !className?.includes('language-')
@@ -50,13 +41,23 @@ const INITIAL_COMPONENTS: Partial<Components> = {
return <>{children}</>
},
h1: function H1Component({ children }) {
return <h1 className="text-xl font-medium text-primary-950">{children}</h1>
return (
<h1 className="text-xl font-medium text-primary-950 text-balance">
{children}
</h1>
)
},
h2: function H2Component({ children }) {
return <h2 className="text-lg font-medium text-primary-900">{children}</h2>
return (
<h2 className="text-lg font-medium text-primary-900 text-balance">
{children}
</h2>
)
},
h3: function H3Component({ children }) {
return <h3 className="font-medium text-primary-900">{children}</h3>
return (
<h3 className="font-medium text-primary-900 text-balance">{children}</h3>
)
},
p: function PComponent({ children }) {
return (
@@ -142,50 +143,18 @@ const INITIAL_COMPONENTS: Partial<Components> = {
},
}
const MemoizedMarkdownBlock = memo(
function MarkdownBlock({
content,
components = INITIAL_COMPONENTS,
}: {
content: string
components?: Partial<Components>
}) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={components}
>
{content}
</ReactMarkdown>
)
},
function propsAreEqual(prevProps, nextProps) {
return prevProps.content === nextProps.content
},
)
MemoizedMarkdownBlock.displayName = 'MemoizedMarkdownBlock'
function MarkdownComponent({
children,
id,
className,
components = INITIAL_COMPONENTS,
}: MarkdownProps) {
const generatedId = useId()
const blockId = id ?? generatedId
const blocks = useMemo(() => parseMarkdownIntoBlocks(children), [children])
return (
<div className={cn('flex flex-col gap-2', className)}>
{blocks.map((block, index) => (
<MemoizedMarkdownBlock
key={`${blockId}-block-${index}`}
content={block}
components={components}
/>
))}
</div>
<Streamdown
className={cn('flex flex-col gap-2', className)}
components={components}
>
{children}
</Streamdown>
)
}
+1
View File
@@ -1,5 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=EB+Garamond:opsz,wght@8..120,400..800&family=JetBrains+Mono:wght@400;500&display=swap');
@import 'tailwindcss';
@source "../../../node_modules/streamdown/dist/*.js";
.root {
isolation: isolate;
+141 -50
View File
@@ -87,9 +87,6 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
marked:
specifier: ^17.0.1
version: 17.0.1
motion:
specifier: ^12.29.2
version: 12.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -102,18 +99,12 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.4(react@19.2.4)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.13)(react@19.2.4)
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
shiki:
specifier: ^3.21.0
version: 3.22.0
streamdown:
specifier: ^2.2.0
version: 2.2.0(react@19.2.4)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -2315,15 +2306,33 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-sanitize@5.0.2:
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -2655,9 +2664,6 @@ packages:
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-newline-to-break@2.0.0:
resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -2942,12 +2948,6 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@@ -2973,8 +2973,14 @@ packages:
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
rehype-harden@1.1.7:
resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -2988,6 +2994,9 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
remend@1.2.0:
resolution: {integrity: sha512-NbKrdWweTRuByPYErzQCNpNtsR9M1QQ0hK2UzmnmlSaEqHnkQ5Korlyi8KpdbOJ0rImJfRy4EAY0uDxYnL9Plw==}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@@ -3097,6 +3106,11 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
streamdown@2.2.0:
resolution: {integrity: sha512-Y51o1I/sjpAy4Yn7j7R4TbUl9gcUZ7BTrHS+68IhrUBoYpNQZ28z06vww1MBFu4mSwvgF8xQIxIH2b9S9IHDyQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -3370,6 +3384,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -3472,6 +3489,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
web-vitals@5.1.0:
resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
@@ -5598,6 +5618,43 @@ snapshots:
has-flag@4.0.0: {}
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
property-information: 7.1.0
vfile: 6.0.3
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw@9.1.0:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.0
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.1
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
parse5: 7.3.0
unist-util-position: 5.0.0
unist-util-visit: 5.1.0
vfile: 6.0.3
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-sanitize@5.0.2:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.0
unist-util-position: 5.0.0
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
@@ -5632,10 +5689,28 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.11.0
@@ -6012,11 +6087,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-newline-to-break@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-find-and-replace: 3.0.2
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -6448,24 +6518,6 @@ snapshots:
react-is@17.0.2: {}
react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.13
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.4
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-refresh@0.18.0: {}
react@19.2.4: {}
@@ -6492,11 +6544,20 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
remark-breaks@4.0.0:
rehype-harden@1.1.7:
dependencies:
'@types/mdast': 4.0.4
mdast-util-newline-to-break: 2.0.0
unified: 11.0.5
unist-util-visit: 5.1.0
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
rehype-sanitize@6.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
remark-gfm@4.0.1:
dependencies:
@@ -6532,6 +6593,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
remend@1.2.0: {}
require-from-string@2.0.2: {}
reselect@5.1.1: {}
@@ -6678,6 +6741,27 @@ snapshots:
std-env@3.10.0: {}
streamdown@2.2.0(react@19.2.4):
dependencies:
clsx: 2.1.1
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
marked: 17.0.1
react: 19.2.4
rehype-harden: 1.1.7
rehype-raw: 7.0.0
rehype-sanitize: 6.0.0
remark-gfm: 4.0.1
remark-parse: 11.0.0
remark-rehype: 11.1.2
remend: 1.2.0
tailwind-merge: 3.4.0
unified: 11.0.5
unist-util-visit: 5.1.0
unist-util-visit-parents: 6.0.2
transitivePeerDependencies:
- supports-color
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -6893,6 +6977,11 @@ snapshots:
dependencies:
react: 19.2.4
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
vfile: 6.0.3
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -7012,6 +7101,8 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
web-namespaces@2.0.1: {}
web-vitals@5.1.0: {}
webidl-conversions@8.0.1: {}