From b3550a07e4a5a1b30d3db6a9b7b749478cf42af3 Mon Sep 17 00:00:00 2001 From: xc2 Date: Tue, 2 Apr 2024 10:16:17 +0800 Subject: [PATCH] feat(plugin-rss): RSS Plugin (#851) --- .changeset/tiny-zoos-beg.md | 5 + e2e/fixtures/plugin-rss/doc/blog/bar.md | 13 + e2e/fixtures/plugin-rss/doc/blog/foo.md | 11 + e2e/fixtures/plugin-rss/doc/index.md | 5 + e2e/fixtures/plugin-rss/doc/releases/1.0.0.md | 12 + e2e/fixtures/plugin-rss/fixture.json | 5 + e2e/fixtures/plugin-rss/package.json | 17 + e2e/fixtures/plugin-rss/rspress.config.ts | 32 ++ e2e/fixtures/plugin-rss/tsconfig.json | 8 + e2e/tests/plugin-rss.test.ts | 99 ++++++ .../en/plugin/official-plugins/_meta.json | 1 + .../en/plugin/official-plugins/overview.mdx | 1 + .../docs/en/plugin/official-plugins/rss.mdx | 290 ++++++++++++++++++ .../zh/plugin/official-plugins/_meta.json | 1 + .../zh/plugin/official-plugins/overview.mdx | 1 + .../docs/zh/plugin/official-plugins/rss.mdx | 283 +++++++++++++++++ packages/plugin-rss/LICENSE | 21 ++ packages/plugin-rss/README.md | 5 + packages/plugin-rss/modern.config.ts | 34 ++ packages/plugin-rss/package.json | 67 ++++ packages/plugin-rss/src/exports.ts | 5 + packages/plugin-rss/src/feed.ts | 63 ++++ packages/plugin-rss/src/index.ts | 5 + packages/plugin-rss/src/internals/index.ts | 3 + packages/plugin-rss/src/internals/lang.ts | 29 ++ packages/plugin-rss/src/internals/node.ts | 8 + packages/plugin-rss/src/internals/type.ts | 15 + packages/plugin-rss/src/options.ts | 77 +++++ packages/plugin-rss/src/plugin-rss.ts | 160 ++++++++++ packages/plugin-rss/src/type.ts | 98 ++++++ .../global-components/FeedsAnnotations.tsx | 24 ++ packages/plugin-rss/tests/tsconfig.json | 7 + packages/plugin-rss/tsconfig.json | 16 + packages/plugin-rss/tsconfig.runtime.json | 15 + packages/plugin-rss/tsconfig.tools.json | 14 + packages/plugin-rss/vitest.config.ts | 10 + pnpm-lock.yaml | 56 ++++ 37 files changed, 1516 insertions(+) create mode 100644 .changeset/tiny-zoos-beg.md create mode 100644 e2e/fixtures/plugin-rss/doc/blog/bar.md create mode 100644 e2e/fixtures/plugin-rss/doc/blog/foo.md create mode 100644 e2e/fixtures/plugin-rss/doc/index.md create mode 100644 e2e/fixtures/plugin-rss/doc/releases/1.0.0.md create mode 100644 e2e/fixtures/plugin-rss/fixture.json create mode 100644 e2e/fixtures/plugin-rss/package.json create mode 100644 e2e/fixtures/plugin-rss/rspress.config.ts create mode 100644 e2e/fixtures/plugin-rss/tsconfig.json create mode 100644 e2e/tests/plugin-rss.test.ts create mode 100644 packages/document/docs/en/plugin/official-plugins/rss.mdx create mode 100644 packages/document/docs/zh/plugin/official-plugins/rss.mdx create mode 100644 packages/plugin-rss/LICENSE create mode 100644 packages/plugin-rss/README.md create mode 100644 packages/plugin-rss/modern.config.ts create mode 100644 packages/plugin-rss/package.json create mode 100644 packages/plugin-rss/src/exports.ts create mode 100644 packages/plugin-rss/src/feed.ts create mode 100644 packages/plugin-rss/src/index.ts create mode 100644 packages/plugin-rss/src/internals/index.ts create mode 100644 packages/plugin-rss/src/internals/lang.ts create mode 100644 packages/plugin-rss/src/internals/node.ts create mode 100644 packages/plugin-rss/src/internals/type.ts create mode 100644 packages/plugin-rss/src/options.ts create mode 100644 packages/plugin-rss/src/plugin-rss.ts create mode 100644 packages/plugin-rss/src/type.ts create mode 100644 packages/plugin-rss/static/global-components/FeedsAnnotations.tsx create mode 100644 packages/plugin-rss/tests/tsconfig.json create mode 100644 packages/plugin-rss/tsconfig.json create mode 100644 packages/plugin-rss/tsconfig.runtime.json create mode 100644 packages/plugin-rss/tsconfig.tools.json create mode 100644 packages/plugin-rss/vitest.config.ts 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/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 85de76b61..dc4dceee4 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):Generate RSS files with [feed](https://github.com/jpmonette/feed) for the document site. ## 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..1efda962b --- /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 Options](#other-options) 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 0b36f02f9..0f9cd9ea3 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):使用 [feed](https://github.com/jpmonette/feed) 为 Rspress 站点生成 RSS 文件。 ## 社区插件 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..c49678f67 --- /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..12e65c177 --- /dev/null +++ b/packages/plugin-rss/src/plugin-rss.ts @@ -0,0 +1,160 @@ +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< + string, + PromiseLike + > = 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` for each page + // - let's cache rss items within a complete rspress build + _rssWorkaround[pageData.id] = + _rssWorkaround[pageData.id] || + getRssItems( + feedsSet.get(), + pageData, + _config!, + pluginRssOptions.siteUrl, + ); + + const feeds = await _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( + ...(await Promise.all(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 27af458c3..d08131987 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,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: @@ -964,6 +977,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': @@ -6077,6 +6115,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} @@ -9682,6 +9727,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: @@ -11326,6 +11375,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'}