diff --git a/docs/.vuepress/configs/navbar/en.ts b/docs/.vuepress/configs/navbar/en.ts
index 778e5104b5..a653026dca 100644
--- a/docs/.vuepress/configs/navbar/en.ts
+++ b/docs/.vuepress/configs/navbar/en.ts
@@ -29,6 +29,10 @@ export const navbarEn: NavbarConfig = [
text: 'Search',
children: ['/plugins/docsearch', '/plugins/search'],
},
+ {
+ text: 'Blogging',
+ children: ['/plugins/feed/'],
+ },
{
text: 'PWA',
children: ['/plugins/pwa', '/plugins/pwa-popup'],
diff --git a/docs/.vuepress/configs/navbar/zh.ts b/docs/.vuepress/configs/navbar/zh.ts
index 5f8b1d6c80..a28d6c4dd4 100644
--- a/docs/.vuepress/configs/navbar/zh.ts
+++ b/docs/.vuepress/configs/navbar/zh.ts
@@ -29,6 +29,10 @@ export const navbarZh: NavbarConfig = [
text: '搜索',
children: ['/zh/plugins/docsearch', '/zh/plugins/search'],
},
+ {
+ text: '博客',
+ children: ['/plugins/feed/'],
+ },
{
text: 'PWA',
children: ['/zh/plugins/pwa', '/zh/plugins/pwa-popup'],
diff --git a/docs/.vuepress/configs/sidebar/en.ts b/docs/.vuepress/configs/sidebar/en.ts
index 80c3beef67..8e9e0be1f5 100644
--- a/docs/.vuepress/configs/sidebar/en.ts
+++ b/docs/.vuepress/configs/sidebar/en.ts
@@ -18,6 +18,22 @@ export const sidebarEn: SidebarConfig = {
text: 'Content Search',
children: ['/plugins/docsearch', '/plugins/search'],
},
+ {
+ text: 'Blogging',
+ children: [
+ {
+ text: 'Feed',
+ link: '/plugins/feed/',
+ children: [
+ '/plugins/feed/guide',
+ '/plugins/feed/config',
+ '/plugins/feed/frontmatter',
+ '/plugins/feed/channel',
+ '/plugins/feed/getter',
+ ],
+ },
+ ],
+ },
{
text: 'PWA',
children: ['/plugins/pwa', '/plugins/pwa-popup'],
@@ -27,6 +43,7 @@ export const sidebarEn: SidebarConfig = {
children: [
{
text: 'Sitemap',
+ link: '/plugins/sitemap/',
children: [
'/plugins/sitemap/guide',
'/plugins/sitemap/config',
diff --git a/docs/.vuepress/configs/sidebar/zh.ts b/docs/.vuepress/configs/sidebar/zh.ts
index a146575f74..25e742273e 100644
--- a/docs/.vuepress/configs/sidebar/zh.ts
+++ b/docs/.vuepress/configs/sidebar/zh.ts
@@ -18,6 +18,22 @@ export const sidebarZh: SidebarConfig = {
text: '搜索',
children: ['/zh/plugins/docsearch', '/zh/plugins/search'],
},
+ {
+ text: '博客',
+ children: [
+ {
+ text: 'Feed',
+ link: '/zh/plugins/feed/',
+ children: [
+ '/zh/plugins/feed/guide',
+ '/zh/plugins/feed/config',
+ '/zh/plugins/feed/frontmatter',
+ '/zh/plugins/feed/channel',
+ '/zh/plugins/feed/getter',
+ ],
+ },
+ ],
+ },
{
text: 'PWA',
children: ['/zh/plugins/pwa', '/zh/plugins/pwa-popup'],
@@ -27,6 +43,7 @@ export const sidebarZh: SidebarConfig = {
children: [
{
text: '站点地图',
+ link: '/zh/plugins/sitemap/',
children: [
'/zh/plugins/sitemap/guide',
'/zh/plugins/sitemap/config',
diff --git a/docs/plugins/feed/README.md b/docs/plugins/feed/README.md
new file mode 100644
index 0000000000..7e06d32979
--- /dev/null
+++ b/docs/plugins/feed/README.md
@@ -0,0 +1,21 @@
+# feed
+
+
+
+## Usage
+
+```bash
+npm i -D @vuepress/plugin-feed@next
+```
+
+```ts title=".vuepress/config.ts"
+import { feedPlugin } from "@vuepress/plugin-feed";
+
+export default {
+ plugins: [
+ feedPlugin({
+ // options
+ }),
+ ],
+}
+```
diff --git a/docs/plugins/feed/channel.md b/docs/plugins/feed/channel.md
new file mode 100644
index 0000000000..ec332b003d
--- /dev/null
+++ b/docs/plugins/feed/channel.md
@@ -0,0 +1,120 @@
+# Channel Config
+
+The channel plugin option is used to config the feed channel.
+
+## channel.title
+
+- Type: `string`
+- Default: `SiteConfig.title`
+
+Channel title
+
+## channel.link
+
+- Type: `string`
+- Default: Deployment link (generated by `options.hostname` and `context.base`)
+
+Channel address
+
+## channel.description
+
+- Type: `string`
+- Default: `SiteConfig.description`
+
+Channel description
+
+## channel.language
+
+- Type: `string`
+
+- Default:
+ - `siteConfig.locales['/'].lang`
+ - If the above is not provided, fall back to `"en-US"`
+
+The language of the channel
+
+## channel.copyright
+
+- Type: `string`
+
+- Default:
+
+ - Try to read the `author.name` in channel options, and fall back to `Copyright by $author`
+
+- Recommended to set manually: **Yes**
+
+Channel copyright information
+
+## channel.pubDate
+
+- Type: `string` (must be a valid Date ISOString)
+- Default: time when the plugin is called each time
+- Recommended to set manually: **Yes**
+
+Publish date of the Channel
+
+## channel.lastUpdated
+
+- Type: `string` (must be a valid Date ISOString)
+- Default: time when the plugin is called each time
+
+Last update time of channel content
+
+## channel.ttl
+
+- Type: `number`
+- Recommended to set manually: **Yes**
+
+The effective time of the content. It's the time to keep the cache after request without making new requests.
+
+## channel.image
+
+- Type: `string`
+- Recommended to set manually: **Yes**
+
+A picture presenting the channel. A square picture with a size not smaller than 512×512 is recommended.
+
+## channel.icon
+
+- Type: `string`
+- Recommended to set manually: **Yes**
+
+An icon representing a channel, a square picture, with not less than 128×128 in size, and transparent background color is recommended.
+
+## channel.author
+
+- Type: `FeedAuthor`
+- Recommended to set manually: **Yes**
+
+The author of the channel.
+
+::: details FeedAuthor format
+
+```ts
+interface FeedAuthor {
+ /** Author name */
+ name: string;
+ /** Author's email */
+ email?: string;
+ /** Author's site */
+ url?: string;
+ /**
+ * Author's avatar address
+ *
+ * Square, preferably not less than 128×128 with transparent background
+ */
+ avatar?: string;
+}
+```
+
+## channel.hub
+
+- Type: `string`
+
+Link to Websub. Websub requires a server backend, which is inconsistent with VuePress, so ignore it if there is no special need.
+
+::: tip WebSub
+
+For details, see [Websub](https://w3c.github.io/websub/#subscription-migration).
+
+:::
diff --git a/docs/plugins/feed/config.md b/docs/plugins/feed/config.md
new file mode 100644
index 0000000000..874985d7eb
--- /dev/null
+++ b/docs/plugins/feed/config.md
@@ -0,0 +1,197 @@
+# Plugin Config
+
+## hostname
+
+- Type: `string`
+- Required: Yes
+
+The domain name of the deployment site.
+
+## atom
+
+- Type: `boolean`
+- Default: `false`
+
+Whether to output Atom syntax files.
+
+## json
+
+- Type: `boolean`
+- Default: `false`
+
+Whether output JSON syntax files.
+
+## rss
+
+- Type: `boolean`
+- Default: `false`
+
+Whether to output RSS syntax files.
+
+## image
+
+- Type: `string`
+
+A large image/icon of the feed, probably used as banner.
+
+## icon
+
+- Type: `string`
+
+A small icon of the feed, probably used as favicon.
+
+## count
+
+- Type: `number`
+- Default: `100`
+
+Set the maximum number of items in the feed. After all pages are sorted, the first `count` items will be intercepted.
+
+If your site has a lot of articles, you may consider this option to reduce feed file size.
+
+## preservedElements
+
+- Type: `(RegExp | string)[] | (tagName: string) => boolean`
+
+Custom element or component which should be preserved in feed.
+
+::: tip By default, all unknown tags will be removed.
+
+:::
+
+## filter
+
+- Type: `(page: Page)=> boolean`
+- Default:
+
+ ```ts
+ ({ frontmatter, filePathRelative }: Page): boolean =>
+ !(
+ frontmatter.home ||
+ !filePathRelative ||
+ frontmatter.article === false ||
+ frontmatter.feed === false
+ );
+ ```
+
+A custom filter function, used to filter feed items.
+
+## sorter
+
+- Type: `(pageA: Page, pageB: Page)=> number`
+
+- Default:
+
+ ```ts
+ // compareDate is from vuepress-shared
+ (pageA: Page, pageB: Page): number =>
+ compareDate(
+ pageA.data.git?.createdTime
+ ? new Date(pageA.data.git?.createdTime)
+ : pageA.frontmatter.date,
+ pageB.data.git?.createdTime
+ ? new Date(pageB.data.git?.createdTime)
+ : pageB.frontmatter.date,
+ );
+ ```
+
+Custom sorter function for feed items.
+
+The default sorting behavior is by file adding time coming from git (needs `@vuepress/plugin-git`).
+
+::: tip
+
+You should enable `@vuepress/plugin-git` to get the newest created pages as feed items. Otherwise, the feed items will be sorted by the default order of pages in VuePress.
+
+:::
+
+## channel
+
+`channel` option is used to config _Feed Channels_.
+
+For available options, please see [Config → Channel](channel.md)
+
+## devServer
+
+- Type: `boolean`
+- Default: `false`
+
+Whether enabled in devServer.
+
+::: tip
+
+For performance reasons, we do not provide hot reload. Reboot your devServer to sync your changes.
+
+:::
+
+## devHostname
+
+- Type: `string`
+- Default: `"http://localhost:${port}"`
+
+Hostname to use in devServer
+
+## atomOutputFilename
+
+- Type: `string`
+- Default: `"atom.xml"`
+
+Atom syntax output filename, relative to dest folder.
+
+## atomXslTemplate
+
+- Type: `string`
+- Default: Content of `@vuepress/plugin-feed/templates/atom.xsl`
+
+Atom xsl template file content.
+
+## atomXslFilename
+
+- Type: `string`
+- Default: `"atom.xsl"`
+
+Atom xsl filename, relative to dest folder.
+
+## jsonOutputFilename
+
+- Type: `string`
+- Default: `"feed.json"`
+
+JSON syntax output filename, relative to dest folder.
+
+## rssOutputFilename
+
+- Type: `string`
+- Default: `"rss.xml"`
+
+RSS syntax output filename, relative to dest folder.
+
+## rssXslTemplate
+
+- Type: `string`
+- Default: Content of `@vuepress/plugin-feed/templates/rss.xsl`
+
+RSS xsl template file content.
+
+## rssXslFilename
+
+- Type: `string`
+- Default: `"rss.xsl"`
+
+RSS syntax xsl filename, relative to dest folder.
+
+## getter
+
+Feed generation controller, see [Feed Getter](./getter.md).
+
+::: tip The plugin has a built-in getter, only set this if you want full control of feed generation.
+
+:::
+
+## locales
+
+- Type: `Record`
+
+You can use it to specific options for each locale.
+
+Any options above are supported except `hostname`.
diff --git a/docs/plugins/feed/frontmatter.md b/docs/plugins/feed/frontmatter.md
new file mode 100644
index 0000000000..c7f33957e4
--- /dev/null
+++ b/docs/plugins/feed/frontmatter.md
@@ -0,0 +1,153 @@
+# Frontmatter Config
+
+You can control each feed item generation by setting page frontmatter.
+
+## Additions and Removals
+
+By default, all articles are added to the feed stream. Set `feed: false` in frontmatter to remove a page from feed.
+
+## Frontmatter Information
+
+### title
+
+- Type: `string`
+
+Automatically generated by VuePress, defaults to the h1 content of the page
+
+### description
+
+- Type: `string`
+
+Description of the page
+
+### date
+
+- Type: `Date`
+
+Date when the page was published
+
+### article
+
+- Type: `boolean`
+
+Whether the page is an article
+
+> If this is set to `false`, the page will not be included in the final feed.
+
+### copyright
+
+- Type: `string`
+
+Page copyright information
+
+### cover / image / banner
+
+- Type: `string`
+
+Image used as page cover , should be full link or absolute link.
+
+## Frontmatter Options
+
+### feed.title
+
+- Type: `string`
+
+The title of the feed item
+
+### feed.description
+
+- Type: `string`
+
+Description of the feed item
+
+### feed.content
+
+- Type: `string`
+
+The content of the feed item
+
+### feed.author
+
+- Type: `FeedAuthor[] | FeedAuthor`
+
+The author of the feed item
+
+::: details FeedAuthor format
+
+```ts
+interface FeedAuthor {
+ /**
+ * Author name
+ */
+ name?: string;
+
+ /**
+ * Author email
+ */
+ email?: string;
+
+ /**
+ * Author site
+ *
+ * @description json format only
+ */
+ url?: string;
+
+ /**
+ * Author avatar
+ *
+ * @description json format only
+ */
+ avatar?: string;
+}
+```
+
+:::
+
+### feed.contributor
+
+- Type: `FeedContributor[] | FeedContributor`
+
+Contributors to feed item
+
+::: details FeedContributor format
+
+```ts
+interface FeedContributor {
+ /**
+ * Author name
+ */
+ name?: string;
+
+ /**
+ * Author email
+ */
+ email?: string;
+
+ /**
+ * Author site
+ *
+ * @description json format only
+ */
+ url?: string;
+
+ /**
+ * Author avatar
+ *
+ * @description json format only
+ */
+ avatar?: string;
+}
+```
+
+:::
+
+### feed.guid
+
+- Type: `string`
+
+The identifier of feed item, used to identify the feed item.
+
+::: tip You should ensure every feed has a unique guid.
+
+:::
diff --git a/docs/plugins/feed/getter.md b/docs/plugins/feed/getter.md
new file mode 100644
index 0000000000..58b230e117
--- /dev/null
+++ b/docs/plugins/feed/getter.md
@@ -0,0 +1,211 @@
+# Feed Getter
+
+You can take full control of feed items generation by setting `getter` in the plugin options.
+
+## getter.title
+
+- Type: `(page: Page) => string`
+
+Item title getter
+
+## getter.link
+
+- Type: `(page: Page) => string`
+
+Item link getter
+
+## getter.description
+
+- Type: `(page: Page) => string | undefined`
+
+Item description getter
+
+::: tip
+
+Due to Atom support HTML in summary, so you can return HTML content here if possible, but the content must start with mark `html:`.
+
+:::
+
+## getter.content
+
+- Type: `(page: Page) => string`
+
+Item content getter
+
+## getter.author
+
+- Type: `(page: Page) => FeedAuthor[]`
+
+Item author getter.
+
+::: tip The getter should return an empty array when author information is missing.
+
+:::
+
+::: details FeedAuthor format
+
+```ts
+interface FeedAuthor {
+ /**
+ * Author name
+ */
+ name?: string;
+
+ /**
+ * Author email
+ */
+ email?: string;
+
+ /**
+ * Author site
+ *
+ * @description json format only
+ */
+ url?: string;
+
+ /**
+ * Author avatar
+ *
+ * @description json format only
+ */
+ avatar?: string;
+}
+```
+
+:::
+
+## getter.category
+
+- Type: `(page: Page) => FeedCategory[] | undefined`
+
+Item category getter.
+
+::: details FeedCategory format
+
+```ts
+interface FeedCategory {
+ /**
+ * Category Name
+ */
+ name: string;
+
+ /**
+ * A string that identifies a categorization taxonomy
+ *
+ * @description rss format only
+ */
+ domain?: string;
+
+ /**
+ * the categorization scheme via a URI
+ *
+ * @description atom format only
+ */
+ scheme?: string;
+}
+```
+
+:::
+
+## getter.enclosure
+
+- Type: `(page: Page) => FeedEnclosure | undefined`
+
+Item enclosure getter.
+
+::: details FeedEnclosure format
+
+```ts
+interface FeedEnclosure {
+ /**
+ * Enclosure link
+ */
+ url: string;
+
+ /**
+ * what its type is
+ *
+ * @description should be a standard MIME Type, rss format only
+ */
+ Type: string;
+
+ /**
+ * Size in bytes
+ *
+ * @description rss format only
+ */
+ length?: number;
+}
+```
+
+:::
+
+## getter.publishDate
+
+- Type: `(page: Page) => Date | undefined`
+
+Item release date getter
+
+## getter.lastUpdateDate
+
+- Type: `(page: Page) => Date`
+
+Item last update date getter
+
+## getter.image
+
+- Type: `(page: Page) => string`
+
+Item Image Getter
+
+::: tip Ensure it's returning a full URL
+
+:::
+
+## getter.contributor
+
+- Type: `(page: Page) => FeedContributor[]`
+
+Item Contributor Getter
+
+::: tip The getter should return an empty array when contributor information is missing.
+
+:::
+
+::: details FeedContributor format
+
+```ts
+interface FeedContributor {
+ /**
+ * Author name
+ */
+ name?: string;
+
+ /**
+ * Author email
+ */
+ email?: string;
+
+ /**
+ * Author site
+ *
+ * @description json format only
+ */
+ url?: string;
+
+ /**
+ * Author avatar
+ *
+ * @description json format only
+ */
+ avatar?: string;
+}
+```
+
+:::
+
+## getter.copyright
+
+- Type: `(page: Page) => string | undefined`
+
+Item copyright getter
diff --git a/docs/plugins/feed/guide.md b/docs/plugins/feed/guide.md
new file mode 100644
index 0000000000..bff5166712
--- /dev/null
+++ b/docs/plugins/feed/guide.md
@@ -0,0 +1,46 @@
+# Guide
+
+## Usage
+
+The plugin can generate feed files in the following three formats for you:
+
+- Atom 1.0
+- JSON 1.1
+- RSS 2.0
+
+Please set `atom`, `json` or `rss` to `true` in the plugin options according to the formats you want to generate.
+
+To correctly generate feed links, you need to set `hostname` in the plugin options,
+
+## Readable Preview
+
+When you open the feed file in browser, we magically convert atom and rss feed xml to human readable html via xsl template. Check [atom](/atom.xml) and [rss](/rss.xml) feed of this site as an example!
+
+If you want to preview your feed in devServer, set `devServer: true` in plugin options. You may also need to set `devHostname` if you are not using the default `http://localhost:{port}`.
+
+## Channel settings
+
+You can customize the feed channel information by setting the `channel` option.
+
+We recommend the following settings:
+
+- Convert the date of creating the feed to ISOString and write it into `channel.pubDate`
+- The update period of the content set in `channel.ttl` (unit: minutes)
+- Set copyright information via `channel.copyright`
+- Set the channel author via `channel.author`.
+
+For detailed options and their default values, see [Channel Config](./channel.md)
+
+## Feed Generation
+
+By default, all articles are added to the feed stream.
+
+You can set `feed` and other options in page frontmatter to control contents of feed item. See [Frontmatter Config](./frontmatter.md) for how they are converted.
+
+You can take full control of feed items generation by configuring the `getter` in the plugin options. For detailed options and their default values, see [Configuration → Feed Getter](./getter.md).
+
+### I18n Config
+
+The plugin generates separate feeds for each language.
+
+You can provide different settings for different languages via `locales` in the plugin options.
diff --git a/docs/zh/plugins/feed/README.md b/docs/zh/plugins/feed/README.md
new file mode 100644
index 0000000000..40c9c1a537
--- /dev/null
+++ b/docs/zh/plugins/feed/README.md
@@ -0,0 +1,21 @@
+# feed
+
+
+
+## 使用
+
+```bash
+npm i -D @vuepress/plugin-feed@next
+```
+
+```ts title=".vuepress/config.ts"
+import { feedPlugin } from "@vuepress/plugin-feed";
+
+export default {
+ plugins: [
+ feedPlugin({
+ // 选项
+ }),
+ ],
+}
+```
diff --git a/docs/zh/plugins/feed/channel.md b/docs/zh/plugins/feed/channel.md
new file mode 100644
index 0000000000..01a1ad2641
--- /dev/null
+++ b/docs/zh/plugins/feed/channel.md
@@ -0,0 +1,118 @@
+# 频道设置
+
+`channel` 插件选项用于配置 feed 的频道。
+
+## channel.title
+
+- 类型: `string`
+- 默认值: `SiteConfig.title`
+
+频道的标题
+
+## channel.link
+
+- 类型: `string`
+- 默认值: 部署的网址 (通过 `options.hostname` 和 `context.base` 生成)
+
+频道地址
+
+## channel.description
+
+- 类型: `string`
+- 默认值: `SiteConfig.description`
+
+频道描述信息
+
+## channel.language
+
+- 类型: `string`
+- 默认值:
+ - `siteConfig.locales['/'].locales`
+ - 如果上述未提供,回退到 `"en-US"`
+
+频道使用的语言
+
+## channel.copyright
+
+- 类型: `string`
+- 默认值:
+ - 尝试读取 channel 选项中的 `author.name` 生成 `Copyright by $author`
+- 建议自行设置: **是**
+
+频道版权信息
+
+## channel.pubDate
+
+- 类型: `string` (需是合法的 Date ISOString)
+- 默认值: 每次插件构建时刻
+- 建议自行设置: **是**
+
+频道内容的发布时间
+
+## channel.lastUpdated
+
+- 类型: `string` (需是合法的 Date ISOString)
+- 默认值: 每次插件构建时刻
+
+频道内容的上次更新时间
+
+## channel.ttl
+
+- 类型: `number`
+- 建议自行设置: **是**
+
+内容有效时间,即获取后保持缓存而不进行新获取的时间
+
+## channel.image
+
+- 类型: `string`
+- 建议自行设置: **是**
+
+这是一个会在频道中使用的图片,建议设置正方形图片、尺寸最好不小于 512×512。
+
+## channel.icon
+
+- 类型: `string`
+- 建议自行设置: **是**
+
+一个代表频道的图标,建议设置正方形图片、尺寸最好不小于 128×128,背景色透明。
+
+## channel.author
+
+- 类型: `FeedAuthor`
+- 建议自行设置: **是**
+
+频道的作者。
+
+::: details FeedAuthor 格式
+
+```ts
+interface FeedAuthor {
+ /** 作者姓名 */
+ name: string;
+ /** 作者电子邮箱 */
+ email?: string;
+ /** 作者网站 */
+ url?: string;
+ /**
+ * 作者头像地址
+ *
+ * 正方形,最好不小于 128×128,透明背景
+ */
+ avatar?: string;
+}
+```
+
+:::
+
+## channel.hub
+
+- 类型: `string`
+
+Websub 的链接。Websub 需要服务器后端,与 VuePress 主旨不符,如无特殊需要忽略即可。
+
+::: tip WebSub
+
+有关信息,详见 [Websub](https://w3c.github.io/websub/#subscription-migration)。
+
+:::
diff --git a/docs/zh/plugins/feed/config.md b/docs/zh/plugins/feed/config.md
new file mode 100644
index 0000000000..ab9b02bb1e
--- /dev/null
+++ b/docs/zh/plugins/feed/config.md
@@ -0,0 +1,197 @@
+# 插件配置
+
+## hostname
+
+- 类型:`string`
+- 必填:是
+
+部署网站的域名。
+
+## atom
+
+- 类型:`boolean`
+- 默认值:`false`
+
+是否启用 Atom 格式输出。
+
+## json
+
+- 类型:`boolean`
+- 默认值:`false`
+
+是否启用 JSON 格式输出。
+
+## rss
+
+- 类型:`boolean`
+- 默认值:`false`
+
+是否启用 RSS 格式输出。
+
+## image
+
+- 类型:`string`
+
+一个大的图片,用作 feed 展示。
+
+## icon
+
+- 类型:`string`
+
+一个小的图标,显示在订阅列表中。
+
+## count
+
+- 类型:`number`
+- 默认值:`100`
+
+设置 feed 的最大项目数量。在所有页面排序好后,插件会截取前 count 个项目。
+
+如果你的站点文章很多,你应该考虑设置这个选项以减少 feed 文件大小。
+
+## preservedElements
+
+- 类型:`(RegExp | string)[] | (tagName:string) => boolean`
+
+应在 Feed 中保留的自定义元素或组件。
+
+::: tip 默认情况下,所有未知标签均会被移除。
+
+:::
+
+## filter
+
+- 类型:`(page: Page)=> boolean`
+- 默认值:
+
+ ```ts
+ ({ frontmatter, filePathRelative }: Page): boolean =>
+ !(
+ frontmatter.home ||
+ !filePathRelative ||
+ frontmatter.article === false ||
+ frontmatter.feed === false
+ );
+ ```
+
+自定义的过滤函数,用于过滤哪些项目在 feed 中显示。
+
+## sorter
+
+- 类型: `(pageA: Page, pageB: Page)=> number`
+
+- 默认值:
+
+ ```ts
+ // compareDate 来源于 vuepress-shared
+ (pageA, pageB): number =>
+ compareDate(
+ pageA.data.git?.createdTime
+ ? new Date(pageA.data.git?.createdTime)
+ : pageA.frontmatter.date,
+ pageB.data.git?.createdTime
+ ? new Date(pageB.data.git?.createdTime)
+ : pageB.frontmatter.date,
+ );
+ ```
+
+Feed 项目的排序器。
+
+默认的排序行为是通过 Git 的文件添加日期 (需要 `@vuepress/plugin-git`)。
+
+::: tip
+
+你应该启用 `@vuepress/plugin-git` 来获取最新创建的页面作为 feed 项目。否则,feed 项目将按照 VuePress 中页面的默认顺序排序。
+
+:::
+
+## channel
+
+`channel` 选项用于配置 Feed 频道。
+
+可用选项详见 [配置 → 频道设置](channel.md)
+
+## devServer
+
+- 类型:`boolean`
+- 默认值:`false`
+
+是否在开发服务器中启用
+
+::: tip
+
+由于性能原因,我们不提供热更新。重启开发服务器以同步你的变更。
+
+:::
+
+## devHostname
+
+- 类型:`string`
+- 默认值:`"http://localhost:${port}"`
+
+开发服务器使用的主机名
+
+## atomOutputFilename
+
+- 类型:`string`
+- 默认值:`"atom.xml"`
+
+Atom 格式输出路径,相对于输出路径。
+
+## atomXslTemplate
+
+- 类型:`string`
+- 默认值:`@vuepress/plugin-feed/templates/atom.xsl` 的内容
+
+Atom xsl 模板文件没人陪美国
+
+## atomXslFilename
+
+- 类型:`string`
+- 默认值:`"atom.xsl"`
+
+Atom xsl 输出路径,相对于输出路径。
+
+## jsonOutputFilename
+
+- 类型:`string`
+- 默认值:`"feed.json"`
+
+JSON 格式输出路径,相对于输出路径。
+
+## rssOutputFilename
+
+- 类型:`string`
+- 默认值:`"rss.xml"`
+
+RSS 格式输出路径,相对于输出路径。
+
+## rssXslTemplate
+
+- 类型:`string`
+- 默认值:`@vuepress/plugin-feed/templates/rss.xsl` 的内容
+
+RSS xsl 模板文件内容。
+
+## rssXslFilename
+
+- 类型:`string`
+- 默认值:`"rss.xsl"`
+
+RSS xsl 输出路径,相对于输出路径。
+
+## getter
+
+Feed 生成控制器,详见 [Feed 生成器](./getter.md)。
+
+::: tip 此插件内置了生成器,只有当你想完全控制 feed 生成时才需要设置此选项。
+
+:::
+
+## locales
+
+- 类型:`Record`
+
+你可以将它用于每个语言环境的特定选项。
+
+除 `hostname` 外,上述任何选项均受支持。
diff --git a/docs/zh/plugins/feed/frontmatter.md b/docs/zh/plugins/feed/frontmatter.md
new file mode 100644
index 0000000000..930a80ab54
--- /dev/null
+++ b/docs/zh/plugins/feed/frontmatter.md
@@ -0,0 +1,153 @@
+# Frontmatter 配置
+
+你可以通过配置每个页面的 Frontmatter,来对每个 Feed 项目生成进行单独的控制。
+
+## 添加与移除
+
+默认情况下,所有文章均会被添加至 feed 流。如果你想在 feed 中移除特定页面,你可以在 frontmatter 中设置 `feed: false`。
+
+## 读取的 Frontmatter 信息
+
+### title
+
+- 类型:`string`
+
+由 VuePress 自动生成,默认为页面的 h1 内容
+
+### description
+
+- 类型:`string`
+
+页面描述
+
+### date
+
+- 类型:`Date`
+
+页面的发布日期
+
+### article
+
+- 类型:`boolean`
+
+该页面是否是文章
+
+> 如果此项设置为 `false`,则该页不会包含在最终的 feed 中。
+
+### copyright
+
+- 类型:`string`
+
+页面版权信息
+
+### cover / image / banner
+
+- 类型:`string`
+
+页面的封面/分享图,需为完整链接或绝对链接。
+
+## Frontmatter 选项
+
+### feed.title
+
+- 类型:`string`
+
+Feed 项目的标题
+
+### feed.description
+
+- 类型:`string`
+
+Feed 项目的描述
+
+### feed.content
+
+- 类型:`string`
+
+Feed 项目的内容
+
+### feed.author
+
+- 类型:`FeedAuthor[] | FeedAuthor`
+
+Feed 项目的作者
+
+::: details FeedAuthor 格式
+
+```ts
+interface FeedAuthor {
+ /**
+ * 作者名字
+ */
+ name?: string;
+
+ /**
+ * 作者邮件
+ */
+ email?: string;
+
+ /**
+ * 作者网站
+ *
+ * @description json format only
+ */
+ url?: string;
+
+ /**
+ * 作者头像
+ *
+ * @description json format only
+ */
+ avatar?: string;
+}
+```
+
+:::
+
+### feed.contributor
+
+- 类型:`FeedContributor[] | FeedContributor`
+
+Feed 项目的贡献者
+
+::: details FeedContributor 格式
+
+```ts
+interface FeedContributor {
+ /**
+ * 作者名字
+ */
+ name?: string;
+
+ /**
+ * 作者邮件
+ */
+ email?: string;
+
+ /**
+ * 作者网站
+ *
+ * @description json format only
+ */
+ url?: string;
+
+ /**
+ * 作者头像
+ *
+ * @description json format only
+ */
+ avatar?: string;
+}
+```
+
+:::
+
+### feed.guid
+
+- 类型:`string`
+
+Feed 项目的标识符,用于标识 Feed 项目。
+
+::: tip 你应该确保每个 Feed 项目有全局唯一的 guid。
+
+:::
diff --git a/docs/zh/plugins/feed/getter.md b/docs/zh/plugins/feed/getter.md
new file mode 100644
index 0000000000..37d7bc5bc5
--- /dev/null
+++ b/docs/zh/plugins/feed/getter.md
@@ -0,0 +1,211 @@
+# Feed 获取器
+
+你可以通过控制插件选项中的 `getter` 来完全控制 Feed 项目的生成。
+
+## getter.title
+
+- 类型:`(page: Page) => string`
+
+项目标题获取器
+
+## getter.link
+
+- 类型:`(page: Page) => string`
+
+项目链接获取器
+
+## getter.description
+
+- 类型:`(page: Page) => string | undefined`
+
+项目描述获取器
+
+::: tip
+
+因为 Atom 在摘要中支持 HTML,所以如果可能的话,你可以在这里返回 HTML 内容,但内容必须以标记 `html:` 开头。
+
+:::
+
+## getter.content
+
+- 类型:`(page: Page) => string`
+
+项目内容获取器
+
+## getter.author
+
+- 类型:`(page: Page) => FeedAuthor[]`
+
+项目作者获取器。
+
+::: tip 获取器应在作者信息缺失时返回空数组。
+
+:::
+
+::: details FeedAuthor 格式
+
+```ts
+interface FeedAuthor {
+ /**
+ * 作者名字
+ */
+ name?: string;
+
+ /**
+ * 作者邮件
+ */
+ email?: string;
+
+ /**
+ * 作者网站
+ *
+ * @description json format only
+ */
+ url?: string;
+
+ /**
+ * 作者头像
+ *
+ * @description json format only
+ */
+ avatar?: string;
+}
+```
+
+:::
+
+## getter.category
+
+- 类型:`(page: Page) => FeedCategory[] | undefined`
+
+项目分类获取器。
+
+::: details FeedCategory 格式
+
+```ts
+interface FeedCategory {
+ /**
+ * 分类名称
+ */
+ name: string;
+
+ /**
+ * 标识分类法的字符串
+ *
+ * @description rss format only
+ */
+ domain?: string;
+
+ /**
+ * URI 标识的分类 scheme
+ *
+ * @description atom format only
+ */
+ scheme?: string;
+}
+```
+
+:::
+
+## getter.enclosure
+
+- 类型:`(page: Page) => FeedEnclosure | undefined`
+
+项目附件获取器。
+
+::: details FeedEnclosure 格式
+
+```ts
+interface FeedEnclosure {
+ /**
+ * Enclosure 地址
+ */
+ url: string;
+
+ /**
+ * 类型
+ *
+ * @description 应为一个标准的 MIME 类型,rss format only
+ */
+ type: string;
+
+ /**
+ * 按照字节数计算的大小
+ *
+ * @description rss format only
+ */
+ length?: number;
+}
+```
+
+:::
+
+## getter.publishDate
+
+- 类型:`(page: Page) => Date | undefined`
+
+项目发布日期获取器
+
+## getter.lastUpdateDate
+
+- 类型:`(page: Page) => Date`
+
+项目最后更新日期获取器
+
+## getter.image
+
+- 类型:`(page: Page) => string`
+
+项目图片获取器
+
+::: tip 确保返回一个完整的 URL。
+
+:::
+
+## getter.contributor
+
+- 类型:`(page: Page) => FeedContributor[]`
+
+项目贡献者获取器
+
+::: tip 获取器应在贡献者信息缺失时返回空数组。
+
+:::
+
+::: details FeedContributor 格式
+
+```ts
+interface FeedContributor {
+ /**
+ * 作者名字
+ */
+ name?: string;
+
+ /**
+ * 作者邮件
+ */
+ email?: string;
+
+ /**
+ * 作者网站
+ *
+ * @description json format only
+ */
+ url?: string;
+
+ /**
+ * 作者头像
+ *
+ * @description json format only
+ */
+ avatar?: string;
+}
+```
+
+:::
+
+## getter.copyright
+
+- 类型:`(page: Page) => string | undefined`
+
+项目版权获取器
diff --git a/docs/zh/plugins/feed/guide.md b/docs/zh/plugins/feed/guide.md
new file mode 100644
index 0000000000..df6ae764ae
--- /dev/null
+++ b/docs/zh/plugins/feed/guide.md
@@ -0,0 +1,46 @@
+# 指南
+
+## 使用
+
+插件可为你生成以下三种格式的 feed 文件:
+
+- Atom 1.0
+- JSON 1.1
+- RSS 2.0
+
+请按照需要生成的格式,在插件选项中设置 `atom`, `json` 或 `rss` 为 `true`。
+
+为了正确生成 Feed 链接,你需要在插件选项中设置 `hostname`。
+
+## 可读的预览
+
+当你在浏览器中打开 Feed 文件时,我们会通过 xsl 模板将 atom 和 rss feed xml 魔法般地转换为可读的 html。你可以查看本站的 [atom](/zh/atom.xml) 和 [rss](/zh/rss.xml) feed 作为案例!
+
+如果你想在开发服务器中预览 Feed,你需要在插件选项中设置 `devServer: true`。如果你没有使用默认的 `http://localhost:{port}`,你还需要设置 `devHostname`。
+
+## 频道设置
+
+你可以通过设置 `channel` 选项来自自定义 Feed 频道的各项信息。
+
+我们推荐进行如下设置:
+
+- 将建立 Feed 的日期转换为 ISOString 写入到 `channel.pubDate` 中
+- 通过 `channel.ttl` 中设置内容的更新周期(单位: 分钟)
+- 通过 `channel.copyright` 设置版权信息
+- 通过 `channel.author` 设置频道作者。
+
+详细的选项及其默认值详见 [配置 → 频道设置](./channel.md)
+
+## Feed 生成
+
+默认情况下,所有文章均会被添加至 feed 流。
+
+你可以在 frontmatter 中配置 `feed` 和其他选项控制每个页面的 Feed 项目内容,详见 [Frontmatter 选项](./frontmatter.md) 了解它们如何被转换。
+
+你可以通过配置插件选项中的 `getter` 完全控制 Feed 项目的生成逻辑。 详细的选项及其默认值详见 [配置 → Feed 获取器](./getter.md)
+
+### 多语言配置
+
+插件会针对每个语言生成单独的 Feed。
+
+你可以通过插件选项中的 `locales` 分别对不同语言提供不同的默认设置。
diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts
index 584e3522e0..b0e115dd34 100644
--- a/e2e/docs/.vuepress/config.ts
+++ b/e2e/docs/.vuepress/config.ts
@@ -1,6 +1,7 @@
import process from 'node:process'
import { viteBundler } from '@vuepress/bundler-vite'
import { webpackBundler } from '@vuepress/bundler-webpack'
+import { feedPlugin } from '@vuepress/plugin-feed'
import { defaultTheme } from '@vuepress/theme-default'
import { defineUserConfig } from 'vuepress/cli'
import type { UserConfig } from 'vuepress/cli'
@@ -40,7 +41,7 @@ export default defineUserConfig({
bundler: E2E_BUNDLER === 'webpack' ? webpackBundler() : viteBundler(),
theme: defaultTheme({
- hostname: 'https://e2e-test.com',
+ hostname: 'https://ecosystem-e2e-test.com',
logo: 'https://v2.vuepress.vuejs.org/images/hero.png',
navbar: [
{
@@ -76,8 +77,20 @@ export default defineUserConfig({
themePlugins: {
sitemap: {
devServer: true,
+ devHostname: 'https://ecosystem-e2e-test.com',
excludeUrls: ['/sitemap/config-exclude.html', '/404.html'],
},
},
}),
+
+ plugins: [
+ feedPlugin({
+ hostname: 'https://ecosystem-e2e-test.com',
+ devServer: true,
+ devHostname: 'https://example.com',
+ atom: true,
+ json: true,
+ rss: true,
+ }),
+ ],
}) as UserConfig
diff --git a/e2e/docs/feed/custom.md b/e2e/docs/feed/custom.md
new file mode 100644
index 0000000000..d2e6ebd203
--- /dev/null
+++ b/e2e/docs/feed/custom.md
@@ -0,0 +1,9 @@
+---
+title: Custom feed page
+feed:
+ title: Custom feed title
+ description: Custom feed description
+ content: Custom feed content.
+---
+
+Feed item of this page is customized.
diff --git a/e2e/docs/feed/demo.md b/e2e/docs/feed/demo.md
new file mode 100644
index 0000000000..311df93cae
--- /dev/null
+++ b/e2e/docs/feed/demo.md
@@ -0,0 +1,29 @@
+---
+title: Feed Demo Page
+author: Mr.Hope
+date: 2021-01-01
+category:
+ - Demo
+tag:
+ - Demo
+---
+
+Here is **article excerpt**.
+
+```js
+const a = 1;
+```
+
+
+
+## Content
+
+Here is main content of **article**.
+
+1. A
+1. B
+1. C
+
+```js
+const a = 1;
+```
diff --git a/e2e/docs/feed/exclude.md b/e2e/docs/feed/exclude.md
new file mode 100644
index 0000000000..7343891208
--- /dev/null
+++ b/e2e/docs/feed/exclude.md
@@ -0,0 +1,6 @@
+---
+title: Feed exclude Page
+feed: false
+---
+
+Excluded feed page content.
diff --git a/e2e/package.json b/e2e/package.json
index 4b588a0598..fa0a6c7f66 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -18,6 +18,7 @@
"@vuepress/bundler-vite": "2.0.0-rc.2",
"@vuepress/bundler-webpack": "2.0.0-rc.2",
"@vuepress/client": "2.0.0-rc.2",
+ "@vuepress/plugin-feed": "workspace:*",
"@vuepress/theme-default": "workspace:*",
"sass": "^1.70.0",
"sass-loader": "^14.0.0",
diff --git a/e2e/tests/plugin-feed/feed.cy.ts b/e2e/tests/plugin-feed/feed.cy.ts
new file mode 100644
index 0000000000..711f3306b6
--- /dev/null
+++ b/e2e/tests/plugin-feed/feed.cy.ts
@@ -0,0 +1,77 @@
+describe('feed', () => {
+ const BASE = Cypress.env('E2E_BASE')
+
+ it('have atom feed', () => {
+ cy.request(`${BASE}atom.xml`).then((res) => {
+ expect(res.body).to.be.a('string')
+ expect(res.body).to.contain('article excerpt')
+ })
+ cy.request(`${BASE}atom.xsl`).then((res) => {
+ expect(res.body).to.be.a('string')
+ expect(res.body).to.contain(' {
+ cy.request(`${BASE}feed.json`).then((res) => {
+ expect(res.body).to.be.a('object')
+ expect(res.body).to.have.property(
+ 'version',
+ 'https://jsonfeed.org/version/1.1',
+ )
+ expect(JSON.stringify(res.body)).to.contain(
+ 'article excerpt',
+ )
+ })
+ })
+
+ it('have rss feed', () => {
+ cy.request(`${BASE}rss.xml`).then((res) => {
+ expect(res.body).to.be.a('string')
+ expect(res.body).to.contain('article excerpt')
+ })
+ cy.request(`${BASE}rss.xsl`).then((res) => {
+ expect(res.body).to.be.a('string')
+ expect(res.body).to.contain(' {
+ cy.request(`${BASE}atom.xml`).then((res) => {
+ expect(res.body).to.contain('Custom feed title')
+ expect(res.body).to.contain('Custom feed content.')
+ })
+
+ cy.request(`${BASE}feed.json`).then((res) => {
+ const content = JSON.stringify(res.body)
+
+ expect(content).to.contain('Custom feed title')
+ expect(content).to.contain('Custom feed description')
+ expect(content).to.contain('Custom feed content.')
+ })
+
+ cy.request(`${BASE}rss.xml`).then((res) => {
+ expect(res.body).to.contain('Custom feed title')
+ expect(res.body).to.contain('Custom feed description')
+ expect(res.body).to.contain('Custom feed content.')
+ })
+ })
+
+ it('exclude feed', () => {
+ cy.request(`${BASE}atom.xml`).then((res) => {
+ expect(res.body).to.not.contain('Excluded feed page content.')
+ })
+
+ cy.request(`${BASE}feed.json`).then((res) => {
+ const content = JSON.stringify(res.body)
+
+ expect(content).to.not.contain('Excluded feed page content.')
+ })
+
+ cy.request(`${BASE}rss.xml`).then((res) => {
+ expect(res.body).to.not.contain('Excluded feed page content.')
+ })
+ })
+})
diff --git a/e2e/tests/plugin-sitemap/sitemap.cy.ts b/e2e/tests/plugin-sitemap/sitemap.cy.ts
index dcd16775d9..816fe37c10 100644
--- a/e2e/tests/plugin-sitemap/sitemap.cy.ts
+++ b/e2e/tests/plugin-sitemap/sitemap.cy.ts
@@ -1,11 +1,20 @@
describe('sitemap', () => {
const BASE = Cypress.env('E2E_BASE')
- it('have sitemap', () => {
+ it('have sitemap xml', () => {
cy.request(`${BASE}sitemap.xml`).then((res) => {
expect(res.body).to.be.a('string')
expect(res.body).to.contain('`,
+ )
+ })
+ })
+
+ it('have sitemap xsl', () => {
+ cy.request(`${BASE}sitemap.xsl`).then((res) => {
+ expect(res.body).to.be.a('string')
+ expect(res.body).to.contain(' {
+ const { base } = app.options
+ const { siteData } = app
+ const localePaths = keys(options)
+
+ // there is only one language, so we append it to siteData
+ if (localePaths.length === 1) {
+ const { atomOutputFilename, jsonOutputFilename, rssOutputFilename } =
+ getFeedFilenames(options['/'])
+ const { atom, json, rss, hostname } = options['/']
+
+ const getHeadItem = (
+ name: string,
+ fileName: string,
+ type: string,
+ ): HeadConfig => [
+ 'link',
+ {
+ rel: 'alternate',
+ type,
+ href: getUrl(hostname, base, fileName),
+ title: `${
+ siteData.title || siteData.locales['/']?.title || ''
+ } ${name} Feed`,
+ },
+ ]
+
+ // ensure head exists
+ siteData.head ??= []
+
+ // add atom link
+ if (atom)
+ siteData.head.push(
+ getHeadItem('Atom', atomOutputFilename, 'application/atom+xml'),
+ )
+
+ // add json link
+ if (json)
+ siteData.head.push(
+ getHeadItem('JSON', jsonOutputFilename, 'application/json'),
+ )
+
+ // add rss link
+ if (rss)
+ siteData.head.push(
+ getHeadItem('RSS', rssOutputFilename, 'application/rss+xml'),
+ )
+ }
+ // there are multiple languages, so we should append to page
+ else {
+ app.pages.forEach((page) => {
+ const { pathLocale } = page
+ const localeOptions = options[pathLocale]
+
+ if (localePaths.includes(pathLocale)) {
+ const { atomOutputFilename, jsonOutputFilename, rssOutputFilename } =
+ getFeedFilenames(localeOptions, pathLocale)
+
+ const getHeadItem = (
+ name: string,
+ fileName: string,
+ type: string,
+ ): HeadConfig => [
+ 'link',
+ {
+ rel: 'alternate',
+ type,
+ href: getUrl(localeOptions.hostname, base, fileName),
+ title: `${
+ siteData.locales[pathLocale]?.title ||
+ siteData.title ||
+ siteData.locales['/']?.title ||
+ ''
+ } ${name} Feed`,
+ },
+ ]
+
+ // ensure head exists
+ page.frontmatter.head ??= []
+
+ // add atom link
+ if (localeOptions.atom)
+ page.frontmatter.head.push(
+ getHeadItem('Atom', atomOutputFilename, 'application/atom+xml'),
+ )
+
+ // add json link
+ if (localeOptions.json)
+ page.frontmatter.head.push(
+ getHeadItem('JSON', jsonOutputFilename, 'application/json'),
+ )
+
+ // add rss link
+ if (localeOptions.rss)
+ page.frontmatter.head.push(
+ getHeadItem('RSS', rssOutputFilename, 'application/rss+xml'),
+ )
+ }
+ })
+ }
+}
diff --git a/plugins/plugin-feed/src/node/feed/index.ts b/plugins/plugin-feed/src/node/feed/index.ts
new file mode 100644
index 0000000000..214da5b2b1
--- /dev/null
+++ b/plugins/plugin-feed/src/node/feed/index.ts
@@ -0,0 +1,2 @@
+export * from './item.js'
+export * from './store.js'
diff --git a/plugins/plugin-feed/src/node/feed/item.ts b/plugins/plugin-feed/src/node/feed/item.ts
new file mode 100644
index 0000000000..73eecb7db9
--- /dev/null
+++ b/plugins/plugin-feed/src/node/feed/item.ts
@@ -0,0 +1,276 @@
+import {
+ getPageExcerpt,
+ getPageText,
+ isAbsoluteUrl,
+ isArray,
+ isFunction,
+ isPlainObject,
+ isUrl,
+} from '@vuepress/helper/node'
+import type { GitData } from '@vuepress/plugin-git'
+import type { App, Page } from 'vuepress/core'
+import { isString } from 'vuepress/shared'
+import type { PageFrontmatter } from 'vuepress/shared'
+import type {
+ AuthorInfo,
+ FeedAuthor,
+ FeedCategory,
+ FeedContributor,
+ FeedEnclosure,
+ FeedFrontmatterOption,
+ FeedGetter,
+ FeedPluginFrontmatter,
+} from '../../typings/index.js'
+import type { ResolvedFeedOptions } from '../getFeedOptions.js'
+import {
+ getFeedAuthor,
+ getFeedCategory,
+ getImageMineType,
+ getUrl,
+} from '../utils/index.js'
+
+export class FeedItem {
+ private pageOptions: FeedFrontmatterOption
+ private frontmatter: PageFrontmatter
+ private base: string
+ private getter: FeedGetter
+
+ constructor(
+ private app: App,
+ private options: ResolvedFeedOptions,
+ private page: Page<
+ { excerpt?: string; git?: GitData },
+ FeedPluginFrontmatter
+ >,
+ private hostname: string,
+ ) {
+ this.base = this.app.options.base
+ this.frontmatter = page.frontmatter
+ this.getter = options.getter || {}
+ this.pageOptions = this.frontmatter.feed || {}
+ }
+
+ /**
+ * Feed item title
+ */
+ get title(): string {
+ if (isFunction(this.getter.title)) return this.getter.title(this.page)
+
+ return this.pageOptions.title || this.page.title
+ }
+
+ /**
+ * The URL of the item.
+ */
+ get link(): string {
+ if (isFunction(this.getter.link)) return this.getter.link(this.page)
+
+ return getUrl(this.hostname, this.base, this.page.path)
+ }
+
+ /**
+ * Feed item description.
+ */
+ get description(): string | null {
+ if (isFunction(this.getter.description))
+ return this.getter.description(this.page)
+
+ if (this.pageOptions.description) return this.pageOptions.description
+
+ if (this.frontmatter.description) return this.frontmatter.description
+
+ const pageText = getPageText(this.app, this.page, { length: 180 })
+
+ return pageText.length > 180 ? `${pageText.slice(0, 177)}...` : pageText
+ }
+
+ /**
+ * A string that uniquely identifies feed item.
+ */
+ get guid(): string {
+ return this.pageOptions.guid || this.link
+ }
+
+ /**
+ * Authors of feed item.
+ */
+ get author(): FeedAuthor[] {
+ if (isFunction(this.getter.author)) return this.getter.author(this.page)
+
+ if (isArray(this.pageOptions.author)) return this.pageOptions.author
+
+ if (isPlainObject(this.pageOptions.author)) return [this.pageOptions.author]
+
+ return this.frontmatter.author
+ ? getFeedAuthor(this.frontmatter.author)
+ : this.options.channel?.author
+ ? getFeedAuthor(this.options.channel?.author as AuthorInfo)
+ : []
+ }
+
+ /**
+ * Categories of feed item.
+ */
+ get category(): FeedCategory[] | null {
+ if (isFunction(this.getter.category)) return this.getter.category(this.page)
+
+ if (isArray(this.pageOptions.category)) return this.pageOptions.category
+
+ if (isPlainObject(this.pageOptions.category))
+ return [this.pageOptions.category]
+
+ const { categories, category = categories } = this.frontmatter
+
+ return getFeedCategory(category).map((item) => ({ name: item }))
+ }
+
+ /**
+ * Describes a media object that is attached to feed item.
+ *
+ * @description rss format only
+ */
+ get enclosure(): FeedEnclosure | null {
+ if (isFunction(this.getter.enclosure))
+ return this.getter.enclosure(this.page)
+
+ if (this.image)
+ return {
+ url: this.image,
+ type: getImageMineType(this.image.split('.').pop()),
+ }
+
+ return null
+ }
+
+ /**
+ * Indicates when feed item was published.
+ */
+ get pubDate(): Date | null {
+ if (isFunction(this.getter.publishDate))
+ return this.getter.publishDate(this.page)
+
+ const { time, date = time } = this.page.frontmatter
+
+ const { createdTime } = this.page.data.git || {}
+
+ return date && date instanceof Date
+ ? date
+ : createdTime
+ ? new Date(createdTime)
+ : null
+ }
+
+ /**
+ * Indicates when feed item was updated.
+ */
+ get lastUpdated(): Date {
+ if (isFunction(this.getter.lastUpdateDate))
+ return this.getter.lastUpdateDate(this.page)
+
+ const { updatedTime } = this.page.data.git || {}
+
+ return updatedTime ? new Date(updatedTime) : new Date()
+ }
+
+ /**
+ * Feed item summary
+ */
+ get summary(): string | null {
+ if (isFunction(this.getter.excerpt)) return this.getter.excerpt(this.page)
+
+ if (this.pageOptions.summary) return this.pageOptions.summary
+
+ return getPageExcerpt(this.app, this.page, {
+ isCustomElement: this.options.isPreservedElement,
+ })
+ }
+
+ /**
+ * Feed Item content
+ */
+
+ get content(): string {
+ if (isFunction(this.getter.content)) return this.getter.content(this.page)
+
+ if (this.pageOptions.content) return this.pageOptions.content
+
+ return getPageExcerpt(this.app, this.page, {
+ isCustomElement: this.options.isPreservedElement,
+ separator: '',
+ length: Infinity,
+ })
+ }
+
+ /**
+ * Image of feed item
+ *
+ * @description json format only
+ */
+ get image(): string | null {
+ if (isFunction(this.getter.image)) return this.getter.image(this.page)
+
+ const { hostname, base } = this
+ const { banner, cover } = this.frontmatter
+
+ if (banner) {
+ if (isAbsoluteUrl(banner)) return getUrl(hostname, base, banner)
+
+ if (isUrl(banner)) return banner
+ }
+
+ if (cover) {
+ if (isAbsoluteUrl(cover)) return getUrl(hostname, base, cover)
+
+ if (isUrl(cover)) return cover
+ }
+
+ const result = /!\[.*?\]\((.*?)\)/iu.exec(this.page.content)
+
+ if (result) {
+ if (isAbsoluteUrl(result[1])) return getUrl(hostname, base, result[1])
+
+ if (isUrl(result[1])) return result[1]
+ }
+
+ return null
+ }
+
+ /**
+ * Contributors of feed item.
+ *
+ * @description atom format only
+ */
+ get contributor(): FeedContributor[] {
+ if (isFunction(this.getter.contributor))
+ return this.getter.contributor(this.page)
+
+ if (isArray(this.pageOptions.contributor))
+ return this.pageOptions.contributor
+
+ if (isPlainObject(this.pageOptions.contributor))
+ return [this.pageOptions.contributor]
+
+ return this.author
+ }
+
+ /**
+ * Copyright text of feed item.
+ *
+ * @description atom format only
+ */
+ get copyright(): string | null {
+ if (isFunction(this.getter.copyright))
+ return this.getter.copyright(this.page)
+
+ if (isString(this.frontmatter.copyright)) return this.frontmatter.copyright
+ const firstAuthor = this.author[0]
+
+ if (firstAuthor?.name) return `Copyright by ${firstAuthor.name}`
+
+ return null
+ }
+
+ get isValid(): boolean {
+ return Boolean(this.title || this.description)
+ }
+}
diff --git a/plugins/plugin-feed/src/node/feed/store.ts b/plugins/plugin-feed/src/node/feed/store.ts
new file mode 100644
index 0000000000..d76e52ec91
--- /dev/null
+++ b/plugins/plugin-feed/src/node/feed/store.ts
@@ -0,0 +1,62 @@
+import type { App } from 'vuepress/core'
+import type {
+ FeedCategory,
+ FeedChannelOptions,
+ FeedContributor,
+} from '../../typings/index.js'
+import { getFeedChannelOptions } from '../getFeedChannelOptions.js'
+import type { FeedLinks } from '../getFeedLinks.js'
+import { getFeedLinks } from '../getFeedLinks.js'
+import type { ResolvedFeedOptions } from '../getFeedOptions.js'
+import type { FeedItem } from './item.js'
+
+export class FeedStore {
+ public categories = new Set()
+ public contributors: FeedContributor[] = []
+ public items: FeedItem[] = []
+
+ private _contributorKeys = new Set()
+ public channel: FeedChannelOptions
+ public links: FeedLinks
+ constructor(
+ app: App,
+ localeOptions: ResolvedFeedOptions,
+ localePath: string,
+ ) {
+ this.channel = getFeedChannelOptions(app, localeOptions, localePath)
+ this.links = getFeedLinks(app, localeOptions, localePath)
+ }
+
+ /**
+ * Add category to store
+ */
+ private addCategory = (category: FeedCategory): void => {
+ this.categories.add(category.name)
+ }
+
+ /**
+ * Add contributor to store
+ */
+ private addContributor = (contributor: FeedContributor): void => {
+ // use keys to avoid adding same contributor
+ const key = contributor.email || contributor.name
+
+ if (key && !this._contributorKeys.has(key)) {
+ this._contributorKeys.add(key)
+ this.contributors.push(contributor)
+ }
+ }
+
+ /**
+ * Add a feed item
+ */
+ public add = (item: FeedItem): void => {
+ if (item.isValid) {
+ const { category, contributor } = item
+
+ this.items.push(item)
+ category?.forEach(this.addCategory)
+ contributor?.forEach(this.addContributor)
+ }
+ }
+}
diff --git a/plugins/plugin-feed/src/node/feedPlugin.ts b/plugins/plugin-feed/src/node/feedPlugin.ts
new file mode 100644
index 0000000000..2c41bd30d9
--- /dev/null
+++ b/plugins/plugin-feed/src/node/feedPlugin.ts
@@ -0,0 +1,89 @@
+import { customizeDevServer, values } from '@vuepress/helper/node'
+import type { PluginFunction, PluginObject } from 'vuepress/core'
+import { isLinkHttp, removeEndingSlash } from 'vuepress/shared'
+import { colors } from 'vuepress/utils'
+import type { FeedPluginOptions } from '../typings/index.js'
+import { addFeedLinks } from './addFeedLinks.js'
+import { getFeedFiles } from './getFeedFiles.js'
+import { getFeedOptions } from './getFeedOptions.js'
+import { getAtomTemplates, getRSSTemplates } from './getTemplates.js'
+import { writeFiles } from './output.js'
+import { FEED_GENERATOR, logger } from './utils/index.js'
+
+export const feedPlugin =
+ (options: FeedPluginOptions): PluginFunction =>
+ (app) => {
+ if (app.env.isDebug) logger.info('Options:', options)
+
+ const plugin: PluginObject = {
+ name: FEED_GENERATOR,
+ }
+
+ let hostname = app.env.isDev
+ ? options.devHostname || `http://localhost:${app.options.port}`
+ : options.hostname
+
+ if (!hostname) {
+ logger.error(`Option ${colors.magenta('hostname')} is required!`)
+
+ return plugin
+ }
+
+ // make sure hostname do not end with `/`
+ hostname = removeEndingSlash(
+ isLinkHttp(hostname) ? hostname : `https://${hostname}`,
+ )
+
+ if (
+ // no output in root
+ !options.atom &&
+ !options.json &&
+ !options.rss &&
+ // no output in every locales
+ options.locales &&
+ values(options.locales).every(
+ ({ atom, json, rss }) => !atom && !json && !rss,
+ )
+ ) {
+ logger.info('No feed output requested, the plugin won’t start!')
+
+ return plugin
+ }
+
+ const feedOptions = getFeedOptions(app, options)
+
+ return {
+ ...plugin,
+
+ onInitialized: (app): void => {
+ if (app.env.isBuild || options.devServer) addFeedLinks(app, feedOptions)
+ },
+
+ extendsBundlerOptions: (config, app): void => {
+ if (options.devServer)
+ [
+ ...getFeedFiles(app, feedOptions, hostname),
+ ...getAtomTemplates(feedOptions),
+ ...getRSSTemplates(feedOptions),
+ ].forEach(([path, content, contentType]) => {
+ customizeDevServer(config, app, {
+ path,
+ response: (_res, req) => {
+ if (contentType) req.setHeader('Content-Type', contentType)
+
+ return Promise.resolve(content)
+ },
+ errMsg: 'Unexpected feed generation error',
+ })
+ })
+ },
+
+ onGenerated: async (app): Promise => {
+ await Promise.all([
+ ...writeFiles(app, getFeedFiles(app, feedOptions, hostname)),
+ ...writeFiles(app, getAtomTemplates(feedOptions)),
+ ...writeFiles(app, getRSSTemplates(feedOptions)),
+ ])
+ },
+ }
+ }
diff --git a/plugins/plugin-feed/src/node/generator/atom/index.ts b/plugins/plugin-feed/src/node/generator/atom/index.ts
new file mode 100644
index 0000000000..c701e5c6a3
--- /dev/null
+++ b/plugins/plugin-feed/src/node/generator/atom/index.ts
@@ -0,0 +1,166 @@
+import { isArray } from '@vuepress/helper/node'
+import { js2xml } from 'xml-js'
+import type { FeedAuthor, FeedCategory } from '../../../typings/index.js'
+import type { FeedStore } from '../../feed/store.js'
+import { encodeXML, FEED_GENERATOR } from '../../utils/index.js'
+import type {
+ AtomAuthor,
+ AtomCategory,
+ AtomContent,
+ AtomEntry,
+} from './typings.js'
+
+const getAtomAuthor = (author: FeedAuthor): AtomAuthor => {
+ const { name = 'Unknown', email, url } = author
+
+ return {
+ name,
+ ...(email ? { email } : {}),
+ ...(url ? { uri: url } : {}),
+ }
+}
+
+const getAtomCategory = (category: FeedCategory): AtomCategory => {
+ const { name, scheme = '' } = category
+
+ return {
+ _attributes: {
+ term: name,
+ scheme,
+ },
+ }
+}
+
+/**
+ * Returns an Atom 1.0 feed
+ *
+ * @see http://www.atomenabled.org/developers/syndication/
+ */
+export const getAtomFeed = (feedStore: FeedStore): string => {
+ const { channel, links } = feedStore
+
+ const content: AtomContent = {
+ _declaration: {
+ _attributes: {
+ version: '1.0',
+ encoding: 'utf-8',
+ },
+ },
+ _instruction: {
+ 'xml-stylesheet': `type="text/xsl" href="${links.atomXsl}"`,
+ },
+ feed: {
+ _attributes: {
+ xmlns: 'http://www.w3.org/2005/Atom',
+ ...(channel.language ? { 'xml:lang': channel.language } : {}),
+ },
+ id: channel.link,
+ title: channel.title,
+
+ ...(channel.description ? { subtitle: channel.description } : {}),
+ ...(channel.author
+ ? {
+ author: isArray(channel.author)
+ ? channel.author.map((author) => getAtomAuthor(author))
+ : [getAtomAuthor(channel.author)],
+ }
+ : {}),
+ ...(channel.icon ? { icon: channel.icon } : {}),
+ ...(channel.image ? { logo: channel.image } : {}),
+ ...(channel.copyright ? { rights: channel.copyright } : {}),
+
+ updated: channel.lastUpdated
+ ? channel.lastUpdated.toISOString()
+ : new Date().toISOString(),
+ generator: FEED_GENERATOR,
+ link: [{ _attributes: { rel: 'self', href: links.atom } }],
+ },
+ }
+
+ if (channel.link)
+ content.feed.link.push({
+ _attributes: { rel: 'alternate', href: channel.link },
+ })
+
+ if (channel.hub)
+ content.feed.link.push({
+ _attributes: { rel: 'hub', href: channel.hub },
+ })
+
+ if (channel.image) content.feed.logo = channel.image
+
+ if (channel.icon) content.feed.icon = channel.icon
+
+ if (channel.copyright) content.feed.rights = channel.copyright
+
+ content.feed.category = Array.from(feedStore.categories).map((category) => ({
+ _attributes: { term: category },
+ }))
+
+ content.feed.contributor = Array.from(feedStore.contributors)
+ .filter((contributor) => contributor.name)
+ .map((contributor) => getAtomAuthor(contributor))
+
+ /**
+ * "entry" nodes
+ */
+ content.feed.entry = feedStore.items.map((item) => {
+ // entry: required elements
+ const entry: AtomEntry = {
+ title: { _attributes: { type: 'text' }, _text: item.title },
+ id: item.guid || item.link,
+ link: { _attributes: { href: item.link } },
+ updated: item.lastUpdated.toISOString(),
+ }
+
+ // entry: recommended elements
+ if (item.summary)
+ entry.summary = {
+ _attributes: { type: 'html' },
+ _cdata: item.summary,
+ }
+ else if (item.description)
+ entry.summary = {
+ _attributes: { type: 'text' },
+ _text: item.description,
+ }
+
+ if (item.content)
+ entry.content = {
+ _attributes: { type: 'html' },
+ _cdata: item.content,
+ }
+
+ // author(s)
+ if (item.author)
+ entry.author = item.author
+ .filter((author) => author.name)
+ .map((author) => getAtomAuthor(author))
+
+ if (item.category)
+ // category
+ entry.category = item.category.map((category) =>
+ getAtomCategory(category),
+ )
+
+ // contributor
+ if (item.contributor)
+ entry.contributor = item.contributor.map((contributor) =>
+ getAtomAuthor(contributor),
+ )
+
+ // published
+ if (item.pubDate) entry.published = item.pubDate.toISOString()
+
+ // rights
+ if (item.copyright) entry.rights = item.copyright
+
+ return entry
+ })
+
+ return js2xml(encodeXML(content), {
+ compact: true,
+ ignoreComment: true,
+ spaces: 2,
+ })
+}
diff --git a/plugins/plugin-feed/src/node/generator/atom/typings.ts b/plugins/plugin-feed/src/node/generator/atom/typings.ts
new file mode 100644
index 0000000000..1e970bf39b
--- /dev/null
+++ b/plugins/plugin-feed/src/node/generator/atom/typings.ts
@@ -0,0 +1,189 @@
+export interface AtomText {
+ _attributes: {
+ type: 'text'
+ }
+ _text: string
+}
+
+export interface AtomCDATA {
+ _attributes: {
+ type: 'html'
+ }
+ _cdata: string
+}
+
+export interface AtomAuthor {
+ /**
+ * human-readable name
+ */
+ name?: string
+ /**
+ * email address
+ */
+ email?: string
+ /**
+ * home page
+ */
+ uri?: string
+}
+
+export interface AtomCategory {
+ _attributes: {
+ /**
+ * identifies the category
+ */
+ term: string
+ /**
+ * the categorization scheme via a URI
+ */
+ scheme?: string
+ /**
+ * a human-readable label for display
+ */
+ label?: string
+ }
+}
+
+export interface AtomLink {
+ _attributes: {
+ href: string
+ rel?: string
+ }
+}
+
+export interface AtomEntry {
+ /**
+ * Identifies the entry using a universally unique and permanent URI.
+ */
+ id: string
+ /**
+ * Contains a human readable title for the entry.
+ */
+
+ title: AtomText
+ /**
+ * Indicates the last time the entry was modified in a significant way.
+ */
+
+ updated: string
+ /**
+ * Names one author of the entry. An entry may have multiple authors.
+ */
+ author?: AtomAuthor[]
+ /**
+ * Contains or links to the complete content of the entry.
+ */
+ content?: AtomCDATA
+ /**
+ * Identifies a related Web page.
+ */
+ link: AtomLink
+ /**
+ * Conveys a short summary, abstract, or excerpt of the entry.
+ */
+ summary?: AtomText | AtomCDATA
+ /**
+ * Specifies a category that the entry belongs to.
+ *
+ * A entry may have multiple category elements.
+ */
+
+ category?: AtomCategory[]
+ /**
+ * Names one contributor to the entry.
+ *
+ * An entry may have multiple contributor elements.
+ */
+ contributor?: AtomAuthor[]
+ /**
+ * Contains the time of the initial creation or first availability of the entry.
+ */
+ published?: string
+ /**
+ * Conveys information about rights
+ */
+ rights?: string
+}
+
+export interface AtomContent {
+ _declaration: {
+ _attributes: {
+ version: string
+ encoding: string
+ }
+ }
+ _instruction: {
+ 'xml-stylesheet': `type="text/xsl" href="${string}"`
+ }
+ feed: {
+ _attributes: {
+ xmlns: string
+ lang?: string
+ }
+ /**
+ * Identifies the feed using a universally unique and permanent URI.
+ */
+ id: string
+ /**
+ * Contains a human readable title for the feed.
+ * Often the same as the title of the associated website.
+ */
+ title: string
+ /**
+ * Indicates the last time the feed was modified in a significant way.
+ */
+ updated: string
+ /**
+ * Names one author of the feed. A feed may have multiple author elements.
+ *
+ * A feed must contain at least one author element unless all of the entry elements contain at least one author element.
+ */
+ author?: AtomAuthor[]
+ /**
+ * Identifies a related Web page. The type of relation is defined by the rel attribute. A feed is limited to one alternate per type and hreflang. A feed should contain a link back to the feed itself.
+ */
+ link: AtomLink[]
+ /**
+ * Specifies a category that the feed belongs to. A feed may have multiple category elements.
+ */
+ category?: {
+ _attributes: {
+ term: string
+ }
+ }[]
+ /**
+ * Names one contributor to the feed. An feed may have multiple contributor elements.
+ */
+ contributor?: AtomAuthor[]
+ /**
+ * Identifies the software used to generate the feed
+ */
+ generator:
+ | {
+ _attributes?: {
+ uri?: string
+ version?: string
+ }
+ _text: string
+ }
+ | string
+ /**
+ * Identifies a small image which provides iconic visual identification for the feed. Icons should be square.
+ */
+ icon?: string
+ /**
+ * Identifies a larger image which provides visual identification for the feed. Images should be twice as wide as they are tall.
+ */
+ logo?: string
+ /**
+ * Conveys information about rights, e.g. copyrights, held in and over the feed.
+ */
+ rights?: string
+ /**
+ * Contains a human-readable description or subtitle for the feed.
+ */
+ subtitle?: string
+
+ entry?: AtomEntry[]
+ }
+}
diff --git a/plugins/plugin-feed/src/node/generator/index.ts b/plugins/plugin-feed/src/node/generator/index.ts
new file mode 100644
index 0000000000..b207594406
--- /dev/null
+++ b/plugins/plugin-feed/src/node/generator/index.ts
@@ -0,0 +1,3 @@
+export * from './atom/index.js'
+export * from './json/index.js'
+export * from './rss/index.js'
diff --git a/plugins/plugin-feed/src/node/generator/json/index.ts b/plugins/plugin-feed/src/node/generator/json/index.ts
new file mode 100644
index 0000000000..e4de9e8749
--- /dev/null
+++ b/plugins/plugin-feed/src/node/generator/json/index.ts
@@ -0,0 +1,78 @@
+import { isArray } from '@vuepress/helper/node'
+import type { FeedAuthor } from '../../../typings/index.js'
+import type { FeedStore } from '../../feed/store.js'
+import type { JSONAuthor, JSONContent, JSONItem } from './typings.js'
+
+const getJSONAuthor = (author: FeedAuthor): JSONAuthor => ({
+ name: author.name!,
+ ...(author.url ? { url: author.url } : {}),
+ ...(author.avatar ? { avatar: author.avatar } : {}),
+})
+
+/**
+ * JSON 1.1 feed
+ *
+ * @see https://jsonfeed.org/version/1.1
+ */
+export const getJSONFeed = (feedStore: FeedStore): string => {
+ const { channel, links } = feedStore
+
+ const content: JSONContent = {
+ version: 'https://jsonfeed.org/version/1.1',
+ title: channel.title,
+ home_page_url: channel.link,
+ feed_url: links.json,
+ }
+
+ if (channel.description) content.description = channel.description
+
+ if (channel.image) content.icon = channel.image
+ if (channel.icon) content.favicon = channel.icon
+
+ const channelAuthors = (
+ isArray(channel.author)
+ ? channel.author
+ : channel.author
+ ? [channel.author]
+ : []
+ ).filter((author) => Boolean(author?.name))
+
+ if (channelAuthors.length)
+ content.authors = channelAuthors.map((author) => getJSONAuthor(author))
+
+ content.items = feedStore.items.map((item) => {
+ const feedItem: JSONItem = {
+ title: item.title,
+ url: item.link,
+ id: item.guid || item.link,
+ ...(item.description ? { summary: item.description } : {}),
+
+ // json_feed distinguishes between html and text content
+ // but since we only take a single type, we'll assume HTML
+ content_html: item.content,
+ }
+
+ if (item.image) feedItem.image = item.image
+
+ if (item.pubDate) feedItem.date_published = item.pubDate.toISOString()
+
+ if (item.lastUpdated)
+ feedItem.date_modified = item.lastUpdated.toISOString()
+
+ // author
+ if (isArray(item.author))
+ feedItem.authors = item.author
+ .filter((author) => author.name)
+ .map((author) => getJSONAuthor(author))
+
+ // tags
+ if (item.category)
+ feedItem.tags = item.category
+ .filter((category) => category.name)
+ .map((category) => category.name)
+
+ return feedItem
+ })
+
+ return JSON.stringify(content, null, 2)
+}
diff --git a/plugins/plugin-feed/src/node/generator/json/typings.ts b/plugins/plugin-feed/src/node/generator/json/typings.ts
new file mode 100644
index 0000000000..be4399940b
--- /dev/null
+++ b/plugins/plugin-feed/src/node/generator/json/typings.ts
@@ -0,0 +1,84 @@
+export interface JSONAuthor {
+ name: string
+ /**
+ * the URL of a site owned by the author.
+ */
+ url?: string
+ avatar?: string
+}
+
+export interface JSONItem {
+ /**
+ * unique for that item for that feed over time.
+ */
+ id: string
+ /**
+ * the URL of the resource described by the item.
+ */
+ url: string
+ title: string
+ /**
+ * This is the HTML or plain text of the item
+ */
+ content_html?: string
+ /**
+ * a plain text sentence or two describing the item.
+ */
+ summary?: string
+ /**
+ * the URL of the main image for the item.
+ */
+ image?: string
+ /**
+ * publish date
+ */
+ date_published?: string
+ /**
+ * last updated at
+ */
+ date_modified?: string
+ /**
+ * authors
+ */
+ authors?: JSONAuthor[]
+ /**
+ * categories
+ */
+ tags?: string[]
+}
+
+export interface JSONContent {
+ /**
+ * The URL of the version of the format the feed uses.
+ */
+ version: 'https://jsonfeed.org/version/1.1'
+ /**
+ * Name of the feed
+ */
+ title: string
+ /**
+ * The URL of the resource that the feed describes
+ */
+ home_page_url: string
+ /**
+ * the URL of the feed
+ */
+ feed_url?: string
+ /**
+ * provides more detail, beyond the title, on what the feed is about.
+ */
+ description?: string
+ /**
+ * the URL of an image for the feed suitable to be used in a timeline
+ */
+ icon?: string
+ /**
+ * the URL of an image for the feed suitable to be used in a source list.
+ */
+ favicon?: string
+ /**
+ * specifies one or more feed authors
+ */
+ authors?: JSONAuthor[]
+ items?: JSONItem[]
+}
diff --git a/plugins/plugin-feed/src/node/generator/rss/index.ts b/plugins/plugin-feed/src/node/generator/rss/index.ts
new file mode 100644
index 0000000000..08d16732c5
--- /dev/null
+++ b/plugins/plugin-feed/src/node/generator/rss/index.ts
@@ -0,0 +1,194 @@
+import { isUrl } from '@vuepress/helper/node'
+import { js2xml } from 'xml-js'
+import type { FeedCategory, FeedEnclosure } from '../../../typings/index.js'
+import type { FeedItem } from '../../feed/item.js'
+import type { FeedStore } from '../../feed/store.js'
+import { encodeXML, FEED_GENERATOR } from '../../utils/index.js'
+import type {
+ RSSCategory,
+ RSSContent,
+ RSSEnclosure,
+ RSSGuid,
+ RSSItem,
+} from './typings.js'
+
+const getRSSCategory = (category: FeedCategory): RSSCategory => {
+ const { name, domain } = category
+
+ return {
+ _text: name,
+ ...(domain ? { _attributes: { domain } } : {}),
+ }
+}
+
+const getRSSGuid = (item: FeedItem): RSSGuid => {
+ const guid = item.guid || item.link
+
+ return {
+ ...(isUrl(guid)
+ ? {}
+ : {
+ _attributes: {
+ isPermaLink: 'false',
+ },
+ }),
+ _text: guid,
+ }
+}
+
+const getRSSEnclosure = (enclosure: FeedEnclosure): RSSEnclosure => ({
+ _attributes: {
+ url: enclosure.url,
+ ...(enclosure.length ? { length: enclosure.length } : {}),
+ ...(enclosure.type ? { type: enclosure.type } : {}),
+ },
+})
+
+/**
+ * Returns a RSS 2.0 feed
+ *
+ * @see https://validator.w3.org/feed/docs/rss2.html
+ */
+export const getRssFeed = (feedStore: FeedStore): string => {
+ const { channel, links } = feedStore
+ let hasContent = false
+
+ const content: RSSContent = {
+ _declaration: { _attributes: { version: '1.0', encoding: 'utf-8' } },
+ _instruction: {
+ 'xml-stylesheet': `type="text/xsl" href="${links.rssXsl}"`,
+ },
+ rss: {
+ _attributes: {
+ 'version': '2.0',
+ 'xmlns:atom': 'http://www.w3.org/2005/Atom',
+ },
+ channel: {
+ /**
+ * @see http://validator.w3.org/feed/docs/warning/MissingAtomSelfLink.html
+ */
+ 'atom:link': {
+ _attributes: {
+ href: links.rss,
+ rel: 'self',
+ type: 'application/rss+xml',
+ },
+ },
+ 'title': { _text: channel.title },
+ 'link': { _text: channel.link },
+ 'description': { _text: channel.description },
+ 'language': { _text: channel.language },
+ 'pubDate': {
+ _text: channel.pubDate
+ ? channel.pubDate.toUTCString()
+ : new Date().toUTCString(),
+ },
+ 'lastBuildDate': {
+ _text: channel.lastUpdated
+ ? channel.lastUpdated.toUTCString()
+ : new Date().toUTCString(),
+ },
+ 'generator': { _text: FEED_GENERATOR },
+ 'docs': {
+ _text: 'https://validator.w3.org/feed/docs/rss2.html',
+ },
+ },
+ },
+ }
+
+ if (channel.copyright)
+ content.rss.channel.copyright = { _text: channel.copyright }
+
+ if (channel.ttl) content.rss.channel.ttl = { _text: channel.ttl.toString() }
+
+ if (channel.image)
+ content.rss.channel.image = {
+ title: { _text: channel.title },
+ url: { _text: channel.image },
+ link: { _text: channel.link },
+ }
+
+ /**
+ * Channel Categories
+ *
+ * @see https://validator.w3.org/feed/docs/rss2.html#comments
+ */
+ content.rss.channel.category = Array.from(feedStore.categories).map(
+ (category) => ({ _text: category }),
+ )
+
+ /**
+ * Channel Categories
+ *
+ * @see https://validator.w3.org/feed/docs/rss2.html#hrelementsOfLtitemgt
+ */
+ content.rss.channel.item = feedStore.items.map((entry) => {
+ const item: RSSItem = {
+ title: { _text: entry.title },
+ link: { _text: entry.link },
+ guid: getRSSGuid(entry),
+ source: {
+ _attributes: { url: links.rss },
+ _text: entry.title,
+ },
+ }
+
+ if (entry.description)
+ item.description = {
+ _text: entry.description,
+ }
+
+ /**
+ * Item Author
+ */
+ if (entry.author) {
+ const author = entry.author.find((author) => author.email && author.name)
+
+ if (author)
+ item.author = {
+ _text: `${author.email!} (${author.name!})`,
+ }
+ }
+
+ /**
+ * Item Category
+ *
+ * @see https://validator.w3.org/feed/docs/rss2.html#ltcategorygtSubelementOfLtitemgt
+ */
+ if (entry.category)
+ item.category = entry.category
+ .filter((category) => category.name)
+ .map((category) => getRSSCategory(category))
+
+ // TODO: This is currently not supported
+ // if (entry.comments) item.comments = { _text: entry.link };
+
+ if (entry.pubDate) item.pubDate = { _text: entry.pubDate.toUTCString() }
+
+ if (entry.content) {
+ hasContent = true
+ item['content:encoded'] = { _cdata: entry.content }
+ }
+
+ /**
+ * Item Enclosure
+ *
+ * @see https://validator.w3.org/feed/docs/rss2.html#ltenclosuregtSubelementOfLtitemgt
+ */
+ if (entry.enclosure) item.enclosure = getRSSEnclosure(entry.enclosure)
+
+ return item
+ })
+
+ if (hasContent) {
+ content.rss._attributes['xmlns:content'] =
+ 'http://purl.org/rss/1.0/modules/content/'
+ content.rss._attributes['xmlns:dc'] = 'http://purl.org/dc/elements/1.1/'
+ }
+
+ return js2xml(encodeXML(content), {
+ compact: true,
+ ignoreComment: true,
+ spaces: 2,
+ })
+}
diff --git a/plugins/plugin-feed/src/node/generator/rss/typings.ts b/plugins/plugin-feed/src/node/generator/rss/typings.ts
new file mode 100644
index 0000000000..359145ff4b
--- /dev/null
+++ b/plugins/plugin-feed/src/node/generator/rss/typings.ts
@@ -0,0 +1,207 @@
+interface RSSText {
+ _text: string
+}
+
+interface RSSCDATA {
+ _cdata: string
+}
+
+export interface RSSCategory extends RSSText {
+ _attributes?: {
+ /**
+ * a string that identifies a categorization taxonomy
+ */
+ domain: string
+ }
+}
+
+export interface RSSEnclosure {
+ _attributes: {
+ /**
+ * where the enclosure is located
+ */
+ url: string
+ /**
+ * how big it is in bytes
+ */
+ length?: number
+ /**
+ * what its type is (a standard MIME type)
+ */
+ type?: string
+ }
+}
+
+export interface RSSGuid extends RSSText {
+ _attributes?: {
+ isPermaLink: 'true' | 'false'
+ }
+}
+
+interface RSSSource extends RSSText {
+ _attributes: {
+ url: string
+ }
+}
+
+export interface RSSItem {
+ /**
+ * The title of the item.
+ */
+ 'title': RSSText
+
+ /**
+ * The URL of the item.
+ */
+ 'link': RSSText
+
+ /**
+ * The item synopsis.
+ */
+ 'description'?: RSSText
+
+ /**
+ * Email address of the author of the item
+ */
+ 'author'?: RSSText
+
+ /**
+ * Includes the item in one or more categories.
+ */
+ 'category'?: RSSCategory[]
+
+ /**
+ * URL of a page for comments relating to the item.
+ */
+ 'comments'?: RSSText
+
+ /**
+ * Describes a media object that is attached to the item.
+ */
+ 'enclosure'?: RSSEnclosure
+
+ /**
+ * A string that uniquely identifies the item.
+ */
+ 'guid': RSSGuid
+
+ /**
+ * Indicates when the item was published.
+ */
+ 'pubDate'?: RSSText
+
+ /**
+ * The RSS channel that the item came from.
+ */
+ 'source': RSSSource
+
+ 'content:encoded'?: RSSCDATA
+}
+
+/**
+ * @see https://validator.w3.org/feed/docs/rss2.html
+ */
+export interface RSSContent {
+ _declaration: {
+ _attributes: {
+ version: string
+ encoding: string
+ }
+ }
+ _instruction: {
+ 'xml-stylesheet': `type="text/xsl" href="${string}"`
+ }
+ rss: {
+ _attributes: {
+ 'version': string
+
+ 'xmlns:atom'?: string
+
+ 'xmlns:content'?: string
+
+ 'xmlns:dc'?: string
+ }
+ channel: {
+ /**
+ * atom link
+ */
+
+ 'atom:link': {
+ _attributes: {
+ href: string
+ rel: string
+ type: string
+ }
+ }
+
+ /**
+ * Channel title
+ */
+ 'title': RSSText
+
+ /**
+ * The URL to the HTML website corresponding to the channel.
+ */
+ 'link': RSSText
+
+ /**
+ * Phrase or sentence describing the channel.
+ */
+ 'description': RSSText
+
+ /**
+ * The language the channel is written in.
+ */
+ 'language'?: RSSText
+
+ /**
+ * Copyright notice for content in the channel.
+ */
+ 'copyright'?: RSSText
+
+ /**
+ * The publication date for the content in the channel.
+ */
+ 'pubDate'?: RSSText
+
+ /**
+ * The last time the content of the channel changed.
+ */
+
+ 'lastBuildDate': RSSText
+
+ /**
+ * Specify one or more categories that the channel belongs to
+ */
+ 'category'?: RSSText[]
+
+ /**
+ * A string indicating the program used to generate the channel.
+ */
+ 'generator': RSSText
+
+ /**
+ * URL that points to the documentation for the format used in the RSS file.
+ */
+ 'docs': RSSText
+
+ /**
+ * time to live.
+ *
+ * It’s a number of minutes that indicates how long a channel can be cached before refreshing from the source.
+ */
+ 'ttl'?: RSSText
+
+ /**
+ * Specifies a GIF, JPEG or PNG image that can be displayed with the channel.
+ */
+ 'image'?: {
+ title: Partial
+ url: Partial
+ link: Partial
+ }
+
+ 'item'?: RSSItem[]
+ }
+ }
+}
diff --git a/plugins/plugin-feed/src/node/getFeedChannelOptions.ts b/plugins/plugin-feed/src/node/getFeedChannelOptions.ts
new file mode 100644
index 0000000000..d04e855385
--- /dev/null
+++ b/plugins/plugin-feed/src/node/getFeedChannelOptions.ts
@@ -0,0 +1,62 @@
+import { isArray } from '@vuepress/helper/node'
+import type { App } from 'vuepress/core'
+import { isLinkHttp } from 'vuepress/shared'
+import type { FeedChannelOptions, FeedPluginOptions } from '../typings/index.js'
+import { getUrl } from './utils/index.js'
+
+export const getFeedChannelOptions = (
+ app: App,
+ options: FeedPluginOptions,
+ localePath = '',
+): FeedChannelOptions => {
+ const { base } = app.options
+ const { title, description, lang, locales } = app.siteData
+ const {
+ channel: { icon: channelIcon, image: channelImage, ...channel } = {},
+ hostname,
+ icon,
+ image,
+ } = options
+ const authorName = isArray(options.channel?.author)
+ ? options.channel?.author[0]?.name
+ : options.channel?.author?.name
+
+ const defaultChannelOption: FeedChannelOptions = {
+ title: locales[localePath]?.title || title || locales['/']?.title || '',
+ link: getUrl(hostname, base, localePath),
+ description:
+ locales[localePath]?.description ||
+ description ||
+ locales['/']?.description ||
+ '',
+ language: locales[localePath]?.lang || lang,
+ copyright: authorName ? `Copyright by ${authorName}` : '',
+ pubDate: new Date(),
+ lastUpdated: new Date(),
+ ...(icon
+ ? { icon: isLinkHttp(icon) ? icon : getUrl(hostname, base, icon) }
+ : {}),
+ ...(image
+ ? { image: isLinkHttp(image) ? image : getUrl(hostname, base, image) }
+ : {}),
+ }
+
+ return {
+ ...defaultChannelOption,
+ ...channel,
+ ...(channelIcon
+ ? {
+ icon: isLinkHttp(channelIcon)
+ ? channelIcon
+ : getUrl(hostname, base, channelIcon),
+ }
+ : {}),
+ ...(channelImage
+ ? {
+ image: isLinkHttp(channelImage)
+ ? channelImage
+ : getUrl(hostname, base, channelImage),
+ }
+ : {}),
+ }
+}
diff --git a/plugins/plugin-feed/src/node/getFeedFilenames.ts b/plugins/plugin-feed/src/node/getFeedFilenames.ts
new file mode 100644
index 0000000000..050207310f
--- /dev/null
+++ b/plugins/plugin-feed/src/node/getFeedFilenames.ts
@@ -0,0 +1,70 @@
+import type { App } from 'vuepress/core'
+import { removeLeadingSlash } from 'vuepress/shared'
+import type { FeedPluginOptions } from '../typings/index.js'
+import type { ResolvedFeedOptions } from './getFeedOptions.js'
+import { getUrl } from './utils/index.js'
+
+export const getFeedFilenames = (
+ options: ResolvedFeedOptions,
+ prefix = '/',
+): Required<
+ Pick<
+ FeedPluginOptions,
+ | 'atomOutputFilename'
+ | 'atomXslFilename'
+ | 'jsonOutputFilename'
+ | 'rssOutputFilename'
+ | 'rssXslFilename'
+ >
+> => ({
+ atomOutputFilename: `${removeLeadingSlash(prefix)}${
+ options.atomOutputFilename || 'atom.xml'
+ }`,
+ atomXslFilename: `${removeLeadingSlash(prefix)}${
+ options.atomXslFilename || 'atom.xsl'
+ }`,
+
+ jsonOutputFilename: `${removeLeadingSlash(prefix)}${
+ options.jsonOutputFilename || 'feed.json'
+ }`,
+ rssOutputFilename: `${removeLeadingSlash(prefix)}${
+ options.rssOutputFilename || 'rss.xml'
+ }`,
+ rssXslFilename: `${removeLeadingSlash(prefix)}${
+ options.rssXslFilename || 'rss.xsl'
+ }`,
+})
+
+export interface FeedLinks {
+ localePath: string
+ atom: string
+ atomXsl: string
+ json: string
+ rss: string
+ rssXsl: string
+}
+
+export const getFeedLinks = (
+ app: App,
+ options: ResolvedFeedOptions,
+ localePath: string,
+): FeedLinks => {
+ const { base } = app.options
+ const { hostname } = options
+ const {
+ atomOutputFilename,
+ atomXslFilename,
+ jsonOutputFilename,
+ rssOutputFilename,
+ rssXslFilename,
+ } = getFeedFilenames(options, localePath)
+
+ return {
+ localePath,
+ atom: getUrl(hostname, base, atomOutputFilename),
+ atomXsl: getUrl(hostname, base, atomXslFilename),
+ json: getUrl(hostname, base, jsonOutputFilename),
+ rss: getUrl(hostname, base, rssOutputFilename),
+ rssXsl: getUrl(hostname, base, rssXslFilename),
+ }
+}
diff --git a/plugins/plugin-feed/src/node/getFeedFiles.ts b/plugins/plugin-feed/src/node/getFeedFiles.ts
new file mode 100644
index 0000000000..80b436754a
--- /dev/null
+++ b/plugins/plugin-feed/src/node/getFeedFiles.ts
@@ -0,0 +1,97 @@
+import { entries, fromEntries } from '@vuepress/helper/node'
+import type { GitData } from '@vuepress/plugin-git'
+import type { App, Page } from 'vuepress/core'
+import { colors } from 'vuepress/utils'
+import type { FeedConfig, FeedPluginFrontmatter } from '../typings/index.js'
+import { FeedItem, FeedStore } from './feed/index.js'
+import { getAtomFeed } from './generator/atom/index.js'
+import { getJSONFeed } from './generator/json/index.js'
+import { getRssFeed } from './generator/rss/index.js'
+import { getFeedFilenames } from './getFeedFilenames.js'
+import type { ResolvedFeedOptionsMap } from './getFeedOptions.js'
+import { logger } from './utils/index.js'
+
+export const getFeedFiles = (
+ app: App,
+ options: ResolvedFeedOptionsMap,
+ hostname: string,
+): FeedConfig[] => {
+ const localMap: Record = fromEntries(
+ entries(options).map(([localePath, localeOptions]) => [
+ localePath,
+ new FeedStore(app, localeOptions, localePath),
+ ]),
+ )
+
+ return (
+ entries(options)
+ // filter enabled locales
+ .filter(([, { atom, json, rss }]) => atom || json || rss)
+ .map(([localePath, localeOptions]) => {
+ const {
+ atom,
+ json,
+ rss,
+ count: feedCount = 100,
+ filter,
+ sorter,
+ } = localeOptions
+
+ const feedStore = localMap[localePath]
+ const pages = app.pages
+ .filter((page) => page.pathLocale === localePath)
+ .filter(filter)
+ .sort(sorter)
+
+ // add feed items
+ for (const page of pages) {
+ const feedItem = new FeedItem(
+ app,
+ localeOptions,
+ page as Page<{ git?: GitData }, FeedPluginFrontmatter>,
+ hostname,
+ )
+
+ feedStore.add(feedItem)
+ if (feedStore.items.length === feedCount) break
+ }
+
+ const count = feedStore.items.length
+
+ logger.succeed(
+ `added ${colors.cyan(
+ `${count} page${count > 1 ? 's' : ''}`,
+ )} as feed item${count > 1 ? 's' : ''} in locale ${colors.cyan(
+ localePath,
+ )}`,
+ )
+
+ const { atomOutputFilename, jsonOutputFilename, rssOutputFilename } =
+ getFeedFilenames(localeOptions, localePath)
+ const results: FeedConfig[] = []
+
+ // generate feed
+ if (atom)
+ results.push([
+ atomOutputFilename,
+ getAtomFeed(feedStore),
+ 'application/atom+xml',
+ ])
+ if (json)
+ results.push([
+ jsonOutputFilename,
+ getJSONFeed(feedStore),
+ 'application/json',
+ ])
+ if (rss)
+ results.push([
+ rssOutputFilename,
+ getRssFeed(feedStore),
+ 'application/xml',
+ ])
+
+ return results
+ })
+ .flat()
+ )
+}
diff --git a/plugins/plugin-feed/src/node/getFeedLinks.ts b/plugins/plugin-feed/src/node/getFeedLinks.ts
new file mode 100644
index 0000000000..6415f8b139
--- /dev/null
+++ b/plugins/plugin-feed/src/node/getFeedLinks.ts
@@ -0,0 +1,38 @@
+import type { App } from 'vuepress/core'
+import { getFeedFilenames } from './getFeedFilenames.js'
+import type { ResolvedFeedOptions } from './getFeedOptions.js'
+import { getUrl } from './utils/index.js'
+
+export interface FeedLinks {
+ localePath: string
+ atom: string
+ atomXsl: string
+ json: string
+ rss: string
+ rssXsl: string
+}
+
+export const getFeedLinks = (
+ app: App,
+ options: ResolvedFeedOptions,
+ localePath: string,
+): FeedLinks => {
+ const { base } = app.options
+ const { hostname } = options
+ const {
+ atomOutputFilename,
+ atomXslFilename,
+ jsonOutputFilename,
+ rssOutputFilename,
+ rssXslFilename,
+ } = getFeedFilenames(options, localePath)
+
+ return {
+ localePath,
+ atom: getUrl(hostname, base, atomOutputFilename),
+ atomXsl: getUrl(hostname, base, atomXslFilename),
+ json: getUrl(hostname, base, jsonOutputFilename),
+ rss: getUrl(hostname, base, rssOutputFilename),
+ rssXsl: getUrl(hostname, base, rssXslFilename),
+ }
+}
diff --git a/plugins/plugin-feed/src/node/getFeedOptions.ts b/plugins/plugin-feed/src/node/getFeedOptions.ts
new file mode 100644
index 0000000000..ea128acc11
--- /dev/null
+++ b/plugins/plugin-feed/src/node/getFeedOptions.ts
@@ -0,0 +1,84 @@
+import {
+ dateSorter,
+ fromEntries,
+ isArray,
+ isFunction,
+ keys,
+} from '@vuepress/helper/node'
+import type { GitData } from '@vuepress/plugin-git'
+import type { App, Page } from 'vuepress/core'
+import type {
+ BaseFeedPluginOptions,
+ FeedPluginOptions,
+} from '../typings/index.js'
+
+export interface ResolvedFeedOptions
+ extends Omit<
+ BaseFeedPluginOptions,
+ 'sorter' | 'filter' | 'preservedElements'
+ >,
+ Required> {
+ hostname: string
+ isPreservedElement: (tagName: string) => boolean
+}
+
+export type ResolvedFeedOptionsMap = Record
+
+export const getFeedOptions = (
+ { siteData }: App,
+ options: FeedPluginOptions,
+): ResolvedFeedOptionsMap =>
+ fromEntries(
+ keys({
+ // root locale must exists
+ '/': {},
+ ...siteData.locales,
+ }).map((localePath) => {
+ const preservedElements =
+ options.locales?.[localePath]?.preservedElements ||
+ options.preservedElements
+ const { hostname, devServer, locales, ...rest } = options
+
+ return [
+ localePath,
+ {
+ // default values
+ filter: ({ frontmatter, filePathRelative }: Page): boolean =>
+ !(
+ frontmatter.home ||
+ !filePathRelative ||
+ frontmatter.article === false ||
+ frontmatter.feed === false
+ ),
+ sorter: (
+ pageA: Page<{ git?: GitData }, Record>,
+ pageB: Page<{ git?: GitData }, Record>,
+ ): number =>
+ dateSorter(
+ pageA.data.git?.createdTime
+ ? new Date(pageA.data.git?.createdTime)
+ : pageA.frontmatter.date,
+ pageB.data.git?.createdTime
+ ? new Date(pageB.data.git?.createdTime)
+ : pageB.frontmatter.date,
+ ),
+
+ ...rest,
+ ...options.locales?.[localePath],
+
+ // make sure these are not overrode
+ hostname,
+ isPreservedElement: isArray(preservedElements)
+ ? (tagName: string): boolean =>
+ preservedElements.some((item) =>
+ item instanceof RegExp
+ ? item.test(tagName)
+ : item === tagName,
+ )
+ : isFunction(preservedElements)
+ ? preservedElements
+ : (): boolean => false,
+ } as ResolvedFeedOptions,
+ ]
+ }),
+ )
diff --git a/plugins/plugin-feed/src/node/getTemplates.ts b/plugins/plugin-feed/src/node/getTemplates.ts
new file mode 100644
index 0000000000..d01791ef9e
--- /dev/null
+++ b/plugins/plugin-feed/src/node/getTemplates.ts
@@ -0,0 +1,51 @@
+import { entries } from '@vuepress/helper/node'
+import { ensureEndingSlash } from 'vuepress/shared'
+import { fs, getDirname, path } from 'vuepress/utils'
+import type { FeedConfig } from '../typings/index.js'
+import { getFeedFilenames } from './getFeedFilenames.js'
+import type { ResolvedFeedOptionsMap } from './getFeedOptions.js'
+
+const __dirname = getDirname(import.meta.url)
+
+const TEMPLATE_FOLDER = ensureEndingSlash(
+ path.resolve(__dirname, '../../templates'),
+)
+
+const DEFAULT_ATOM_XML_TEMPLATE = fs.readFileSync(
+ `${TEMPLATE_FOLDER}atom.xsl`,
+ 'utf-8',
+)
+
+const DEFAULT_RSS_XML_TEMPLATE = fs.readFileSync(
+ `${TEMPLATE_FOLDER}rss.xsl`,
+ 'utf-8',
+)
+
+export const getAtomTemplates = (
+ options: ResolvedFeedOptionsMap,
+): FeedConfig[] =>
+ entries(options)
+ // filter enabled locales
+ .filter(([, { atom }]) => atom)
+ // write template
+ .map(([localePath, localeOptions]) => {
+ const { atomXslTemplate = DEFAULT_ATOM_XML_TEMPLATE } = localeOptions
+ const { atomXslFilename } = getFeedFilenames(localeOptions, localePath)
+
+ return [atomXslFilename, atomXslTemplate]
+ })
+
+export const getRSSTemplates = (
+ options: ResolvedFeedOptionsMap,
+): FeedConfig[] =>
+ entries(options)
+ // filter enabled locales
+ .filter(([, { rss }]) => rss)
+ // write template
+ .map(([localePath, localeOptions]) => {
+ const { rssXslTemplate = DEFAULT_RSS_XML_TEMPLATE } = localeOptions
+
+ const { rssXslFilename } = getFeedFilenames(localeOptions, localePath)
+
+ return [rssXslFilename, rssXslTemplate]
+ })
diff --git a/plugins/plugin-feed/src/node/index.ts b/plugins/plugin-feed/src/node/index.ts
new file mode 100644
index 0000000000..697ae196f4
--- /dev/null
+++ b/plugins/plugin-feed/src/node/index.ts
@@ -0,0 +1,2 @@
+export * from './feedPlugin.js'
+export * from '../typings/index.js'
diff --git a/plugins/plugin-feed/src/node/output.ts b/plugins/plugin-feed/src/node/output.ts
new file mode 100644
index 0000000000..f4de3b9e5c
--- /dev/null
+++ b/plugins/plugin-feed/src/node/output.ts
@@ -0,0 +1,11 @@
+import type { App } from 'vuepress/core'
+import { fs, path } from 'vuepress/utils'
+import type { FeedConfig } from '../typings/index.js'
+
+export const writeFiles = (app: App, files: FeedConfig[]): Promise[] =>
+ files.map(async ([filename, content]) => {
+ const location = app.dir.dest(filename)
+
+ await fs.ensureDir(path.dirname(location))
+ await fs.writeFile(location, content, 'utf-8')
+ })
diff --git a/plugins/plugin-feed/src/node/utils/encodeXML.ts b/plugins/plugin-feed/src/node/utils/encodeXML.ts
new file mode 100644
index 0000000000..4cb49bdbf3
--- /dev/null
+++ b/plugins/plugin-feed/src/node/utils/encodeXML.ts
@@ -0,0 +1,51 @@
+import {
+ entries,
+ fromEntries,
+ isArray,
+ isPlainObject,
+} from '@vuepress/helper/node'
+import type { ElementCompact } from 'xml-js'
+
+/**
+ * @see https://stackoverflow.com/questions/223652/is-there-a-way-to-escape-a-cdata-end-token-in-xml
+ */
+export const encodeCDATA = (content: string): string =>
+ content.replace(/]]>/g, ']]]]>')
+
+export const encodeXMLContent = (content: string): string =>
+ content
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+
+export const encodeXML = (content: ElementCompact): ElementCompact =>
+ fromEntries(
+ entries(content).map(([key, value]) => {
+ if (key === '_attributes' && value)
+ return [
+ key,
+ fromEntries(
+ entries(value as Record).map(
+ ([key, value]) => [
+ key,
+ value ? encodeXMLContent(value.toString()) : undefined,
+ ],
+ ),
+ ),
+ ]
+
+ if (key === '_text')
+ return [key, encodeXMLContent((value as string | number).toString())]
+ if (key === '_cdata') return [key, encodeCDATA(value as string)]
+ if (key === '_comment' || key === '_instruction') return [key, value]
+
+ if (isArray(value))
+ return [key, value.map((item) => encodeXML(item as ElementCompact))]
+
+ if (isPlainObject(value)) return [key, encodeXML(value as ElementCompact)]
+
+ return [key, encodeXMLContent(String(value))]
+ }),
+ ) satisfies ElementCompact
diff --git a/plugins/plugin-feed/src/node/utils/getAuthor.ts b/plugins/plugin-feed/src/node/utils/getAuthor.ts
new file mode 100644
index 0000000000..54f34bed9e
--- /dev/null
+++ b/plugins/plugin-feed/src/node/utils/getAuthor.ts
@@ -0,0 +1,31 @@
+import { isArray, isPlainObject, isString } from '@vuepress/helper/node'
+import type { FeedAuthor, FrontmatterAuthor } from '../../typings/index.js'
+
+const isFeedAuthor = (author: unknown): author is FeedAuthor =>
+ isPlainObject(author) && isString(author.name)
+
+export const getFeedAuthor = (
+ author: FrontmatterAuthor | false | undefined,
+): FeedAuthor[] => {
+ if (author) {
+ if (isArray(author))
+ return author
+ .map((item) =>
+ isString(item) ? { name: item } : isFeedAuthor(item) ? item : null,
+ )
+ .filter((item): item is FeedAuthor => item !== null)
+
+ if (isString(author)) return [{ name: author }]
+
+ if (isFeedAuthor(author)) return [author]
+
+ console.error(
+ `Expect "author" to be \`AuthorInfo[] | AuthorInfo | string[] | string | undefined\`, but got`,
+ author,
+ )
+
+ return []
+ }
+
+ return []
+}
diff --git a/plugins/plugin-feed/src/node/utils/getCategory.ts b/plugins/plugin-feed/src/node/utils/getCategory.ts
new file mode 100644
index 0000000000..570f9b34a7
--- /dev/null
+++ b/plugins/plugin-feed/src/node/utils/getCategory.ts
@@ -0,0 +1,12 @@
+import { isArray, isString } from '@vuepress/helper/node'
+
+export const getFeedCategory = (
+ category: string[] | string | undefined,
+): string[] => {
+ if (category) {
+ if (isArray(category) && category.every(isString)) return category
+ if (isString(category)) return [category]
+ }
+
+ return []
+}
diff --git a/plugins/plugin-feed/src/node/utils/getMineType.ts b/plugins/plugin-feed/src/node/utils/getMineType.ts
new file mode 100644
index 0000000000..2b4aed6fb4
--- /dev/null
+++ b/plugins/plugin-feed/src/node/utils/getMineType.ts
@@ -0,0 +1,14 @@
+export const getImageMineType = (ext = ''): string =>
+ `image/${
+ ext === 'jpg'
+ ? 'jpeg'
+ : ext === 'svg'
+ ? 'svg+xml'
+ : ext === 'jpeg' ||
+ ext === 'png' ||
+ ext === 'bmp' ||
+ ext === 'gif' ||
+ ext === 'webp'
+ ? ext
+ : ''
+ }`
diff --git a/plugins/plugin-feed/src/node/utils/getUrl.ts b/plugins/plugin-feed/src/node/utils/getUrl.ts
new file mode 100644
index 0000000000..86cf421fff
--- /dev/null
+++ b/plugins/plugin-feed/src/node/utils/getUrl.ts
@@ -0,0 +1,12 @@
+import {
+ isLinkHttp,
+ removeEndingSlash,
+ removeLeadingSlash,
+} from 'vuepress/shared'
+
+export const getUrl = (hostname: string, base = '', path = ''): string =>
+ `${
+ isLinkHttp(hostname)
+ ? removeEndingSlash(hostname)
+ : `https://${removeEndingSlash(hostname)}`
+ }${base}${removeLeadingSlash(path)}`
diff --git a/plugins/plugin-feed/src/node/utils/index.ts b/plugins/plugin-feed/src/node/utils/index.ts
new file mode 100644
index 0000000000..a20a97ad38
--- /dev/null
+++ b/plugins/plugin-feed/src/node/utils/index.ts
@@ -0,0 +1,6 @@
+export * from './encodeXML.js'
+export * from './getAuthor.js'
+export * from './getCategory.js'
+export * from './getMineType.js'
+export * from './getUrl.js'
+export * from './logger.js'
diff --git a/plugins/plugin-feed/src/node/utils/logger.ts b/plugins/plugin-feed/src/node/utils/logger.ts
new file mode 100644
index 0000000000..866c1e7e20
--- /dev/null
+++ b/plugins/plugin-feed/src/node/utils/logger.ts
@@ -0,0 +1,5 @@
+import { Logger } from '@vuepress/helper/node'
+
+export const FEED_GENERATOR = '@vuepress/plugin-feed'
+
+export const logger = new Logger(FEED_GENERATOR)
diff --git a/plugins/plugin-feed/src/typings/feed.ts b/plugins/plugin-feed/src/typings/feed.ts
new file mode 100644
index 0000000000..fd7283ede5
--- /dev/null
+++ b/plugins/plugin-feed/src/typings/feed.ts
@@ -0,0 +1,171 @@
+export interface FeedAuthor {
+ /**
+ * Author name
+ *
+ * 作者名字
+ */
+ name?: string
+
+ /**
+ * Author Email
+ *
+ * 作者邮件
+ */
+ email?: string
+
+ /**
+ * Author site
+ *
+ * 作者网站
+ *
+ * @description json format only
+ */
+ url?: string
+
+ /**
+ * Author avatar
+ *
+ * 作者头像
+ *
+ * @description json format only
+ */
+ avatar?: string
+}
+
+export type FeedContributor = FeedAuthor
+
+export interface FeedCategory {
+ /**
+ * Category name
+ *
+ * 分类名称
+ */
+ name: string
+
+ /**
+ * A string that identifies a categorization taxonomy
+ *
+ * 标识分类法的字符串
+ *
+ * @description rss format only
+ */
+ domain?: string
+
+ /**
+ * the categorization scheme via a URI
+ *
+ * URI 标识的分类 scheme
+ *
+ * @description atom format only
+ */
+ scheme?: string
+}
+
+export interface FeedEnclosure {
+ /**
+ * enclosure link
+ *
+ * Enclosure 地址
+ */
+ url: string
+
+ /**
+ * what its type is
+ *
+ * @description should be standard MIME type
+ *
+ * 类型
+ *
+ * @description 应为一个标准的 MIME 类型
+ */
+ type: string
+
+ /**
+ * Size in bytes
+ *
+ * 按照字节数计算的大小
+ */
+ length?: number
+}
+
+export interface FeedChannelOptions {
+ /**
+ * Channel title
+ *
+ * 频道的标题
+ */
+ title: string
+
+ /**
+ * The URL to the HTML site corresponding to the channel.
+ *
+ * 频道地址
+ */
+ link: string
+
+ /**
+ * Phrase or sentence describing the channel.
+ *
+ * 频道描述信息
+ */
+ description: string
+
+ /**
+ * The language the channel is written in.
+ *
+ * 频道使用的语言
+ */
+ language: string
+
+ /**
+ * Copyright notice for content in the channel.
+ *
+ * 频道版权信息
+ */
+ copyright: string
+
+ /**
+ * The publication date for the content in the channel.
+ *
+ * 频道内容的发布时间
+ */
+ pubDate?: Date
+
+ /**
+ * The last time the content of the channel changed.
+ *
+ * 频道内容的上次更新时间
+ */
+ lastUpdated?: Date
+
+ /**
+ * time to live.
+ *
+ * It’s a number of minutes that indicates how long a channel can be cached before refreshing from the source.
+ */
+ ttl?: number
+
+ /**
+ * Specifies a GIF, JPEG or PNG image that can be displayed with the channel.
+ */
+ image?: string
+
+ /**
+ * Icon of the channel
+ *
+ * Probably your favicon
+ */
+ icon?: string
+
+ /**
+ * Global Author
+ */
+ author?: FeedAuthor[] | FeedAuthor
+
+ /**
+ * Link for websub
+ *
+ * @see https://w3c.github.io/websub/#subscription-migration
+ */
+ hub?: string
+}
diff --git a/plugins/plugin-feed/src/typings/frontmatter.ts b/plugins/plugin-feed/src/typings/frontmatter.ts
new file mode 100644
index 0000000000..350cad7cfa
--- /dev/null
+++ b/plugins/plugin-feed/src/typings/frontmatter.ts
@@ -0,0 +1,124 @@
+import type { PageFrontmatter } from 'vuepress/core'
+import type { FeedAuthor, FeedCategory, FeedContributor } from './feed.js'
+
+export type AuthorName = string
+
+export interface AuthorInfo {
+ /**
+ * Author name
+ *
+ * 作者姓名
+ */
+ name: string
+
+ /**
+ * Author website
+ *
+ * 作者网站
+ */
+ url?: string
+
+ /**
+ * Author email
+ *
+ * 作者 Email
+ */
+ email?: string
+}
+
+export type FrontmatterAuthor =
+ | AuthorName
+ | AuthorName[]
+ | AuthorInfo
+ | AuthorInfo[]
+
+export interface FeedFrontmatterOption {
+ /**
+ * Feed title
+ */
+ title?: string
+
+ /**
+ * Feed description
+ *
+ * @description Should be plain text
+ */
+ description?: string
+
+ /**
+ * Feed summary
+ *
+ * @description Should be html content
+ */
+ summary?: string
+
+ /**
+ * Feed content
+ */
+ content?: string
+
+ /**
+ * Feed author
+ */
+ author?: FeedAuthor[] | FeedAuthor
+
+ /**
+ * Feed contributor
+ */
+ contributor?: FeedContributor[] | FeedContributor
+
+ /**
+ * Feed category
+ */
+ category?: FeedCategory[] | FeedCategory
+
+ /**
+ * @description guid should be unique globally
+ */
+ guid?: string
+}
+
+export interface FeedPluginFrontmatter extends PageFrontmatter {
+ /**
+ * Feed plugin options
+ *
+ * Feed 插件选项
+ */
+ feed?: FeedFrontmatterOption | false
+
+ /**
+ * Page Author(s)
+ *
+ * 页面作者
+ */
+ author?: FrontmatterAuthor
+
+ /**
+ * Page Category(ies)
+ *
+ * 页面分类
+ */
+ category?: string | string[]
+ categories?: string[]
+
+ /**
+ * Page Cover
+ *
+ * 页面封面
+ */
+ cover?: string
+
+ /**
+ * Page Banner
+ *
+ * 页面 Banner 图
+ */
+ banner?: string
+
+ /**
+ * Copyright text
+ *
+ * 版权文字
+ */
+ copyright?: string
+}
diff --git a/plugins/plugin-feed/src/typings/index.ts b/plugins/plugin-feed/src/typings/index.ts
new file mode 100644
index 0000000000..32b0ed5f92
--- /dev/null
+++ b/plugins/plugin-feed/src/typings/index.ts
@@ -0,0 +1,4 @@
+export * from './feed.js'
+export * from './frontmatter.js'
+export * from './internal.js'
+export * from './options.js'
diff --git a/plugins/plugin-feed/src/typings/internal.ts b/plugins/plugin-feed/src/typings/internal.ts
new file mode 100644
index 0000000000..085eefd805
--- /dev/null
+++ b/plugins/plugin-feed/src/typings/internal.ts
@@ -0,0 +1 @@
+export type FeedConfig = [path: string, content: string, contentType?: string]
diff --git a/plugins/plugin-feed/src/typings/options.ts b/plugins/plugin-feed/src/typings/options.ts
new file mode 100644
index 0000000000..5b9f0a54fb
--- /dev/null
+++ b/plugins/plugin-feed/src/typings/options.ts
@@ -0,0 +1,494 @@
+import type { Page } from 'vuepress/core'
+import type {
+ FeedAuthor,
+ FeedCategory,
+ FeedChannelOptions,
+ FeedContributor,
+ FeedEnclosure,
+} from './feed.js'
+import type { FeedPluginFrontmatter } from './frontmatter.js'
+
+export interface FeedGetter {
+ /**
+ * Item title getter
+ *
+ * 项目标题获取器
+ */
+ title?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => string
+
+ /**
+ * Item link getter
+ *
+ * 项目链接获取器
+ */
+ link?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => string
+
+ /**
+ * Item description getter
+ *
+ * 项目描述获取器
+ */
+ description?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => string | null
+
+ /**
+ * Item excerpt getter
+ *
+ * 项目摘要获取器
+ */
+ excerpt?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => string | null
+
+ /**
+ * Item content getter
+ *
+ * 项目内容获取器
+ */
+ content?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => string
+
+ /**
+ * Item author getter
+ *
+ * @description The getter should return an empty array when lacking author info
+ *
+ * 项目作者获取器
+ *
+ * @description 获取器应在作者信息缺失时返回空数组
+ */
+ author?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => FeedAuthor[]
+
+ /**
+ * Item category getter
+ *
+ * 项目分类获取器
+ */
+ category?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => FeedCategory[] | null
+
+ /**
+ * Item enclosure getter
+ *
+ * 项目附件获取器
+ */
+ enclosure?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => FeedEnclosure | null
+
+ /**
+ * Item publish date getter
+ *
+ * 项目发布日期获取器
+ */
+ publishDate?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => Date | null
+
+ /**
+ * Item last update date getter
+ *
+ * 项目最后更新日期获取器
+ */
+ lastUpdateDate?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => Date
+
+ /**
+ * Item image getter
+ *
+ * 项目图片获取器
+ */
+ image?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => string
+
+ /**
+ * Item contributor getter
+ *
+ * 项目贡献者获取器
+ */
+ contributor?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => FeedContributor[]
+
+ /**
+ * Item copyright getter
+ *
+ * 项目版权获取器
+ */
+ copyright?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => string | null
+}
+
+export interface BaseFeedPluginOptions {
+ /**
+ * Whether to output Atom syntax files.
+ *
+ * 是否启用 Atom 格式输出。
+ *
+ * @default false
+ */
+ atom?: boolean
+
+ /**
+ * Whether to output JSON syntax files.
+ *
+ * 是否启用 JSON 格式输出。
+ *
+ * @default false
+ */
+ json?: boolean
+
+ /**
+ * Whether to output RSS syntax files.
+ *
+ * 是否启用 RSS 格式输出。
+ *
+ * @default false
+ */
+ rss?: boolean
+
+ /**
+ * A large image/icon of the feed, probably used as banner.
+ *
+ * 一个大的图片,用作 feed 展示。
+ */
+ image?: string
+
+ /**
+ * A small icon of the feed, probably used as favicon.
+ *
+ * 一个小的图标,显示在订阅列表中。
+ */
+ icon?: string
+
+ /**
+ * Maximum output items
+ *
+ * 输出的最大条目数量
+ *
+ * @default 100
+ */
+ count?: number
+
+ /**
+ * Custom tags or elements which need to be preserved
+ *
+ * 需要保留的的自定义组件或元素
+ */
+ preservedElements?: (string | RegExp)[] | ((tagName: string) => boolean)
+
+ /**
+ * A custom filter function, used to filter feed items.
+ *
+ * Feed 项目过滤器
+ */
+ filter?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ page: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => boolean
+
+ /**
+ * A custom sort function, used to sort feed items.
+ *
+ * Feed 项目排序器
+ */
+ sorter?: <
+ ExtraPageData extends Record = Record,
+ ExtraPageFrontmatter extends Record = Record<
+ string,
+ unknown
+ >,
+ ExtraPageFields extends Record = Record,
+ >(
+ pageA: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ pageB: Page<
+ ExtraPageData,
+ ExtraPageFrontmatter & FeedPluginFrontmatter,
+ ExtraPageFields
+ >,
+ ) => number
+
+ /**
+ * Options to init feed channel
+ *
+ * Feed 频道选项
+ */
+ channel?: Partial
+
+ /**
+ * Atom syntax output filename, relative to dest folder.
+ *
+ * Atom 格式输出路径,相对于输出路径。
+ *
+ * @default "atom.xml"
+ */
+ atomOutputFilename?: string
+
+ /**
+ * Atom xsl template file content
+ *
+ * Atom xsl 模板文件内容
+ */
+ atomXslTemplate?: string
+
+ /**
+ * Atom xsl filename, relative to dest folder.
+ *
+ * Atom xsl 输出路径,相对于输出路径。
+ *
+ * @default "atom.xsl"
+ */
+ atomXslFilename?: string
+
+ /**
+ * JSON syntax output filename, relative to dest folder.
+ *
+ * JSON 格式输出路径,相对于输出路径。
+ *
+ * @default "feed.json"
+ */
+ jsonOutputFilename?: string
+
+ /**
+ * RSS syntax output filename, relative to dest folder.
+ *
+ * RSS 格式输出路径,相对于输出路径。
+ *
+ * @default "rss.xml"
+ */
+ rssOutputFilename?: string
+
+ /**
+ * RSS xsl template file content
+ *
+ * RSS xsl 模板文件内容
+ */
+ rssXslTemplate?: string
+
+ /**
+ * RSS xsl filename, relative to dest folder.
+ *
+ * RSS xsl 输出路径,相对于输出路径。
+ *
+ * @default "rss.xsl"
+ */
+ rssXslFilename?: string
+
+ /**
+ * Feed generation controller
+ *
+ * @description The plugin is providing a reasonable getter by default, if you want full control of feed generating, you can set this field.
+ *
+ * Feed 生成控制器
+ *
+ * @description 插件已经在默认情况下提供了合理的获取器,如果你需要完全控制 Feed 生成,你可以设置此项。
+ */
+ getter?: FeedGetter
+}
+
+export interface FeedPluginOptions extends BaseFeedPluginOptions {
+ /**
+ * Deploy hostname
+ *
+ * 部署的域名
+ */
+ hostname: string
+
+ /**
+ * Whether enabled in devServer
+ *
+ * @description For performance reasons, we do not provide hot reload. Reboot your devServer to sync your changes.
+ *
+ * 是否在开发服务器中启用
+ *
+ * @description 由于性能原因,我们不提供热更新。重启开发服务器以同步你的变更。
+ *
+ * @default false
+ */
+ devServer?: boolean
+
+ /**
+ * Hostname to use in devServer
+ *
+ * 开发服务器使用的主机名
+ *
+ * @default 'http://localhost:${port}'
+ */
+ devHostname?: string
+
+ /**
+ * Locales options for feed
+ *
+ * Feed 的多语言选项
+ */
+ locales?: Record
+}
diff --git a/plugins/plugin-feed/templates/atom.xsl b/plugins/plugin-feed/templates/atom.xsl
new file mode 100644
index 0000000000..26404c7143
--- /dev/null
+++ b/plugins/plugin-feed/templates/atom.xsl
@@ -0,0 +1,534 @@
+
+
+
+
+
+
+ Atom Feed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Last update time: |
+
+
+ |
+
+
+
+ Author: |
+
+
+ ,
+
+
+ |
+
+
+
+
+ Contributor: |
+
+
+ ,
+
+
+
+ |
+
+
+
+
+ Categories: |
+
+
+ ,
+
+
+ |
+
+
+
+
+ Copyright: |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/plugin-feed/templates/rss.xsl b/plugins/plugin-feed/templates/rss.xsl
new file mode 100644
index 0000000000..972565991d
--- /dev/null
+++ b/plugins/plugin-feed/templates/rss.xsl
@@ -0,0 +1,506 @@
+
+
+
+
+
+
+ RSS Feed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Language: |
+
+
+ |
+
+
+
+ Published Date: |
+
+
+ |
+
+
+
+ Last Build Date: |
+
+
+ |
+
+
+
+ Copyright: |
+
+
+ |
+
+
+
+
+
+ Catetory:
+ |
+
+
+ ,
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/plugin-feed/tests/node/encode.spec.ts b/plugins/plugin-feed/tests/node/encode.spec.ts
new file mode 100644
index 0000000000..303b5125b1
--- /dev/null
+++ b/plugins/plugin-feed/tests/node/encode.spec.ts
@@ -0,0 +1,17 @@
+import { expect, it } from 'vitest'
+import {
+ encodeCDATA,
+ encodeXMLContent,
+} from '../../src/node/utils/encodeXML.js'
+
+it('Should encode CDATA', () => {
+ expect(encodeCDATA('Certain tokens like ]]> can be difficult')).toBe(
+ 'Certain tokens like ]]]]> can be difficult',
+ )
+})
+
+it('Should encore XMLContent', () => {
+ const content = '"1 > 2"'
+
+ expect(encodeXMLContent(content)).toBe('"1 > 2"')
+})
diff --git a/plugins/plugin-feed/tsconfig.build.json b/plugins/plugin-feed/tsconfig.build.json
new file mode 100644
index 0000000000..dca67783be
--- /dev/null
+++ b/plugins/plugin-feed/tsconfig.build.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.build.json",
+ "compilerOptions": {
+ "rootDir": "./src",
+ "outDir": "./lib"
+ },
+ "include": ["./src"],
+ "references": [{ "path": "../../tools/helper/tsconfig.build.json" }]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3cc18407f7..8658390eb6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -149,6 +149,9 @@ importers:
'@vuepress/client':
specifier: 2.0.0-rc.2
version: 2.0.0-rc.2(typescript@5.3.3)
+ '@vuepress/plugin-feed':
+ specifier: workspace:*
+ version: link:../plugins/plugin-feed
'@vuepress/theme-default':
specifier: workspace:*
version: link:../themes/theme-default
@@ -253,6 +256,25 @@ importers:
specifier: 2.0.0-rc.2
version: 2.0.0-rc.2(@vuepress/bundler-vite@2.0.0-rc.2)(@vuepress/bundler-webpack@2.0.0-rc.2)(typescript@5.3.3)(vue@3.4.15)
+ plugins/plugin-feed:
+ dependencies:
+ '@vuepress/helper':
+ specifier: workspace:*
+ version: link:../../tools/helper
+ cheerio:
+ specifier: 1.0.0-rc.12
+ version: 1.0.0-rc.12
+ vuepress:
+ specifier: 2.0.0-rc.2
+ version: 2.0.0-rc.2(@vuepress/bundler-vite@2.0.0-rc.2)(@vuepress/bundler-webpack@2.0.0-rc.2)(typescript@5.3.3)(vue@3.4.15)
+ xml-js:
+ specifier: ^1.6.11
+ version: 1.6.11
+ devDependencies:
+ '@vuepress/plugin-git':
+ specifier: 2.0.0-rc.1
+ version: link:../plugin-git
+
plugins/plugin-git:
dependencies:
execa:
@@ -12380,6 +12402,13 @@ packages:
utf-8-validate:
optional: true
+ /xml-js@1.6.11:
+ resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
+ hasBin: true
+ dependencies:
+ sax: 1.3.0
+ dev: false
+
/xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}