From 836e9fe7ca19ed5056c60e6ba0d647dca38ff682 Mon Sep 17 00:00:00 2001 From: xc2 Date: Wed, 27 Mar 2024 23:47:58 +0800 Subject: [PATCH] feat(plugin-rss): add rss plugin --- biome.json | 6 + e2e/fixtures/plugin-rss/doc/blog/bar.md | 8 + e2e/fixtures/plugin-rss/doc/blog/foo.md | 10 ++ e2e/fixtures/plugin-rss/doc/index.md | 5 + e2e/fixtures/plugin-rss/doc/releases/1.0.0.md | 7 + e2e/fixtures/plugin-rss/fixture.json | 5 + e2e/fixtures/plugin-rss/package.json | 17 ++ e2e/fixtures/plugin-rss/rspress.config.ts | 31 ++++ e2e/fixtures/plugin-rss/tsconfig.json | 8 + e2e/tests/plugin-rss.test.ts | 70 +++++++++ .../en/plugin/official-plugins/_meta.json | 1 + .../en/plugin/official-plugins/overview.mdx | 1 + .../docs/en/plugin/official-plugins/rss.mdx | 11 ++ .../zh/plugin/official-plugins/_meta.json | 1 + .../zh/plugin/official-plugins/overview.mdx | 1 + .../docs/zh/plugin/official-plugins/rss.mdx | 11 ++ 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 | 52 +++++++ 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 | 147 ++++++++++++++++++ packages/plugin-rss/src/type.ts | 96 ++++++++++++ .../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 | 59 ++++++- 37 files changed, 900 insertions(+), 2 deletions(-) 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/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..48f4dad9e --- /dev/null +++ b/e2e/fixtures/plugin-rss/doc/blog/bar.md @@ -0,0 +1,8 @@ +--- +title: Bar but Frontmatter +date: 2024-01-02 08:00:00 +--- + +# 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..840fd6cc2 --- /dev/null +++ b/e2e/fixtures/plugin-rss/doc/blog/foo.md @@ -0,0 +1,10 @@ +--- +date: 2024-01-01 08:00:00 +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..39efe73e6 --- /dev/null +++ b/e2e/fixtures/plugin-rss/doc/index.md @@ -0,0 +1,5 @@ +--- +show-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..cfbe12e9f --- /dev/null +++ b/e2e/fixtures/plugin-rss/doc/releases/1.0.0.md @@ -0,0 +1,7 @@ +--- +date: 2024-01-01 08:00:00 +--- + +# 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..5e9f00d0a --- /dev/null +++ b/e2e/fixtures/plugin-rss/fixture.json @@ -0,0 +1,5 @@ +{ + "base": "/sub/", + "siteUrl": "https://example.com/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..46dd12c92 --- /dev/null +++ b/e2e/fixtures/plugin-rss/rspress.config.ts @@ -0,0 +1,31 @@ +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: { + /* use .xml for preview server */ + filename: 'blog.xml', + }, + }, + { + id: 'releases', + test: /^\/releases\//, + title: 'FooBar Releases', + output: { type: 'atom', 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..160bb36e9 --- /dev/null +++ b/e2e/tests/plugin-rss.test.ts @@ -0,0 +1,70 @@ +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('`show-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); + }); + }); +}); 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..13254cfa9 --- /dev/null +++ b/packages/document/docs/en/plugin/official-plugins/rss.mdx @@ -0,0 +1,11 @@ +# @rspress/plugin-rss + +import { SourceCode, PackageManagerTabs } from 'rspress/theme'; + + + +todo(xc2) + +## Installation + + \ No newline at end of file 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..13254cfa9 --- /dev/null +++ b/packages/document/docs/zh/plugin/official-plugins/rss.mdx @@ -0,0 +1,11 @@ +# @rspress/plugin-rss + +import { SourceCode, PackageManagerTabs } from 'rspress/theme'; + + + +todo(xc2) + +## Installation + + \ No newline at end of file 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..b7d46698f --- /dev/null +++ b/packages/plugin-rss/src/feed.ts @@ -0,0 +1,52 @@ +import { resolve as resolveUrl } from 'node:url'; +import type { PageIndexInfo, UserConfig } from '@rspress/shared'; +import type { 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 { + title: selectNonNullishProperty(fm.title, page.title) || '', + id: selectNonNullishProperty(page.id) || '', + 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 || '', + }; +} 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..0e8dece0d --- /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 || 'rss'; + 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..a7b8c6533 --- /dev/null +++ b/packages/plugin-rss/src/plugin-rss.ts @@ -0,0 +1,147 @@ +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 || '', + ...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 || ((_: any, feed: FeedItem) => feed); + const item = await after(page, generateFeedItem(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['show-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..ee45abec8 --- /dev/null +++ b/packages/plugin-rss/src/type.ts @@ -0,0 +1,96 @@ +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 page page data + * @param item pre-generated feed item + * @returns modified feed item + */ + item?: ( + page: PageIndexInfo, + item: FeedItem, + ) => 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 c8a88babf..5bffa2448 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'}