diff --git a/.changeset/tiny-zoos-beg.md b/.changeset/tiny-zoos-beg.md
new file mode 100644
index 000000000..6a4b4caf1
--- /dev/null
+++ b/.changeset/tiny-zoos-beg.md
@@ -0,0 +1,5 @@
+---
+"@rspress/plugin-rss": patch
+---
+
+Introduce the RSS plugin for rspress
diff --git a/biome.json b/biome.json
index 24e9a0e21..a45450698 100644
--- a/biome.json
+++ b/biome.json
@@ -3,6 +3,12 @@
"organizeImports": {
"enabled": true
},
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "defaultBranch": "main",
+ "useIgnoreFile": true
+ },
"javascript": {
"formatter": {
"quoteStyle": "single",
diff --git a/e2e/fixtures/plugin-rss/doc/blog/bar.md b/e2e/fixtures/plugin-rss/doc/blog/bar.md
new file mode 100644
index 000000000..5065ca16b
--- /dev/null
+++ b/e2e/fixtures/plugin-rss/doc/blog/bar.md
@@ -0,0 +1,13 @@
+---
+title: Bar but Frontmatter
+date: 2024-01-02 08:00:00
+category: development
+slug: bar
+author:
+ name: Lamperouge
+ email: lelouch@royal.br
+---
+
+# Bar but Markdown
+
+This is content
diff --git a/e2e/fixtures/plugin-rss/doc/blog/foo.md b/e2e/fixtures/plugin-rss/doc/blog/foo.md
new file mode 100644
index 000000000..5c5d67654
--- /dev/null
+++ b/e2e/fixtures/plugin-rss/doc/blog/foo.md
@@ -0,0 +1,11 @@
+---
+date: 2024-01-01 08:00:00
+id: foo
+summary: |
+ This is summary
+ Second line of summary
+---
+
+# Foo
+
+This is content
diff --git a/e2e/fixtures/plugin-rss/doc/index.md b/e2e/fixtures/plugin-rss/doc/index.md
new file mode 100644
index 000000000..b4b0fd3d3
--- /dev/null
+++ b/e2e/fixtures/plugin-rss/doc/index.md
@@ -0,0 +1,5 @@
+---
+link-rss: blog
+---
+
+Nothing but should have rss
\ No newline at end of file
diff --git a/e2e/fixtures/plugin-rss/doc/releases/1.0.0.md b/e2e/fixtures/plugin-rss/doc/releases/1.0.0.md
new file mode 100644
index 000000000..1dd53e2f4
--- /dev/null
+++ b/e2e/fixtures/plugin-rss/doc/releases/1.0.0.md
@@ -0,0 +1,12 @@
+---
+date: 2024-01-01 08:00:00
+author:
+ - Lelouch
+ - Lamperouge
+ - name: Geass
+ link: /author/geass
+---
+
+# Release 1.0.0
+
+Nothing Happened
\ No newline at end of file
diff --git a/e2e/fixtures/plugin-rss/fixture.json b/e2e/fixtures/plugin-rss/fixture.json
new file mode 100644
index 000000000..eeff91abe
--- /dev/null
+++ b/e2e/fixtures/plugin-rss/fixture.json
@@ -0,0 +1,5 @@
+{
+ "base": "/sub/",
+ "siteUrl": "http://localhost:4173/sub/",
+ "title": "FooBar"
+}
\ No newline at end of file
diff --git a/e2e/fixtures/plugin-rss/package.json b/e2e/fixtures/plugin-rss/package.json
new file mode 100644
index 000000000..8f8979db9
--- /dev/null
+++ b/e2e/fixtures/plugin-rss/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@rspress-fixture/doc-plugin-rss",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "rspress dev",
+ "build": "rspress build",
+ "preview": "rspress preview"
+ },
+ "dependencies": {
+ "@rspress/plugin-rss": "workspace:*",
+ "rspress": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^14"
+ }
+}
diff --git a/e2e/fixtures/plugin-rss/rspress.config.ts b/e2e/fixtures/plugin-rss/rspress.config.ts
new file mode 100644
index 000000000..86e6ca53a
--- /dev/null
+++ b/e2e/fixtures/plugin-rss/rspress.config.ts
@@ -0,0 +1,32 @@
+import * as NodePath from 'path';
+import { pluginRss } from '@rspress/plugin-rss';
+import { defineConfig } from 'rspress/config';
+import fixture from './fixture.json';
+
+export default defineConfig({
+ root: NodePath.resolve(__dirname, 'doc'),
+ title: fixture.title,
+ base: fixture.base,
+ plugins: [
+ pluginRss({
+ siteUrl: fixture.siteUrl,
+ feed: [
+ {
+ id: 'blog',
+ test: '/blog/',
+ output: {
+ type: 'rss',
+ /* use .xml for preview server */
+ filename: 'blog.xml',
+ },
+ },
+ {
+ id: 'releases',
+ test: '/releases/',
+ title: 'FooBar Releases',
+ output: { filename: 'feed.xml', dir: 'releases' },
+ },
+ ],
+ }),
+ ],
+});
diff --git a/e2e/fixtures/plugin-rss/tsconfig.json b/e2e/fixtures/plugin-rss/tsconfig.json
new file mode 100644
index 000000000..ce8112e63
--- /dev/null
+++ b/e2e/fixtures/plugin-rss/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "Bundler",
+ "module": "Node16"
+ }
+}
diff --git a/e2e/tests/plugin-rss.test.ts b/e2e/tests/plugin-rss.test.ts
new file mode 100644
index 000000000..b05dee557
--- /dev/null
+++ b/e2e/tests/plugin-rss.test.ts
@@ -0,0 +1,99 @@
+import path from 'path';
+import { expect, test } from '@playwright/test';
+import fixture from '../fixtures/plugin-rss/fixture.json';
+import {
+ getPort,
+ killProcess,
+ runBuildCommand,
+ runPreviewCommand,
+} from '../utils/runCommands';
+
+const appDir = path.resolve(__dirname, '../fixtures/plugin-rss');
+const { siteUrl } = fixture;
+
+test.describe('plugin rss test', async () => {
+ let appPort: number;
+ let app: unknown;
+ let prefix: string;
+ test.beforeAll(async () => {
+ appPort = await getPort();
+ await runBuildCommand(appDir);
+ app = await runPreviewCommand(appDir, appPort);
+ prefix = `http://localhost:${appPort}${fixture.base}`;
+ });
+
+ test.afterAll(async () => {
+ if (app) {
+ await killProcess(app);
+ }
+ });
+
+ test('`link-rss` should add rss to this page', async ({ page }) => {
+ await page.goto(`${prefix}`, { waitUntil: 'networkidle' });
+
+ const link = page.locator('link[rel="alternative"]', {});
+
+ await expect(link.getAttribute('href')).resolves.toBe(
+ `${siteUrl}rss/blog.xml`,
+ );
+ });
+
+ test('should add rss to pages matched', async ({ page }) => {
+ await page.goto(`${prefix}blog/foo`, { waitUntil: 'networkidle' });
+
+ const link = page.locator('link[rel="alternative"]', {});
+
+ await expect(link.getAttribute('href')).resolves.toBe(
+ `${siteUrl}rss/blog.xml`,
+ );
+ });
+
+ test('should change output dir if dir is given', async ({ page }) => {
+ // for: output.dir, output.type
+ await page.goto(`${prefix}releases/feed.xml`, { waitUntil: 'networkidle' });
+
+ const feed = page.locator('feed>id');
+
+ await expect(feed.textContent()).resolves.toBe('releases');
+ });
+
+ test.describe('rss content', async () => {
+ // todo: add more tests for rss content
+ test('should has expected content', async ({ page }) => {
+ await page.goto(`${prefix}rss/blog.xml`, { waitUntil: 'networkidle' });
+
+ await expect(
+ page.locator('rss>channel>title').textContent(),
+ ).resolves.toBe(fixture.title);
+
+ await expect(
+ page.locator('rss>channel>link').textContent(),
+ ).resolves.toBe(fixture.siteUrl);
+
+ const foo = page
+ .locator('rss>channel>item')
+ // frontmatter.id first
+ .filter({ has: page.locator('guid:text-is("foo")') });
+
+ // frontmatter.summary first
+ await expect(
+ foo.locator("xpath=/*[name()='content:encoded']").textContent(),
+ ).resolves.toBe('This is summary\nSecond line of summary\n');
+
+ const bar = page
+ .locator('rss>channel>item')
+ // frontmatter.slug first
+ .filter({ has: page.locator('guid:text-is("bar")') });
+
+ // frontmatter.category first
+ await expect(bar.locator('>category').textContent()).resolves.toBe(
+ 'development',
+ );
+
+ // frontmatter.author
+ await expect(bar.locator('>author').textContent()).resolves.toBe(
+ 'lelouch@royal.br (Lamperouge)',
+ );
+ });
+ });
+});
diff --git a/packages/document/docs/en/plugin/official-plugins/_meta.json b/packages/document/docs/en/plugin/official-plugins/_meta.json
index 8a4bb3840..db01f6702 100644
--- a/packages/document/docs/en/plugin/official-plugins/_meta.json
+++ b/packages/document/docs/en/plugin/official-plugins/_meta.json
@@ -6,5 +6,6 @@
"typedoc",
"preview",
"playground",
+ "rss",
"shiki"
]
diff --git a/packages/document/docs/en/plugin/official-plugins/overview.mdx b/packages/document/docs/en/plugin/official-plugins/overview.mdx
index 59af55671..4fb09728a 100644
--- a/packages/document/docs/en/plugin/official-plugins/overview.mdx
+++ b/packages/document/docs/en/plugin/official-plugins/overview.mdx
@@ -11,6 +11,7 @@ Official plugins include:
- [@rspress/plugin-preview](./preview): Support preview of code blocks in Markdown/MDX.
- [@rspress/plugin-playground](./playground): Provide a real-time playground to preview the code blocks in Markdown/MDX files.
- [@rspress/plugin-shiki](./shiki): Integrates [Shiki](https://github.com/shikijs/shiki) for code syntax highlighting.
+- [@rspress/plugin-rss](./rss):todo(xc2)
## Community Plugins
diff --git a/packages/document/docs/en/plugin/official-plugins/rss.mdx b/packages/document/docs/en/plugin/official-plugins/rss.mdx
new file mode 100644
index 000000000..5a611c2c9
--- /dev/null
+++ b/packages/document/docs/en/plugin/official-plugins/rss.mdx
@@ -0,0 +1,290 @@
+# @rspress/plugin-rss
+
+import { SourceCode, PackageManagerTabs } from 'rspress/theme';
+
+
+
+Generates RSS files for specific document pages with [feed](https://github.com/jpmonette/feed).
+
+## Installation
+
+
+
+### Update Rspress Config
+
+```ts title="rspress.config.ts"
+import { defineConfig } from 'rspress/config';
+import { pluginRss } from '@rspress/plugin-rss';
+
+export default defineConfig({
+ plugins: [
+ pluginRss({
+ // The URL of your document site
+ siteUrl: 'https://example.com',
+ // ...more configurations below
+ })
+ ],
+});
+```
+
+By default, this plugin generates a `blog.xml` file in the `doc_build/rss/` folder for all pages starting with `/blog/`.
+
+The RSS file can be accessed via `/rss/blog.xml`.
+
+:::tip
+This plugin only works with `rspress build` and does not generate RSS files on `rspress dev`.
+:::
+
+## Usage
+
+### Selecting pages to be included in RSS
+
+Use the `feed.test` option to select which pages to be included in the RSS file.
+
+```ts
+pluginRss({
+ // ...
+ feed: { test: '/zh/blog' }
+})
+```
+
+### Requirements
+
+All documents included in the RSS must have either `date` or `published_at` in their frontmatter to ensure that the RSS updates are stable on the user side.
+
+```md
+---
+published_at: 2024-01-10 08:00:00
+---
+
+Or frontmatter `date`.
+```
+
+### Generating multiple RSS files
+
+Sometimes, you may need to generate multiple RSS files for, e.g., different languages or categories.
+
+You can provide a list of RSS options to the `feed` option. For instance:
+
+```ts
+pluginRss({
+ feed: [
+ { id: 'blog', test: '/blog/', title: 'Rspress Blog', language: 'en-US' },
+ { id: 'blog-zh', test: '/zh/blog/', title: 'Rspress 博客', language: 'zh-CN' },
+ { id: 'rspack', test: ({frontmatter}) => frontmatter.categories.includes('rspack'), title: 'Rspack Releases', language: 'en-US' },
+ { id: 'rsbuild', test: ({frontmatter}) => frontmatter.categories.includes('rsbuild'), title: 'Rsbuild Releases', language: 'en-US' },
+ ]
+})
+```
+
+The options above will generate four RSS files: `blog.xml`, `blog-zh.xml`, `rspack.xml`, `rsbuild.xml`, all located in the `rss` folder.
+
+### Modifying the output path
+
+You can customize the output path using the `output` and `feed.output` parameters.
+
+Please refer to the [FeedOutputOptions](#FeedOutputOptions) below.
+
+### Linking RSS to document pages
+
+By default, this plugin will insert a `` tag in the selected pages that are included in the RSS, linking to the URL of the RSS file. RSS readers can automatically detect the RSS URL.
+
+If you want to insert this tag in pages that are not included in the RSS (such as the homepage), you can add the `link-rss` frontmatter to the document, with the value being the feed id. For example:
+
+```markdown
+---
+link-rss: blog
+---
+
+This frontmatter will insert a `` tag in the page of this document, pointing to the RSS URL of the `blog` feed.
+
+However, this page itself will not be included in that RSS.
+```
+
+### Cook the RSS content
+
+The RSS file consists of two parts: the RSS basic information, known as the `channel` in the RSS format, and the list of articles, known as the `item` in the RSS format.
+
+Here's how you can cook each part:
+
+- The `channel` can be fully modified through the `feed` parameter. Please refer to the [Other Parameters](#other-parameters) below.
+- The `item` can be fully modified through the `feed.item` parameter. Please refer to the [Item](#item) section below.
+
+## Options
+
+### PluginRssOptions
+
+Options of the plugin.
+
+```ts
+export interface PluginRssOptions {
+ siteUrl: string;
+ feed?: Partial | FeedChannel[];
+ output?: Omit;
+}
+```
+
+#### `siteUrl`
+
+- **Type**: `string`
+- **Required**
+
+The site URL of the current document site. It will be used in the RSS file.
+
+#### `feed`
+
+- **Type**: `FeedChannel | FeedChannel[]`
+- **Default**: `{ id: 'blog', test: '/blog/' }`
+
+Options for RSS file(s). Provide an array to generate multiple RSS files.
+
+See [FeedChannel](#FeedChannel) for more information.
+
+#### `output`
+
+- **Type**: `Omit`
+- **Default**: `{ dir: 'rss', type: 'atom' }`
+
+Options for document output. Please refer to [FeedOutputOptions](#FeedOutputOptions) below.
+
+### FeedChannel
+
+Options for RSS file.
+
+```ts
+export interface FeedChannel extends Partial {
+ id: string;
+ test:
+ | RegExp
+ | string
+ | (RegExp | string)[]
+ | ((item: PageIndexInfo, base: string) => boolean);
+ item?: (
+ item: FeedItem,
+ page: PageIndexInfo,
+ siteUrl: string,
+ ) => FeedItem | PromiseLike;
+ output?: FeedOutputOptions;
+}
+```
+
+#### `id`
+
+- **Type**: `string`
+- **Required**
+
+The ID of an RSS, unique among multiple RSS options. It is also the default file basename for the RSS file.
+
+#### `test`
+
+- **Type**: `RegExp | string | (RegExp | string)[] | ((item: PageIndexInfo, base: string) => boolean)`
+- **Required**
+
+Used to select documents to be included in the RSS. The types are as follows:
+
+- `RegExp`: Regular expression to match the document's route. When the rspress site has `base` configured, it will match both routes with and without the `base` path.
+- `string`: Prefix-based matching to the document's route. When the rspress site has `base` configured, it will match both routes with and without the `base` path.
+- `(item: PageIndexInfo, base: string) => boolean`: Match pages based on page data and frontmatter.
+
+#### `item`
+
+- **Type**: `(item: FeedItem, page: PageIndexInfo, siteUrl: string) => FeedItem | PromiseLike`
+- **Default**: Elaborated below
+
+Generates structured data for each article in the RSS file.
+
+Refer to the type of structured data:
+
+The plugin has a built-in generator that utilizes the document's frontmatter and page data.
+
+For example, the `content` in the RSS will prioritize the `summary` from frontmatter, and then try the document content.
+
+You can provide the `item` function to modify the generated data which will be passed as the first parameter to the function you provided.
+
+For example, the following configuration truncates the content of articles in the RSS:
+
+```ts
+const item: FeedChannel['item'] = (item) => ({...item, content: item.content.slice(0, 1000)});
+```
+
+For details on the logic of the built-in generator, please refer to:
+
+#### `output`
+
+- **Type**: `FeedOutputOptions`
+- **Default**: Uses the plugin's `output` option by default
+
+In addition to the plugin's `output` option, there is an additional `filename` to modify the output filename.
+
+Please refer to [FeedOutputOptions](#FeedOutputOptions) below.
+
+#### Other options
+
+`FeedChannel` also inherits the `FeedOptions` from the feed package. Please refer to for parameters that are not listed.
+
+### FeedOutputOptions
+
+Output options for RSS files, available at both the top level of the plugin options and the `feed` level. Using the following type:
+
+```ts
+interface FeedOutputOptions {
+ dir?: string;
+ type?: "atom" | "rss" | "json";
+ filename?: string;
+ publicPath?: string;
+}
+```
+
+Example:
+
+```ts
+pluginRss({
+ // Applied to all RSS outputs
+ output: {
+ // Change the output folder for RSS files to 'feeds', relative to `doc_build`
+ dir: 'feeds',
+ // Output in RSS 2.0 format, use `.rss` extension by default.
+ type: 'rss'
+ },
+ feed: [
+ { id: 'blog', test: '/blog/', title: 'My Blog', output: { type: 'atom' /* default to using `id` as the base file name */ } },
+ { id: 'releases', test: '/releases/', title: 'Releases', output: { dir: 'releases', filename: 'feed.rss' } },
+ ]
+})
+```
+
+Building with the options above will output two files: `feeds/blog.xml` and `releases/feed.rss`.
+
+#### `dir`
+
+- **Type**: `string`
+- **Default**: `rss`
+
+Output folder for RSS files, relative to `doc_build`.
+
+#### `type`
+
+- **Type**: `"atom" | "rss" | "json"`
+- **Default**: `atom`
+
+Output format of the RSS file, `atom` by default. Details of these types:
+
+| Value | Format | Default Extension | MIME Type |
+|-------|-----------------------------------------------------------|-------------------|------------------------|
+| `atom` | [Atom 1.0](https://www.ietf.org/rfc/rfc4287.txt) | `.xml` | `application/atom+xml` |
+| `rss` | [RSS 2.0](https://www.rssboard.org/rss-specification) | `.rss` | `application/rss+xml` |
+| `json` | [JSON Feed 1.1](https://www.jsonfeed.org/version/1.1/) | `.json` | `application/json` |
+
+#### `filename`
+
+- **Type**: `string`
+- **Default**: ID as the file basename; extension by RSS output format
+
+Modify the full filename of the RSS file.
+
+#### `publicPath`
+
+- **Type**: `string`
+- **Default**: the value of `siteUrl`
+
+URL Prefix for the RSS file. An RSS URL is composed of `publicPath`, `dir`, and `filename`.
diff --git a/packages/document/docs/zh/plugin/official-plugins/_meta.json b/packages/document/docs/zh/plugin/official-plugins/_meta.json
index 8a4bb3840..db01f6702 100644
--- a/packages/document/docs/zh/plugin/official-plugins/_meta.json
+++ b/packages/document/docs/zh/plugin/official-plugins/_meta.json
@@ -6,5 +6,6 @@
"typedoc",
"preview",
"playground",
+ "rss",
"shiki"
]
diff --git a/packages/document/docs/zh/plugin/official-plugins/overview.mdx b/packages/document/docs/zh/plugin/official-plugins/overview.mdx
index 0db3f377a..57cb02be8 100644
--- a/packages/document/docs/zh/plugin/official-plugins/overview.mdx
+++ b/packages/document/docs/zh/plugin/official-plugins/overview.mdx
@@ -11,6 +11,7 @@
- [@rspress/plugin-preview](./preview):支持代码块中的组件预览。
- [@rspress/plugin-playground](./playground):支持代码块中的组件预览,并提供实时 Playground。
- [@rspress/plugin-shiki](./shiki):集成 [Shiki](https://github.com/shikijs/shiki) 来进行代码高亮的插件。
+- [@rspress/plugin-rss](./rss):todo(xc2)
## 社区插件
diff --git a/packages/document/docs/zh/plugin/official-plugins/rss.mdx b/packages/document/docs/zh/plugin/official-plugins/rss.mdx
new file mode 100644
index 000000000..b89fdf7fd
--- /dev/null
+++ b/packages/document/docs/zh/plugin/official-plugins/rss.mdx
@@ -0,0 +1,283 @@
+# @rspress/plugin-rss
+
+import { SourceCode, PackageManagerTabs } from 'rspress/theme';
+
+
+
+使用 [feed](https://github.com/jpmonette/feed) 为文档中的某些页面生成 RSS 文件。
+
+## 安装
+
+
+
+### 引入插件
+
+修改 `rspress.config.ts` 引入插件
+
+```ts title="rspress.config.ts"
+import { defineConfig } from 'rspress/config';
+import { pluginRss } from '@rspress/plugin-rss';
+
+export default defineConfig({
+ plugins: [
+ pluginRss({
+ // 您的文档站点的 url
+ siteUrl: 'https://example.com',
+ // ...更多配置请见下文
+ })
+ ],
+});
+```
+
+默认配置下,该插件会为所有路由为 `/blog/` 开头的文档在 `doc_build/rss/` 文件夹中生成一份名为 `blog.xml` 的文件,可以通过 `/rss/blog.xml` 访问。
+
+:::tip
+该插件仅工作于 build 阶段,在 dev 阶段不会生成 RSS 文件。
+:::
+
+## 用法
+
+### 选择要纳入 RSS 的文档
+
+可以通过 `feed.test` 参数来选择要包含在 RSS 文件中的文档。
+
+```ts
+pluginRss({
+ // ...
+ feed: { test: '/zh/blog' }
+})
+```
+
+### 文档要求
+
+所有包含在 RSS 内的文档都需要含有 `date` 或 `published_at` 的 frontmatter,以确保 RSS 在用户侧的更新是稳定的。
+
+```md
+---
+published_at: 2024-01-10 08:00:00
+---
+
+或者使用 `date` 这个 frontmatter
+```
+
+### 输出多个 RSS 文件
+
+通常情况下,您会有输出多个 RSS 文件的需要,比如按语言区分、按类目区分。
+
+可以为 `feed` 传一组 RSS 配置来实现。比如
+
+```ts
+pluginRss({
+ feed: [
+ { id: 'blog', test: '/blog/', title: 'Rspress Blog', language: 'en-US' },
+ { id: 'blog-zh', test: '/zh/blog/', title: 'Rspress 博客', language: 'zh-CN' },
+ { id: 'rspack', test: ({frontmatter}) => frontmatter.categories.includes('rspack'), title: 'Rspack Releases', language: 'en-US' },
+ { id: 'rsbuild', test: ({frontmatter}) => frontmatter.categories.includes('rsbuild'), title: 'Rsbuild Releases', language: 'en-US' },
+ ]
+})
+```
+
+以上配置会输出四个 RSS 文件,分别为 `blog.xml`、`blog-zh.xml`、`rspack.xml`、`rsbuild.xml`,均在 `rss` 文件夹中。
+
+### 修改输出路径
+
+可以通过 `output` 以及 `feed.output` 参数来修改输出路径。
+
+请参考下文的 [FeedOutputOptions](#FeedOutputOptions)。
+
+### 链接 RSS 到文档页面中
+
+默认情况下,该插件会在被选择纳入 RSS 的页面中插入一个 `` 标签,链接到 RSS 文件的 URL。RSS 阅读器能够自动识别到 RSS URL。
+
+如果您想在没被纳入 RSS 的页面(比如首页)中也插入这个标签,可以在文档中添加 `link-rss` 这个 frontmatter,取值为 feed id。比如
+
+```markdown
+---
+link-rss: blog
+---
+
+这个 frontmatter 会为这个文档的页面中插入 `` 标签指向 blog 的 RSS URL。
+
+但这个页面自身不会被纳入该 RSS。
+```
+
+### 修改输出的 RSS 内容
+
+RSS 文件由两部分组成,一部分是 RSS 的基础信息,在 RSS 格式中称为 `channel`,另一部分是文章列表,在 RSS 格式中称为 `item`。
+
+其中:
+- `channel` 可以由 `feed` 这个参数完全控制,请参考下文的 [其他参数](#其他参数)。
+- `item` 可以由 `feed.item` 这个参数完全控制,请参考下文的 [item](#item)。
+
+## 配置
+
+### PluginRssOptions
+
+插件的选项。
+
+```ts
+export interface PluginRssOptions {
+ siteUrl: string;
+ feed?: Partial | FeedChannel[];
+ output?: Omit;
+}
+```
+
+#### `siteUrl`
+
+- **类型**: `string`
+- **必填**
+
+当前文档站点的站点 URL。将在 RSS 文件中使用。
+
+#### `feed`
+
+- **类型**:`FeedChannel | FeedChannel[]`
+- **默认值**:`{ id: 'blog', test: '/blog/' }`
+
+RSS 配置,多值数组可以生成多个 RSS 文件。
+
+详见 [FeedChannel](#FeedChannel)。
+
+#### `output`
+
+- **类型**:`Omit`
+- **默认值**:`{ dir: 'rss', type: 'atom' }`
+
+文档输出配置。请参考下文的 [FeedOutputOptions](#FeedOutputOptions)。
+
+### FeedChannel
+
+```ts
+export interface FeedChannel extends Partial {
+ id: string;
+ test:
+ | RegExp
+ | string
+ | (RegExp | string)[]
+ | ((item: PageIndexInfo, base: string) => boolean);
+ item?: (
+ item: FeedItem,
+ page: PageIndexInfo,
+ siteUrl: string,
+ ) => FeedItem | PromiseLike;
+ output?: FeedOutputOptions;
+}
+```
+
+#### `id`
+
+- **类型**:`string`
+- **必填**
+
+RSS 的 ID,在多个 RSS 配置中唯一。同时也是该 RSS 文件的默认文件名。
+
+#### `test`
+
+- **类型**:`RegExp | string | (RegExp | string)[] | ((item: PageIndexInfo, base: string) => boolean)`
+- **必填**
+
+用于匹配文档。匹配上的文档会纳入该 RSS 中。类型说明:
+
+- `RegExp`: 正则匹配文档的路由。当文档设有 `base` 时,会同时匹配含 `base` 和不含 `base` 的路由。
+- `string`: 前缀匹配文档的路由。当文档设有 `base` 时,会同时匹配含 `base` 和不含 `base` 的路由。
+- `(item: PageIndexInfo, base: string) => boolean`: 根据文档基础信息和 frontmatter 来匹配页面。
+
+#### `item`
+
+- **类型**:`(item: FeedItem, page: PageIndexInfo, siteUrl: string) => FeedItem | PromiseLike`
+- **默认值**:内部逻辑
+
+生成 RSS 文件中每一篇文章的结构化数据。
+
+结构化数据的的类型请参考:
+
+该插件内置有一个生成逻辑,会充分利用文档的 frontmatter 和页面数据,比如 RSS 中的 `content` 会优先取 frontmatter 中的 `summary`,再尝试取文章内容。
+
+您可以传入一个函数来修改内置逻辑生成的数据,该数据会作为第一个参数传给您传入的这个函数。比如下方配置可以截断 RSS 中的文章内容:
+
+```ts
+const item: FeedChannel['item'] = (item) => ({...item, content: item.content.slice(0, 1000)});
+```
+
+默认逻辑详情请参考:
+
+#### `output`
+
+- **类型**:`FeedOutputOptions`
+- **默认值**:默认使用插件的 `output` 参数
+
+相比插件的 `output` 参数,多一个 `filename`,用于修改输出的文件名。
+
+请参考下文的 [FeedOutputOptions](#FeedOutputOptions)。
+
+#### 其他参数
+
+`FeedChannel` 还继承了 feed package 的 `FeedOptions`,未列出的参数请参考
+
+### FeedOutputOptions
+
+RSS 的输出配置,在插件参数顶层和 `feed` 这一层都有该配置。使用以下类型。
+
+```ts
+interface FeedOutputOptions {
+ dir?: string;
+ type?: "atom" | "rss" | "json";
+ filename?: string;
+ publicPath?: string;
+}
+```
+
+一个例子:
+
+```ts
+pluginRss({
+ // 应用于所有 RSS 输出
+ output: {
+ // 将 RSS 文件输出文件夹改为 `feeds`,该值为相对于 `doc_build` 的位置
+ dir: 'feeds',
+ // 输出为 RSS 2.0 格式,默认为 .rss 后缀。
+ type: 'rss'
+ },
+ feed: [
+ { id: 'blog', test: '/blog/', title: 'My Blog', output: { type: 'atom' /* 默认会以 `id` 作为基础文件名 */ } },
+ { id: 'releases', test: '/releases/', title: 'Releases', output: { dir: 'releases', filename: 'feed.rss' } },
+ ]
+})
+```
+
+以上配置将输出 `feeds/blog.xml`、`releases/feed.rss` 两个文件。
+
+#### `dir`
+
+- **类型**:`string`
+- **默认值**:`rss`
+
+RSS 文件的输出文件夹,相对于 `doc_build` 的相对位置。
+
+#### `type`
+
+- **类型**:`"atom" | "rss" | "json"`
+- **默认值**:`atom`
+
+修改 RSS 文件的输出格式,默认为 `atom`。不同类型的说明如下:
+
+| 值 | 格式 | 默认后缀名 | MIME Type |
+|---|-------------------------------------------------------|--------|---|
+| `atom` | [Atom 1.0](https://www.ietf.org/rfc/rfc4287.txt) | `.xml` | `application/atom+xml` |
+| `rss` | [RSS 2.0](https://www.rssboard.org/rss-specification) | `.rss` | `application/rss+xml` |
+| `json` | [JSON Feed 1.1](https://www.jsonfeed.org/version/1.1/) | `.json` | `application/json` |
+
+#### `filename`
+
+- **类型**:`string`
+- **默认值**:由 id 作为文件名,根据 RSS 输出格式选择后缀名
+
+修改 RSS 文件的输出完整文件名。
+
+#### `publicPath`
+
+- **类型**:`string`
+- **默认值**:使用 `siteUrl` 的值作为默认值
+
+RSS 文件 URL 的前缀。一个 RSS URL 由 `publicPath` 、`dir`、`filename` 共同组成。
diff --git a/packages/plugin-rss/LICENSE b/packages/plugin-rss/LICENSE
new file mode 100644
index 000000000..82d38c25b
--- /dev/null
+++ b/packages/plugin-rss/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023-present Bytedance, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/plugin-rss/README.md b/packages/plugin-rss/README.md
new file mode 100644
index 000000000..02c6c14b6
--- /dev/null
+++ b/packages/plugin-rss/README.md
@@ -0,0 +1,5 @@
+# @rspress/plugin-rss
+
+> RSS plugin for rspress
+
+[Documentation](https://rspress.dev/plugin/official-plugins/rss)
diff --git a/packages/plugin-rss/modern.config.ts b/packages/plugin-rss/modern.config.ts
new file mode 100644
index 000000000..d0d243e70
--- /dev/null
+++ b/packages/plugin-rss/modern.config.ts
@@ -0,0 +1,34 @@
+import {
+ PartialBaseBuildConfig,
+ defineConfig,
+ moduleTools,
+} from '@modern-js/module-tools';
+
+const base = {
+ buildType: 'bundle',
+ format: 'cjs',
+ sourceMap: true,
+ target: 'es2020',
+} satisfies PartialBaseBuildConfig;
+
+// https://modernjs.dev/module-tools/en/api
+export default defineConfig({
+ buildConfig: [
+ {
+ ...base,
+ format: 'cjs',
+ dts: false,
+ esbuildOptions: options => ({
+ ...options,
+ outExtension: { '.js': '.cjs' },
+ }),
+ },
+ { ...base, format: 'esm', dts: false, autoExtension: true },
+ {
+ ...base,
+ format: 'esm',
+ dts: { respectExternal: false, only: true },
+ },
+ ],
+ plugins: [moduleTools()],
+});
diff --git a/packages/plugin-rss/package.json b/packages/plugin-rss/package.json
new file mode 100644
index 000000000..4bc27bd89
--- /dev/null
+++ b/packages/plugin-rss/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "@rspress/plugin-rss",
+ "version": "1.16.1",
+ "description": "A plugin for rss generation for rspress",
+ "bugs": "https://github.com/web-infra-dev/rspress/issues",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/web-infra-dev/rspress",
+ "directory": "packages/plugin-rss"
+ },
+ "license": "MIT",
+ "jsnext:source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.mjs",
+ "exports": {
+ ".": {
+ "import": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.cjs"
+ },
+ "./FeedsAnnotations": "./static/global-components/FeedsAnnotations"
+ },
+ "scripts": {
+ "dev": "modern build -w",
+ "build": "modern build",
+ "reset": "rimraf ./**/node_modules",
+ "lint": "modern lint",
+ "change": "modern change",
+ "bump": "modern bump",
+ "pre": "modern pre",
+ "change-status": "modern change-status",
+ "gen-release-note": "modern gen-release-note",
+ "release": "modern release",
+ "new": "modern new",
+ "test": "vitest run --passWithNoTests",
+ "upgrade": "modern upgrade"
+ },
+ "engines": {
+ "node": ">=14.17.6"
+ },
+ "dependencies": {
+ "feed": "^4.2.2"
+ },
+ "devDependencies": {
+ "@types/node": "^18.11.17",
+ "@types/react": "^18",
+ "@rspress/shared": "workspace:*",
+ "@rspress/runtime": "workspace:*",
+ "react": "^18",
+ "typescript": "^5"
+ },
+ "peerDependencies": {
+ "react": ">=17.0.0",
+ "@types/react": ">=17.0.0",
+ "@rspress/runtime": "^1.0.0"
+ },
+ "files": [
+ "dist",
+ "static"
+ ],
+ "publishConfig": {
+ "access": "public",
+ "provenance": true,
+ "registry": "https://registry.npmjs.org/"
+ }
+}
diff --git a/packages/plugin-rss/src/exports.ts b/packages/plugin-rss/src/exports.ts
new file mode 100644
index 000000000..a8077fb64
--- /dev/null
+++ b/packages/plugin-rss/src/exports.ts
@@ -0,0 +1,5 @@
+export const PluginName = '@rspress/plugin-rss';
+
+export const PluginComponents = {
+ FeedsAnnotations: '@rspress/plugin-rss/FeedsAnnotations',
+} as const;
diff --git a/packages/plugin-rss/src/feed.ts b/packages/plugin-rss/src/feed.ts
new file mode 100644
index 000000000..07a2fa1d9
--- /dev/null
+++ b/packages/plugin-rss/src/feed.ts
@@ -0,0 +1,63 @@
+import { resolve as resolveUrl } from 'node:url';
+import type { PageIndexInfo, UserConfig } from '@rspress/shared';
+import type { Author, FeedOptions } from 'feed';
+import {
+ ResolvedOutput,
+ concatArray,
+ selectNonNullishProperty,
+ toDate,
+} from './internals';
+import type { FeedChannel, FeedItem } from './type';
+
+/**
+ * @public
+ * @param page Rspress Page Data
+ * @param siteUrl
+ */
+export function generateFeedItem(page: PageIndexInfo, siteUrl: string) {
+ const { frontmatter: fm } = page;
+ return {
+ id: selectNonNullishProperty(fm.slug, fm.id, page.id) || '',
+ title: selectNonNullishProperty(fm.title, page.title) || '',
+ author: toAuthors(fm.author),
+ link: resolveUrl(
+ siteUrl,
+ selectNonNullishProperty(fm.permalink, page.routePath) || '',
+ ),
+ description: selectNonNullishProperty(fm.description) || '',
+ content: selectNonNullishProperty(fm.summary, page.content) || '',
+ date: toDate((fm.date as string) || (fm.published_at as string))!,
+ category: concatArray(fm.categories as string[], fm.category as string).map(
+ cat => ({ name: cat }),
+ ),
+ } satisfies FeedItem;
+}
+
+export function createFeed(
+ options: Omit & {
+ item?: any;
+ test?: any;
+ output: ResolvedOutput;
+ },
+ config: UserConfig,
+): FeedOptions {
+ const { output, item, id, title, ..._options } = options;
+ return {
+ id,
+ copyright: config.themeConfig?.footer?.message || '',
+ description: config.description || '',
+ link: output.url,
+ ..._options,
+ title: title || config.title || '',
+ };
+}
+
+function toAuthors(author: unknown): Author[] | undefined {
+ const authors = (Array.isArray(author) ? author : [author])
+ .filter(Boolean)
+ .map(author => ({
+ // email is mandatory for RSS 2.0.
+ ...(typeof author === 'string' ? { name: author } : author),
+ }));
+ return authors.length ? authors : undefined;
+}
diff --git a/packages/plugin-rss/src/index.ts b/packages/plugin-rss/src/index.ts
new file mode 100644
index 000000000..87dfc8d91
--- /dev/null
+++ b/packages/plugin-rss/src/index.ts
@@ -0,0 +1,5 @@
+export * from './plugin-rss';
+export * from './type';
+export * from './exports';
+export * from './options';
+export * from './feed';
diff --git a/packages/plugin-rss/src/internals/index.ts b/packages/plugin-rss/src/internals/index.ts
new file mode 100644
index 000000000..66187b0ee
--- /dev/null
+++ b/packages/plugin-rss/src/internals/index.ts
@@ -0,0 +1,3 @@
+export * from './type';
+export * from './lang';
+export * from './node';
diff --git a/packages/plugin-rss/src/internals/lang.ts b/packages/plugin-rss/src/internals/lang.ts
new file mode 100644
index 000000000..e124b057a
--- /dev/null
+++ b/packages/plugin-rss/src/internals/lang.ts
@@ -0,0 +1,29 @@
+export type PartialPartial = Partial> &
+ Omit;
+
+export type ItemOf = T extends Array ? K : never;
+
+export function notNullish(n: T | undefined | null): n is T {
+ return n !== undefined && n !== null;
+}
+export function concatArray(...arrList: (T[] | T | undefined)[]) {
+ return arrList.reduce(
+ (arr, item) =>
+ arr.concat((Array.isArray(item) ? item : [item]).filter(notNullish)),
+ [] as T[],
+ );
+}
+
+export function selectNonNullishProperty(...list: unknown[]) {
+ for (const item of list) {
+ if (item === '') return '';
+ if (item === 0) return '0';
+ if (typeof item === 'number') return `${item}`;
+ if (typeof item === 'string') return item;
+ }
+}
+
+export function toDate(s: string | Date): null | Date {
+ const d = new Date(s);
+ return Number.isNaN(d.getDate()) ? null : d;
+}
diff --git a/packages/plugin-rss/src/internals/node.ts b/packages/plugin-rss/src/internals/node.ts
new file mode 100644
index 000000000..fdba6a0e2
--- /dev/null
+++ b/packages/plugin-rss/src/internals/node.ts
@@ -0,0 +1,8 @@
+import { mkdir, writeFile as _writeFile } from 'node:fs/promises';
+import * as NodePath from 'node:path';
+
+export async function writeFile(path: string, content: string | Buffer) {
+ const dir = NodePath.dirname(path);
+ await mkdir(dir, { mode: 0o755, recursive: true });
+ return _writeFile(path, content);
+}
diff --git a/packages/plugin-rss/src/internals/type.ts b/packages/plugin-rss/src/internals/type.ts
new file mode 100644
index 000000000..f070284db
--- /dev/null
+++ b/packages/plugin-rss/src/internals/type.ts
@@ -0,0 +1,15 @@
+import type { PageIndexInfo } from '@rspress/shared';
+import type { Feed } from 'feed';
+import type { FeedOutputType, PageFeedData } from '../type';
+
+export type PageWithFeeds = PageIndexInfo & { feeds: PageFeedData[] };
+
+export interface ResolvedOutput {
+ type: FeedOutputType;
+ mime: string;
+ filename: string;
+ getContent: (feed: Feed) => string;
+ dir: string;
+ publicPath: string;
+ url: string;
+}
diff --git a/packages/plugin-rss/src/options.ts b/packages/plugin-rss/src/options.ts
new file mode 100644
index 000000000..8e4367ef5
--- /dev/null
+++ b/packages/plugin-rss/src/options.ts
@@ -0,0 +1,77 @@
+import { resolve as resolveUrl } from 'node:url';
+import type { PageIndexInfo } from '@rspress/shared';
+import { Feed } from 'feed';
+import type { ResolvedOutput } from './internals';
+import type { FeedChannel, FeedOutputType, PluginRssOptions } from './type';
+
+export function testPage(
+ test: FeedChannel['test'],
+ page: PageIndexInfo,
+ base = '/',
+): boolean {
+ if (Array.isArray(test)) {
+ return test.some(item => testPage(item, page, base));
+ }
+ if (typeof test === 'function') {
+ return test(page, base);
+ }
+ const routePath = page.routePath;
+ const pureRoutePath = `/${
+ routePath.startsWith(base) ? routePath.slice(base.length) : routePath
+ }`.replace(/^\/+/, '/');
+ if (typeof test === 'string') {
+ return [routePath, pureRoutePath].some(path => path.startsWith(test));
+ }
+ if (test instanceof RegExp) {
+ return [routePath, pureRoutePath].some(path => test.test(path));
+ }
+
+ throw new Error(
+ 'test must be of `RegExp` or `string` or `(page: PageIndexInfo, base: string) => boolean`',
+ );
+}
+
+export function getDefaultFeedOption() {
+ return { id: 'blog', test: '/blog/' } satisfies FeedChannel;
+}
+
+export function getFeedFileType(type: FeedOutputType) {
+ switch (type) {
+ case 'rss':
+ return {
+ extension: 'rss',
+ mime: 'application/rss+xml',
+ getContent: (feed: Feed) => feed.rss2(),
+ };
+ case 'json':
+ return {
+ extension: 'json',
+ mime: 'application/json',
+ getContent: (feed: Feed) => feed.json1(),
+ };
+ case 'atom':
+ default:
+ return {
+ extension: 'xml',
+ mime: 'application/atom+xml',
+ getContent: (feed: Feed) => feed.atom1(),
+ };
+ }
+}
+export function getOutputInfo(
+ { id, output }: Pick,
+ {
+ siteUrl,
+ output: globalOutput,
+ }: Pick,
+): ResolvedOutput {
+ const type = output?.type || globalOutput?.type || 'atom';
+ const { extension, mime, getContent } = getFeedFileType(type);
+ const filename = output?.filename || `${id}.${extension}`;
+ const dir = output?.dir || globalOutput?.dir || 'rss';
+ const publicPath = output?.publicPath || globalOutput?.publicPath || siteUrl;
+ const url = [publicPath, `${dir}/`, filename].reduce((u, part) =>
+ u ? resolveUrl(u, part) : part,
+ );
+ return { type, mime, filename, getContent, dir, publicPath, url };
+}
diff --git a/packages/plugin-rss/src/plugin-rss.ts b/packages/plugin-rss/src/plugin-rss.ts
new file mode 100644
index 000000000..4b3538427
--- /dev/null
+++ b/packages/plugin-rss/src/plugin-rss.ts
@@ -0,0 +1,153 @@
+import NodePath from 'node:path';
+import { resolve as resolveUrl } from 'node:url';
+import type { PageIndexInfo, RspressPlugin, UserConfig } from '@rspress/shared';
+import { Feed } from 'feed';
+import { PluginComponents, PluginName } from './exports';
+import { createFeed, generateFeedItem } from './feed';
+
+import {
+ PageWithFeeds,
+ ResolvedOutput,
+ concatArray,
+ writeFile,
+} from './internals';
+import { getDefaultFeedOption, getOutputInfo, testPage } from './options';
+import type { FeedChannel, FeedItem, PluginRssOptions } from './type';
+
+type FeedItemWithChannel = FeedItem & { channel: string };
+type TransformedFeedChannel = FeedChannel & { output: ResolvedOutput };
+
+class FeedsSet {
+ feeds: TransformedFeedChannel[] = [];
+ feedsMapById: Record = Object.create(null);
+ set({ feed, output, siteUrl }: PluginRssOptions, config: UserConfig) {
+ this.feeds = (
+ Array.isArray(feed) ? feed : [{ ...getDefaultFeedOption(), ...feed }]
+ ).map(options => ({
+ title: config.title || '',
+ description: config.description || '',
+ favicon: config.icon && resolveUrl(siteUrl, config.icon),
+ copyright: config.themeConfig?.footer?.message || '',
+ link: siteUrl,
+ docs: '',
+ ...options,
+ output: getOutputInfo(options, { siteUrl, output }),
+ }));
+
+ this.feedsMapById = this.feeds.reduce(
+ (m, f) => ({ ...m, [f.id]: f }),
+ Object.create(null),
+ );
+ }
+ get(): TransformedFeedChannel[];
+ get(id: string): TransformedFeedChannel | null;
+ get(id?: string): TransformedFeedChannel[] | TransformedFeedChannel | null {
+ if (id) {
+ return this.feedsMapById[id] || null;
+ }
+ return this.feeds.slice(0);
+ }
+}
+
+function getRssItems(
+ feeds: TransformedFeedChannel[],
+ page: PageIndexInfo,
+ config: UserConfig,
+ siteUrl: string,
+): Promise {
+ return Promise.all(
+ feeds
+ .filter(options => testPage(options.test, page, config.base))
+ .map(async options => {
+ const after = options.item || ((feed: FeedItem) => feed);
+ const item = await after(
+ generateFeedItem(page, siteUrl),
+ page,
+ siteUrl,
+ );
+ return { ...item, channel: options.id };
+ }),
+ );
+}
+
+export function pluginRss(pluginRssOptions: PluginRssOptions): RspressPlugin {
+ const feedsSet = new FeedsSet();
+
+ /**
+ * workaround for retrieving data of pages in `afterBuild`
+ * TODO: get pageData list directly in `afterBuild`
+ **/
+ let _rssWorkaround: null | Record = null;
+ let _config: null | UserConfig;
+
+ return {
+ name: PluginName,
+ globalUIComponents: Object.values(PluginComponents),
+ beforeBuild(config, isProd) {
+ if (!isProd) {
+ _rssWorkaround = null;
+ return;
+ }
+ _rssWorkaround = {};
+ _config = config;
+ feedsSet.set(pluginRssOptions, config);
+ },
+ async extendPageData(_pageData) {
+ if (!_rssWorkaround) return;
+
+ const pageData = _pageData as PageWithFeeds;
+ // rspress run `extendPageData` twice for each page - we need one only
+ if (!_rssWorkaround[pageData.id]) {
+ _rssWorkaround[pageData.id] = await getRssItems(
+ feedsSet.get(),
+ pageData,
+ _config!,
+ pluginRssOptions.siteUrl,
+ );
+ }
+
+ const feeds = _rssWorkaround[pageData.id];
+ const showRssList = new Set(
+ concatArray(pageData.frontmatter['link-rss'] as string[] | string),
+ );
+ for (const feed of feeds) {
+ showRssList.add(feed.channel);
+ }
+
+ pageData.feeds = Array.from(showRssList, id => {
+ const { output, language } = feedsSet.get(id)!;
+ return {
+ url: output.url,
+ mime: output.mime,
+ language: language || pageData.lang,
+ };
+ });
+ },
+ async afterBuild(config) {
+ if (!_rssWorkaround) return;
+
+ const items = concatArray(...Object.values(_rssWorkaround));
+ const feeds: Record = Object.create(null);
+
+ for (const { channel, ...item } of items) {
+ feeds[channel] =
+ feeds[channel] ||
+ new Feed(createFeed(feedsSet.get(channel)!, config));
+ feeds[channel].addItem(item);
+ }
+
+ for (const [channel, feed] of Object.entries(feeds)) {
+ const { output } = feedsSet.get(channel)!;
+
+ const path = NodePath.resolve(
+ config.outDir || 'doc_build',
+ output.dir,
+ output.filename,
+ );
+ await writeFile(path, output.getContent(feed));
+ }
+ _rssWorkaround = null;
+ _config = null;
+ },
+ };
+}
diff --git a/packages/plugin-rss/src/type.ts b/packages/plugin-rss/src/type.ts
new file mode 100644
index 000000000..9ff1a6b63
--- /dev/null
+++ b/packages/plugin-rss/src/type.ts
@@ -0,0 +1,98 @@
+import type { PageIndexInfo } from '@rspress/shared';
+import type { FeedOptions, Item } from 'feed';
+import type { PartialPartial } from './internals';
+
+/**
+ * feed information attached in `PageIndexInfo['feeds']` array
+ */
+export interface PageFeedData {
+ url: string;
+ language: string;
+ mime: string;
+}
+
+export type FeedItem = Item;
+
+/**
+ * output feed file type
+ */
+export type FeedOutputType =
+ | /** Atom 1.0 Feed */ 'atom'
+ | /** RSS 2.0 Feed */ 'rss'
+ | /** JSON1 Feed */ 'json';
+
+/**
+ * output config of a feed.
+ * a feed will be written into path `${rspress.outDir || 'doc_build'}/${dir}/${filename}`
+ */
+export interface FeedOutputOptions {
+ /**
+ * output dir of feed files, relative to rspress's outDir
+ */
+ dir?: string;
+ /**
+ * type of feed files
+ */
+ type?: FeedOutputType;
+ /**
+ * base filename of feed files. `${id}.${extension by type}` by default.
+ */
+ filename?: string;
+ /**
+ * public path of feed files. siteUrl by default
+ */
+ publicPath?: string;
+}
+
+export interface FeedChannel
+ extends PartialPartial {
+ /**
+ * used as the basename of rss file, should be unique
+ **/
+ id: string;
+ /**
+ * to match pages that should be listed in this feed
+ * if RegExp is given, it will match against the route path of each page
+ **/
+ test:
+ | RegExp
+ | string
+ | (RegExp | string)[]
+ | ((item: PageIndexInfo, base: string) => boolean);
+ /**
+ * a function to modify feed item
+ * @param item pre-generated feed item
+ * @param page page data
+ * @param base base path of the rspress site
+ * @returns modified feed item
+ */
+ item?: (
+ item: FeedItem,
+ page: PageIndexInfo,
+ base: string,
+ ) => FeedItem | PromiseLike;
+ /**
+ * feed level output config
+ */
+ output?: FeedOutputOptions;
+}
+
+/**
+ * plugin options for `pluginRss`
+ */
+export interface PluginRssOptions {
+ /**
+ * site url of this rspress site. it will be used in feed files and feed link.
+ * @requires
+ */
+ siteUrl: string;
+ /**
+ * Feed options for each rss. If array is given, this plugin will produce multiple feed files.
+ * @default {{ id: 'blog', test: /^\/blog\// }}
+ */
+ feed?: PartialPartial | FeedChannel[];
+ /**
+ * output config for all feed files
+ */
+ output?: Omit;
+}
diff --git a/packages/plugin-rss/static/global-components/FeedsAnnotations.tsx b/packages/plugin-rss/static/global-components/FeedsAnnotations.tsx
new file mode 100644
index 000000000..95ecb2d74
--- /dev/null
+++ b/packages/plugin-rss/static/global-components/FeedsAnnotations.tsx
@@ -0,0 +1,24 @@
+import type { PageFeedData } from '@rspress/plugin-rss';
+import { Helmet, usePageData } from '@rspress/runtime';
+import { LinkHTMLAttributes } from 'react';
+
+export default function FeedsAnnotations() {
+ const { page } = usePageData();
+ const feeds = (page.feeds as PageFeedData[]) || [];
+
+ return (
+
+ {feeds.map(({ language, url, mime }) => {
+ const props: LinkHTMLAttributes = {
+ rel: 'alternative',
+ type: mime,
+ href: url,
+ };
+ if (language) {
+ props.hrefLang = language;
+ }
+ return ;
+ })}
+
+ );
+}
diff --git a/packages/plugin-rss/tests/tsconfig.json b/packages/plugin-rss/tests/tsconfig.json
new file mode 100644
index 000000000..dcc2a879d
--- /dev/null
+++ b/packages/plugin-rss/tests/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": "../"
+ },
+ "include": ["**/*", "../src"]
+}
diff --git a/packages/plugin-rss/tsconfig.json b/packages/plugin-rss/tsconfig.json
new file mode 100644
index 000000000..da19a72a7
--- /dev/null
+++ b/packages/plugin-rss/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "@modern-js/tsconfig/base",
+ "compilerOptions": {
+ "declaration": false,
+ "module": "Node16",
+ "target": "ES2020",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "moduleResolution": "Node16"
+ },
+ "include": ["src"],
+ "references": [
+ { "path": "./tsconfig.runtime.json" },
+ { "path": "./tsconfig.tools.json" }
+ ]
+}
diff --git a/packages/plugin-rss/tsconfig.runtime.json b/packages/plugin-rss/tsconfig.runtime.json
new file mode 100644
index 000000000..a8ead6054
--- /dev/null
+++ b/packages/plugin-rss/tsconfig.runtime.json
@@ -0,0 +1,15 @@
+{
+ "extends": "@modern-js/tsconfig/react",
+ "compilerOptions": {
+ "composite": true,
+ "declaration": false,
+ "module": "ESNext",
+ "target": "ESNext",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["static"]
+}
diff --git a/packages/plugin-rss/tsconfig.tools.json b/packages/plugin-rss/tsconfig.tools.json
new file mode 100644
index 000000000..410aa673f
--- /dev/null
+++ b/packages/plugin-rss/tsconfig.tools.json
@@ -0,0 +1,14 @@
+{
+ "extends": "@modern-js/tsconfig/base",
+ "compilerOptions": {
+ "composite": true,
+ "declaration": false,
+ "module": "Node16",
+ "target": "ES2020",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "moduleResolution": "Node16",
+ "noEmit": true
+ },
+ "include": ["modern.config.ts", "vitest.config.ts"]
+}
diff --git a/packages/plugin-rss/vitest.config.ts b/packages/plugin-rss/vitest.config.ts
new file mode 100644
index 000000000..5251a6a8e
--- /dev/null
+++ b/packages/plugin-rss/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ passWithNoTests: true,
+ exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'],
+ threads: true,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 171c25106..8983b3e55 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -253,6 +253,19 @@ importers:
specifier: ^14
version: 14.0.0
+ e2e/fixtures/plugin-rss:
+ dependencies:
+ '@rspress/plugin-rss':
+ specifier: workspace:*
+ version: link:../../../packages/plugin-rss
+ rspress:
+ specifier: workspace:*
+ version: link:../../../packages/cli
+ devDependencies:
+ '@types/node':
+ specifier: ^14
+ version: 14.0.0
+
e2e/fixtures/production:
dependencies:
rspress:
@@ -967,6 +980,31 @@ importers:
specifier: ^4.1.1
version: 4.1.1
+ packages/plugin-rss:
+ dependencies:
+ feed:
+ specifier: ^4.2.2
+ version: 4.2.2
+ devDependencies:
+ '@rspress/runtime':
+ specifier: workspace:*
+ version: link:../runtime
+ '@rspress/shared':
+ specifier: workspace:*
+ version: link:../shared
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ '@types/react':
+ specifier: ^18
+ version: 18.0.26
+ react:
+ specifier: ^18
+ version: 18.2.0
+ typescript:
+ specifier: ^5
+ version: 5.0.4
+
packages/plugin-shiki:
dependencies:
'@rspress/shared':
@@ -1295,7 +1333,7 @@ packages:
'@emotion/hash': 0.8.0
'@emotion/unitless': 0.7.5
classnames: 2.3.2
- csstype: 3.1.2
+ csstype: 3.1.3
rc-util: 5.37.0(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@@ -5560,7 +5598,6 @@ packages:
/csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
- dev: false
/csv-generate@3.4.3:
resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==}
@@ -6309,6 +6346,13 @@ packages:
format: 0.2.2
dev: false
+ /feed@4.2.2:
+ resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==}
+ engines: {node: '>=0.4.0'}
+ dependencies:
+ xml-js: 1.6.11
+ dev: false
+
/fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
@@ -10434,6 +10478,10 @@ packages:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: true
+ /sax@1.3.0:
+ resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==}
+ dev: false
+
/scheduler@0.23.0:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
dependencies:
@@ -12096,6 +12144,13 @@ packages:
/wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ /xml-js@1.6.11:
+ resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
+ hasBin: true
+ dependencies:
+ sax: 1.3.0
+ dev: false
+
/xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}