From 6787b7c481e7a6396e679c2d0b674ef9459b56ad Mon Sep 17 00:00:00 2001 From: sunyiteng Date: Mon, 18 May 2026 16:07:28 +0800 Subject: [PATCH] fix(blog-list): support ssg markdown output --- .prettierignore | 1 + .storybook/main.ts | 11 +++++ package.json | 4 ++ pnpm-lock.yaml | 64 +++++++++++++++++++++++++++ rslib.config.ts | 7 +++ rstest.config.ts | 13 ++++++ src/blog-list/index.test.tsx | 67 ++++++++++++++++++++++++++++ src/blog-list/index.tsx | 15 +++++++ src/blog-list/ssg-md.ts | 85 ++++++++++++++++++++++++++++++++++++ src/env.d.ts | 4 ++ 10 files changed, 271 insertions(+) create mode 100644 rstest.config.ts create mode 100644 src/blog-list/index.test.tsx create mode 100644 src/blog-list/ssg-md.ts diff --git a/.prettierignore b/.prettierignore index bd5535a..0290c7b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ pnpm-lock.yaml +.claude/ diff --git a/.storybook/main.ts b/.storybook/main.ts index cd8dba4..5da8d74 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -4,6 +4,17 @@ const config: StorybookConfig = { framework: 'storybook-react-rsbuild', stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'], addons: ['@storybook/addon-themes', 'storybook-addon-rslib'], + rsbuildFinal(config) { + config.source ??= {}; + config.source.define = { + ...config.source.define, + 'import.meta.env': JSON.stringify({ + SSG_MD: false, + }), + }; + + return config; + }, }; export default config; diff --git a/package.json b/package.json index 03d45ae..38fa3cb 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "prepare": "rslib && simple-git-hooks", "build": "rslib", "build:watch": "rslib -w", + "test": "rstest", "lint": "rslint && prettier -c .", "lint:write": "rslint --fix && prettier -w .", "lint:fix": "pnpm lint:write", @@ -101,6 +102,8 @@ "@rslint/core": "^0.5.3", "@rspress/core": "^2.0.11", "@rstack-dev/doc-ui": "workspace:*", + "@rstest/adapter-rslib": "^0.2.2", + "@rstest/core": "^0.9.10", "@storybook/addon-themes": "^10.4.0", "@storybook/react": "^10.4.0", "@storybook/test": "9.0.0-alpha.2", @@ -115,6 +118,7 @@ "prettier": "~3.8.3", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-render-to-markdown": "^19.1.0", "rimraf": "~6.1.3", "semver": "7.8.0", "simple-git-hooks": "^2.13.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 162f370..2300191 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: '@rstack-dev/doc-ui': specifier: workspace:* version: 'link:' + '@rstest/adapter-rslib': + specifier: ^0.2.2 + version: 0.2.2(@rslib/core@0.21.5(typescript@6.0.3))(@rstest/core@0.9.10)(typescript@6.0.3) + '@rstest/core': + specifier: ^0.9.10 + version: 0.9.10 '@storybook/addon-themes': specifier: ^10.4.0 version: 10.4.0(storybook@10.4.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) @@ -68,6 +74,9 @@ importers: react-dom: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) + react-render-to-markdown: + specifier: ^19.1.0 + version: 19.1.0(react@19.2.6) rimraf: specifier: ~6.1.3 version: 6.1.3 @@ -1396,6 +1405,29 @@ packages: '@rspress/shared@2.0.11': resolution: {integrity: sha512-7l5Pso4s597utJyisVEnd7n/40h053nfE8DwGQMeS8RLGtSwVgxFwNHsSrvQEGtFlLrg2aWWSITqnAVO1wfTew==} + '@rstest/adapter-rslib@0.2.2': + resolution: {integrity: sha512-pDfROoS8EY5mzxEmy9KVcd+Xila3QYeovlygCVmoc5p95qJ+3h+adr+elHFy/OZWUn/GMDHwYfhAR7BHQyQUKA==} + peerDependencies: + '@rslib/core': '>=0.18.6' + '@rstest/core': '>=0.7.7' + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + '@rstest/core@0.9.10': + resolution: {integrity: sha512-JUSUYYXWIHEBUn193u2RglZujvGPv46Blxpl17QFwc0y9vEXe2y/IfGwGrVsJfZz7PaWZ5NnbFf0J55YIIns+g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + happy-dom: ^20.8.3 + jsdom: '*' + peerDependenciesMeta: + happy-dom: + optional: true + jsdom: + optional: true + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -2992,6 +3024,11 @@ packages: peerDependencies: react: '>=19' + react-render-to-markdown@19.1.0: + resolution: {integrity: sha512-dF9b3tO41ezqdmHP8X92kbHbMexJ6iC7iHw4ykC8fwiO7DgpFc9PhMoKlI+BcPzRxGcWgQSdrixVB9RykhjJpQ==} + peerDependencies: + react: '>=19' + react-router-dom@7.15.1: resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==} engines: {node: '>=20.0.0'} @@ -3508,6 +3545,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} @@ -4960,6 +5001,22 @@ snapshots: - '@module-federation/runtime-tools' - core-js + '@rstest/adapter-rslib@0.2.2(@rslib/core@0.21.5(typescript@6.0.3))(@rstest/core@0.9.10)(typescript@6.0.3)': + dependencies: + '@rslib/core': 0.21.5(typescript@6.0.3) + '@rstest/core': 0.9.10 + optionalDependencies: + typescript: 6.0.3 + + '@rstest/core@0.9.10': + dependencies: + '@rsbuild/core': 2.0.6 + '@types/chai': 5.2.3 + tinypool: 2.1.0 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - core-js + '@sec-ant/readable-stream@0.4.1': {} '@shikijs/core@4.0.2': @@ -7028,6 +7085,11 @@ snapshots: react: 19.2.6 react-reconciler: 0.33.0(react@19.2.6) + react-render-to-markdown@19.1.0(react@19.2.6): + dependencies: + react: 19.2.6 + react-reconciler: 0.33.0(react@19.2.6) + react-router-dom@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 @@ -7585,6 +7647,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@2.1.0: {} + tinyrainbow@1.2.0: {} tinyrainbow@2.0.0: {} diff --git a/rslib.config.ts b/rslib.config.ts index 2bf4a3e..f937346 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -4,6 +4,13 @@ import { defineConfig, rspack } from '@rslib/core'; export default defineConfig({ plugins: [pluginReact(), pluginSass()], + source: { + define: { + // Keep the Rspress SSG-MD runtime flag in library output. + // Consumers replace `import.meta.env.SSG_MD` in their own builds. + 'import.meta.env': 'import.meta.env', + }, + }, lib: [ { syntax: 'es2018', diff --git a/rstest.config.ts b/rstest.config.ts new file mode 100644 index 0000000..5b0fbe8 --- /dev/null +++ b/rstest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core'; +import { withRslibConfig } from '@rstest/adapter-rslib'; + +export default defineConfig({ + extends: withRslibConfig(), + include: ['src/**/*.test.{ts,tsx}'], + testEnvironment: 'node', + source: { + define: { + 'import.meta.env.SSG_MD': true, + }, + }, +}); diff --git a/src/blog-list/index.test.tsx b/src/blog-list/index.test.tsx new file mode 100644 index 0000000..14324b3 --- /dev/null +++ b/src/blog-list/index.test.tsx @@ -0,0 +1,67 @@ +import { expect, test } from '@rstest/core'; +import { renderToMarkdownString } from 'react-render-to-markdown'; +import { BlogList, type BlogListItem } from './index'; + +const posts: BlogListItem[] = [ + { + title: 'Announcing Rspack 2.0', + href: '/blog/announcing-2-0', + date: '2026-04-22', + description: + 'Rspack 2.0 is out with modern defaults, API design, and build outputs.', + authors: [ + { + name: 'Rspack Team', + avatar: 'https://rspack.rs/logo.png', + }, + ], + }, + { + title: 'Bundler tree shaking principles and differences', + href: '/blog/tree-shaking', + date: '2025-07-31', + description: + 'A brief overview of tree shaking principles across different bundlers.', + authors: [ + { + name: 'ahabhgk', + avatar: 'https://github.com/ahabhgk.png', + }, + ], + }, +]; + +test('renders blog list as markdown when SSG_MD is enabled', async () => { + const markdown = await renderToMarkdownString( + + Browse release notes and technical deep dives from{' '} + @rspack_dev. + + } + />, + ); + + expect(markdown).toMatchInlineSnapshot(` + "# Rspack blogs + + Browse release notes and technical deep dives from @rspack_dev. + + ## [Announcing Rspack 2.0](/blog/announcing-2-0) + + > April 22, 2026 · Rspack Team + + Rspack 2.0 is out with modern defaults, API design, and build outputs. + + ## [Bundler tree shaking principles and differences](/blog/tree-shaking) + + > July 31, 2025 · ahabhgk + + A brief overview of tree shaking principles across different bundlers. + " + `); +}); diff --git a/src/blog-list/index.tsx b/src/blog-list/index.tsx index d038f05..0d0abbb 100644 --- a/src/blog-list/index.tsx +++ b/src/blog-list/index.tsx @@ -8,6 +8,7 @@ import { useMemo, useState } from 'react'; import { type BlogAvatarAuthor, BlogAvatarGroup } from '../blog-avatar'; import { ALink, type LinkComp } from '../shared'; import { BorderBeam } from './BorderBeam'; +import { renderBlogListSsgMarkdown } from './ssg-md'; import { useTiltEffect } from './useTiltEffect'; import styles from './index.module.scss'; @@ -307,6 +308,20 @@ export function BlogList({ lang === 'zh' ? 'zh-CN' : lang || 'en-US', dateFormatOptions, ); + + if (import.meta.env.SSG_MD) { + return ( + <> + {renderBlogListSsgMarkdown({ + formatDate: value => formatBlogDate(value, dateFormatter), + posts, + subtitle, + title, + })} + + ); + } + const tiltDisabled = !interactive || isTouchDevice(); const featuredPost = useMemo(() => { diff --git a/src/blog-list/ssg-md.ts b/src/blog-list/ssg-md.ts new file mode 100644 index 0000000..6af0a6d --- /dev/null +++ b/src/blog-list/ssg-md.ts @@ -0,0 +1,85 @@ +import { isValidElement, type ReactNode } from 'react'; +import type { BlogListItem } from './index'; + +const getTextFromReactNode = (value: ReactNode): string => { + if (value === undefined || value === null || typeof value === 'boolean') { + return ''; + } + + if (typeof value === 'string' || typeof value === 'number') { + return String(value); + } + + if (Array.isArray(value)) { + return value.map(getTextFromReactNode).join(''); + } + + if (isValidElement<{ children?: ReactNode }>(value)) { + return getTextFromReactNode(value.props.children); + } + + return ''; +}; + +const escapeMarkdownText = (value: string) => { + return value.replace(/([\\[\]])/g, '\\$1'); +}; + +const normalizeMarkdownText = (value: ReactNode) => { + return getTextFromReactNode(value).replace(/\s+/g, ' ').trim(); +}; + +const getPostMarkdownTitle = (post: BlogListItem, index: number) => { + return normalizeMarkdownText(post.title) || post.href || `Post ${index + 1}`; +}; + +export const renderBlogListSsgMarkdown = ({ + formatDate, + posts, + subtitle, + title, +}: { + formatDate: (value: BlogListItem['date']) => string | undefined; + posts: BlogListItem[]; + subtitle?: ReactNode; + title?: ReactNode; +}) => { + const sections: string[] = []; + const titleText = normalizeMarkdownText(title); + const subtitleText = normalizeMarkdownText(subtitle); + + if (titleText) { + sections.push(`# ${titleText}`); + } + + if (subtitleText) { + sections.push(subtitleText); + } + + posts.forEach((post, index) => { + const postSections: string[] = []; + const postTitle = getPostMarkdownTitle(post, index); + const postDate = formatDate(post.date); + const postDescription = normalizeMarkdownText(post.description); + const authors = post.authors?.map(author => author.name).join(', '); + const meta = [postDate, authors].filter(Boolean).join(' · '); + + postSections.push( + post.href + ? `## [${escapeMarkdownText(postTitle)}](${post.href})` + : `## ${postTitle}`, + ); + + if (meta) { + postSections.push(`> ${meta}`); + } + + if (postDescription) { + postSections.push(postDescription); + } + + sections.push(postSections.join('\n\n')); + }); + + return `${sections.join('\n\n')}\n`; +}; diff --git a/src/env.d.ts b/src/env.d.ts index 0accf57..995b2ac 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1 +1,5 @@ /// + +interface ImportMetaEnv { + readonly SSG_MD?: boolean; +}