diff --git a/packages/core/package.json b/packages/core/package.json index bba8568fc..76b9524b5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,9 @@ "./shiki-transformers": { "types": "./dist/shiki-transformers.d.ts", "default": "./dist/shiki-transformers.js" + }, + "./_private/react": { + "default": "./dist/_private/react/index.js" } }, "main": "./dist/index.js", @@ -74,16 +77,19 @@ "hast-util-heading-rank": "^3.0.0", "html-to-text": "^9.0.5", "lodash-es": "^4.17.21", + "mdast-util-mdx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.1", "medium-zoom": "1.1.0", "picocolors": "^1.1.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-lazy-with-preload": "^2.2.1", + "react-reconciler": "0.33.0", "react-router-dom": "^6.30.1", "rehype-external-links": "^3.0.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", "shiki": "^3.12.2", "tinyglobby": "^0.2.15", "tinypool": "^1.1.1", @@ -102,6 +108,7 @@ "@types/node": "^22.8.1", "@types/react": "^19.1.15", "@types/react-dom": "^19.1.9", + "@types/react-reconciler": "^0.32.1", "execa": "8.0.1", "mdast-util-directive": "^3.1.0", "mdast-util-mdx-expression": "^2.0.1", diff --git a/packages/core/rslib.config.ts b/packages/core/rslib.config.ts index 24deee494..789eee579 100644 --- a/packages/core/rslib.config.ts +++ b/packages/core/rslib.config.ts @@ -24,6 +24,22 @@ export default defineConfig({ lib: [ generateEntry('./src/runtime.ts'), generateEntry('./src/theme.ts'), + { + bundle: false, + dts: false, + format: 'esm', + syntax: 'es2022', + source: { + entry: { + index: './src/node/ssg-md/react/*.ts', + }, + }, + output: { + distPath: { + root: './dist/_private/react', + }, + }, + }, { format: 'esm', syntax: 'es2022', diff --git a/packages/core/src/node/PluginDriver.ts b/packages/core/src/node/PluginDriver.ts index 5cb756dae..a91c7fd82 100644 --- a/packages/core/src/node/PluginDriver.ts +++ b/packages/core/src/node/PluginDriver.ts @@ -1,8 +1,10 @@ -import type { - PageIndexInfo, - RouteMeta, - RspressPlugin, - UserConfig, +import { + addLeadingSlash, + addTrailingSlash, + type PageIndexInfo, + type RouteMeta, + type RspressPlugin, + type UserConfig, } from '@rspress/shared'; import { haveNavSidebarConfig } from './auto-nav-sidebar'; import type { RouteService } from './route/RouteService'; @@ -108,7 +110,17 @@ export class PluginDriver { } } + private async normalizeConfig() { + this.#config.ssg ??= true; + this.#config.llms ??= false; + this.#config.base = addTrailingSlash( + addLeadingSlash(this.#config.base ?? '/'), + ); + } + async modifyConfig() { + this.normalizeConfig(); + let config = this.#config; for (let i = 0; i < this.#plugins.length; i++) { diff --git a/packages/core/src/node/auto-nav-sidebar/utils.ts b/packages/core/src/node/auto-nav-sidebar/utils.ts index d4a9cdfc7..3e22461f5 100644 --- a/packages/core/src/node/auto-nav-sidebar/utils.ts +++ b/packages/core/src/node/auto-nav-sidebar/utils.ts @@ -24,6 +24,7 @@ export async function extractInfoFromFrontmatterWithAbsolutePath( overviewHeaders: number[] | undefined; context: string | undefined; tag: string | undefined; + description: string | undefined; }> { const fileHandle = await fs.open(absolutePath, 'r'); try { @@ -112,6 +113,7 @@ export async function extractInfoFromFrontmatterWithAbsolutePath( overviewHeaders: frontmatter.overviewHeaders, context: frontmatter.context, tag: frontmatter.tag, + description: frontmatter.description, }; } finally { await fileHandle.close(); diff --git a/packages/core/src/node/build.ts b/packages/core/src/node/build.ts index 2bb7eca26..46f9cc768 100644 --- a/packages/core/src/node/build.ts +++ b/packages/core/src/node/build.ts @@ -17,7 +17,9 @@ export async function build(options: BuildOptions) { // 1. create PluginDriver const pluginDriver = await PluginDriver.create(config, configFilePath, true); const modifiedConfig = await pluginDriver.modifyConfig(); - const ssgConfig = Boolean(modifiedConfig.ssg ?? true); + const enableSSG = Boolean( + (modifiedConfig.ssg || modifiedConfig.llms) ?? true, + ); // 2. create RouteService const additionalPages = await pluginDriver.addPages(); @@ -42,7 +44,7 @@ export async function build(options: BuildOptions) { modifiedConfig, pluginDriver, routeService, - ssgConfig, + enableSSG, ); await rsbuild.build(); } finally { @@ -50,7 +52,7 @@ export async function build(options: BuildOptions) { } await pluginDriver.afterBuild(); - if (!ssgConfig) { + if (!enableSSG) { hintSSGFalse(); } } diff --git a/packages/core/src/node/constants.ts b/packages/core/src/node/constants.ts index 5f9e66b68..786ff815a 100644 --- a/packages/core/src/node/constants.ts +++ b/packages/core/src/node/constants.ts @@ -52,6 +52,13 @@ export const SSR_SERVER_ENTRY = path.join( 'ssrServerEntry.js', ); +export const SSG_MD_SERVER_ENTRY = path.join( + PACKAGE_ROOT, + 'dist', + 'runtime', + 'ssrMdServerEntry.js', +); + export const OUTPUT_DIR = 'doc_build'; export const APP_HTML_MARKER = ''; @@ -64,3 +71,6 @@ export const PUBLIC_DIR = 'public'; // Prevent the risk of naming conflicts with the user's folders export const NODE_SSG_BUNDLE_FOLDER = '__ssg__'; export const NODE_SSG_BUNDLE_NAME = 'rspress-ssg-entry.cjs'; + +export const NODE_SSG_MD_BUNDLE_FOLDER = '__ssg_md__'; +export const NODE_SSG_MD_BUNDLE_NAME = 'rspress-ssg-md-entry.cjs'; diff --git a/packages/core/src/node/initRsbuild.ts b/packages/core/src/node/initRsbuild.ts index 3e08b27fd..392c67cf6 100644 --- a/packages/core/src/node/initRsbuild.ts +++ b/packages/core/src/node/initRsbuild.ts @@ -21,9 +21,12 @@ import { isProduction, NODE_SSG_BUNDLE_FOLDER, NODE_SSG_BUNDLE_NAME, + NODE_SSG_MD_BUNDLE_FOLDER, + NODE_SSG_MD_BUNDLE_NAME, OUTPUT_DIR, PACKAGE_ROOT, PUBLIC_DIR, + SSG_MD_SERVER_ENTRY, SSR_CLIENT_ENTRY, SSR_SERVER_ENTRY, TEMPLATE_PATH, @@ -45,6 +48,7 @@ import { socialLinksVMPlugin } from './runtimeModule/socialLinks'; import type { FactoryContext } from './runtimeModule/types'; import { rsbuildPluginCSR } from './ssg/rsbuildPluginCSR'; import { rsbuildPluginSSG } from './ssg/rsbuildPluginSSG'; +import { rsbuildPluginSSGMD } from './ssg-md/rsbuildPluginSSGMD'; import { detectReactVersion, resolveReactAlias, @@ -127,6 +131,7 @@ async function createInternalBuildConfig( detectCustomIcon(CUSTOM_THEME_DIR), resolveReactAlias(reactVersion, false), enableSSG ? resolveReactAlias(reactVersion, true) : Promise.resolve({}), + resolveReactAlias(reactVersion, true), resolveReactRouterDomAlias(), ]); @@ -182,7 +187,7 @@ async function createInternalBuildConfig( ...siteDataVMPlugin(context), }, }), - enableSSG + enableSSG && config.ssg ? rsbuildPluginSSG({ routeService, config, @@ -191,6 +196,12 @@ async function createInternalBuildConfig( routeService, config, }), + enableSSG && config.llms + ? rsbuildPluginSSGMD({ + routeService, + config, + }) + : null, ], server: { port: @@ -299,8 +310,9 @@ async function createInternalBuildConfig( }, }, tools: { - bundlerChain(chain, { CHAIN_ID, target }) { - const isServer = target === 'node'; + bundlerChain(chain, { CHAIN_ID, environment }) { + const isSsg = environment.name === 'node'; + const isSsgMd = environment.name === 'node_md'; const jsModuleRule = chain.module.rule(CHAIN_ID.RULE.JS); const swcLoaderOptions = jsModuleRule @@ -328,6 +340,7 @@ async function createInternalBuildConfig( docDirectory: userDocRoot, routeService, pluginDriver, + isSsgMd, }) .end(); @@ -356,12 +369,20 @@ async function createInternalBuildConfig( .test(/\.rspress[\\/]runtime[\\/]virtual-global-styles/) .merge({ sideEffects: true }); - if (isServer) { + if (isSsg) { chain.output.filename( `${NODE_SSG_BUNDLE_FOLDER}/${NODE_SSG_BUNDLE_NAME}`, ); chain.output.chunkFilename(`${NODE_SSG_BUNDLE_FOLDER}/[name].cjs`); - chain.target('async-node'); // For MF support + // For perf + chain.output.set('asyncChunks', false); + } else if (isSsgMd) { + chain.output.filename( + `${NODE_SSG_MD_BUNDLE_FOLDER}/${NODE_SSG_MD_BUNDLE_NAME}`, + ); + chain.output.chunkFilename(`${NODE_SSG_MD_BUNDLE_FOLDER}/[name].cjs`); + // For perf + chain.output.set('asyncChunks', false); } }, }, @@ -384,6 +405,7 @@ async function createInternalBuildConfig( ], define: { 'process.env.__SSR__': JSON.stringify(false), + 'process.env.__SSR_MD__': JSON.stringify(false), }, }, output: { @@ -393,7 +415,7 @@ async function createInternalBuildConfig( }, }, }, - ...(enableSSG + ...(enableSSG && config.ssg ? { node: { resolve: { @@ -408,6 +430,46 @@ async function createInternalBuildConfig( }, define: { 'process.env.__SSR__': JSON.stringify(true), + 'process.env.__SSR_MD__': JSON.stringify(false), + }, + }, + performance: { + printFileSize: { + compressed: true, + }, + }, + output: { + emitAssets: false, + target: 'node', + minify: false, + }, + }, + } + : {}), + ...(enableSSG && config.llms + ? { + node_md: { + resolve: { + alias: { + ...reactSSRAlias, + ...reactRouterDomAlias, + }, + }, + tools: { + rspack: { + optimization: { + moduleIds: 'named', + chunkIds: 'named', + }, + }, + }, + source: { + entry: { + index: SSG_MD_SERVER_ENTRY, + }, + define: { + 'process.env.__SSR__': JSON.stringify(true), + 'process.env.__SSR_MD__': JSON.stringify(true), }, }, performance: { diff --git a/packages/core/src/node/mdx/loader.ts b/packages/core/src/node/mdx/loader.ts index 2095a51ae..0f8e9824d 100644 --- a/packages/core/src/node/mdx/loader.ts +++ b/packages/core/src/node/mdx/loader.ts @@ -12,13 +12,23 @@ export default async function mdxLoader( const options = this.getOptions(); const filepath = this.resourcePath; - const { config, docDirectory, routeService, pluginDriver } = options; + const { + config, + docDirectory, + routeService, + pluginDriver, + isSsgMd = false, + } = options; const crossCompilerCache = config?.markdown?.crossCompilerCache ?? true; try { // TODO wrong but good enough for now (example: "build --watch") - if (crossCompilerCache && process.env.NODE_ENV === 'production') { + if ( + crossCompilerCache && + process.env.NODE_ENV === 'production' && + !isSsgMd + ) { const compileResult = await compileWithCrossCompilerCache({ source, filepath, @@ -37,6 +47,7 @@ export default async function mdxLoader( pluginDriver, routeService, addDependency: this.addDependency, + isSsgMd, }); callback(null, compileResult); } diff --git a/packages/core/src/node/mdx/options.ts b/packages/core/src/node/mdx/options.ts index 6b8a605a6..4dcfdad55 100644 --- a/packages/core/src/node/mdx/options.ts +++ b/packages/core/src/node/mdx/options.ts @@ -9,6 +9,7 @@ import remarkGFM from 'remark-gfm'; import type { PluggableList } from 'unified'; import type { PluginDriver } from '../PluginDriver'; import type { RouteService } from '../route/RouteService'; +import { remarkSplitMdx } from '../ssg-md/remarkSplitMdx'; import { rehypeCodeMeta } from './rehypePlugins/codeMeta'; import { rehypeHeaderAnchor } from './rehypePlugins/headerAnchor'; import { createRehypeShikiOptions } from './rehypePlugins/shiki'; @@ -26,6 +27,7 @@ export async function createMDXOptions(options: { routeService: RouteService | null; pluginDriver: PluginDriver | null; addDependency?: Rspack.LoaderContext['addDependency']; + isSsgMd?: boolean; }): Promise { const { docDirectory, @@ -34,6 +36,7 @@ export async function createMDXOptions(options: { filepath, pluginDriver, addDependency, + isSsgMd = false, } = options; const remarkLinkOptions = config?.markdown?.link; const format = path.extname(filepath).slice(1) as 'mdx' | 'md'; @@ -64,19 +67,36 @@ export async function createMDXOptions(options: { remarkPlugins: [ remarkGFM, remarkToc, - remarkContainerSyntax, + !isSsgMd && remarkContainerSyntax, [remarkFileCodeBlock, { filepath, addDependency }], [ remarkLink, - { - // we do cleanUrls in runtime side - cleanUrls: false, - root: docDirectory, - routeService, - remarkLinkOptions, - }, + isSsgMd + ? { + cleanUrls: '.md', + root: docDirectory, + routeService, + remarkLinkOptions: { + checkDeadLinks: false, + autoPrefix: true, + }, + __base: config?.base, + } + : { + // we do cleanUrls in runtime side + cleanUrls: false, + root: docDirectory, + routeService, + remarkLinkOptions, + }, ], remarkImage, + isSsgMd && [ + remarkSplitMdx, + typeof config?.llms === 'object' + ? config.llms.remarkSplitMdxOptions + : undefined, + ], globalComponents.length && [ remarkBuiltin, { @@ -86,29 +106,33 @@ export async function createMDXOptions(options: { ...remarkPluginsFromConfig, ...remarkPluginsFromPlugins, ].filter(Boolean) as PluggableList, - rehypePlugins: [ - rehypeHeaderAnchor, - ...(format === 'md' - ? [ - // make the code node compatible with `rehype-raw` which will remove `node.data` unconditionally - rehypeCodeMeta, - // why adding rehype-raw? - // This is what permits to embed HTML elements with format 'md' - // See https://github.com/facebook/docusaurus/pull/8960 - // See https://github.com/mdx-js/mdx/pull/2295#issuecomment-1540085960 - [rehypeRaw, { passThrough: nodeTypes }], - ] - : []), - [rehypeShiki, createRehypeShikiOptions(showLineNumbers, shiki)], - [ - rehypeExternalLinks, - { - target: '_blank', - rel: 'noopener noreferrer', - }, - ], - ...rehypePluginsFromConfig, - ...rehypePluginsFromPlugins, - ] as PluggableList, + ...(isSsgMd + ? {} + : { + rehypePlugins: [ + rehypeHeaderAnchor, + ...(format === 'md' + ? [ + // make the code node compatible with `rehype-raw` which will remove `node.data` unconditionally + rehypeCodeMeta, + // why adding rehype-raw? + // This is what permits to embed HTML elements with format 'md' + // See https://github.com/facebook/docusaurus/pull/8960 + // See https://github.com/mdx-js/mdx/pull/2295#issuecomment-1540085960 + [rehypeRaw, { passThrough: nodeTypes }], + ] + : []), + [rehypeShiki, createRehypeShikiOptions(showLineNumbers, shiki)], + [ + rehypeExternalLinks, + { + target: '_blank', + rel: 'noopener noreferrer', + }, + ], + ...rehypePluginsFromConfig, + ...rehypePluginsFromPlugins, + ] as PluggableList, + }), }; } diff --git a/packages/core/src/node/mdx/processor.ts b/packages/core/src/node/mdx/processor.ts index 54e47bacf..d1dd1bbbb 100644 --- a/packages/core/src/node/mdx/processor.ts +++ b/packages/core/src/node/mdx/processor.ts @@ -26,6 +26,11 @@ interface CompileOptions { pluginDriver: PluginDriver | null; addDependency?: Rspack.LoaderContext['addDependency']; // remarkFileCodeBlock hmr + + /** + * @default false + */ + isSsgMd?: boolean; } async function compile(options: CompileOptions): Promise { @@ -37,6 +42,7 @@ async function compile(options: CompileOptions): Promise { routeService, pluginDriver, addDependency, + isSsgMd = false, } = options; const mdxOptions = await createMDXOptions({ @@ -46,6 +52,7 @@ async function compile(options: CompileOptions): Promise { pluginDriver, routeService, addDependency, + isSsgMd, }); // Separate frontmatter and content in MDX source const { frontmatter, emptyLinesSource } = loadFrontMatter( diff --git a/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts b/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts index 4e1bde76f..9e548fe59 100644 --- a/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts +++ b/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts @@ -81,6 +81,10 @@ const createContainer = ( return { type: 'containerDirective', name: type, + attributes: { + type, + title, + }, data: { hName: rootHName, hProperties: { diff --git a/packages/core/src/node/mdx/remarkPlugins/link.ts b/packages/core/src/node/mdx/remarkPlugins/link.ts index cdfc91273..2b3000c43 100644 --- a/packages/core/src/node/mdx/remarkPlugins/link.ts +++ b/packages/core/src/node/mdx/remarkPlugins/link.ts @@ -151,17 +151,21 @@ function normalizeLink( if (typeof cleanUrls === 'boolean') { url = normalizeHref(url, cleanUrls); + // preserve dead links + if (!routeService.isExistRoute(url)) { + deadLinks.set(nodeUrl, url); + return nodeUrl; + } } else { url = normalizeHref(url, false); + // preserve dead links + if (!routeService.isExistRoute(url)) { + deadLinks.set(nodeUrl, url); + return nodeUrl; + } url = url.replace(/\.html$/, cleanUrls); } - // preserve dead links - if (!routeService.isExistRoute(url)) { - deadLinks.set(nodeUrl, url); - return nodeUrl; - } - if (hash) { url += `#${hash}`; } diff --git a/packages/core/src/node/mdx/types.ts b/packages/core/src/node/mdx/types.ts index e6fa84aeb..6f2aa6683 100644 --- a/packages/core/src/node/mdx/types.ts +++ b/packages/core/src/node/mdx/types.ts @@ -10,6 +10,10 @@ export interface MdxLoaderOptions { checkDeadLinks: boolean; routeService: RouteService; pluginDriver: PluginDriver; + /** + * @default false + */ + isSsgMd?: boolean; } export interface PageMeta { diff --git a/packages/core/src/node/route/RouteService.ts b/packages/core/src/node/route/RouteService.ts index ac108d2ad..7927034cd 100644 --- a/packages/core/src/node/route/RouteService.ts +++ b/packages/core/src/node/route/RouteService.ts @@ -319,4 +319,16 @@ ${routeMeta getRoutePageByRoutePath(routePath: string) { return this.routeData.get(routePath); } + + getDocsDir(): string { + return this.#scanDir; + } + + getLangs() { + return this.#langs; + } + + getDefaultLang() { + return this.#defaultLang; + } } diff --git a/packages/core/src/node/runtimeModule/siteData/createSiteData.ts b/packages/core/src/node/runtimeModule/siteData/createSiteData.ts index aea4015b7..f515bc12f 100644 --- a/packages/core/src/node/runtimeModule/siteData/createSiteData.ts +++ b/packages/core/src/node/runtimeModule/siteData/createSiteData.ts @@ -1,9 +1,4 @@ -import { - addLeadingSlash, - addTrailingSlash, - type SiteData, - type UserConfig, -} from '@rspress/shared'; +import type { SiteData, UserConfig } from '@rspress/shared'; import { getIconUrlPath } from '@rspress/shared/node-utils'; import { normalizeThemeConfig } from './normalizeThemeConfig'; @@ -18,13 +13,8 @@ export async function createSiteData(userConfig: UserConfig): Promise<{ tempSearchObj.searchHooks = undefined; } - const { base } = userConfig; - - // TODO: base can be normalized in compile time side in an earlier stage - const normalizedBase = addTrailingSlash(addLeadingSlash(base ?? '/')); - const siteData: Omit = { - base: normalizedBase, + base: userConfig.base ?? '/', title: userConfig?.title || '', description: userConfig?.description || '', icon: getIconUrlPath(userConfig?.icon) || '', diff --git a/packages/core/src/node/ssg-md/__snapshots__/remarkSplitMdx.test.ts.snap b/packages/core/src/node/ssg-md/__snapshots__/remarkSplitMdx.test.ts.snap new file mode 100644 index 000000000..f3cf1506a --- /dev/null +++ b/packages/core/src/node/ssg-md/__snapshots__/remarkSplitMdx.test.ts.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`remarkWrapMarkdown with filters > should lynx 1`] = ` +"/*@jsxRuntime automatic*/ +/*@jsxImportSource react*/ +import {Required, Deprecated, AndroidOnly, IOSOnly, Go, LegacyCompatTable} from '@lynx'; +import {PropsTable, EventsTable, MethodsTable} from '@theme/reference'; +import {InputProps, InputEvents, InputMethods} from '@theme/reference/generated/input'; +function _createMdxContent(props) { + return <>{"***\\n"}{"\\n"}{"## context: 'XElement'\\n"}{"\\n"}{"# \`\`\\n"}{"\\n"}{"\\n"}{"\`\` 用于创建交互式输入控件,允许用户输入和编辑单行文本。\\n"}{"\\n"}{":::info\\n"}{"\\n"}{"此功能需要客户端添加额外依赖才能被使用,集成方式请请参考 [More Elements](/guide/start/integrate-with-existing-apps).\\n"}{"\\n"}{":::\\n"}{"\\n"}{"## 使用指南\\n"}{"\\n"}{"### 基本用法\\n"}{"\\n"}{"下面是一个基本的 \`\` 组件用法示例:\\n"}{"\\n"}{"\\n"}{"### 避让键盘\\n"}{"\\n"}{"\`\`不会自动的避让键盘,但可以通过监听键盘事件获取相应的高度,并以此改变\`\`的位置,来进行避让:\\n"}{"\\n"}{"\\n"}{"## 属性\\n"}{"\\n"}{"属性名和属性值用于描述元件的行为和外观。\\n"}{"\\n"}{"### \`placeholder\`\\n"}{"\\n"}{"\`\`\`ts\\n// DefaultValue: undefined\\nplaceholder?: string\\n\`\`\`\\n"}{"\\n"}{"占位文字\\n"}{"\\n"}{"### \`confirm-type\`\\n"}{"\\n"}{"\`\`\`ts\\n// DefaultValue: undefined\\n'confirm-type'?: 'send' | 'search' | 'go' | 'done' | 'next';\\n\`\`\`\\n"}{"\\n"}{"指定输入法回车键的表现形式\\n"}{"\\n"}{"### \`maxlength\`\\n"}{"\\n"}{"\`\`\`ts\\n// DefaultValue: 140\\nmaxlength?: number;\\n\`\`\`\\n"}{"\\n"}{"输入框最大字符数量限制\\n"}{"\\n"}{"### \`readonly\`\\n"}{"\\n"}{"\`\`\`ts\\n// DefaultValue: false\\nreadonly?: boolean;\\n\`\`\`\\n"}{"\\n"}{"是否允许输入框可交互,不影响对其进行组件方法调用\\n"}{"\\n"}{"### \`show-soft-input-on-focus\`\\n"}{"\\n"}{"\`\`\`ts\\n// DefaultValue: true\\n'show-soft-input-on-focus'?: boolean;\\n\`\`\`\\n"}{"\\n"}{"聚焦时是否允许拉起系统软键盘\\n"}{"\\n"}{"### \`type\`\\n"}{"\\n"}{"\`\`\`ts\\n// DefaultValue: 'text'\\ntype?: 'text' | 'number' | 'digit' | 'password' | 'tel' | 'email';\\n\`\`\`\\n"}{"\\n"}{"指定键盘的类型\\n"}{"\\n"}{"- text\\\\tel\\\\email: 全符号\\n- number: 0-9 +-.\\n- digit: 0-9 .\\n- password: 内容变为 \\\\* 号,全符号\\n"}{"\\n"}{"::: info\\n不同的输入法可能会有不同的视觉表现\\n:::\\n"}{"\\n"}{"### \`input-filter\`\\n"}{"\\n"}{"\`\`\`ts\\n// DefaultValue: undefined\\n'input-filter'?: string;\\n\`\`\`\\n"}{"\\n"}{"指定单个字符的过滤条件,用正则表达式描述\\n"}{"\\n"}{"## 事件\\n"}{"\\n"}{"前端可以在元件上设置[事件处理器属性](../../../guide/interaction/event-handling/event-propagation.mdx#事件处理器属性)来监听元件的运行时行为。\\n"}{"\\n"}{"### \`bindfocus\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputFocusEvent {\\n value: string;\\n}\\n bindfocus?: (e: BaseEvent<'bindfocus', InputFocusEvent>) => void;\\n\`\`\`\\n"}{"\\n"}{"输入框聚焦时的回调\\n"}{"\\n"}{"### \`bindblur\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputBlurEvent {\\n value: string;\\n}\\nbindblur?: (e: BaseEvent<'bindblur', InputBlurEvent>) => void;\\n\`\`\`\\n"}{"\\n"}{"输入框失去焦点时的回调\\n"}{"\\n"}{"### \`bindconfirm\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputConfirmEvent {\\n value: string;\\n}\\nbindconfirm?: (e: BaseEvent<'bindconfirm', InputConfirmEvent>) => void;\\n\`\`\`\\n"}{"\\n"}{"点击软键盘回车时的回调\\n"}{"\\n"}{"### \`bindinput\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputInputEvent {\\n value: string;\\n selectionStart: number;\\n selectionEnd: number;\\n isComposing?: boolean;\\n}\\nbindinput?: (e: BaseEvent<'bindinput', InputInputEvent>) => void;\\n\`\`\`\\n"}{"\\n"}{"输入框内容变化的回调\\n"}{"\\n"}{"### \`bindselection\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputSelectionEvent {\\n selectionStart: number;\\n selectionEnd: number;\\n}\\nbindselection?: (e: BaseEvent<'bindselection', InputSelectionEvent>) => void;\\n\`\`\`\\n"}{"\\n"}{"输入框光标变化的回调\\n"}{"\\n"}{"## 方法\\n"}{"\\n"}{"前端可以通过 [SelectorQuery](/api/lynx-api/nodes-ref/nodes-ref-invoke.html) API 执行元件的方法。\\n"}{"\\n"}{"### \`focus\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputFocusMethod extends BaseMethod {\\n method: 'focus';\\n}\\n\`\`\`\\n"}{"\\n"}{"控制输入框主动聚焦\\n"}{"\\n"}{"### \`blur\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputBlurMethod extends BaseMethod {\\n method: 'blur';\\n}\\n\`\`\`\\n"}{"\\n"}{"控制输入框主动取消聚焦\\n"}{"\\n"}{"### \`getValue\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputGetValueMethod extends BaseMethod {\\n method: 'getValue';\\n success?: Callback<{\\n value: string;\\n selectionStart: number;\\n selectionEnd: number;\\n isComposing: boolean;\\n }>;\\n}\\n\`\`\`\\n"}{"\\n"}{"获取输入框的内容\\n"}{"\\n"}{"### \`setValue\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputSetValueMethod extends BaseMethod {\\n method: 'setValue';\\n params: {\\n value: string;\\n };\\n}\\n\`\`\`\\n"}{"\\n"}{"主动设置输入框的内容\\n"}{"\\n"}{"### \`setSelectionRange\`\\n"}{"\\n"}{"\`\`\`ts\\nexport interface InputSetSelectionRangeMethod extends BaseMethod {\\n method: 'setSelectionRange';\\n params: {\\n selectionStart: number;\\n selectionEnd: number;\\n };\\n}\\n\`\`\`\\n"}{"\\n"}{"主动设置输入框的光标\\n"}{"\\n"}{"## 兼容性\\n"}{"\\n"}{"\\n"}; +} +export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); +} +" +`; diff --git a/packages/core/src/node/ssg-md/fixtures/test.mdx b/packages/core/src/node/ssg-md/fixtures/test.mdx new file mode 100644 index 000000000..075af7c8c --- /dev/null +++ b/packages/core/src/node/ssg-md/fixtures/test.mdx @@ -0,0 +1,262 @@ +--- +context: 'XElement' +--- + +# `` + +import { + Required, + Deprecated, + AndroidOnly, + IOSOnly, + Go, + LegacyCompatTable, +} from '@lynx'; +import { PropsTable, EventsTable, MethodsTable } from '@theme/reference'; +import { + InputProps, + InputEvents, + InputMethods, +} from '@theme/reference/generated/input'; + +`` 用于创建交互式输入控件,允许用户输入和编辑单行文本。 + +:::info + +此功能需要客户端添加额外依赖才能被使用,集成方式请请参考 [More Elements](/guide/start/integrate-with-existing-apps). + +::: + +## 使用指南 + +### 基本用法 + +下面是一个基本的 `` 组件用法示例: + + + +### 避让键盘 + +``不会自动的避让键盘,但可以通过监听键盘事件获取相应的高度,并以此改变``的位置,来进行避让: + + + +## 属性 + +属性名和属性值用于描述元件的行为和外观。 + +### `placeholder` + +```ts +// DefaultValue: undefined +placeholder?: string +``` + +占位文字 + +### `confirm-type` + +```ts +// DefaultValue: undefined +'confirm-type'?: 'send' | 'search' | 'go' | 'done' | 'next'; +``` + +指定输入法回车键的表现形式 + +### `maxlength` + +```ts +// DefaultValue: 140 +maxlength?: number; +``` + +输入框最大字符数量限制 + +### `readonly` + +```ts +// DefaultValue: false +readonly?: boolean; +``` + +是否允许输入框可交互,不影响对其进行组件方法调用 + +### `show-soft-input-on-focus` + +```ts +// DefaultValue: true +'show-soft-input-on-focus'?: boolean; +``` + +聚焦时是否允许拉起系统软键盘 + +### `type` + +```ts +// DefaultValue: 'text' +type?: 'text' | 'number' | 'digit' | 'password' | 'tel' | 'email'; +``` + +指定键盘的类型 + +- text\tel\email: 全符号 +- number: 0-9 +-. +- digit: 0-9 . +- password: 内容变为 \* 号,全符号 + +::: info +不同的输入法可能会有不同的视觉表现 +::: + +### `input-filter` + +```ts +// DefaultValue: undefined +'input-filter'?: string; +``` + +指定单个字符的过滤条件,用正则表达式描述 + +## 事件 + +前端可以在元件上设置[事件处理器属性](../../../guide/interaction/event-handling/event-propagation.mdx#事件处理器属性)来监听元件的运行时行为。 + +### `bindfocus` + +```ts +export interface InputFocusEvent { + value: string; +} + bindfocus?: (e: BaseEvent<'bindfocus', InputFocusEvent>) => void; +``` + +输入框聚焦时的回调 + +### `bindblur` + +```ts +export interface InputBlurEvent { + value: string; +} +bindblur?: (e: BaseEvent<'bindblur', InputBlurEvent>) => void; +``` + +输入框失去焦点时的回调 + +### `bindconfirm` + +```ts +export interface InputConfirmEvent { + value: string; +} +bindconfirm?: (e: BaseEvent<'bindconfirm', InputConfirmEvent>) => void; +``` + +点击软键盘回车时的回调 + +### `bindinput` + +```ts +export interface InputInputEvent { + value: string; + selectionStart: number; + selectionEnd: number; + isComposing?: boolean; +} +bindinput?: (e: BaseEvent<'bindinput', InputInputEvent>) => void; +``` + +输入框内容变化的回调 + +### `bindselection` + +```ts +export interface InputSelectionEvent { + selectionStart: number; + selectionEnd: number; +} +bindselection?: (e: BaseEvent<'bindselection', InputSelectionEvent>) => void; +``` + +输入框光标变化的回调 + +## 方法 + +前端可以通过 [SelectorQuery](/api/lynx-api/nodes-ref/nodes-ref-invoke.html) API 执行元件的方法。 + +### `focus` + +```ts +export interface InputFocusMethod extends BaseMethod { + method: 'focus'; +} +``` + +控制输入框主动聚焦 + +### `blur` + +```ts +export interface InputBlurMethod extends BaseMethod { + method: 'blur'; +} +``` + +控制输入框主动取消聚焦 + +### `getValue` + +```ts +export interface InputGetValueMethod extends BaseMethod { + method: 'getValue'; + success?: Callback<{ + value: string; + selectionStart: number; + selectionEnd: number; + isComposing: boolean; + }>; +} +``` + +获取输入框的内容 + +### `setValue` + +```ts +export interface InputSetValueMethod extends BaseMethod { + method: 'setValue'; + params: { + value: string; + }; +} +``` + +主动设置输入框的内容 + +### `setSelectionRange` + +```ts +export interface InputSetSelectionRangeMethod extends BaseMethod { + method: 'setSelectionRange'; + params: { + selectionStart: number; + selectionEnd: number; + }; +} +``` + +主动设置输入框的光标 + +## 兼容性 + + diff --git a/packages/core/src/node/ssg-md/llms/emitLlmsTxt.ts b/packages/core/src/node/ssg-md/llms/emitLlmsTxt.ts new file mode 100644 index 000000000..f4009c274 --- /dev/null +++ b/packages/core/src/node/ssg-md/llms/emitLlmsTxt.ts @@ -0,0 +1,176 @@ +import { matchPath } from '@rspress/runtime/server'; +import { + getSidebarDataGroup, + type Nav, + type NavItemWithLink, + type Sidebar, + type SidebarDivider, + type SidebarGroup, + type SidebarItem, + type SidebarSectionHeader, + type UserConfig, +} from '@rspress/shared'; +import type { RouteService } from '../../route/RouteService'; +import { generateLlmsFullTxt, generateLlmsTxt } from './llmsTxt'; + +export async function emitLlmsTxt( + config: UserConfig, + routeService: RouteService, + emitAsset: (assetName: string, content: string | Buffer) => void, + mdContents: Map, +) { + const base = config.base ?? '/'; + const locales = config.themeConfig?.locales; + const isMultiLang = locales && locales.length > 0; + + const nav = ( + isMultiLang + ? locales + .filter(i => Boolean(i.nav)) + .map(i => ({ nav: i.nav, lang: i.lang })) + : [{ nav: config.themeConfig?.nav, lang: config.lang ?? '' }] + ) as { + nav: Nav; + lang: string; + }[]; + + const sidebars = isMultiLang + ? locales.map(i => i.sidebar) + : [config.themeConfig?.sidebar]; + + const sidebar = sidebars.reduce((prev: Sidebar, curr) => { + Object.assign(prev, curr); + return prev; + }, {} as Sidebar); + + const noVersionNav: (NavItemWithLink & { lang: string })[] = Array.isArray( + nav, + ) + ? ( + nav + .map(i => { + const nav = ((i.nav as any).default || i.nav) as NavItemWithLink[]; + const lang = i.lang; + return nav.map(i => { + return { + ...i, + lang, + }; + }); + }) + .flat() as unknown as (NavItemWithLink & { lang: string })[] + ).filter(i => i.activeMatch || i.link) + : []; + + const defaultLang = routeService.getDefaultLang(); + await Promise.all( + routeService.getLangs().map(async lang => { + const navList = noVersionNav.filter(i => i.lang === lang); + const routeGroups: string[][] = new Array(navList.length) + .fill(0) + .map(() => []); + const others: string[] = []; + + routeService.getRoutes().forEach(routeMeta => { + if (routeMeta.lang !== lang) { + return; + } + const { routePath } = routeMeta; + + for (let i = 0; i < routeGroups.length; i++) { + const routeGroup = routeGroups[i]; + const navItem = navList[i]; + if ( + lang === navItem.lang && + new RegExp(navItem.activeMatch ?? navItem.link).test(routePath) + ) { + routeGroup.push(routePath); + return; + } + } + others.push(routePath); + }); + + for (const routeGroup of routeGroups) { + organizeBySidebar(sidebar, routeGroup); + } + + const llmsTxtContent = await generateLlmsTxt( + routeGroups, + others, + navList, + config.title, + config.description, + config.base!, + routeService, + ); + + const llmsFullTxtContent = generateLlmsFullTxt( + routeGroups, + navList, + others, + base, + mdContents, + ); + + const prefix = defaultLang === lang ? '' : `${lang}/`; + + emitAsset(`${prefix}llms.txt`, llmsTxtContent); + emitAsset(`${prefix}llms-full.txt`, llmsFullTxtContent); + }), + ); +} + +function flatSidebar( + sidebar: ( + | SidebarGroup + | SidebarItem + | SidebarDivider + | SidebarSectionHeader + | string + )[], +): string[] { + if (!sidebar) { + return []; + } + return sidebar + .flatMap(i => { + if (typeof i === 'string') { + return i; + } + if ('link' in i && typeof i.link === 'string') { + return [i.link, ...flatSidebar((i as any)?.items ?? [])]; + } + if ('items' in i && Array.isArray(i.items)) { + return flatSidebar(i.items); + } + return undefined; + }) + .filter(Boolean) as string[]; +} + +function organizeBySidebar(sidebar: Sidebar, routes: string[]) { + if (routes.length === 0) { + return; + } + const route = routes[0]; + const currSidebar = getSidebarDataGroup(sidebar as any, route); + + if (currSidebar.length === 0) { + return; + } + const orderList = flatSidebar(currSidebar); + + routes.sort((a, b) => { + let aIndex = orderList.findIndex(order => matchPath(order, a)); + // if not in sidebar, put it to last + if (aIndex === -1) { + aIndex = Number.MAX_SAFE_INTEGER; + } + let bIndex = orderList.findIndex(order => matchPath(order, b)); + if (bIndex === -1) { + bIndex = Number.MAX_SAFE_INTEGER; + } + return aIndex - bIndex; + }); +} diff --git a/packages/core/src/node/ssg-md/llms/llmsTxt.ts b/packages/core/src/node/ssg-md/llms/llmsTxt.ts new file mode 100644 index 000000000..db8425180 --- /dev/null +++ b/packages/core/src/node/ssg-md/llms/llmsTxt.ts @@ -0,0 +1,120 @@ +import { type NavItemWithLink, normalizeHref, withBase } from '@rspress/shared'; +import { extractInfoFromFrontmatterWithAbsolutePath } from '../../auto-nav-sidebar/utils'; +import type { RouteService } from '../../route/RouteService'; + +function routePathToMdPath(routePath: string, base: string): string { + let url: string = routePath; + url = normalizeHref(url, false); + url = url.replace(/\.html$/, '.md'); + return withBase(url, base); +} + +async function generateLlmsTxt( + routeGroups: string[][], + others: string[], + navList: { text: string }[], + title: string | undefined, + description: string | undefined, + base: string, + routeService: RouteService, +): Promise { + const lines: string[] = []; + + const summary = `# ${title}${description ? `\n\n> ${description}` : ''}`; + + async function genH2Part( + nav: { text: string }, + routes: string[], + ): Promise { + const lines: string[] = []; + const { text } = nav; + if (routes.length === 0) { + return lines; + } + + const routeLines: string[] = ( + await Promise.all( + routes.map(async route => { + const routePage = routeService.getRoutePageByRoutePath(route)!; + const { lang, routePath, absolutePath } = routePage.routeMeta; + if (routePath === '/' || routePath === `/${lang}/`) { + return; + } + + const { title, description } = + await extractInfoFromFrontmatterWithAbsolutePath( + absolutePath, + routeService.getDocsDir(), + ); + + return `- [${title}](${routePathToMdPath(routePath, base)})${description ? `: ${description}` : ''}`; + }), + ) + ).filter((i): i is string => Boolean(i)); + if (routeLines.length > 0) { + const title = text; + lines.push(`\n## ${title}\n`); + lines.push(...routeLines); + } + + return lines; + } + + const h2Parts = await Promise.all( + navList.map(async (nav, i) => { + const routes = routeGroups[i]; + const h2Part = await genH2Part(nav, routes); + return h2Part; + }), + ); + lines.push(...h2Parts.flat()); + + // handle others + const otherLines = await genH2Part( + { + text: 'Others', + }, + others, + ); + lines.push(...otherLines); + const llmsTxt = `${summary}\n${lines.join('\n')}`; + + return llmsTxt; +} + +function generateLlmsFullTxt( + routeGroups: string[][], + navList: (NavItemWithLink & { lang: string })[], + others: string[], + base: string, + mdContents: Map, +): string { + const lines: string[] = []; + // generate llms.txt with obj + for (let i = 0; i < navList.length; i++) { + const routeGroup = routeGroups[i]; + if (routeGroup.length === 0) { + continue; + } + for (const routePath of routeGroup) { + lines.push(`--- +url: ${routePathToMdPath(routePath, base)} +--- +`); + lines.push(mdContents.get(routePath) ?? ''); + lines.push('\n'); + } + } + for (const routePath of others) { + lines.push(`--- +url: ${routePathToMdPath(routePath, base)} +--- +`); + lines.push(mdContents.get(routePath) ?? ''); + lines.push('\n'); + } + + return lines.join('\n'); +} + +export { generateLlmsFullTxt, generateLlmsTxt, routePathToMdPath }; diff --git a/packages/core/src/node/ssg-md/react/index.ts b/packages/core/src/node/ssg-md/react/index.ts new file mode 100644 index 000000000..c09dae2f3 --- /dev/null +++ b/packages/core/src/node/ssg-md/react/index.ts @@ -0,0 +1 @@ +export { renderToMarkdownString } from './render'; diff --git a/packages/core/src/node/ssg-md/react/reconciler.ts b/packages/core/src/node/ssg-md/react/reconciler.ts new file mode 100644 index 000000000..d6f2caffb --- /dev/null +++ b/packages/core/src/node/ssg-md/react/reconciler.ts @@ -0,0 +1,347 @@ +import { createContext } from 'react'; +import createReconciler, { type ReactContext } from 'react-reconciler'; +import { + DefaultEventPriority, + NoEventPriority, +} from 'react-reconciler/constants.js'; + +// Define types +type ElementNames = string; +type Props = Record; +type HostContext = { + isInsideText: boolean; +}; + +// Text node class +export class TextNode { + public text: string; + public parent?: MarkdownNode; + + constructor(text: string) { + this.text = text; + } + + setText(text: string): void { + this.text = text; + } +} + +// Markdown node class +export class MarkdownNode { + public type: string; + public props: Record; + public children: (MarkdownNode | TextNode)[]; + public parent?: MarkdownNode; + + constructor(type: string, props: Record = {}) { + this.type = type; + this.props = props; + this.children = []; + } + + appendChild(child: MarkdownNode | TextNode): void { + this.children.push(child); + if ('parent' in child) { + child.parent = this; + } + } + + removeChild(child: MarkdownNode | TextNode): void { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + } + } + + insertBefore( + child: MarkdownNode | TextNode, + beforeChild: MarkdownNode | TextNode, + ): void { + const index = this.children.indexOf(beforeChild); + if (index !== -1) { + this.children.splice(index, 0, child); + } else { + this.appendChild(child); + } + if ('parent' in child) { + child.parent = this; + } + } +} + +// Current update priority +let currentUpdatePriority = NoEventPriority; + +// Create React Reconciler config +const hostConfig: createReconciler.HostConfig< + string, + Props, + MarkdownNode, + MarkdownNode, + TextNode, + MarkdownNode, + unknown, + unknown, + unknown, + HostContext, + unknown, + unknown, + unknown, + unknown +> = { + // Basic capabilities + supportsMutation: true, + supportsPersistence: false, + supportsHydration: false, + isPrimaryRenderer: false, + + // Get root host context + getRootHostContext(): HostContext { + return { isInsideText: false }; + }, + + // Get child host context + getChildHostContext( + parentHostContext: HostContext, + type: string, + ): HostContext { + const previousIsInsideText = parentHostContext.isInsideText; + // Determine whether this should be treated as text + const isInsideText = + type === 'text' || type === 'span' || previousIsInsideText; + + if (previousIsInsideText === isInsideText) { + return parentHostContext; + } + + return { isInsideText }; + }, + + // Create instance + createInstance(type: string, props: Props): MarkdownNode { + return new MarkdownNode(type, props); + }, + + // Create text instance + createTextInstance(text: string): TextNode { + return new TextNode(text); + }, + + // Determine whether to set text content directly + shouldSetTextContent(_type: string, _props: Props): boolean { + // For simple text content, return false so React creates a text node + return false; + }, + + // Append child + appendChild(parent: MarkdownNode, child: MarkdownNode | TextNode): void { + parent.appendChild(child); + }, + + // Insert child before another + insertBefore( + parent: MarkdownNode, + child: MarkdownNode | TextNode, + beforeChild: MarkdownNode | TextNode, + ): void { + parent.insertBefore(child, beforeChild); + }, + + // Remove child + removeChild(parent: MarkdownNode, child: MarkdownNode | TextNode): void { + parent.removeChild(child); + }, + + // Commit text update + commitTextUpdate( + textInstance: TextNode, + _oldText: string, + newText: string, + ): void { + textInstance.setText(newText); + }, + + // Commit update + commitUpdate( + instance: MarkdownNode, + _type: string, + _oldProps: Props, + newProps: Props, + _internalHandle: unknown, + ): void { + // Update instance props with new props + instance.props = newProps; + }, + + // Finalize initial children + finalizeInitialChildren(): boolean { + // Return false to prevent commitMount from being called + return false; + }, + + // Commit mount - explicitly no-op to prevent lifecycle methods + // This prevents componentDidMount from being called (SSR behavior) + commitMount(): void { + // No-op: In SSR/static rendering, we don't want lifecycle methods to run + }, + + // Clear container + clearContainer(container: MarkdownNode): void { + container.children = []; + }, + + // Handle update priority + setCurrentUpdatePriority(newPriority: number): void { + currentUpdatePriority = newPriority; + }, + + getCurrentUpdatePriority(): number { + return currentUpdatePriority; + }, + + resolveUpdatePriority(): number { + if (currentUpdatePriority !== NoEventPriority) { + return currentUpdatePriority; + } + return DefaultEventPriority; + }, + + // Append initial child + appendInitialChild( + parent: MarkdownNode, + child: MarkdownNode | TextNode, + ): void { + parent.appendChild(child); + }, + + preparePortalMount(): void { + // Portal mount preparation logic + }, + + // Other required implementations + resetTextContent(): void {}, + getPublicInstance(instance: MarkdownNode | TextNode) { + return instance; + }, + prepareForCommit(_containerInfo: MarkdownNode): null { + return null; + }, + resetAfterCommit(_containerInfo: MarkdownNode): void {}, + // Use real setTimeout/clearTimeout to allow React to schedule work properly + // Effects will still be scheduled, but we'll read the result before they execute + scheduleTimeout: setTimeout, + cancelTimeout: clearTimeout, + noTimeout: -1, + scheduleMicrotask: + typeof queueMicrotask === 'function' + ? queueMicrotask + : (fn: () => unknown) => Promise.resolve().then(fn), + getInstanceFromNode(): null { + return null; + }, + beforeActiveInstanceBlur(): void {}, + afterActiveInstanceBlur(): void {}, + prepareScopeUpdate(): void {}, + getInstanceFromScope(): null { + return null; + }, + detachDeletedInstance(): void {}, + + // Container related methods + appendChildToContainer( + container: MarkdownNode, + child: MarkdownNode | TextNode, + ): void { + container.appendChild(child); + }, + + insertInContainerBefore( + container: MarkdownNode, + child: MarkdownNode | TextNode, + beforeChild: MarkdownNode | TextNode, + ): void { + container.insertBefore(child, beforeChild); + }, + + removeChildFromContainer( + container: MarkdownNode, + child: MarkdownNode | TextNode, + ): void { + container.removeChild(child); + }, + + hideInstance(_instance: MarkdownNode): void { + // Logic to hide an instance; can set a flag here + }, + + hideTextInstance(textInstance: TextNode): void { + textInstance.setText(''); + }, + + unhideInstance(_instance: MarkdownNode): void { + // Logic to unhide an instance + }, + + unhideTextInstance(textInstance: TextNode, text: string): void { + textInstance.setText(text); + }, + + // React 18+ additional methods + maySuspendCommit(): boolean { + return false; + }, + + preloadInstance(): boolean { + return true; + }, + + startSuspendingCommit(): void {}, + + suspendInstance(): void {}, + + waitForCommitToBeReady(): null { + return null; + }, + + NotPendingTransition: null as unknown, + + HostTransitionContext: createContext( + null, + ) as unknown as ReactContext, + + resetFormInstance(): void {}, + + requestPostPaintCallback(): void {}, + + shouldAttemptEagerTransition(): boolean { + return false; + }, + + trackSchedulerEvent(): void {}, + + resolveEventType(): null { + return null; + }, + + resolveEventTimeStamp(): number { + return -1.1; + }, +}; + +// Create reconciler instance +export const reconciler = createReconciler< + ElementNames, + Props, + MarkdownNode, + MarkdownNode, + TextNode, + MarkdownNode, + unknown, + unknown, + unknown, + HostContext, + unknown, + unknown, + unknown, + unknown +>(hostConfig); diff --git a/packages/core/src/node/ssg-md/react/render.test.tsx b/packages/core/src/node/ssg-md/react/render.test.tsx new file mode 100644 index 000000000..78e920234 --- /dev/null +++ b/packages/core/src/node/ssg-md/react/render.test.tsx @@ -0,0 +1,211 @@ +import React, { + createContext, + type ReactNode, + useContext, + useEffect, + useLayoutEffect, + useState, +} from 'react'; +import { describe, expect, it } from 'vitest'; +import { renderToMarkdownString } from './render'; + +describe('renderToMarkdownString', () => { + it('renders text', async () => { + expect( + await renderToMarkdownString( +
+ foo + bar +
, + ), + ).toMatchInlineSnapshot(`"**foo**bar"`); + }); + it('renders header and paragraph', async () => { + const Comp1 = ({ children }: { children?: ReactNode }) => { + const [count, setCount] = useState(1); + return ( +

{ + setCount(count => count + 1); + }} + > + Header {count} {children} +

+ ); + }; + const Comp2 = () => { + return ( + <> + +

Paragraph

+ + ); + }; + + const Comp3 = () => { + return ( + <> + {<>children text} +

Paragraph

+ + ); + }; + expect(await renderToMarkdownString()).toMatchInlineSnapshot(` + "# Header 1 + + Paragraph + + " + `); + expect(await renderToMarkdownString()).toMatchInlineSnapshot(` + "# Header 1 children text + + Paragraph + + " + `); + }); + + it('renders markdown string', async () => { + const context = createContext({ a: 1 }); + const Comp = () => { + const value = useContext(context); + return ( +
+

{value.a}

+
+ ); + }; + expect( + await renderToMarkdownString( + +
+

Title

+

Paragraph

+ +
+
, + ), + ).toMatchInlineSnapshot(` + "## Title + + Paragraph + + 2 + + " + `); + }); + + it('useEffect and useLayoutEffect', async () => { + const Comp1 = ({ children }: { children?: ReactNode }) => { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + window.location.assign('about:blank'); + }, []); + return ( + <> + {mounted ? ( +

+ Header {mounted} {children} +

+ ) : null} + + ); + }; + + const Comp2 = ({ children }: { children?: ReactNode }) => { + const [mounted, setMounted] = useState(false); + useLayoutEffect(() => { + setMounted(true); + window.location.assign('about:blank'); + }, []); + return ( + <> + {mounted ? ( +

+ Header {mounted} {children} +

+ ) : null} + + ); + }; + + const Comp3 = () => { + return ( + <> + +

Paragraph

+ + ); + }; + + const Comp4 = () => { + return ( + <> + +

Paragraph

+ + ); + }; + expect(await renderToMarkdownString()).toMatchInlineSnapshot(` + "Paragraph + + " + `); + // SSR behavior: useLayoutEffect also does NOT run + expect(await renderToMarkdownString()).toMatchInlineSnapshot(` + "Paragraph + + " + `); + }); + + it('componentDidMount', async () => { + class Base extends React.Component { + state: Readonly<{ mounted: boolean }> = { mounted: false }; + render(): ReactNode { + return
Base {this.state.mounted}
; + } + componentDidMount(): void { + window.location.assign('about:blank'); + this.setState({ mounted: true }); + } + } + + expect(await renderToMarkdownString()).toMatchInlineSnapshot( + `"Base "`, + ); + }); + + it('text-indent of code', async () => { + function _createMdxContent() { + return ( + <> + <>{'# Code Example\\n'} + {'\n'} + <> + { + // biome-ignore lint/suspicious/noTemplateCurlyInString: special case of ansi + '```tsx\n"console.log(\'Hello, world!\');\nfunction greet(name: string) {\n return `Hello, \${name}!`;\n}"\n```\n' + } + + + ); + } + + expect( + await renderToMarkdownString(_createMdxContent()), + ).toMatchInlineSnapshot(` + "# Code Example\\n + \`\`\`tsx + "console.log('Hello, world!'); + function greet(name: string) { + return \`Hello, \${name}!\`; + }" + \`\`\` + " + `); + }); +}); diff --git a/packages/core/src/node/ssg-md/react/render.ts b/packages/core/src/node/ssg-md/react/render.ts new file mode 100644 index 000000000..a37bdfc32 --- /dev/null +++ b/packages/core/src/node/ssg-md/react/render.ts @@ -0,0 +1,144 @@ +import { MarkdownNode, reconciler, TextNode } from './reconciler.js'; + +// Convert node tree to Markdown string +function toMarkdown(root: MarkdownNode): string { + const { type, props, children } = root; + + // Get children's Markdown + const childrenMd = children + .map(child => { + if (child instanceof TextNode) { + return child.text; + } + return toMarkdown(child); + }) + .join(''); + + // Generate corresponding Markdown based on element type + switch (type) { + case 'root': + return childrenMd; + case 'h1': + return `# ${childrenMd}\n\n`; + case 'h2': + return `## ${childrenMd}\n\n`; + case 'h3': + return `### ${childrenMd}\n\n`; + case 'h4': + return `#### ${childrenMd}\n\n`; + case 'h5': + return `##### ${childrenMd}\n\n`; + case 'h6': + return `###### ${childrenMd}\n\n`; + case 'p': + return `${childrenMd}\n\n`; + case 'strong': + case 'b': + return `**${childrenMd}**`; + case 'em': + case 'i': + return `*${childrenMd}*`; + case 'code': + return `\`${childrenMd}\``; + case 'pre': { + const language = props.lang || props.language || ''; + return `\`\`\`${language}\n${childrenMd}\n\`\`\`\n\n`; + } + case 'a': + return `[${childrenMd}](${props.href || '#'})`; + case 'img': + return `![${props.alt || ''}](${props.src || ''})`; + case 'ul': + return `${childrenMd}\n`; + case 'ol': + return `${childrenMd}\n`; + case 'li': { + const isOrdered = root.parent && root.parent.type === 'ol'; + const prefix = isOrdered ? '1. ' : '- '; + return `${prefix}${childrenMd}\n`; + } + case 'blockquote': + return `> ${childrenMd.split('\n').join('\n> ')}\n\n`; + case 'br': + return '\n'; + case 'hr': + return '---\n\n'; + case 'table': + return `${childrenMd}\n`; + case 'thead': + return childrenMd; + case 'tbody': + return childrenMd; + case 'tr': { + const cells = children + .filter((child): child is MarkdownNode => child instanceof MarkdownNode) + .map(cell => toMarkdown(cell).trim()); + + // If it's a header row, add separator + if (root.parent && root.parent.type === 'thead') { + const separator = `|${cells.map(() => ' --- ').join('|')}|\n`; + return `| ${cells.join(' | ')} |\n${separator}`; + } + + return `| ${cells.join(' | ')} |\n`; + } + case 'th': + case 'td': + return childrenMd; + case 'div': + case 'span': + case 'section': + case 'article': + case 'main': + case 'aside': + case 'header': + case 'footer': + case 'nav': + default: + return childrenMd; + } +} + +// Render function (SSR-like behavior: neither useEffect nor useLayoutEffect run) +export async function renderToMarkdownString( + element: React.ReactElement, +): Promise { + const container = new MarkdownNode('root'); + + const root = reconciler.createContainer( + container, + 1, // tag (LegacyRoot = 0) + null, // hydrationCallbacks + false, // isStrictMode + false, // concurrentUpdatesByDefaultOverride + '', // identifierPrefix + (...args) => { + if (process.env.DEBUG) { + console.log('Reconciler Error:', ...args); + } + }, // onUncaughtError + () => {}, // onCaughtError + (...args) => { + if (process.env.DEBUG) { + console.log('Reconciler onRecoverable Error:', ...args); + } + }, // onRecoverableError + () => {}, // transitionCallbacks + null, // onPostPaintCallback + ); + + // Set up a promise that resolves when commit completes + let resolveCommit: ((arg: string) => void) | null = null; + const commitPromise = new Promise(resolve => { + resolveCommit = resolve; + }); + + reconciler.updateContainer(element, root, null, () => { + // This callback is called after commit + if (resolveCommit) { + resolveCommit(toMarkdown(container)); + } + }); + + return commitPromise; +} diff --git a/packages/core/src/node/ssg-md/remarkSplitMdx.test.ts b/packages/core/src/node/ssg-md/remarkSplitMdx.test.ts new file mode 100644 index 000000000..d4bb4c4b9 --- /dev/null +++ b/packages/core/src/node/ssg-md/remarkSplitMdx.test.ts @@ -0,0 +1,534 @@ +import fs from 'node:fs/promises'; +import { compile } from '@mdx-js/mdx'; +import { describe, expect, it } from 'vitest'; +import { remarkSplitMdx } from './remarkSplitMdx'; + +/** + * Process MDX with custom remark plugin + */ +export async function processMdx(source: string): Promise { + // Compile MDX with our custom remark plugin + const result = await compile(source, { + remarkPlugins: [remarkSplitMdx], + jsx: true, + }); + + // Get the compiled code - this is the original MDX output + const code = String(result); + + return code; +} + +describe('mdx-to-md-loader', () => { + it('should transform MDX with import and JSX component to template literal', async () => { + const input = `# title + +import Foo from '@components' + +`; + + const result = await processMdx(input); + + expect(result).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import Foo from '@components'; + function _createMdxContent(props) { + return <>{"# title\\n"}{"\\n"}{"\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should handle MDX with props', async () => { + const input = `# Hello World + +import Button from '@components/Button' + +`; + + const result = await processMdx(input); + + expect(result).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import Button from '@components/Button'; + function _createMdxContent(props) { + return <>{"# Hello World\\n"}{"\\n"}{"\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should handle multiple components', async () => { + const input = `# Documentation + +import Foo from '@components/Foo' +import Bar from '@components/Bar' + +Some text here. + + + +More content. + +`; + + const result = await processMdx(input); + + expect(result).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import Foo from '@components/Foo'; + import Bar from '@components/Bar'; + function _createMdxContent(props) { + return <>{"# Documentation\\n"}{"\\n"}{"\\n"}{"Some text here.\\n"}{"\\n"}{"\\n"}{"More content.\\n"}{"\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should handle codeblock', async () => { + const input = `# Code Example + +\`\`\`tsx +console.log('Hello, world!'); +function greet(name: string) { + return \`Hello, \${name}!\`; +} +\`\`\` +`; + const result = await processMdx(input); + expect(result).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + function _createMdxContent(props) { + return <>{"# Code Example\\n"}{"\\n"}{"\`\`\`tsx\\nconsole.log('Hello, world!');\\nfunction greet(name: string) {\\n return \`Hello, \${name}!\`;\\n}\\n\`\`\`\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should handle self-closing and non-self-closing components', async () => { + const input = ` +# title + +import Card from '@components' + +Content inside + +Content outside + + + +\`\`\`tsx +console.log('Hello, world!'); +\`\`\` + + +\`\`\`tsx +console.log('Hello, world!'); +\`\`\` +`; + + const result = await processMdx(input); + + expect(result).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import Card from '@components'; + function _createMdxContent(props) { + const _components = { + code: "code", + pre: "pre", + ...props.components + }; + return <>{"# title\\n"}{"\\n"}{"\\n"}{"Content inside"}{"\\n"}{"Content outside\\n"}{"\\n"}<_components.pre><_components.code className="language-tsx">{"console.log('Hello, world!');\\n"}{"\\n"}{"\`\`\`tsx\\nconsole.log('Hello, world!');\\n\`\`\`\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should handle MDX with image', async () => { + const input = `# Image Example + +import Img from '@components/Image' + +import Svg from '@assets/image.svg' + +Here is an image: + +An image + +An SVG image + +End of content.`; + + const result = await processMdx(input); + expect(result).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import Img from '@components/Image'; + import Svg from '@assets/image.svg'; + function _createMdxContent(props) { + return <>{"# Image Example\\n"}{"\\n"}{"\\n"}{"\\n"}{"Here is an image:\\n"}{"\\n"}An image{"\\n"}An SVG image{"\\n"}{"End of content.\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); +}); + +describe('remarkWrapMarkdown with filters', () => { + it('should filter imports by rule - includes only @lynx', async () => { + const input = `import { Table } from '@lynx'; +import Button from 'react'; + + +`; + + const result = await compile(input, { + remarkPlugins: [ + [ + remarkSplitMdx, + { + includes: [[['Table'], '@lynx']], + }, + ], + ], + jsx: true, + }); + + const code = String(result); + expect(code).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import {Table} from '@lynx'; + import Button from 'react'; + function _createMdxContent(props) { + return <>
{"\\n"}{"\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should filter imports by multiple specifiers', async () => { + const input = `import { Table, Card } from '@lynx'; +import Button from 'react'; + +
+ +`; + + const result = await compile(input, { + remarkPlugins: [ + [ + remarkSplitMdx, + { + includes: [ + [['Table', 'Button'], '@lynx'], + [['Button'], 'react'], + ], + }, + ], + ], + jsx: true, + }); + + const code = String(result); + expect(code).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import {Table, Card} from '@lynx'; + import Button from 'react'; + function _createMdxContent(props) { + return <>
{"\\n"}{"\\n"}{"\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should filter imports by source - excludes react', async () => { + const input = `import { Table } from '@lynx'; +import Button from 'react'; + +
+`; + + const result = await compile(input, { + remarkPlugins: [ + [ + remarkSplitMdx, + { + excludes: [[['Button'], 'react']], + }, + ], + ], + jsx: true, + }); + + const code = String(result); + expect(code).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import {Table} from '@lynx'; + import Button from 'react'; + function _createMdxContent(props) { + return <>
{"\\n"}{"\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should filter imports by multiple specifiers in exclude', async () => { + const input = `import { Table, Card } from '@lynx'; + +
+`; + + const result = await compile(input, { + remarkPlugins: [ + [ + remarkSplitMdx, + { + excludes: [[['Card'], '@lynx']], + }, + ], + ], + jsx: true, + }); + + const code = String(result); + expect(code).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import {Table, Card} from '@lynx'; + function _createMdxContent(props) { + return <>
{"\\n"}{"\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should handle import aliases correctly', async () => { + const input = `import { Table as Tab } from '@lynx'; + +`; + + const result = await compile(input, { + remarkPlugins: [ + [ + remarkSplitMdx, + { + includes: [ + [['Table'], '@lynx'], // Match the local name (alias) + ], + }, + ], + ], + jsx: true, + }); + + const code = String(result); + expect(code).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import {Table as Tab} from '@lynx'; + function _createMdxContent(props) { + return <>{"\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should handle combined includes and excludes - excludes takes precedence', async () => { + const input = `import { Table, Card } from '@lynx'; +import Button from 'react'; + +
+ +`; + + const result = await compile(input, { + remarkPlugins: [ + [ + remarkSplitMdx, + { + includes: [[['Table', 'Card'], '@lynx']], + excludes: [[['Card'], '@lynx']], + }, + ], + ], + jsx: true, + }); + + const code = String(result); + expect(code).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import {Table, Card} from '@lynx'; + import Button from 'react'; + function _createMdxContent(props) { + return <>
{"\\n"}{"\\n"}{"\\n"}{"\\n"}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should support multiple include rules', async () => { + const input = `import { Table } from '@lynx'; +import { Button } from 'antd'; +import Card from 'react'; + +
+
{"\\n"} and link example.`; + + const result = await processMdx(input); + + expect(result).toMatchInlineSnapshot(` + "/*@jsxRuntime automatic*/ + /*@jsxImportSource react*/ + import {Button, Link} from '@components'; + function _createMdxContent(props) { + return <><>{"This is a "}{" and "}{"link"}{" example."}; + } + export default function MDXContent(props = {}) { + const {wrapper: MDXLayout} = props.components || ({}); + return MDXLayout ? <_createMdxContent {...props} /> : _createMdxContent(props); + } + " + `); + }); + + it('should lynx', async () => { + const fixturesTestMdxPath = new URL('./fixtures/test.mdx', import.meta.url); + const input = await fs.readFile(fixturesTestMdxPath, 'utf-8'); + + const result = await compile(input, { + remarkPlugins: [ + [ + remarkSplitMdx, + { + includes: [[['Go'], '@lynx']], + }, + ], + ], + jsx: true, + }); + expect(result.toString()).toMatchSnapshot(); + }); +}); diff --git a/packages/core/src/node/ssg-md/remarkSplitMdx.ts b/packages/core/src/node/ssg-md/remarkSplitMdx.ts new file mode 100644 index 000000000..fda4c0c04 --- /dev/null +++ b/packages/core/src/node/ssg-md/remarkSplitMdx.ts @@ -0,0 +1,431 @@ +import type { Root, RootContent } from 'mdast'; +import type { + MdxFlowExpression, + MdxJsxFlowElement, + MdxJsxTextElement, + MdxTextExpression, +} from 'mdast-util-mdx'; +import remarkGfm from 'remark-gfm'; +import remarkMdx from 'remark-mdx'; +import remarkStringify from 'remark-stringify'; +import { unified } from 'unified'; + +/** + * Filter rule format: [specifiers, source] + * - specifiers: Array of component/function names to match + * - source: Import source to match + * + * Example: [['Table', 'Button'], '@lynx'] + * Matches: import { Table, Button } from '@lynx' + */ +export type FilterRule = [string[], string]; + +export interface RemarkSplitMdxOptions { + /** + * Include rules for filtering imports and JSX elements + * Format: [[specifiers, source], ...] + * + * @example + * includes: [ + * [['Table', 'Button'], '@lynx'], + * [['Card'], 'antd'] + * ] + */ + includes?: FilterRule[]; + + /** + * Exclude rules for filtering imports and JSX elements + * Takes precedence over includes + * Format: [[specifiers, source], ...] + * + * @example + * excludes: [ + * [['LegacyTable'], '@lynx'] + * ] + */ + excludes?: FilterRule[]; +} + +/** + * Custom remark plugin that wraps markdown content in React Fragment + * Only processes top-level markdown nodes, keeps JSX elements and their children intact + */ +export function remarkSplitMdx( + options: RemarkSplitMdxOptions = {}, +): (tree: Root) => void { + return (tree: Root) => { + const newChildren: RootContent[] = []; + const importMap = buildImportMap(tree); + + for (const node of tree.children) { + // Process imports - keep all the import + if (node.type === 'mdxjsEsm') { + newChildren.push(node); + continue; + } + + // Process JSX elements - check if they should be kept based on import filters + if ( + node.type === 'mdxJsxFlowElement' || + node.type === 'mdxJsxTextElement' + ) { + const componentName = (node as any).name; + const shouldKeep = shouldKeepJsxElement( + componentName, + importMap, + options, + ); + + if (shouldKeep) { + newChildren.push(node); + } else { + // Convert to markdown text if not kept + newChildren.push(buildMdxFlowExpressionFragment(node)); + } + continue; + } + + // For any other markdown node (heading, paragraph, blockquote, list, code, etc.) + // Check if it has JSX children that need to be extracted + const hasJsxChildren = (node as any).children?.some( + (child: any) => + child.type === 'mdxJsxFlowElement' || + child.type === 'mdxJsxTextElement', + ); + + if (hasJsxChildren) { + // Process mixed content nodes (e.g., heading with JSX) + const processedChildren = processMixedContent( + node, + (node as any).children || [], + importMap, + options, + ); + + newChildren.push({ + type: 'mdxJsxFlowElement', + name: null, // Fragment + attributes: [], + // @ts-expect-error mdxJsxFlowElement children type + children: processedChildren, + }); + } else { + // Pure markdown node - serialize to text + const fragment = buildMdxFlowExpressionFragment(node); + newChildren.push(fragment); + } + } + + tree.children = newChildren as any; + }; +} + +/** + * Process mixed content that contains both markdown and JSX elements + * Returns an array of children suitable for a Fragment + */ +function processMixedContent( + parentNode: RootContent, + children: RootContent[], + importMap: Map, + options: RemarkSplitMdxOptions, +): ( + | MdxJsxFlowElement + | MdxJsxTextElement + | MdxFlowExpression + | MdxTextExpression +)[] { + const result: ( + | MdxJsxFlowElement + | MdxJsxTextElement + | MdxFlowExpression + | MdxTextExpression + )[] = []; + let textBuffer: string[] = []; + + // Get markdown prefix for certain node types (e.g., heading) + const getPrefix = () => { + if (parentNode.type === 'heading') { + const depth = parentNode.depth || 1; + return `${'#'.repeat(depth)} `; + } + return ''; + }; + + const prefix = getPrefix(); + let prefixAdded = false; + + const flushTextBuffer = () => { + if (textBuffer.length > 0) { + let combined = textBuffer.join(''); + + // Add prefix to the first text chunk if not yet added + if (!prefixAdded && prefix) { + combined = prefix + combined; + prefixAdded = true; + } + + // Only add if there's actual content + if (combined) { + const stringified = JSON.stringify(combined); + result.push({ + type: 'mdxTextExpression', + value: stringified, + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'Literal', + value: combined, + raw: stringified, + }, + }, + ], + sourceType: 'module', + }, + }, + }); + } + textBuffer = []; + } else if (!prefixAdded && prefix) { + // If textBuffer is empty but we haven't added prefix yet, + // we still need to add the prefix (e.g., when first child is JSX) + const trimmedPrefix = prefix.trimEnd(); + const stringified = JSON.stringify(trimmedPrefix); + result.push({ + type: 'mdxTextExpression', + value: stringified, + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'Literal', + value: trimmedPrefix, + raw: stringified, + }, + }, + ], + sourceType: 'module', + }, + }, + }); + prefixAdded = true; + } + }; + + for (const child of children) { + if ( + child.type === 'mdxJsxFlowElement' || + child.type === 'mdxJsxTextElement' + ) { + // Flush any accumulated text before the JSX element + flushTextBuffer(); + + const componentName = child.name; + const shouldKeep = shouldKeepJsxElement( + componentName, + importMap, + options, + ); + + if (shouldKeep) { + result.push(child); + } else { + // Convert excluded JSX to text + const fragment = buildMdxFlowExpressionFragment(child); + result.push(fragment); + } + } else if (child.type === 'text') { + // Accumulate text node value directly + textBuffer.push(child.value || ''); + } else { + // For other inline elements (strong, em, code, etc.), serialize and accumulate + const serialized = serializeNodeToMarkdown(child); + if (serialized) { + textBuffer.push(serialized); + } + } + } + + // Flush any remaining text + flushTextBuffer(); + + return result; +} + +/** + * Convert a markdown node to an MDX Fragment containing a Flow Expression + * @example + * input + * + * ```mdx + * # Heading + * Some **bold** text. + * ``` + * + * output + * + * ```jsx + * <>{"# Heading\nSome **bold** text."} + * ``` + */ +function buildMdxFlowExpressionFragment(node: RootContent): MdxFlowExpression { + const textContent = serializeNodeToMarkdown(node); + // <>{"string"} + const stringified = JSON.stringify(textContent); + + const fragment: MdxFlowExpression = { + type: 'mdxFlowExpression', + value: stringified, + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'Literal', + value: textContent, + raw: stringified, + }, + }, + ], + sourceType: 'module', + }, + }, + }; + + return fragment; +} + +// Use unified with remark-stringify to convert the node back to markdown +const processor = unified().use(remarkMdx).use(remarkGfm).use(remarkStringify, { + bullet: '-', + emphasis: '_', + fences: true, + incrementListMarker: true, +}); + +/** + * Serialize a markdown node back to its markdown text representation + * Uses remark-stringify for proper serialization of all node types + */ +function serializeNodeToMarkdown(node: RootContent): string { + // Create a temporary root node containing only this node + const tempRoot: Root = { + type: 'root', + children: [node], + }; + + const result = processor.stringify(tempRoot); + + return result; +} + +/** + * Build a map of component names to their import sources + * Example: { Table: '@lynx', Button: 'react' } + */ +function buildImportMap(tree: Root): Map { + const importMap = new Map(); + + for (const node of tree.children) { + if (node.type === 'mdxjsEsm' && (node as any).data?.estree) { + const estree = (node as any).data.estree; + + for (const statement of estree.body) { + if (statement.type === 'ImportDeclaration') { + const source = statement.source.value; + + for (const specifier of statement.specifiers) { + let localName: string | null = null; + + if (specifier.type === 'ImportDefaultSpecifier') { + // import Table from '@lynx' + localName = specifier.local.name; + } else if (specifier.type === 'ImportSpecifier') { + // import { Table as Tab } from '@lynx' + localName = specifier.local.name; + } else if (specifier.type === 'ImportNamespaceSpecifier') { + // import * as Lynx from '@lynx' + localName = specifier.local.name; + } + + if (localName) { + importMap.set(localName, source); + } + } + } + } + } + } + + return importMap; +} + +/** + * Check if a JSX element should be kept based on its component name and import filters + */ +function shouldKeepJsxElement( + componentName: string | null, + importMap: Map, + options: RemarkSplitMdxOptions, +): boolean { + if (!componentName) { + return true; // Keep fragments and elements without names + } + + const { includes, excludes } = options; + + // If no filters specified, keep all JSX elements, includes all + if (!includes && !excludes) { + return true; + } + + const importSource = importMap.get(componentName); + + if (importSource?.endsWith('.mdx')) { + // Mdx Fragments should always be kept + return true; + } + + // Check excludes first (takes precedence) + if (excludes) { + for (const [excludeSpecifiers, excludeSource] of excludes) { + // If component name matches and source matches (if component was imported) + if (excludeSpecifiers.includes(componentName)) { + if (!importSource || importSource === excludeSource) { + return false; + } + } + } + } + + // Check includes + if (includes) { + let matchesAnyRule = false; + + for (const [includeSpecifiers, includeSource] of includes) { + // If component name matches and source matches (if component was imported) + if (includeSpecifiers.includes(componentName)) { + if (!importSource || importSource === includeSource) { + matchesAnyRule = true; + break; + } + } + } + + // If includes are specified but nothing matches, exclude + if (!matchesAnyRule) { + return false; + } + } + + return true; +} diff --git a/packages/core/src/node/ssg-md/renderPage.ts b/packages/core/src/node/ssg-md/renderPage.ts new file mode 100644 index 000000000..d65a1c12c --- /dev/null +++ b/packages/core/src/node/ssg-md/renderPage.ts @@ -0,0 +1,48 @@ +import { pathToFileURL } from 'node:url'; +import type { Route, RouteMeta } from '@rspress/shared'; +import { logger } from '@rspress/shared/logger'; +import { createHead, type Unhead } from '@unhead/react/server'; +import picocolors from 'picocolors'; + +import { hintSSGFailed } from '../logger/hint'; + +interface MdSSRBundleExports { + render: (pagePath: string, head: Unhead) => Promise<{ appMd: string }>; + routes: Route[]; +} + +export async function renderPage(route: RouteMeta, ssrBundlePath: string) { + let render: MdSSRBundleExports['render']; + try { + const { default: ssrExports } = await import( + pathToFileURL(ssrBundlePath).toString() + ); + render = await ssrExports.render; + } catch (e) { + if (e instanceof Error) { + logger.error( + `Failed to load SSG bundle: ${picocolors.yellow(ssrBundlePath)}: ${e.message}`, + ); + logger.debug(e); + hintSSGFailed(); + } + throw e; + } + const head = createHead(); + const { routePath } = route; + let appMd = ''; + if (render) { + try { + ({ appMd } = await render(routePath, head)); + } catch (e) { + if (e instanceof Error) { + logger.error( + `Page "${picocolors.yellow(routePath)}" SSG-MD rendering failed.\n ${picocolors.gray(e.toString())}`, + ); + throw e; + } + } + } + + return appMd; +} diff --git a/packages/core/src/node/ssg-md/renderPages.ts b/packages/core/src/node/ssg-md/renderPages.ts new file mode 100644 index 000000000..1d22654f6 --- /dev/null +++ b/packages/core/src/node/ssg-md/renderPages.ts @@ -0,0 +1,44 @@ +import { logger } from '@rspress/shared/logger'; +import pMap from 'p-map'; +import picocolors from 'picocolors'; +import type { RouteService } from '../route/RouteService'; +import { SSGConcurrency } from '../ssg/ssgEnv'; +import { renderPage } from './renderPage'; + +const routePage2MdFilename = (routePath: string) => { + let fileName = routePath; + if (fileName.endsWith('/')) { + fileName = `${routePath}index.md`; + } else { + fileName = `${routePath}.md`; + } + + return fileName.replace(/^\/+/, ''); +}; + +export async function renderPages( + routeService: RouteService, + ssrBundlePath: string, + emitAsset: (assetName: string, content: string | Buffer) => void, +): Promise> { + logger.info('Rendering md pages...'); + const startTime = Date.now(); + const result = new Map(); + + const routes = routeService.getRoutes(); + await pMap( + routes, + async route => { + const html = await renderPage(route, ssrBundlePath); + const fileName = routePage2MdFilename(route.routePath); + emitAsset(fileName, html); + result.set(route.routePath, html); + }, + { + concurrency: SSGConcurrency(), + }, + ); + const totalTime = Date.now() - startTime; + logger.success(`Markdown rendered in ${picocolors.yellow(totalTime)} ms.`); + return result; +} diff --git a/packages/core/src/node/ssg-md/rsbuildPluginSSGMD.ts b/packages/core/src/node/ssg-md/rsbuildPluginSSGMD.ts new file mode 100644 index 000000000..2c8546de5 --- /dev/null +++ b/packages/core/src/node/ssg-md/rsbuildPluginSSGMD.ts @@ -0,0 +1,85 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { RsbuildPlugin } from '@rsbuild/core'; +import { isDebugMode, type UserConfig } from '@rspress/shared'; +import { + NODE_SSG_MD_BUNDLE_FOLDER, + NODE_SSG_MD_BUNDLE_NAME, +} from '../constants'; +import type { RouteService } from '../route/RouteService'; +import { emitLlmsTxt } from './llms/emitLlmsTxt'; +import { renderPages } from './renderPages'; + +export const rsbuildPluginSSGMD = ({ + routeService, + config, +}: { + routeService: RouteService; + config: UserConfig; +}): RsbuildPlugin => ({ + name: 'rspress-inner-rsbuild-plugin-ssg', + async setup(api) { + api.onBeforeBuild(() => { + let hasError: boolean = false; + + api.processAssets( + { stage: 'optimize-transfer', environments: ['node_md'] }, + async ({ assets, compilation, environment, compiler }) => { + if (compilation.errors.length > 0) { + hasError = true; + return; + } + + // If user has encountered a compile time error at the web/node output, user needs to first debug the error in this stage. + // we will not do ssg for better debugging + if (hasError) { + return; + } + + const emitAsset = ( + assetName: string, + content: string | Buffer, + ): void => { + compilation.emitAsset( + assetName, + new compiler.webpack.sources.RawSource(content), + ); + }; + + const distPath = environment.distPath; + const ssgFolderPath = join(distPath, NODE_SSG_MD_BUNDLE_FOLDER); + const mainCjsAbsolutePath = join( + ssgFolderPath, + NODE_SSG_MD_BUNDLE_NAME, + ); + + await mkdir(ssgFolderPath, { recursive: true }); + await Promise.all( + Object.entries(assets).map(async ([assetName, assetSource]) => { + if (assetName.startsWith(`${NODE_SSG_MD_BUNDLE_FOLDER}/`)) { + const fileAbsolutePath = join(distPath, assetName); + await writeFile( + fileAbsolutePath, + assetSource.source().toString(), + ); + compilation.deleteAsset(assetName); + } + }), + ); + + const mdContents = await renderPages( + routeService, + mainCjsAbsolutePath, + emitAsset, + ); + + await emitLlmsTxt(config, routeService, emitAsset, mdContents); + + if (!isDebugMode()) { + await rm(ssgFolderPath, { recursive: true }); + } + }, + ); + }); + }, +}); diff --git a/packages/core/src/runtime/ssrMdServerEntry.tsx b/packages/core/src/runtime/ssrMdServerEntry.tsx new file mode 100644 index 000000000..8d7249388 --- /dev/null +++ b/packages/core/src/runtime/ssrMdServerEntry.tsx @@ -0,0 +1,50 @@ +// biome-ignore lint/suspicious/noTsIgnore: bundleless +// @ts-ignore +import { renderToMarkdownString } from '@rspress/core/_private/react'; +import { + Content, + PageContext, + pathnameToRouteService, + removeTrailingSlash, + ThemeContext, + withBase, +} from '@rspress/runtime'; +import { StaticRouter } from '@rspress/runtime/server'; +import { type Unhead, UnheadProvider } from '@unhead/react/server'; +import { initPageData } from './initPageData'; + +const DEFAULT_THEME = 'light'; + +async function preloadRoute(pathname: string) { + const route = pathnameToRouteService(pathname); + await route?.preload(); +} + +export async function render( + routePath: string, + head: Unhead, +): Promise<{ appMd: string }> { + const initialPageData = await initPageData(routePath); + await preloadRoute(routePath); + + const appMd = await renderToMarkdownString( + + + + + + + + + , + ); + + return { + appMd, + }; +} + +export { routes } from 'virtual-routes'; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 71d990ae6..e303d678f 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -22,6 +22,44 @@ export interface Route { lang: string; } +// #region RemarkSplitMdxOptions +/** + * Filter rule format: [specifiers, source] + * - specifiers: Array of component/function names to match + * - source: Import source to match + * + * Example: [['Table', 'Button'], '@lynx'] + * Matches: import { Table, Button } from '@lynx' + */ +type FilterRule = [string[], string]; + +interface RemarkSplitMdxOptions { + /** + * Include rules for filtering imports and JSX elements + * Format: [[specifiers, source], ...] + * + * @example + * includes: [ + * [['Table', 'Button'], '@lynx'], + * [['Card'], 'antd'] + * ] + */ + includes?: FilterRule[]; + + /** + * Exclude rules for filtering imports and JSX elements + * Takes precedence over includes + * Format: [[specifiers, source], ...] + * + * @example + * excludes: [ + * [['LegacyTable'], '@lynx'] + * ] + */ + excludes?: FilterRule[]; +} +// #endregion + export interface RouteMeta { routePath: string; absolutePath: string; @@ -169,6 +207,20 @@ export interface UserConfig { */ experimentalExcludeRoutePaths?: (string | RegExp)[]; }; + + /** + * Whether to enable llms and ssg-md + * @default false + * @experimental + */ + llms?: + | boolean + | { + /** + * @experimental + */ + remarkSplitMdxOptions?: RemarkSplitMdxOptions; + }; /** * Whether to enable medium-zoom * @default true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53fdafa8c..aca80ad22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -849,6 +849,9 @@ importers: lodash-es: specifier: ^4.17.21 version: 4.17.21 + mdast-util-mdx: + specifier: ^3.0.0 + version: 3.0.0 mdast-util-mdxjs-esm: specifier: ^2.0.1 version: 2.0.1 @@ -867,6 +870,9 @@ importers: react-lazy-with-preload: specifier: ^2.2.1 version: 2.2.1 + react-reconciler: + specifier: 0.33.0 + version: 0.33.0(react@19.1.1) react-router-dom: specifier: ^6.30.1 version: 6.30.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -879,6 +885,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-mdx: + specifier: ^3.1.1 + version: 3.1.1 shiki: specifier: ^3.12.2 version: 3.12.2 @@ -928,6 +937,9 @@ importers: '@types/react-dom': specifier: ^19.1.9 version: 19.1.9(@types/react@19.1.15) + '@types/react-reconciler': + specifier: ^0.32.1 + version: 0.32.2(@types/react@19.1.15) execa: specifier: 8.0.1 version: 8.0.1 @@ -952,9 +964,6 @@ importers: remark-directive: specifier: ^4.0.0 version: 4.0.0 - remark-mdx: - specifier: ^3.1.1 - version: 3.1.1 remark-parse: specifier: ^11.0.0 version: 11.0.0 @@ -3463,6 +3472,11 @@ packages: peerDependencies: '@types/react': ^19.0.0 + '@types/react-reconciler@0.32.2': + resolution: {integrity: sha512-gjcm6O0aUknhYaogEl8t5pecPfiOTD8VQkbjOhgbZas/E6qGY+veW9iuJU/7p4Y1E0EuQ0mArga7VEOUWSlVRA==} + peerDependencies: + '@types/react': '*' + '@types/react@19.1.15': resolution: {integrity: sha512-+kLxJpaJzXybyDyFXYADyP1cznTO8HSuBpenGlnKOAkH4hyNINiywvXS/tGJhsrGGP/gM185RA3xpjY0Yg4erA==} @@ -5906,6 +5920,12 @@ packages: '@types/react': '>=18' react: '>=18' + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -6289,6 +6309,9 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schema-utils@4.3.2: resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} engines: {node: '>= 10.13.0'} @@ -8927,6 +8950,10 @@ snapshots: dependencies: '@types/react': 19.1.15 + '@types/react-reconciler@0.32.2(@types/react@19.1.15)': + dependencies: + '@types/react': 19.1.15 + '@types/react@19.1.15': dependencies: csstype: 3.1.3 @@ -12056,6 +12083,11 @@ snapshots: transitivePeerDependencies: - supports-color + react-reconciler@0.33.0(react@19.1.1): + dependencies: + react: 19.1.1 + scheduler: 0.27.0 + react-refresh@0.17.0: {} react-router-dom@6.30.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1): @@ -12510,6 +12542,8 @@ snapshots: scheduler@0.26.0: {} + scheduler@0.27.0: {} + schema-utils@4.3.2: dependencies: '@types/json-schema': 7.0.15 diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 10a23157c..3d5438d46 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -14,9 +14,9 @@ bilibili biomejs Bluch brotli -bunx browserslistrc bundleless +bunx Bytedance caniuse chunkhash @@ -75,6 +75,7 @@ longpaths manypkg mattcompiles mdast +MDSSG Mdxjs mdxrs menlo @@ -120,9 +121,9 @@ rsbuild rsdoctor rsfamily rslib +rslint rslog rspack -rslint rspress rstack rstest @@ -135,6 +136,7 @@ Sizefor sokra speedscope srcset +SSGMD stacktracey styl subdir diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 4109307e4..ff26361a4 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -16,7 +16,7 @@ export default defineWorkspace([ environment: 'node', testTimeout: 30000, // restoreMocks: true, - include: ['packages/**/*.test.ts'], + include: ['packages/**/*.test.{ts,tsx}'], exclude: ['**/node_modules/**'], setupFiles: ['./scripts/test-helper/vitest.setup.ts'], }, diff --git a/website/rspress.config.ts b/website/rspress.config.ts index 9b65eb99e..7eeab2e97 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -2,7 +2,6 @@ import { pluginSass } from '@rsbuild/plugin-sass'; import { defineConfig } from '@rspress/core'; import { transformerCompatibleMetaHighlight } from '@rspress/core/shiki-transformers'; import { pluginAlgolia } from '@rspress/plugin-algolia'; -import { pluginLlms } from '@rspress/plugin-llms'; import { pluginSitemap } from '@rspress/plugin-sitemap'; import { pluginTwoslash } from '@rspress/plugin-twoslash'; import { @@ -50,7 +49,7 @@ export default defineConfig({ pluginAlgolia({ verificationContent: '8F5BFE50E65777F1', }), - pluginLlms(), + // pluginLlms(), ], builderConfig: { plugins: [ @@ -130,4 +129,5 @@ export default defineConfig({ include: [], exclude: [], }, + llms: true, }); diff --git a/website/scripts/generateThemeCssVariables.ts b/website/scripts/generateThemeCssVariables.ts index 917682a40..9e34b0401 100644 --- a/website/scripts/generateThemeCssVariables.ts +++ b/website/scripts/generateThemeCssVariables.ts @@ -120,8 +120,8 @@ function getColorForScope( } // Word boundary match (e.g., "constant" matches "constant.numeric") else if ( - s.startsWith(targetScope + '.') || - targetScope.startsWith(s + '.') + s.startsWith(`${targetScope}.`) || + targetScope.startsWith(`${s}.`) ) { score = Math.min(targetScope.length, s.length) * 1.5; } @@ -148,12 +148,12 @@ export function generateShikiCssVars(themePath: string) { // Extract foreground and background colors const foreground = theme.colors?.['editor.foreground'] || - theme.colors?.['foreground'] || + theme.colors?.foreground || (theme.type === 'dark' ? '#ffffff' : '#000000'); const background = theme.colors?.['editor.background'] || - theme.colors?.['background'] || + theme.colors?.background || (theme.type === 'dark' ? '#000000' : '#ffffff'); result.push(['--shiki-foreground', foreground]);