Skip to content

Commit f3139d1

Browse files
committed
fix(blog-list): support ssg markdown output
1 parent aa1a82c commit f3139d1

9 files changed

Lines changed: 269 additions & 9 deletions

File tree

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
pnpm-lock.yaml
2+
.claude/

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"prepare": "rslib && simple-git-hooks",
8282
"build": "rslib",
8383
"build:watch": "rslib -w",
84+
"test": "rstest",
8485
"lint": "rslint && prettier -c .",
8586
"lint:write": "rslint --fix && prettier -w .",
8687
"lint:fix": "pnpm lint:write",
@@ -96,6 +97,8 @@
9697
"@rslib/core": "^0.21.5",
9798
"@rslint/core": "^0.5.3",
9899
"@rstack-dev/doc-ui": "workspace:*",
100+
"@rstest/adapter-rslib": "^0.2.2",
101+
"@rstest/core": "^0.9.10",
99102
"@storybook/addon-themes": "^10.4.0",
100103
"@storybook/react": "^10.4.0",
101104
"@storybook/test": "9.0.0-alpha.2",
@@ -110,6 +113,7 @@
110113
"prettier": "~3.8.3",
111114
"react": "^19.2.6",
112115
"react-dom": "^19.2.6",
116+
"react-render-to-markdown": "^19.0.1",
113117
"rimraf": "~6.1.3",
114118
"semver": "7.8.0",
115119
"simple-git-hooks": "^2.13.1",

pnpm-lock.yaml

Lines changed: 76 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rstest.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig } from '@rstest/core';
2+
import { withRslibConfig } from '@rstest/adapter-rslib';
3+
4+
export default defineConfig({
5+
extends: withRslibConfig(),
6+
include: ['src/**/*.test.{ts,tsx}'],
7+
testEnvironment: 'node',
8+
source: {
9+
define: {
10+
'import.meta.env.SSG_MD': true,
11+
},
12+
},
13+
});

src/blog-list/index.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect, test } from '@rstest/core';
2+
import { renderToMarkdownString } from 'react-render-to-markdown';
3+
import { BlogList, type BlogListItem } from './index';
4+
5+
const posts: BlogListItem[] = [
6+
{
7+
title: 'Announcing Rspack 2.0',
8+
href: '/blog/announcing-2-0',
9+
date: '2026-04-22',
10+
description:
11+
'Rspack 2.0 is out with modern defaults, API design, and build outputs.',
12+
authors: [
13+
{
14+
name: 'Rspack Team',
15+
avatar: 'https://rspack.rs/logo.png',
16+
},
17+
],
18+
},
19+
{
20+
title: 'Bundler tree shaking principles and differences',
21+
href: '/blog/tree-shaking',
22+
date: '2025-07-31',
23+
description:
24+
'A brief overview of tree shaking principles across different bundlers.',
25+
authors: [
26+
{
27+
name: 'ahabhgk',
28+
avatar: 'https://github.com/ahabhgk.png',
29+
},
30+
],
31+
},
32+
];
33+
34+
test('renders blog list as markdown when SSG_MD is enabled', async () => {
35+
const markdown = await renderToMarkdownString(
36+
<BlogList
37+
posts={posts}
38+
lang="en"
39+
title="Rspack blogs"
40+
subtitle={
41+
<>
42+
Browse release notes and technical deep dives from{' '}
43+
<a href="https://x.com/rspack_dev">@rspack_dev</a>.
44+
</>
45+
}
46+
/>,
47+
);
48+
49+
expect(markdown).toMatchInlineSnapshot(`
50+
"# Rspack blogs
51+
52+
Browse release notes and technical deep dives from @rspack_dev.
53+
54+
## [Announcing Rspack 2.0](/blog/announcing-2-0)
55+
56+
> April 22, 2026 · Rspack Team
57+
58+
Rspack 2.0 is out with modern defaults, API design, and build outputs.
59+
60+
## [Bundler tree shaking principles and differences](/blog/tree-shaking)
61+
62+
> July 31, 2025 · ahabhgk
63+
64+
A brief overview of tree shaking principles across different bundlers.
65+
"
66+
`);
67+
});

src/blog-list/index.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useMemo, useState } from 'react';
88
import { type BlogAvatarAuthor, BlogAvatarGroup } from '../blog-avatar';
99
import { ALink, type LinkComp } from '../shared';
1010
import { BorderBeam } from './BorderBeam';
11+
import { isSsgMd, renderBlogListSsgMarkdown } from './ssg-md';
1112
import { useTiltEffect } from './useTiltEffect';
1213

1314
import styles from './index.module.scss';
@@ -307,6 +308,20 @@ export function BlogList({
307308
lang === 'zh' ? 'zh-CN' : 'en-US',
308309
dateFormatOptions,
309310
);
311+
312+
if (isSsgMd()) {
313+
return (
314+
<>
315+
{renderBlogListSsgMarkdown({
316+
formatDate: value => formatBlogDate(value, dateFormatter),
317+
posts,
318+
subtitle,
319+
title,
320+
})}
321+
</>
322+
);
323+
}
324+
310325
const tiltDisabled = !interactive || isTouchDevice();
311326

312327
const featuredPost = useMemo(() => {

src/blog-list/ssg-md.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { isValidElement, type ReactNode } from 'react';
2+
import type { BlogListItem } from './index';
3+
4+
const getTextFromReactNode = (value: ReactNode): string => {
5+
if (value === undefined || value === null || typeof value === 'boolean') {
6+
return '';
7+
}
8+
9+
if (typeof value === 'string' || typeof value === 'number') {
10+
return String(value);
11+
}
12+
13+
if (Array.isArray(value)) {
14+
return value.map(getTextFromReactNode).join('');
15+
}
16+
17+
if (isValidElement<{ children?: ReactNode }>(value)) {
18+
return getTextFromReactNode(value.props.children);
19+
}
20+
21+
return '';
22+
};
23+
24+
const escapeMarkdownText = (value: string) => {
25+
return value.replace(/([\\[\]])/g, '\\$1');
26+
};
27+
28+
const normalizeMarkdownText = (value: ReactNode) => {
29+
return getTextFromReactNode(value).replace(/\s+/g, ' ').trim();
30+
};
31+
32+
const getPostMarkdownTitle = (post: BlogListItem, index: number) => {
33+
return normalizeMarkdownText(post.title) || post.href || `Post ${index + 1}`;
34+
};
35+
36+
export const isSsgMd = () => Boolean(import.meta.env.SSG_MD);
37+
38+
export const renderBlogListSsgMarkdown = ({
39+
formatDate,
40+
posts,
41+
subtitle,
42+
title,
43+
}: {
44+
formatDate: (value: BlogListItem['date']) => string | undefined;
45+
posts: BlogListItem[];
46+
subtitle?: ReactNode;
47+
title?: ReactNode;
48+
}) => {
49+
const sections: string[] = [];
50+
const titleText = normalizeMarkdownText(title);
51+
const subtitleText = normalizeMarkdownText(subtitle);
52+
53+
if (titleText) {
54+
sections.push(`# ${titleText}`);
55+
}
56+
57+
if (subtitleText) {
58+
sections.push(subtitleText);
59+
}
60+
61+
posts.forEach((post, index) => {
62+
const postSections: string[] = [];
63+
const postTitle = getPostMarkdownTitle(post, index);
64+
const postDate = formatDate(post.date);
65+
const postDescription = normalizeMarkdownText(post.description);
66+
const authors = post.authors?.map(author => author.name).join(', ');
67+
const meta = [postDate, authors].filter(Boolean).join(' · ');
68+
69+
postSections.push(
70+
post.href
71+
? `## [${escapeMarkdownText(postTitle)}](${post.href})`
72+
: `## ${postTitle}`,
73+
);
74+
75+
if (meta) {
76+
postSections.push(`> ${meta}`);
77+
}
78+
79+
if (postDescription) {
80+
postSections.push(postDescription);
81+
}
82+
83+
sections.push(postSections.join('\n\n'));
84+
});
85+
86+
return `${sections.join('\n\n')}\n`;
87+
};

src/env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
/// <reference types='@rslib/core/types' />
2+
3+
interface ImportMetaEnv {
4+
readonly SSG_MD?: boolean;
5+
}

0 commit comments

Comments
 (0)