diff --git a/.github/workflows/diff.yml b/.github/workflows/diff.yml new file mode 100644 index 000000000..28f1e17b9 --- /dev/null +++ b/.github/workflows/diff.yml @@ -0,0 +1,49 @@ +name: Diff + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + branches: [main] + +jobs: + Build: + runs-on: ubuntu-latest + + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Pnpm + run: | + npm install -g corepack@latest --force + corepack enable + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install Dependencies and Build + run: | + pnpm install + + - name: Build Demo Project + run: | + cd website + pnpm install + RSDOCTOR=1 pnpm run build + + - name: Report Compressed Size + uses: web-infra-dev/rsdoctor-action@main + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + file_path: 'website/doc_build/web/rsdoctor-data.json' + target_branch: 'main' diff --git a/.github/workflows/ecosystem-ci.yml b/.github/workflows/ecosystem-ci.yml new file mode 100644 index 000000000..c62b07e04 --- /dev/null +++ b/.github/workflows/ecosystem-ci.yml @@ -0,0 +1,70 @@ +name: Ecosystem CI + +on: + push: + branches: ['main'] + workflow_dispatch: + inputs: + branch: + description: 'The branch of the Ecosystem CI run' + required: true + default: 'main' + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + changes: + runs-on: ubuntu-latest + if: github.repository == 'web-infra-dev/rspress' && github.event_name != 'workflow_dispatch' + outputs: + changed: ${{ steps.changes.outputs.changed }} + steps: + - name: Checkout + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + with: + fetch-depth: 1 + + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: changes + with: + predicate-quantifier: 'every' + filters: | + changed: + - "!**/*.md" + - "!**/*.mdx" + - "!**/_meta.json" + - "!**/dictionary.txt" + - "!document/**" + + ecosystem_ci_dispatch: + name: Dispatch ecosystem CI + runs-on: ubuntu-latest + if: github.repository == 'web-infra-dev/rspress' && github.event_name == 'workflow_dispatch' + steps: + - name: Trigger Ecosystem CI + uses: rspack-contrib/rstack-ecosystem-ci/.github/actions/ecosystem_ci_dispatch@main + with: + github-token: ${{ secrets.REPO_rspress_ECO_CI_GITHUB_TOKEN_NEXT }} + ecosystem-owner: web-infra-dev + ecosystem-repo: rspress + workflow-file: rspress-ecosystem-ci-selected.yml + client-payload: '{"ref":"${{ github.event.inputs.branch }}","repo":"web-infra-dev/rspress","suite":"-","suiteRefType":"precoded","suiteRef":"precoded"}' + branch: ${{ github.event.inputs.branch }} + + ecosystem_ci_per_commit: + name: Run ecosystem CI per commit + needs: changes + runs-on: ubuntu-latest + if: github.repository == 'web-infra-dev/rspress' && github.event_name != 'workflow_dispatch' && needs.changes.outputs.changed == 'true' + steps: + - name: Trigger Ecosystem CI + uses: rspack-contrib/rstack-ecosystem-ci/.github/actions/ecosystem_ci_per_commit@main + with: + github-token: ${{ secrets.REPO_RSPRESS_ECO_CI_GITHUB_TOKEN_NEXT }} + ecosystem-owner: web-infra-dev + ecosystem-repo: rspress + workflow-file: rspress-ecosystem-ci-from-commit.yml + client-payload: '{"commitSHA":"${{ github.sha }}","updateComment":true,"repo":"web-infra-dev/rspress","suite":"-","suiteRefType":"precoded","suiteRef":"precoded"}' diff --git a/e2e/fixtures/tabs-component/doc/index.mdx b/e2e/fixtures/tabs-component/doc/index.mdx index f4c3d8acc..6ebc9e5da 100644 --- a/e2e/fixtures/tabs-component/doc/index.mdx +++ b/e2e/fixtures/tabs-component/doc/index.mdx @@ -4,13 +4,13 @@ import { Tabs, Tab } from '@rspress/core/theme'; ## Tab a - + content1 ## Tab b - + content2 content3 content4 @@ -18,4 +18,4 @@ import { Tabs, Tab } from '@rspress/core/theme'; ## Tab c - + diff --git a/packages/core/package.json b/packages/core/package.json index 6282f0b1c..28d9dc70d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,6 +37,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", @@ -81,6 +84,7 @@ "hast-util-to-jsx-runtime": "^2.3.6", "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", "nprogress": "^0.2.0", @@ -88,10 +92,12 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "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", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^3.12.2", "tinyglobby": "^0.2.15", @@ -115,6 +121,7 @@ "@types/nprogress": "^0.2.3", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", + "@types/react-reconciler": "^0.32.1", "@types/web": "^0.0.288", "execa": "8.0.1", "mdast-util-directive": "^3.1.0", diff --git a/packages/core/rslib.config.ts b/packages/core/rslib.config.ts index d31f157e9..44b5987aa 100644 --- a/packages/core/rslib.config.ts +++ b/packages/core/rslib.config.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import { pluginReact } from '@rsbuild/plugin-react'; import { pluginSass } from '@rsbuild/plugin-sass'; import { pluginSvgr } from '@rsbuild/plugin-svgr'; @@ -22,6 +21,22 @@ const COMMON_EXTERNALS = [ export default defineConfig({ plugins: [pluginPublint()], lib: [ + { + 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 cd71437d2..04dbf0e6d 100644 --- a/packages/core/src/node/constants.ts +++ b/packages/core/src/node/constants.ts @@ -53,6 +53,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 = ''; @@ -65,3 +72,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 ce700c3a4..4bca02d30 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: @@ -247,6 +258,12 @@ async function createInternalBuildConfig( }, }, source: { + preEntry: [ + // ensure CSS orders and access @theme before @rspress/theme-default to avoid circular dependency + path.join(DEFAULT_THEME, './styles/index.js'), // 1. @rspress/theme-default global styles + 'virtual-global-styles', // 2. virtual-global-styles + '@theme', // 3. import './index.css'; from 'theme/index.tsx' + ], include: [PACKAGE_ROOT], define: { 'process.env.TEST': JSON.stringify(process.env.TEST), @@ -300,6 +317,8 @@ async function createInternalBuildConfig( }, tools: { 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 @@ -327,6 +346,7 @@ async function createInternalBuildConfig( docDirectory: userDocRoot, routeService, pluginDriver, + isSsgMd, }) .end(); @@ -355,13 +375,20 @@ async function createInternalBuildConfig( .test(/\.rspress[\\/]runtime[\\/]virtual-global-styles/) .merge({ sideEffects: true }); - if (environment.name === 'node') { + if (isSsg || isSsgMd) { + chain.optimization.splitChunks({}); + } + + if (isSsg) { chain.output.filename( `${NODE_SSG_BUNDLE_FOLDER}/${NODE_SSG_BUNDLE_NAME}`, ); chain.output.chunkFilename(`${NODE_SSG_BUNDLE_FOLDER}/[name].cjs`); - // 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`); } }, }, @@ -378,12 +405,9 @@ async function createInternalBuildConfig( index: enableSSG && isProduction() ? SSR_CLIENT_ENTRY : CSR_CLIENT_ENTRY, }, - preEntry: [ - path.join(DEFAULT_THEME, './styles/index.js'), - 'virtual-global-styles', - ], define: { 'process.env.__SSR__': JSON.stringify(false), + 'process.env.__SSR_MD__': JSON.stringify(false), }, }, output: { @@ -393,7 +417,7 @@ async function createInternalBuildConfig( }, }, }, - ...(enableSSG + ...(enableSSG && config.ssg ? { node: { resolve: { @@ -408,6 +432,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..b29aa955f 100644 --- a/packages/core/src/node/mdx/loader.ts +++ b/packages/core/src/node/mdx/loader.ts @@ -1,4 +1,4 @@ -import { logger, type Rspack } from '@rsbuild/core'; +import type { Rspack } from '@rsbuild/core'; import { compile, compileWithCrossCompilerCache } from './processor'; import type { MdxLoaderOptions } from './types'; @@ -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,16 +47,30 @@ export default async function mdxLoader( pluginDriver, routeService, addDependency: this.addDependency, + isSsgMd, }); callback(null, compileResult); } } catch (e) { if (e instanceof Error) { - logger.debug(e); + // Enhance the message with filepath context for better error reporting + const message = `MDX compile error: ${e.message} in ${filepath}`; + let stack: string | undefined = e.stack; + // Truncate stack trace to first 10 lines for better readability + if (stack) { + const stackLines = stack.split('\n'); + if (stackLines.length > 10) { + stack = stackLines.slice(0, 10).join('\n') + '\n ... (truncated)'; + } + } + // why not `callback(e)` ? + // https://github.com/web-infra-dev/rspack/issues/12080 callback({ - message: `MDX compile error: ${e.message} in ${filepath}`, - name: `${filepath} compile error`, - }); + message, + ...(stack ? { stack } : {}), + name: e.name, + cause: e.cause, + } as Error); } } } diff --git a/packages/core/src/node/mdx/options.ts b/packages/core/src/node/mdx/options.ts index 6b8a605a6..eadac4be2 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,35 @@ 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, + }, + } + : { + // 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 +105,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.test.ts b/packages/core/src/node/mdx/remarkPlugins/containerSyntax.test.ts index f0365154e..884a54635 100644 --- a/packages/core/src/node/mdx/remarkPlugins/containerSyntax.test.ts +++ b/packages/core/src/node/mdx/remarkPlugins/containerSyntax.test.ts @@ -333,6 +333,18 @@ Line 2 with [link](http://example.com). expect(result).toMatchSnapshot(); }); + test('error when container type is unknown', async () => { + await expect( + process(` +:::Tip +This is a tip. +::: +`), + ).rejects.toThrow( + '[remarkContainerSyntax] Unknown container directive type "Tip". Supported types: tip, note, warning, caution, danger, info, details', + ); + }); + test('empty blockquote', async () => { const result = await process(` diff --git a/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts b/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts index dea971351..be5bb0bd9 100644 --- a/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts +++ b/packages/core/src/node/mdx/remarkPlugins/containerSyntax.ts @@ -12,6 +12,7 @@ * So the plugin is used to solve the problem and support both syntaxes in above cases. */ /// + import type { BlockContent, Literal, @@ -41,6 +42,7 @@ export const TITLE_REGEX_IN_MD = /{\s*title=["']?(.+)}\s*/; export const TITLE_REGEX_IN_MDX = /\s*title=["']?(.+)\s*/; const CALLOUT_COMPONENT = '$$$callout$$$'; // in md, we can not add import statement, so we use a special component name to avoid conflict with user components +const ERROR_PREFIX = '[remarkContainerSyntax]'; export type DirectiveType = (typeof DIRECTIVE_TYPES)[number]; @@ -104,262 +106,284 @@ const createContainer = ( * 2. If it is, crawl the next nodes, if there is a paragraph node, we need to check if it is the end of the container directive. If not, we need to push it to the children of the container directive node. * 3. If we find the end of the container directive, we remove the visited node and insert the custom container directive node. */ -function transformer(tree: Parent) { +function transformer( + tree: Parent, + warnUnknownType: (type: string | undefined) => void, +) { let i = 0; - try { - while (i < tree.children.length) { - const node = tree.children[i]; + while (i < tree.children.length) { + const node = tree.children[i]; + + if ('children' in node) { + transformer(node, warnUnknownType); + } - if ('children' in node) { - transformer(node); + if (node.type === 'containerDirective') { + const type = node.name as string; + if (type === CALLOUT_COMPONENT) { + i++; + continue; } + if (DIRECTIVE_TYPES.includes(type as DirectiveType)) { + tree.children.splice( + i, + 1, + createContainer( + type, + node.attributes?.title ?? type.toUpperCase(), + node.children as BlockContent[], + ) as RootContent, + ); + } else { + warnUnknownType(type); + } + } else if ( + /** + * Support for Github Alerts + * > [!TIP] + * > This is a tip + * + * will be transformed to: + * + *
+ *
TIP
+ *
+ *

This is a tip

+ *
+ *
+ */ + node.type === 'blockquote' && + node.children[0]?.type === 'paragraph' && + node.children[0].children?.[0] && + 'value' in node.children[0].children[0] + ) { + const initiatorTag = node.children[0].children[0].value; + const match = initiatorTag.match(REGEX_GH_BEGIN); - if (node.type === 'containerDirective') { - const type = node.name as DirectiveType; - if (DIRECTIVE_TYPES.includes(type)) { - tree.children.splice( - i, - 1, - createContainer( - type, - node.attributes?.title ?? type.toUpperCase(), - node.children as BlockContent[], - ) as RootContent, - ); + if (match) { + const [, type] = match; + if (!DIRECTIVE_TYPES.includes(type.toLowerCase() as DirectiveType)) { + warnUnknownType(type); + i++; + continue; } - } else if ( - /** - * Support for Github Alerts - * > [!TIP] - * > This is a tip - * - * will be transformed to: - * - *
- *
TIP
- *
- *

This is a tip

- *
- *
- */ - node.type === 'blockquote' && - node.children[0]?.type === 'paragraph' && - node.children[0].children?.[0] && - 'value' in node.children[0].children[0] - ) { - const initiatorTag = node.children[0].children[0].value; - const match = initiatorTag.match(REGEX_GH_BEGIN); - - if (match) { - const [, type] = match; - if (!DIRECTIVE_TYPES.includes(type.toLowerCase() as DirectiveType)) { - i++; - continue; - } - if ( - node.children.length === 1 && - node.children[0].type === 'paragraph' - ) { - node.children[0].children[0].value = match[2] ?? ''; - } - const newChild = createContainer( - type.toLowerCase(), - type.toUpperCase(), - (node.children.slice(1).length === 0 - ? node.children.slice(0) - : node.children.slice(1)) as BlockContent[], - ); - tree.children.splice(i, 1, newChild as RootContent); + if ( + node.children.length === 1 && + node.children[0].type === 'paragraph' + ) { + node.children[0].children[0].value = match[2] ?? ''; } + const newChild = createContainer( + type.toLowerCase(), + type.toUpperCase(), + (node.children.slice(1).length === 0 + ? node.children.slice(0) + : node.children.slice(1)) as BlockContent[], + ); + tree.children.splice(i, 1, newChild as RootContent); } + } + + if ( + node.type !== 'paragraph' || + // 1. We get the paragraph and check if it is a container directive + node.children[0].type !== 'text' + ) { + i++; + continue; + } + const firstTextNode = node.children[0]; + const text = firstTextNode.value; + const metaText = text.split('\n')[0]; + const content = text.slice(metaText.length); + const match = metaText.match(REGEX_BEGIN); + if (!match) { + i++; + continue; + } + const [, type, rawTitle] = match; + // In .md, we can get :::tip{title="foo"} in the first text node + // In .mdx, we get :::tip in first node and {title="foo"} in second node + let title = parseTitle(rawTitle); + // :::tip{title="foo"} + const titleExpressionNode = + node.children[1] && node.children[1].type === 'mdxTextExpression' + ? node.children[1] + : null; + // Handle the case of `::: tip {title="foo"}` + if (titleExpressionNode) { + title = parseTitle((titleExpressionNode as Literal).value, true); + // {title="foo"} is not a part of the content, So we need to remove it + node.children.splice(1, 1); + } + if (!DIRECTIVE_TYPES.includes(type as DirectiveType)) { + warnUnknownType(type); + i++; + continue; + } + // 2. If it is, we remove the paragraph and create a container directive + const wrappedChildren: (BlockContent | PhrasingContent)[] = []; + // 2.1 case: with no newline between `:::` and `:::`, for example + // ::: tip + // This is a tip + // ::: + // Here the content is `::: tip\nThis is a tip\n:::` + if (content?.endsWith(':::')) { + wrappedChildren.push({ + type: 'paragraph', + children: [ + { + type: 'text', + value: content.replace(REGEX_END, ''), + }, + ], + }); + const newChild = createContainer(type, title, wrappedChildren); + tree.children.splice(i, 1, newChild as RootContent); + } else { + // 2.2 case: with newline before the end of container, for example: + // ::: tip + // This is a tip + // + // ::: + // Here the content is `::: tip\nThis is a tip` + const paragraphChild: Paragraph = { + type: 'paragraph', + children: [] as PhrasingContent[], + }; + wrappedChildren.push(paragraphChild); + if (content.length) { + paragraphChild.children.push({ + type: 'text', + value: content, + }); + } + paragraphChild.children.push(...node.children.slice(1, -1)); + // If the inserted paragraph is empty, we remove it + if (paragraphChild.children.length === 0) { + wrappedChildren.pop(); + } + const lastChildInNode = node.children[node.children.length - 1]; + // We find the end of the container directive in current paragraph if ( - node.type !== 'paragraph' || - // 1. We get the paragraph and check if it is a container directive - node.children[0].type !== 'text' + lastChildInNode.type === 'text' && + REGEX_END.test(lastChildInNode.value) ) { + const lastChildInNodeText = lastChildInNode.value; + const matchedEndContent = lastChildInNodeText.slice(0, -3).trimEnd(); + // eslint-disable-next-line max-depth + if (wrappedChildren.length) { + (wrappedChildren[0] as Paragraph).children.push({ + type: 'text', + value: matchedEndContent, + }); + } else if (matchedEndContent) { + wrappedChildren.push({ + type: 'paragraph', + children: [ + { + type: 'text', + value: matchedEndContent, + }, + ], + }); + } + const newChild = createContainer(type, title, wrappedChildren); + tree.children.splice(i, 1, newChild as RootContent); i++; continue; } - const firstTextNode = node.children[0]; - const text = firstTextNode.value; - const metaText = text.split('\n')[0]; - const content = text.slice(metaText.length); - const match = metaText.match(REGEX_BEGIN); - if (!match) { - i++; - continue; + if (lastChildInNode !== firstTextNode && wrappedChildren.length) { + // We don't find the end of the container directive in current paragraph + (wrappedChildren[0] as Paragraph).children.push(lastChildInNode); } - const [, type, rawTitle] = match; - // In .md, we can get :::tip{title="foo"} in the first text node - // In .mdx, we get :::tip in first node and {title="foo"} in second node - let title = parseTitle(rawTitle); - // :::tip{title="foo"} - const titleExpressionNode = - node.children[1] && node.children[1].type === 'mdxTextExpression' - ? node.children[1] - : null; - // Handle the case of `::: tip {title="foo"}` - if (titleExpressionNode) { - title = parseTitle((titleExpressionNode as Literal).value, true); - // {title="foo"} is not a part of the content, So we need to remove it - node.children.splice(1, 1); - } - if (!DIRECTIVE_TYPES.includes(type as DirectiveType)) { - i++; - continue; - } - // 2. If it is, we remove the paragraph and create a container directive - const wrappedChildren: (BlockContent | PhrasingContent)[] = []; - // 2.1 case: with no newline between `:::` and `:::`, for example + + // 2.3 The final case: has newline after the start of container, for example: // ::: tip + // // This is a tip // ::: - // Here the content is `::: tip\nThis is a tip\n:::` - if (content?.endsWith(':::')) { - wrappedChildren.push({ - type: 'paragraph', - children: [ - { - type: 'text', - value: content.replace(REGEX_END, ''), - }, - ], - }); - const newChild = createContainer(type, title, wrappedChildren); - tree.children.splice(i, 1, newChild as RootContent); - } else { - // 2.2 case: with newline before the end of container, for example: - // ::: tip - // This is a tip - // - // ::: - // Here the content is `::: tip\nThis is a tip` - const paragraphChild: Paragraph = { - type: 'paragraph', - children: [] as PhrasingContent[], - }; - wrappedChildren.push(paragraphChild); - if (content.length) { - paragraphChild.children.push({ - type: 'text', - value: content, - }); - } - paragraphChild.children.push(...node.children.slice(1, -1)); - // If the inserted paragraph is empty, we remove it - if (paragraphChild.children.length === 0) { - wrappedChildren.pop(); + + // All of the above cases need to crawl the children of the container directive node. + // In other word, We look for the next paragraph nodes and collect all the content until we find the end of the container directive + let j = i + 1; + while (j < tree.children.length) { + const currentParagraph = tree.children[j]; + if (currentParagraph.type !== 'paragraph') { + wrappedChildren.push(currentParagraph as BlockContent); + j++; + continue; } - const lastChildInNode = node.children[node.children.length - 1]; - // We find the end of the container directive in current paragraph + const lastChild = + currentParagraph.children[currentParagraph.children.length - 1]; + // The whole paragraph doesn't arrive at the end of the container directive, we collect the whole paragraph if ( - lastChildInNode.type === 'text' && - REGEX_END.test(lastChildInNode.value) + lastChild !== firstTextNode && + (lastChild.type !== 'text' || !REGEX_END.test(lastChild.value)) ) { - const lastChildInNodeText = lastChildInNode.value; - const matchedEndContent = lastChildInNodeText.slice(0, -3).trimEnd(); - // eslint-disable-next-line max-depth - if (wrappedChildren.length) { - (wrappedChildren[0] as Paragraph).children.push({ - type: 'text', - value: matchedEndContent, - }); - } else if (matchedEndContent) { + wrappedChildren.push({ + ...currentParagraph, + children: currentParagraph.children.filter( + child => child !== firstTextNode, + ), + }); + j++; + } else { + // 3. We find the end of the container directive + // Then create the container directive, and remove the original paragraphs + // Finally, we insert the new container directive and break the loop + const lastChildText = lastChild.value; + const matchedEndContent = lastChildText.slice(0, -3).trimEnd(); + const filteredChildren = currentParagraph.children.filter( + child => child !== firstTextNode && child !== lastChild, + ); + + if (matchedEndContent) { wrappedChildren.push({ type: 'paragraph', children: [ + ...filteredChildren, { type: 'text', value: matchedEndContent, }, ], }); - } - const newChild = createContainer(type, title, wrappedChildren); - tree.children.splice(i, 1, newChild as RootContent); - i++; - continue; - } - - if (lastChildInNode !== firstTextNode && wrappedChildren.length) { - // We don't find the end of the container directive in current paragraph - (wrappedChildren[0] as Paragraph).children.push(lastChildInNode); - } - - // 2.3 The final case: has newline after the start of container, for example: - // ::: tip - // - // This is a tip - // ::: - - // All of the above cases need to crawl the children of the container directive node. - // In other word, We look for the next paragraph nodes and collect all the content until we find the end of the container directive - let j = i + 1; - while (j < tree.children.length) { - const currentParagraph = tree.children[j]; - if (currentParagraph.type !== 'paragraph') { - wrappedChildren.push(currentParagraph as BlockContent); - j++; - continue; - } - const lastChild = - currentParagraph.children[currentParagraph.children.length - 1]; - // The whole paragraph doesn't arrive at the end of the container directive, we collect the whole paragraph - if ( - lastChild !== firstTextNode && - (lastChild.type !== 'text' || !REGEX_END.test(lastChild.value)) - ) { - wrappedChildren.push({ - ...currentParagraph, - children: currentParagraph.children.filter( - child => child !== firstTextNode, - ), - }); - j++; } else { - // 3. We find the end of the container directive - // Then create the container directive, and remove the original paragraphs - // Finally, we insert the new container directive and break the loop - const lastChildText = lastChild.value; - const matchedEndContent = lastChildText.slice(0, -3).trimEnd(); - const filteredChildren = currentParagraph.children.filter( - child => child !== firstTextNode && child !== lastChild, - ); - - if (matchedEndContent) { - wrappedChildren.push({ - type: 'paragraph', - children: [ - ...filteredChildren, - { - type: 'text', - value: matchedEndContent, - }, - ], - }); - } else { - wrappedChildren.push(...filteredChildren); - } - - const newChild = createContainer(type, title, wrappedChildren); - tree.children.splice(i, j - i + 1, newChild as RootContent); - break; + wrappedChildren.push(...filteredChildren); } + + const newChild = createContainer(type, title, wrappedChildren); + tree.children.splice(i, j - i + 1, newChild as RootContent); + break; } } - i++; } - } catch (e) { - console.log(e); - throw e; + i++; } } export const remarkContainerSyntax: Plugin<[], Root> = () => { + const unknownTypes = new Set(); + + const warnUnknownType = (type: string | undefined) => { + if (!type || type === CALLOUT_COMPONENT) { + return; + } + if (unknownTypes.has(type)) { + return; + } + unknownTypes.add(type); + const supportedTypes = DIRECTIVE_TYPES.join(', '); + throw new Error( + `${ERROR_PREFIX} Unknown container directive type "${type}". Supported types: ${supportedTypes}`, + ); + }; + return tree => { - transformer(tree); + transformer(tree, warnUnknownType); tree.children.unshift( getNamedImportAstNode('Callout', CALLOUT_COMPONENT, '@theme'), ); diff --git a/packages/core/src/node/mdx/remarkPlugins/link.ts b/packages/core/src/node/mdx/remarkPlugins/link.ts index 19ca70a59..28efa2759 100644 --- a/packages/core/src/node/mdx/remarkPlugins/link.ts +++ b/packages/core/src/node/mdx/remarkPlugins/link.ts @@ -148,17 +148,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 (search) { url += `?${search}`; } 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/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..a4ac16ab0 --- /dev/null +++ b/packages/core/src/node/ssg-md/react/render.test.tsx @@ -0,0 +1,228 @@ +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}!\`; + }" + \`\`\` + " + `); + }); +}); + +describe('renderToMarkdownString - styles', () => { + it('renders two row correctly', async () => { + const Comp1 = () => { + return ( + <> +
Row 1
+
Row 2
+ + ); + }; + + expect(await renderToMarkdownString()).toMatchInlineSnapshot( + `"Row 1Row 2"`, + ); + }); +}); 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..4c2f3c25f --- /dev/null +++ b/packages/core/src/node/ssg-md/remarkSplitMdx.test.ts @@ -0,0 +1,515 @@ +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); + } + " + `); + }); +}); 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/App.tsx b/packages/core/src/runtime/App.tsx index ea1c8e011..c04f616fa 100644 --- a/packages/core/src/runtime/App.tsx +++ b/packages/core/src/runtime/App.tsx @@ -1,4 +1,4 @@ -import { PageContext, useLocation } from '@rspress/core/runtime'; +import { Content, PageContext, useLocation } from '@rspress/core/runtime'; import { Layout } from '@theme'; import React, { useContext, useLayoutEffect } from 'react'; import globalComponents from 'virtual-global-components'; @@ -38,6 +38,10 @@ export function App() { frontmatter[GLOBAL_COMPONENTS_KEY] === false || query.get(GLOBAL_COMPONENTS_KEY) === QueryStatus.Hide; + if (process.env.__SSR_MD__) { + return ; + } + return ( <> diff --git a/packages/core/src/runtime/ssrMdServerEntry.tsx b/packages/core/src/runtime/ssrMdServerEntry.tsx new file mode 100644 index 000000000..b8b798406 --- /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 { + PageContext, + pathnameToRouteService, + removeTrailingSlash, + ThemeContext, + withBase, +} from '@rspress/runtime'; +import { StaticRouter } from '@rspress/runtime/server'; +import { type Unhead, UnheadProvider } from '@unhead/react/server'; +import { App } from './App'; +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/core/src/theme/components/CodeBlock/index.scss b/packages/core/src/theme/components/CodeBlock/index.scss index 1db5da02b..c0df7103a 100644 --- a/packages/core/src/theme/components/CodeBlock/index.scss +++ b/packages/core/src/theme/components/CodeBlock/index.scss @@ -4,13 +4,9 @@ border: var(--rp-code-block-border); overflow-x: auto; transition: none; - - @media (min-width: 641px) { - border-radius: var(--rp-radius); - } + border-radius: var(--rp-radius); @media (max-width: 640px) { - border-radius: var(--rp-radius-small); margin: 24px 0; contain: content; } diff --git a/packages/core/src/theme/components/HomeBackground/index.scss b/packages/core/src/theme/components/HomeBackground/index.scss index 4eb650e4a..a832bc9a9 100644 --- a/packages/core/src/theme/components/HomeBackground/index.scss +++ b/packages/core/src/theme/components/HomeBackground/index.scss @@ -2,7 +2,6 @@ width: 100%; height: 1000px; position: absolute; - // top: calc(-1 * var(--rp-nav-height)); top: 0; left: 0; @@ -11,23 +10,7 @@ pointer-events: none; user-select: none; - background: - radial-gradient( - 42.12% 56.13% at 100% 0%, - rgba(83, 125, 255, 0.1) 0%, - rgba(255, 255, 255, 0) 100% - ), - radial-gradient( - 42.01% 79.63% at 52.86% 0%, - rgba(83, 125, 255, 0.2) 0%, - rgba(255, 255, 255, 0) 100% - ), - radial-gradient( - 79.67% 58.09% at 0% 0%, - rgba(126, 105, 255, 0.2) 0%, - rgba(255, 255, 255, 0) 100% - ), - #fff; + background: var(--rp-home-background-bg); } .rp-dark .rp-home-background { diff --git a/packages/core/src/theme/components/HomeFeature/index.scss b/packages/core/src/theme/components/HomeFeature/index.scss index 06e6f2681..5e6531025 100644 --- a/packages/core/src/theme/components/HomeFeature/index.scss +++ b/packages/core/src/theme/components/HomeFeature/index.scss @@ -6,9 +6,11 @@ flex-wrap: wrap; max-width: 72rem; gap: var(--rp-home-feature-gap); + padding: 10px; &__item { width: 100%; + position: relative; &--span-2, &--span-4, @@ -52,6 +54,7 @@ } &__item-wrapper { + position: relative; height: 100%; } @@ -61,6 +64,7 @@ border-radius: 2rem; border: 1px solid var(--rp-c-divider-light); background: var(--rp-home-feature-bg); + // fill: var(--rp-home-feature-bg-fill); backdrop-filter: blur(10px); transition: all 0.3s; diff --git a/packages/core/src/theme/components/HomeFeature/index.tsx b/packages/core/src/theme/components/HomeFeature/index.tsx index 5e4648ce7..19f2a2019 100644 --- a/packages/core/src/theme/components/HomeFeature/index.tsx +++ b/packages/core/src/theme/components/HomeFeature/index.tsx @@ -4,6 +4,7 @@ import type { JSX } from 'react'; import { renderHtmlOrText } from '../../logic/utils'; import { useNavigate } from '../Link/useNavigate'; import './index.scss'; +import { useCardAnimation } from './useCardAnimation'; const getGridClass = (feature: Feature): string => { const { span } = feature; @@ -24,17 +25,19 @@ const getGridClass = (feature: Feature): string => { }; function HomeFeatureItem({ feature }: { feature: Feature }): JSX.Element { - const { icon, title, details, link: rawLink } = feature; + const { icon, title, details, link } = feature; - const link = rawLink; const navigate = useNavigate(); + const { innerProps, outerProps, outerRef, shineDom } = useCardAnimation(); return (
-
+

+ {shineDom}
); } diff --git a/packages/core/src/theme/components/HomeFeature/useCardAnimation.tsx b/packages/core/src/theme/components/HomeFeature/useCardAnimation.tsx new file mode 100644 index 000000000..abeb1fd79 --- /dev/null +++ b/packages/core/src/theme/components/HomeFeature/useCardAnimation.tsx @@ -0,0 +1,125 @@ +import { type HTMLAttributes, useRef, useState } from 'react'; + +export const useCardAnimation = () => { + const [pageX, setPageX] = useState(null); + const [pageY, setPageY] = useState(null); + const [isHovering, setIsHovering] = useState(false); + const ref = useRef(null); + + const handleMove = ({ pageX, pageY }: { pageX: number; pageY: number }) => { + setPageX(pageX); + setPageY(pageY); + }; + + const handleTouchMove = (evt: any) => { + evt.preventDefault(); + const { pageX, pageY } = evt.touches[0]; + handleMove({ pageX, pageY }); + }; + + let shine: string; + let shineBg: string; + let container: string; + let outerContainer: string; + const handleEnter = () => { + setIsHovering(true); + }; + const handleLeave = () => { + setIsHovering(false); + }; + + const ele = ref.current; + if (pageX && pageY && ele && isHovering) { + const rootElemWidth = ele.clientWidth || ele.offsetWidth || ele.scrollWidth; + const rootElemHeight = + ele.clientHeight || ele.offsetHeight || ele.scrollHeight; + + const bodyScrollTop = + document.body.scrollTop || + document.getElementsByTagName('html')[0].scrollTop; + const bodyScrollLeft = document.body.scrollLeft; + + const offsets = ele.getBoundingClientRect(); + const wMultiple = 320 / rootElemWidth; + const multiple = wMultiple * 0.05; + const offsetX = + 0.52 - (pageX - offsets.left - bodyScrollLeft) / rootElemWidth; + const offsetY = + 0.52 - (pageY - offsets.top - bodyScrollTop) / rootElemHeight; + const dy = pageY - offsets.top - bodyScrollTop - rootElemHeight / 2; + const dx = pageX - offsets.left - bodyScrollLeft - rootElemWidth / 2; + const yRotate = (offsetX - dx) * multiple; + const xRotate = + (dy - offsetY) * (Math.min(offsets.width / offsets.height, 1) * multiple); + const arad = Math.atan2(dy, dx); + const rawAngle = (arad * 180) / Math.PI - 90; + const angle = rawAngle < 0 ? rawAngle + 360 : rawAngle; + + shine = `translateX(${offsetX - 0.1}px) translateY(${offsetY - 0.1}px)`; + shineBg = `linear-gradient(${angle}deg, rgba(255, 255, 255, ${ + ((pageY - offsets.top - bodyScrollTop) / rootElemHeight) * 0.2 + }) 0%, rgba(255, 255, 255, 0) 50%)`; + + container = `rotateX(${xRotate}deg) rotateY(${yRotate}deg)`; + outerContainer = `perspective(${rootElemWidth * 2}px)`; + } else { + shine = ''; + shineBg = ''; + container = ''; + outerContainer = ''; + } + + const outerProps: HTMLAttributes = { + style: { + transform: outerContainer, + transformStyle: 'preserve-3d', + }, + onMouseEnter: handleEnter, + onMouseLeave: handleLeave, + onMouseMove: handleMove, + onTouchMove: handleTouchMove, + onTouchStart: handleEnter, + onTouchEnd: handleLeave, + }; + const outerRef = ref; + + const innerProps = { + style: { + transform: container, + }, + }; + + const shineDom = ( +
+ ); + + return { + outerProps, + outerRef, + innerProps, + shineDom, + }; +}; diff --git a/packages/core/src/theme/components/HomeHero/index.scss b/packages/core/src/theme/components/HomeHero/index.scss index bb6f4236d..130a353b2 100644 --- a/packages/core/src/theme/components/HomeHero/index.scss +++ b/packages/core/src/theme/components/HomeHero/index.scss @@ -67,12 +67,7 @@ height: 8px; margin-right: 8px; border-radius: 50%; - background: linear-gradient( - 90deg, - var(--rp-c-brand-dark) 0%, - var(--rp-c-brand-dark) 30%, - #a673ff 100% - ); + background: var(--rp-home-hero-title-bg); } display: inline-flex; align-items: center; @@ -97,16 +92,11 @@ } &__title-brand { - background: linear-gradient( - 90deg, - var(--rp-c-brand-dark) 0%, - var(--rp-c-brand-dark) 30%, - #a673ff 100% - ); + background: var(--rp-home-hero-title-bg); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; - color: transparent; + color: var(--rp-home-hero-title-color); width: 640px; } diff --git a/packages/core/src/theme/components/NavScreen/NavScreenLangs.scss b/packages/core/src/theme/components/NavScreen/NavScreenLangs.scss index f7aff2f6d..2075021e2 100644 --- a/packages/core/src/theme/components/NavScreen/NavScreenLangs.scss +++ b/packages/core/src/theme/components/NavScreen/NavScreenLangs.scss @@ -27,23 +27,18 @@ } .rp-nav-screen-langs-group { - display: flex; - flex-direction: column; - align-items: flex-end; - - gap: 8px; width: 100%; - height: 0px; - overflow: hidden; - transition: height 0.3s ease; - font-size: 14px; + &__inner { + overflow: hidden; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + font-size: 14px; + } &__item { padding: 0px 24px; } - - &--open { - height: auto; - } } diff --git a/packages/core/src/theme/components/NavScreen/NavScreenLangs.tsx b/packages/core/src/theme/components/NavScreen/NavScreenLangs.tsx index 73bd8e22e..13f2bfebb 100644 --- a/packages/core/src/theme/components/NavScreen/NavScreenLangs.tsx +++ b/packages/core/src/theme/components/NavScreen/NavScreenLangs.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useLangsMenu } from '../Nav/hooks'; import './NavScreenLangs.scss'; import { Link } from '@theme'; -import clsx from 'clsx'; import { SvgDown } from './NavScreenMenuItem'; export function NavScreenLangs() { @@ -16,27 +15,29 @@ export function NavScreenLangs() {
{activeValue}
- {items.map(item => ( - - {item.text} - - ))} +
+ {items.map(item => ( + + {item.text} + + ))} +
) : null; diff --git a/packages/core/src/theme/components/NavScreen/NavScreenMenuItem.scss b/packages/core/src/theme/components/NavScreen/NavScreenMenuItem.scss index 227993391..3cccb3c53 100644 --- a/packages/core/src/theme/components/NavScreen/NavScreenMenuItem.scss +++ b/packages/core/src/theme/components/NavScreen/NavScreenMenuItem.scss @@ -46,24 +46,14 @@ &__group { width: 100%; + } - height: 0px; + &__group-inner { overflow: hidden; - transition: - height 0.3s ease, - opacity 0.3s ease; - display: flex; flex-direction: column; align-items: center; gap: 2px; - margin-left: 8px; - opacity: 0; - - &--open { - height: auto; - opacity: 1; - } } } diff --git a/packages/core/src/theme/components/NavScreen/NavScreenMenuItem.tsx b/packages/core/src/theme/components/NavScreen/NavScreenMenuItem.tsx index 457391d15..f67134397 100644 --- a/packages/core/src/theme/components/NavScreen/NavScreenMenuItem.tsx +++ b/packages/core/src/theme/components/NavScreen/NavScreenMenuItem.tsx @@ -114,14 +114,22 @@ export function NavScreenMenuItemWithChildren({ />
- {menuItem.items.map(item => ( - - ))} +
+ {menuItem.items.map(item => ( + + ))} +
) : ( diff --git a/packages/core/src/theme/components/NavScreen/index.tsx b/packages/core/src/theme/components/NavScreen/index.tsx index b4362ac13..9e699c3af 100644 --- a/packages/core/src/theme/components/NavScreen/index.tsx +++ b/packages/core/src/theme/components/NavScreen/index.tsx @@ -26,9 +26,20 @@ export function NavScreen(props: NavScreenProps) { useEffect(() => { if (screen.current && isScreenOpen) { disableBodyScroll(screen.current, { reserveScrollBarGap: true }); + const style = `:root { --rp-home-background-bg: transparent; }`; + const styleElement = document.createElement('style'); + styleElement.id = 'rp-nav-screen-body-lock-style'; + styleElement.innerHTML = style; + document.head.appendChild(styleElement); } return () => { clearAllBodyScrollLocks(); + const styleElement = document.getElementById( + 'rp-nav-screen-body-lock-style', + ); + if (styleElement) { + document.head.removeChild(styleElement); + } }; }, [isScreenOpen]); diff --git a/packages/core/src/theme/components/Outline/index.scss b/packages/core/src/theme/components/Outline/index.scss index 061a0693d..c47cd2201 100644 --- a/packages/core/src/theme/components/Outline/index.scss +++ b/packages/core/src/theme/components/Outline/index.scss @@ -1,11 +1,16 @@ +:root { + --rp-outline-padding-x: 20px; +} + .rp-outline { display: flex; flex-direction: column; border-left: 1px solid var(--rp-c-divider-light); - padding-left: 20px; - padding-right: 20px; &__title { + padding-left: var(--rp-outline-padding-x); + padding-right: var(--rp-outline-padding-x); + font-size: 14px; font-weight: 700; height: 32px; @@ -23,6 +28,9 @@ } &__divider { + margin-left: var(--rp-outline-padding-x); + margin-right: var(--rp-outline-padding-x); + height: 1px; background: var(--rp-c-divider-light); margin-top: 16px; @@ -30,6 +38,9 @@ } &__toc { + padding-left: var(--rp-outline-padding-x); + padding-right: var(--rp-outline-padding-x); + display: flex; flex-direction: column; flex: 1; @@ -41,4 +52,9 @@ overflow: auto scroll; } } + + &__bottom { + padding-left: var(--rp-outline-padding-x); + padding-right: var(--rp-outline-padding-x); + } } diff --git a/packages/core/src/theme/components/PageTabs/index.scss b/packages/core/src/theme/components/PageTabs/index.scss index 674433773..8ab7a70d9 100644 --- a/packages/core/src/theme/components/PageTabs/index.scss +++ b/packages/core/src/theme/components/PageTabs/index.scss @@ -16,16 +16,23 @@ color: var(--rp-c-text-2); border-bottom: 2px solid transparent; - &--selected { + &:hover { + border-bottom: 2px solid var(--rp-c-divider-light); + color: var(--rp-c-text-1); + } + + body[data-page-tabs-active-index='0'] &[data-index='0'], + body[data-page-tabs-active-index='1'] &[data-index='1'], + body[data-page-tabs-active-index='2'] &[data-index='2'], + body[data-page-tabs-active-index='3'] &[data-index='3'], + body[data-page-tabs-active-index='4'] &[data-index='4'], + body[data-page-tabs-active-index='5'] &[data-index='5'], + body[data-page-tabs-active-index='6'] &[data-index='6'], + body[data-page-tabs-active-index='7'] &[data-index='7'] { font-weight: 700; color: var(--rp-c-text-0); border-bottom: 2px solid var(--rp-c-brand-dark); } - - &--not-selected:hover { - border-bottom: 2px solid var(--rp-c-divider-light); - color: var(--rp-c-text-1); - } } } @@ -36,10 +43,16 @@ display: none; } - &--hidden { - display: none; - } - &--active { + display: none; + + body[data-page-tabs-active-index='0'] &[data-index='0'], + body[data-page-tabs-active-index='1'] &[data-index='1'], + body[data-page-tabs-active-index='2'] &[data-index='2'], + body[data-page-tabs-active-index='3'] &[data-index='3'], + body[data-page-tabs-active-index='4'] &[data-index='4'], + body[data-page-tabs-active-index='5'] &[data-index='5'], + body[data-page-tabs-active-index='6'] &[data-index='6'], + body[data-page-tabs-active-index='7'] &[data-index='7'] { display: block; } } diff --git a/packages/core/src/theme/components/PageTabs/index.tsx b/packages/core/src/theme/components/PageTabs/index.tsx index 5a4a7ea62..807fdc2f6 100644 --- a/packages/core/src/theme/components/PageTabs/index.tsx +++ b/packages/core/src/theme/components/PageTabs/index.tsx @@ -1,98 +1,90 @@ -import { useLocation, useNavigate } from '@rspress/core/runtime'; +import { useSearchParams } from '@rspress/core/runtime'; import clsx from 'clsx'; import { Children, - type ComponentPropsWithRef, type ForwardedRef, forwardRef, isValidElement, type ReactElement, type ReactNode, + useEffect, useMemo, } from 'react'; import './index.scss'; -type TabItem = { - value?: string; +type PageTabItem = { label?: string | ReactNode; + content?: ReactNode; }; function getTabValuesFromChildren( children: ReactElement[], -): TabItem[] { - return Children.map>(children, child => { - if (isValidElement(child)) { - return { - label: child.props?.label || undefined, - value: - child.props?.value || (child.props?.label as string) || undefined, - }; - } +): PageTabItem[] { + return Children.map>( + children, + (child, index) => { + if (isValidElement(child)) { + return { + label: child.props?.label || undefined, + content: children[index], + } satisfies PageTabItem; + } - return { - label: undefined, - value: undefined, - }; - }); + return { + label: index, + content: children[index], + } satisfies PageTabItem; + }, + ); } export interface PageTabsProps { - values?: ReactNode[] | ReadonlyArray | TabItem[]; + values?: ReactNode[] | ReadonlyArray | PageTabItem[]; /** + * determine the query parameter name for the current tab * @default 'page'' */ id?: string; children: ReactNode; - tabContainerClassName?: string; + className?: string; tabPosition?: 'left' | 'center'; } -function isTabItem(item: unknown): item is TabItem { - if (item && typeof item === 'object' && 'label' in item) { - return true; - } - return false; -} - -const renderTab = (item: ReactNode | TabItem) => { - if (isTabItem(item)) { - return item.label || item.value; - } - return item; -}; - -function usePageTabs(id: string, rawChildren: ReactNode) { - // remove "\n" character when write JSX element in multiple lines, use Children.toArray for Tabs with no Tab element - const children = Children.toArray(rawChildren).filter( - child => !(typeof child === 'string' && child.trim() === ''), - ) as unknown as ReactElement[]; - const navigate = useNavigate(); +function usePageTabs(id: string, children: ReactElement[]) { + const [searchParams, setSearchParams] = useSearchParams(); const tabValues = useMemo(() => { return getTabValuesFromChildren(children); - }, [rawChildren]); - - const { search } = useLocation(); + }, [children]); function navigateToTab(index: number) { - const urlSearchParams = new URLSearchParams(search); if (index === 0) { - urlSearchParams.delete(id); + searchParams.delete(id); } else { - urlSearchParams.set(id, String(index)); + searchParams.set(id, String(index)); } - navigate({ - search: urlSearchParams.toString(), - }); + setSearchParams(searchParams); } - const currentIndex = Number(new URLSearchParams(search).get(id)) || 0; + useEffect(() => { + const currIndex = Number(searchParams.get(id) ?? '0'); + if (!Number.isNaN(currIndex)) { + document.body.dataset.pageTabsActiveIndex = currIndex.toString(); + } + }, [searchParams]); + + const injectScript = `(function () { + var searchParams = new URLSearchParams(window.location.search); + var currIndex = Number(searchParams.get('${id}') || 0); + if (!Number.isNaN(currIndex)) { + document.body.dataset.pageTabsActiveIndex = currIndex; + } + })();`; return { - children, - currentIndex, tabValues, navigateToTab, + injectScript, }; } @@ -103,21 +95,32 @@ export const PageTabs = forwardRef( const { children: rawChildren, tabPosition = 'left', - tabContainerClassName, + className, id = 'page', } = props; - const { children, currentIndex, tabValues, navigateToTab } = usePageTabs( + // remove "\n" character when write JSX element in multiple lines, use Children.toArray for Tabs with no Tab element + const children = Children.toArray(rawChildren).filter( + child => + !(typeof child === 'string' && child.trim() === '') && + isValidElement(child), + ) as unknown as ReactElement[]; + + const { tabValues, navigateToTab, injectScript } = usePageTabs( id, - rawChildren, + children, ); renderCountForTocUpdate++; return ( <> - {/* */} -
+ {/* First screen during SSR */} +