Astro 的 Atom 订阅源
当我迁移到 Astro 时,我使用 Astro 的 RSS 插件 设置了订阅源,它生成 RSS 2.0 订阅源。后来,我读到 Nic Chan 的一篇文章,他采用了另一种方案:使用框架无关的 Feed 插件 来生成 Atom 1.0 订阅源。
我一直更偏爱 Atom 格式,因为它对富文本内容的支持更好,规范也更严格。所以,我立即决定进行切换。
Atom 与 RSS 的比较
与 Atom 相比,RSS 具有更广泛的兼容性和更简单的结构。但现在是 2024 年,担心 Atom 支持感觉有点像担心你的新车是否有 USB 媒体播放器。
Atom 有几个令人信服的优势,值得考虑:
- 更好的标准化,具有清晰、正式的规范
- 对富内容的卓越支持
- 改进的国际化支持
- 使用稍微更复杂的东西带来的自满满足感
最好的部分是什么?Feed 包同时支持 RSS 和 Atom,与 Astro 开箱即用无缝配合——就像发现你的新电动汽车仍然有一个位置可以放你的拇指驱动器音乐库一样。
使用 Astro 设置 Feed
将 jpmonette/feed 包添加到你的 Astro 项目很简单。
1. 安装 jpmonette/feed
首先,使用你选择的包管理器安装包:
使用 yarn: yarn add feed
使用 pnpm: pnpm add feed
2. 创建端点
在 Astro 中创建一个 API 端点来生成你的订阅源,通过在 src/pages/feed.xml.js 创建一个文件:
// src/pages/feed.xml.js
import { Feed } from 'feed';
import { getCollection } from 'astro:content';
export async function GET(context) {
const blog = await getCollection('blog');
const siteUrl = 'https://yoursite.com';
const feed = new Feed({
title: 'Your Site Name',
description: 'Your site description',
id: siteUrl,
link: siteUrl,
language: 'en',
favicon: `${siteUrl}/favicon.ico`,
copyright: `All rights reserved ${new Date().getFullYear()}`,
feedLinks: {
rss: `${siteUrl}/rss.xml`,
},
author: {
name: 'Your Name',
},
});
blog.forEach((post) => {
const url = `${siteUrl}/blog/${post.slug}`;
feed.addItem({
title: post.data.title,
id: url,
link: url,
description: post.data.description,
content: post.body, // 如果需要,可以用 markdown 处理这个
author: [
{
name: post.data.author || 'Your Name',
},
],
date: new Date(post.data.publishDate),
});
});
return new Response(feed.rss2(), {
headers: {
'Content-Type': 'application/xml',
},
});
}
3. 添加到 <head> 的链接
通过在网站的 <head> 部分添加链接来声明订阅源:
<link rel="alternate" type="application/atom+xml" title="Your Site Atom Feed" href="/feed.xml">
解析 Markdown
按照上述步骤后,我发现我的订阅源提供的是原始 Markdown 而不是适当的 HTML。
Astro RSS 文档 建议使用 SanitizeHTML 和 MarkdownIt 来正确解析内容。
1. 安装包
安装必要的包:
使用 yarn: yarn add sanitize-html markdown-it
使用 pnpm: pnpm add sanitize-html markdown-it
2. 修改端点
修改 src/pages/feed.xml.js 以解析内容:
// src/pages/feed.xml.js
import { Feed } from 'feed';
import { getCollection } from 'astro:content';
import sanitizeHtml from 'sanitize-html';
import MarkdownIt from 'markdown-it';
export async function GET(context) {
const siteUrl = 'https://yoursite.com';
// 初始化 markdown 解析器
const md = new MarkdownIt({
html: true, // 在源中启用 HTML 标签
breaks: true, // 将段落中的 '\n' 转换为 <br>
linkify: true // 自动将类似 URL 的文本转换为链接
});
// 创建订阅源实例
const feed = new Feed({
title: 'Your Site Name',
description: 'Your site description',
id: siteUrl,
link: siteUrl,
language: 'en',
favicon: `${siteUrl}/favicon.ico`,
copyright: `All rights reserved ${new Date().getFullYear()}`,
feedLinks: {
atom: `${siteUrl}/feed.xml`,
},
author: {
name: 'Your Name',
},
updated: new Date(),
});
const blog = await getCollection('blog');
// 将每篇文章添加到订阅源
blog.forEach((post) => {
const url = `${siteUrl}/blog/${post.slug}`;
const publishDate = new Date(post.data.date || new Date());
const itemIndex = blog.indexOf(post);
let updatedDate;
if (post.data.modified) {
updatedDate = new Date(post.data.modified);
} else {
// 克隆发布日期并根据索引添加小的偏移(毫秒)
updatedDate = new Date(publishDate.getTime() + itemIndex);
}
// 将 markdown 解析为 HTML
const htmlContent = md.render(post.body);
// 清理 HTML 以删除潜在的不安全标签
const sanitizedContent = sanitizeHtml(htmlContent, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2', 'h3']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'title'],
a: ['href', 'name', 'target', 'rel']
},
// 将相对 URL 转换为绝对 URL
transformTags: {
'a': (tagName, attribs) => {
if (attribs.href && attribs.href.startsWith('/')) {
return {
tagName: 'a',
attribs: {
...attribs,
href: `${siteUrl}${attribs.href}`,
target: '_blank',
rel: 'noopener'
}
};
}
return { tagName, attribs };
},
'img': (tagName, attribs) => {
if (attribs.src && attribs.src.startsWith('/')) {
return {
tagName: 'img',
attribs: {
...attribs,
src: `${siteUrl}${attribs.src}`
}
};
}
return { tagName, attribs };
}
}
});
feed.addItem({
title: post.data.title,
id: url,
link: url,
description: post.data.description || '',
content: sanitizedContent,
author: [
{
name: post.data.author || 'Your Name',
},
],
date: publishDate,
updated: updatedDate,
});
});
return new Response(feed.atom1(), {
headers: { 'Content-Type': 'application/atom+xml' }
});
}
多个订阅源
无法在 RSS 和 Atom 之间做出决定?为什么不两者都选?为每种格式创建端点允许读者使用他们喜欢的方法订阅。以下是如何设置:
创建两个端点文件并修改返回的标头。
对于 /rss.xml 的 RSS 2.0:
// src/pages/rss.xml.js
return new Response(feed.rss2(), {
headers: { 'Content-Type': 'application/xml' }
});
对于 /atom.xml 的 Atom:
src/pages/atom.xml.js
return new Response(feed.atom1(), {
headers: { 'Content-Type': 'application/atom+xml' }
});
总结
通过切换到 Atom 这类更现代的标准,我不仅提升了自己内容的呈现质量,也为订阅者提供了更佳的信息获取体验。这正体现了『拥有你的数据』的核心精神:在不依赖特定平台的前提下,用最好的技术直接服务于你的读者。