Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pnpm-lock.yaml
.claude/
11 changes: 11 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 13 additions & 0 deletions rstest.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
});
67 changes: 67 additions & 0 deletions src/blog-list/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<BlogList
posts={posts}
lang="en"
title="Rspack blogs"
subtitle={
<>
Browse release notes and technical deep dives from{' '}
<a href="https://x.com/rspack_dev">@rspack_dev</a>.
</>
}
/>,
);

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.
"
`);
});
15 changes: 15 additions & 0 deletions src/blog-list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down
85 changes: 85 additions & 0 deletions src/blog-list/ssg-md.ts
Original file line number Diff line number Diff line change
@@ -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`;
};
4 changes: 4 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
/// <reference types='@rslib/core/types' />

interface ImportMetaEnv {
readonly SSG_MD?: boolean;
}
Loading