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 + + + + + + +
+ + + +

+ + atom logo + + +

+

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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'}