From 39467d7be3c48be4dbe5cc633c9719871cab5070 Mon Sep 17 00:00:00 2001 From: 2heal1 Date: Fri, 24 Jul 2026 17:55:09 +0800 Subject: [PATCH] feat(rspress): support remote code block transforms --- .changeset/transform-rspress-code-blocks.md | 5 + packages/rspress-plugin/README.md | 55 +-- packages/rspress-plugin/package.json | 14 +- packages/rspress-plugin/rslib.config.ts | 2 + packages/rspress-plugin/rstest.config.ts | 5 + packages/rspress-plugin/src/plugin.ts | 16 +- .../src/remarkCodeBlockTransform.test.ts | 85 +++++ .../src/remarkCodeBlockTransform.ts | 111 ++++++ .../rspress-plugin/src/runtime/index.test.tsx | 102 ++++++ packages/rspress-plugin/src/runtime/index.tsx | 321 ++++++++++++++++++ packages/rspress-plugin/tsconfig.lib.json | 12 + pnpm-lock.yaml | 18 + 12 files changed, 725 insertions(+), 21 deletions(-) create mode 100644 .changeset/transform-rspress-code-blocks.md create mode 100644 packages/rspress-plugin/rstest.config.ts create mode 100644 packages/rspress-plugin/src/remarkCodeBlockTransform.test.ts create mode 100644 packages/rspress-plugin/src/remarkCodeBlockTransform.ts create mode 100644 packages/rspress-plugin/src/runtime/index.test.tsx create mode 100644 packages/rspress-plugin/src/runtime/index.tsx create mode 100644 packages/rspress-plugin/tsconfig.lib.json diff --git a/.changeset/transform-rspress-code-blocks.md b/.changeset/transform-rspress-code-blocks.md new file mode 100644 index 00000000000..f09fb49ba29 --- /dev/null +++ b/.changeset/transform-rspress-code-blocks.md @@ -0,0 +1,5 @@ +--- +"@module-federation/rspress-plugin": patch +--- + +Allow consumers to replace fenced code block content in exposed Rspress documents. diff --git a/packages/rspress-plugin/README.md b/packages/rspress-plugin/README.md index 3ad43d0342b..7cca0965e62 100644 --- a/packages/rspress-plugin/README.md +++ b/packages/rspress-plugin/README.md @@ -1,33 +1,50 @@ -# @examples/mf-react-component +# @module-federation/rspress-plugin -This example demonstrates how to use Rslib to build a simple Module Federation React component. +Module Federation integration for Rspress. -### Command +## Transform remote code blocks -Build package +Enable the capability on the Rspress producer: -``` -nx build rslib-module +```ts +pluginModuleFederation(mfConfig, { + transformCodeBlocks: true, +}); ``` -Serve package +The producer keeps authoring regular Markdown. It does not need to add an id or +create a code-block component: -``` -nx serve rslib-module -``` +````md +## View all commands -Dev package +```bash +npx mf -h +``` +```` -1. +The consumer creates a transformer with the browser-safe runtime entry and +passes it to the remote MDX document: -``` -nx dev rslib-module -``` +```tsx +import Cli from 'mf-doc/cli-en'; +import { transformCodeBlock } from '@module-federation/rspress-plugin/runtime'; -2. +export const replaceCliName = transformCodeBlock({ + replace: [[/\bmf\b/g, 'vmok']], + filter: ({ lang }) => lang === 'bash' || lang === 'text', +}); +export default function Page() { + return ; +} ``` -nx storybook rslib-module -``` -visit http://localhost:6006 +Replacement rules run in order. Without `filter`, they are considered for every +fenced code block in the remote document; blocks with no matching content keep +their original highlighting. A transformer can also return a different +language when the replacement changes the code type. + +The transformation runs while rendering the remote MDX document, so the same +result is used by browser rendering and SSG. Existing HTML-based llms/Markdown +rebuilds also read the transformed output. diff --git a/packages/rspress-plugin/package.json b/packages/rspress-plugin/package.json index 77f050a5924..8be4c164978 100644 --- a/packages/rspress-plugin/package.json +++ b/packages/rspress-plugin/package.json @@ -25,6 +25,10 @@ ".": { "types": "./dist/plugin.d.ts", "import": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "import": "./dist/runtime.js" } }, "module": "./dist/index.js", @@ -33,17 +37,24 @@ "build": "rslib build", "build:watch": "rslib build --watch", "dev": "pnpm run build:watch", + "test": "rstest", "pre-release": "pnpm exec turbo run build --filter=@module-federation/webpack-bundler-runtime" }, "devDependencies": { + "@mdx-js/mdx": "^3.1.1", "@rslib/core": "^0.23.2", "@rspress/core": "2.0.14", + "@rstest/core": "^0.10.6", "@types/html-to-text": "^9.0.4", "@types/lodash-es": "^4.17.12", "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", "typescript": "7.0.2" }, "dependencies": { + "@mdx-js/react": "^3.1.1", "cheerio": "1.0.0-rc.12", "@module-federation/sdk": "workspace:*", "html-to-text": "9.0.5", @@ -54,6 +65,7 @@ "@rspress/shared": "2.0.14" }, "peerDependencies": { - "@rspress/core": "2.0.14" + "@rspress/core": "2.0.14", + "react": ">=18.0.0" } } diff --git a/packages/rspress-plugin/rslib.config.ts b/packages/rspress-plugin/rslib.config.ts index 3f851993245..be7f1a23b79 100644 --- a/packages/rspress-plugin/rslib.config.ts +++ b/packages/rspress-plugin/rslib.config.ts @@ -12,7 +12,9 @@ export default defineConfig({ source: { entry: { index: 'src/plugin.ts', + runtime: 'src/runtime/index.tsx', }, + tsconfigPath: './tsconfig.lib.json', }, lib: [ { diff --git a/packages/rspress-plugin/rstest.config.ts b/packages/rspress-plugin/rstest.config.ts new file mode 100644 index 00000000000..4df30d6dcc4 --- /dev/null +++ b/packages/rspress-plugin/rstest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + testEnvironment: 'node', +}); diff --git a/packages/rspress-plugin/src/plugin.ts b/packages/rspress-plugin/src/plugin.ts index 6fdc022c9f0..9b51daf3546 100644 --- a/packages/rspress-plugin/src/plugin.ts +++ b/packages/rspress-plugin/src/plugin.ts @@ -8,11 +8,19 @@ import type { moduleFederationPlugin } from '@module-federation/sdk'; import type { RspressPlugin, RouteMeta } from '@rspress/core'; import { rebuildLlmsByHtml } from './rebuildLlmsByHtml'; import { rebuildSearchIndexByHtml } from './rebuildSearchIndexByHtml'; +import { remarkCodeBlockTransform } from './remarkCodeBlockTransform'; -type RspressPluginOptions = { +export type RspressPluginOptions = { autoShared?: boolean; rebuildSearchIndex?: boolean; rebuildLlms?: boolean; + /** + * Allow exposed MDX documents to transform fenced code blocks through a + * `transformCodeBlock` prop. + * + * @default false + */ + transformCodeBlocks?: boolean; }; const isDev = () => process.env.NODE_ENV === 'development'; @@ -25,6 +33,7 @@ export function pluginModuleFederation( autoShared = true, rebuildSearchIndex = true, rebuildLlms = true, + transformCodeBlocks = false, } = rspressOptions || {}; if (autoShared) { @@ -66,6 +75,11 @@ export function pluginModuleFederation( return { name: 'plugin-module-federation', + markdown: transformCodeBlocks + ? { + remarkPlugins: [remarkCodeBlockTransform], + } + : undefined, async config(config) { if (!isDev() && config.ssg !== false) { enableSSG = true; diff --git a/packages/rspress-plugin/src/remarkCodeBlockTransform.test.ts b/packages/rspress-plugin/src/remarkCodeBlockTransform.test.ts new file mode 100644 index 00000000000..18a1be34604 --- /dev/null +++ b/packages/rspress-plugin/src/remarkCodeBlockTransform.test.ts @@ -0,0 +1,85 @@ +import { compile } from '@mdx-js/mdx'; +import { compile as compileRspressMdx } from '@rspress/core/dist/node/mdx/processor.js'; +import type { PluginDriver } from '@rspress/core/dist/node/PluginDriver.js'; +import { describe, expect, it } from '@rstest/core'; +import path from 'node:path'; +import { pluginModuleFederation } from './plugin'; +import { + codeBlockTransformRuntimeModule, + remarkCodeBlockTransform, +} from './remarkCodeBlockTransform'; + +describe('remarkCodeBlockTransform', () => { + it('injects the runtime as the implicit MDX layout', async () => { + const output = String( + await compile('# Title\n\n```bash\nnpx mf -h\n```', { + providerImportSource: '@mdx-js/react', + remarkPlugins: [remarkCodeBlockTransform], + }), + ); + + expect(output).toContain( + `import MDXLayout from "${codeBlockTransformRuntimeModule}";`, + ); + expect(output).toContain('_jsx(MDXLayout'); + expect(output).toContain('...props'); + }); + + it('preserves an explicit user layout', async () => { + const source = [ + 'export default function CustomLayout({ children }) {', + ' return
{children}
;', + '}', + '', + '# Title', + ].join('\n'); + const output = String( + await compile(source, { + providerImportSource: '@mdx-js/react', + remarkPlugins: [remarkCodeBlockTransform], + }), + ); + + expect(output).toContain('function CustomLayout'); + expect(output).not.toContain(codeBlockTransformRuntimeModule); + }); + + it('works in the real Rspress Markdown pipeline', async () => { + const plugin = pluginModuleFederation( + { name: 'rspress-code-block-test' }, + { transformCodeBlocks: true }, + ); + const docDirectory = process.cwd(); + const filepath = path.join(docDirectory, 'code-block-test.mdx'); + const pluginDriver = { + getPlugins: () => [plugin], + } as PluginDriver; + + const output = await compileRspressMdx({ + source: [ + '# Commands', + '', + 'Other content.', + '', + '```bash title=cli wrapCode lineNumbers', + 'npx mf -h', + '```', + ].join('\n'), + filepath, + docDirectory, + config: { markdown: {} }, + routeService: null, + pluginDriver, + }); + + expect(output).toContain( + `import MDXLayout from "${codeBlockTransformRuntimeModule}";`, + ); + expect(output).toContain('className: "shiki css-variables"'); + expect(output).toContain('lang: "bash"'); + expect(output).toContain('title: "cli"'); + expect(output).toContain('lineNumbers: true'); + expect(output).toContain('wrapCode: true'); + expect(output).toContain('"Other content."'); + }); +}); diff --git a/packages/rspress-plugin/src/remarkCodeBlockTransform.ts b/packages/rspress-plugin/src/remarkCodeBlockTransform.ts new file mode 100644 index 00000000000..165a7550568 --- /dev/null +++ b/packages/rspress-plugin/src/remarkCodeBlockTransform.ts @@ -0,0 +1,111 @@ +const RUNTIME_MODULE = '@module-federation/rspress-plugin/runtime'; + +type EstreeNode = { + type: string; + [key: string]: unknown; +}; + +type EstreeProgram = { + type: 'Program'; + sourceType: 'module'; + body: EstreeNode[]; +}; + +type MdxNode = { + type: string; + data?: { + estree?: EstreeProgram; + }; +}; + +type MdxRoot = { + children: MdxNode[]; +}; + +function hasDefaultExport(tree: MdxRoot): boolean { + return tree.children.some((node) => + node.data?.estree?.body?.some((statement) => { + if (statement.type === 'ExportDefaultDeclaration') { + return true; + } + if (statement.type !== 'ExportNamedDeclaration') { + return false; + } + const specifiers = statement.specifiers; + return ( + Array.isArray(specifiers) && + specifiers.some((specifier) => { + if ( + typeof specifier !== 'object' || + specifier === null || + !('exported' in specifier) + ) { + return false; + } + const exported = specifier.exported; + return ( + typeof exported === 'object' && + exported !== null && + 'name' in exported && + exported.name === 'default' + ); + }) + ); + }), + ); +} + +function createLayoutExportNode(): MdxNode { + const rawSource = JSON.stringify(RUNTIME_MODULE); + return { + type: 'mdxjsEsm', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ExportNamedDeclaration', + declaration: null, + specifiers: [ + { + type: 'ExportSpecifier', + local: { + type: 'Identifier', + name: 'default', + }, + exported: { + type: 'Identifier', + name: 'default', + }, + }, + ], + source: { + type: 'Literal', + value: RUNTIME_MODULE, + raw: rawSource, + }, + }, + ], + }, + }, + }; +} + +/** + * Install the plugin runtime as the implicit MDX layout. MDX renders the + * document body as the layout's child, so the layout can provide code-block + * components before the body is evaluated. + */ +export function remarkCodeBlockTransform() { + return (tree: MdxRoot) => { + // Preserve an explicit user-authored MDX layout. Consumers can still use + // the runtime API directly around such documents. + if (hasDefaultExport(tree)) { + return; + } + tree.children.unshift(createLayoutExportNode()); + }; +} + +export const codeBlockTransformRuntimeModule = RUNTIME_MODULE; diff --git a/packages/rspress-plugin/src/runtime/index.test.tsx b/packages/rspress-plugin/src/runtime/index.test.tsx new file mode 100644 index 00000000000..208f46aa536 --- /dev/null +++ b/packages/rspress-plugin/src/runtime/index.test.tsx @@ -0,0 +1,102 @@ +import { MDXProvider, useMDXComponents } from '@mdx-js/react'; +import { describe, expect, it } from '@rstest/core'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import CodeBlockTransformLayout, { transformCodeBlock } from './index'; + +function HighlightedDocument() { + const components = useMDXComponents(); + const Pre = components.pre ?? 'pre'; + const Code = components.code ?? 'code'; + + return ( + <> +

Commands

+

Run this command:

+
+        
+          
+            npx
+             mf
+             -h
+          
+        
+      
+
hand-written pre
+ + ); +} + +describe('code block transform runtime', () => { + it('replaces fenced code without changing surrounding content', () => { + const transformer = transformCodeBlock({ + replace: [[/\bmf\b/g, 'vmok']], + }); + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).toContain('

Commands

'); + expect(html).toContain('

Run this command:

'); + expect(html).toContain( + 'npx vmok -h', + ); + expect(html).toContain('lang="bash"'); + expect(html).toContain('
hand-written pre
'); + }); + + it('keeps the original highlighted block when no replacement matches', () => { + const transformer = transformCodeBlock({ + replace: [['webpack', 'rspack']], + }); + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).toContain('npx mf -h'); + }); + + it('allows changing the language from the transformed content', () => { + const transformer = transformCodeBlock({ + replace: [[/\bmf\b/g, 'vmok']], + lang: ({ code, lang }) => (code.startsWith('npx vmok') ? 'text' : lang), + }); + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).toContain( + 'npx vmok -h', + ); + expect(html).toContain('lang="text"'); + }); + + it('inherits the transformer through nested MDX layouts', () => { + const transformer = transformCodeBlock({ + replace: [[/\bmf\b/g, 'vmok']], + }); + const html = renderToStaticMarkup( + + + + + + + , + ); + + expect(html.match(/ vmok/g)).toHaveLength(1); + }); +}); diff --git a/packages/rspress-plugin/src/runtime/index.tsx b/packages/rspress-plugin/src/runtime/index.tsx new file mode 100644 index 00000000000..a5e92fc2640 --- /dev/null +++ b/packages/rspress-plugin/src/runtime/index.tsx @@ -0,0 +1,321 @@ +import { MDXProvider, useMDXComponents } from '@mdx-js/react'; +import React, { + Children, + cloneElement, + createContext, + isValidElement, + type ReactNode, + useContext, + useMemo, +} from 'react'; + +export type CodeBlockInfo = { + code: string; + lang: string; + title?: string; +}; + +export type CodeBlockTransformResult = + | string + | { + code: string; + lang?: string; + } + | null + | undefined; + +export type CodeBlockTransformer = ( + block: CodeBlockInfo, +) => CodeBlockTransformResult; + +export type CodeBlockReplacement = readonly [from: string | RegExp, to: string]; + +export type TransformCodeBlockOptions = { + /** + * Ordered replacements applied to every fenced code block. + */ + replace: readonly CodeBlockReplacement[]; + /** + * Limit replacements to selected code blocks. + */ + filter?: (block: CodeBlockInfo) => boolean; + /** + * Optionally update the language after replacements have been applied. + */ + lang?: + | string + | ((block: CodeBlockInfo, original: CodeBlockInfo) => string | undefined); +}; + +/** + * Create a code-block transformer from replacement rules. + * + * @example + * ```tsx + * const replaceCliName = transformCodeBlock({ + * replace: [[/\bmf\b/g, 'vmok']], + * }); + * + * + * ``` + */ +export function transformCodeBlock({ + replace, + filter, + lang, +}: TransformCodeBlockOptions): CodeBlockTransformer { + return (original) => { + if (filter && !filter(original)) { + return undefined; + } + + let code = original.code; + for (const [from, to] of replace) { + code = + typeof from === 'string' + ? code.split(from).join(to) + : code.replace(from, to); + } + + const transformed = { + ...original, + code, + }; + const nextLang = + typeof lang === 'function' ? lang(transformed, original) : lang; + const resolvedLang = nextLang ?? original.lang; + + if (code === original.code && resolvedLang === original.lang) { + return undefined; + } + + return { + code, + lang: resolvedLang, + }; + }; +} + +type CodeBlockTransformLayoutProps = { + children?: ReactNode; + transformCodeBlock?: CodeBlockTransformer | null; +}; + +type TransformContextValue = { + transformer?: CodeBlockTransformer; + pre: React.ElementType; +}; + +type TransformPreProps = React.ComponentPropsWithoutRef<'pre'> & { + lang?: string; + title?: string; + wrapCode?: boolean; + lineNumbers?: boolean; + fold?: boolean; + height?: number; +}; + +const CodeBlockTransformContext = createContext( + null, +); + +function getTextContent(node: ReactNode): string { + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + if (!node) { + return ''; + } + if (Array.isArray(node)) { + return node.map(getTextContent).join(''); + } + if (isValidElement<{ children?: ReactNode }>(node)) { + return getTextContent(node.props.children); + } + let text = ''; + Children.forEach(node, (child) => { + text += getTextContent(child); + }); + return text; +} + +function isRspressCodeBlock(className: unknown, lang: unknown): boolean { + return ( + typeof className === 'string' && + className.split(/\s+/).includes('shiki') && + typeof lang === 'string' + ); +} + +function replaceHighlightedText( + node: ReactNode, + originalCode: string, + transformedCode: string, +): ReactNode { + let prefixLength = 0; + while ( + prefixLength < originalCode.length && + prefixLength < transformedCode.length && + originalCode[prefixLength] === transformedCode[prefixLength] + ) { + prefixLength++; + } + + let suffixLength = 0; + while ( + suffixLength < originalCode.length - prefixLength && + suffixLength < transformedCode.length - prefixLength && + originalCode[originalCode.length - suffixLength - 1] === + transformedCode[transformedCode.length - suffixLength - 1] + ) { + suffixLength++; + } + + const oldSuffixStart = originalCode.length - suffixLength; + const replacement = transformedCode.slice( + prefixLength, + transformedCode.length - suffixLength, + ); + let offset = 0; + let replacementInserted = false; + + const visit = (current: ReactNode): ReactNode => { + if (typeof current === 'string' || typeof current === 'number') { + const text = String(current); + const start = offset; + const end = start + text.length; + offset = end; + + if (end <= prefixLength || start >= oldSuffixStart) { + return text; + } + + let next = ''; + if (start < prefixLength) { + next += text.slice(0, prefixLength - start); + } + if (!replacementInserted) { + next += replacement; + replacementInserted = true; + } + if (end > oldSuffixStart) { + next += text.slice(oldSuffixStart - start); + } + return next; + } + if (Array.isArray(current)) { + return Children.map(current, visit); + } + if (isValidElement<{ children?: ReactNode }>(current)) { + return cloneElement(current, undefined, visit(current.props.children)); + } + return current; + }; + + const result = visit(node); + return getTextContent(result) === transformedCode ? result : transformedCode; +} + +function TransformedCodeBlock({ + OriginalPre, + originalProps, + originalCode, + code, + lang, +}: { + OriginalPre: React.ElementType; + originalProps: TransformPreProps; + originalCode: string; + code: string; + lang: string; +}) { + const { children, ...preProps } = originalProps; + const codeElement = isValidElement<{ children?: ReactNode }>(children) + ? cloneElement( + children, + undefined, + replaceHighlightedText(children.props.children, originalCode, code), + ) + : React.createElement('code', undefined, code); + + return ( + + {codeElement} + + ); +} + +/** + * Internal MDX layout injected by the Rspress plugin. + */ +export default function CodeBlockTransformLayout({ + children, + transformCodeBlock: directTransformer, +}: CodeBlockTransformLayoutProps) { + const inherited = useContext(CodeBlockTransformContext); + const mdxComponents = useMDXComponents(); + + // Nested MDX fragments inherit the parent document's provider. Avoid wrapping + // the same code block more than once unless a nested document explicitly + // supplies its own transformer. + if (inherited && directTransformer === undefined) { + return children; + } + + const transformer = + directTransformer === null + ? undefined + : (directTransformer ?? inherited?.transformer); + const OriginalPre = inherited?.pre ?? mdxComponents.pre ?? 'pre'; + + const components = useMemo( + () => ({ + pre: (props: TransformPreProps) => { + const { children: codeChildren, lang, title, className } = props; + + if (!transformer || !isRspressCodeBlock(className, lang)) { + return ; + } + + const original: CodeBlockInfo = { + code: getTextContent(codeChildren as ReactNode), + lang: lang as string, + title: typeof title === 'string' ? title : undefined, + }; + const result = transformer(original); + + if (result == null) { + return ; + } + + const code = typeof result === 'string' ? result : result.code; + const transformedLang = + typeof result === 'string' + ? original.lang + : (result.lang ?? original.lang); + + return ( + + ); + }, + }), + [OriginalPre, transformer], + ); + + return ( + + {children} + + ); +} diff --git a/packages/rspress-plugin/tsconfig.lib.json b/packages/rspress-plugin/tsconfig.lib.json new file mode 100644 index 00000000000..4c75c663482 --- /dev/null +++ b/packages/rspress-plugin/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true + }, + "exclude": [ + "rstest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.test-*.tsx" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b15ef49a5c..ca7d0985616 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4006,6 +4006,9 @@ importers: packages/rspress-plugin: dependencies: + '@mdx-js/react': + specifier: ^3.1.1 + version: 3.1.1(@types/react@18.3.28)(react@19.2.7) '@module-federation/enhanced': specifier: workspace:* version: link:../enhanced @@ -4031,12 +4034,18 @@ importers: specifier: 4.18.1 version: 4.18.1 devDependencies: + '@mdx-js/mdx': + specifier: ^3.1.1 + version: 3.1.1 '@rslib/core': specifier: ^0.23.2 version: 0.23.2(@microsoft/api-extractor@7.57.7(@types/node@26.1.1))(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(typescript@7.0.2) '@rspress/core': specifier: 2.0.14 version: 2.0.14(@module-federation/runtime-tools@2.8.0)(@rspack/core@2.1.2(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@18.3.28)(core-js@3.49.0)(micromark-util-types@2.0.2)(micromark@4.0.2) + '@rstest/core': + specifier: ^0.10.6 + version: 0.10.6(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(jsdom@20.0.3) '@types/html-to-text': specifier: ^9.0.4 version: 9.0.4 @@ -4046,6 +4055,15 @@ importers: '@types/react': specifier: ^18.3.11 version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.28) + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) typescript: specifier: 7.0.2 version: 7.0.2