Astro 即刻短文
前言#
在搭建本博客网站之前,代码仓库只有一个 EveryLearn.md,里面按照时间顺序记录了我每天学到的东西(类似技术日记?)。每段文字都很短,单独无法撑起一个页面;但内容积少成多,作为一个大文章既笨重、又没有“更新”的感觉。我想要的是 “朋友圈” 那样的效果:所有内容按照时间倒序,每天的内容是一个卡片。这就是本文要实现功能。
最终效果请看左侧导航栏的“日记”。
格式约定#
每天的内容用一级标题划分,一级标题前三个数字代表了“年”“月”“日”,后面的保留作为 meta,用于目录的显示(类似于摘要?)。例如:
# 2025 5 1 # 学习了一天## 关于XXXXXX是...## 关于YYYYYY是...
# 2026 5 1出去吃了一顿好的为了进一步方便书写,设置年份和月份可以省略(继承上一个)
方案选择#
首先要理解Astro的渲染流程。首先要 defineCollection,然后具体页面中,通过 const { Content } = await render(post); 获取到 <Content /> 组件,这个组件就是具体 html 内容。
最先考虑的是直接写一个 remark 插件,然后写一个js进行运行时拆分。但是运行时还是太不优雅了。因此得在 defineCollection 进行拆分。
有两个方案:
- 用
file()加载器 将条目表示为 list 或 record。 - 自定义加载器,结果等效于多个独立的
.md。
前者 Gemini 说行不通:
为什么不能在页面中伪造对象或分割属性来渲染单文件多条目?#
想做什么#
我们有一份巨大的 Markdown 记录文件,内部通过 # 年 月 日 划分了多个小日记。为了在网页上能用 <details> 分别折叠渲染每一天,我们曾设想:
- 存放为属性值:读取这个大文件后,将每个日期的文本切片存放到一个大 Post 的
data属性里(例如Record<日期, MD字符串>)。 - 伪造 Entry 对象:在 .astro 页面遍历这些切片时,手工拼装出具有
body字段的伪造对象(如{ id: 'fake', body: '切片的MD文本' })。 - 强行调用引擎:把这些子属性或伪造对象丢给 Astro 自带的
await render(fakePost),期望它能在页面运行时当场解析 Markdown,并带着我们配置的所有 remark/rehype 插件、代码高亮渲染出专属的<Content />组件。
❌ 为什么行不通#
render()不是解析器,是“提取员”:Astro 追求极致性能,耗时的 Markdown 解析流水线(包含所有插件)只在构建时(Build-time)执行一次。页面里的render()根本无法解析 Markdown 字符串,它只是拿着注册过的 ID 去提取早以编译好的 HTML 缓存。- 伪造对象没有“户口”:你在页面时才拼装出的伪造 Entry,没有在构建阶段经历过 Astro 的编译流水线。引擎里找不到它们的编译产物,传入
render()必定直接报错。 - 纯文本痛失插件生态:那些藏在
post.data里的文本切片只会被 Astro 视为普通字符串(它严格遵循一个 Entry 只生成一个主<Content />)。若想在页面里硬把这些字符串渲染出来,只能引入第三方库(如marked),这将导致你的 Astro 专有特性和所有样式插件全部报废。
💡 唯一正解:只能在构建收集期解决。通过手写 Custom Loader 读取并切片该文件,调用底层
renderMarkdown编译后,用store.set()把每一块当成独立的合法 Entry 提前注册进 Astro 数据库。
自定义加载器#
官方文档:
大部分代码让 Gemini 帮我写了,具体代码👈。
有几个注意点:
- 根据官方文档配置
watcher,可以实现热重载 store.set传递的会交给defineCollection中的schema检查,有错误要自己捕获;默认的输出提供不了具体哪里有问题- 输出用
logger
展示一些框架代码:
const everyLearnLoader = { name: 'everylearn-loader', load: async ({ store, parseData, renderMarkdown, logger, watcher }) => { const filePath = './src/content/snippets/EveryLearn.md'; const absoluteFilePath = path.resolve(process.cwd(), filePath);
// 将解析逻辑封装,以支持初次加载和热更新 const syncData = async () => { store.clear(); // 完成划分 略
for (const chunk of chunks) { // 处理得到schema
// 存入 Astro 的 DataStore,传入 meta try { const data = await parseData({ id, data: { pubDate, meta } }); const rendered = await renderMarkdown(bodyContent); store.set({ id, data, body: bodyContent, rendered }); } catch(e) { logger.error(`Error occurred while processing chunk ${chunkIndex}: ${e} @${bodyContent}`); } } };
// 初始化时调用一次 await syncData();
// 将此文件加入 Astro 的热更新监视器中 watcher?.on('change', async (changedPath) => { // 将 Windows 路径里的 \ 替换为 / 以便容错比较 if (changedPath.replace(/\\/g, '/') === absoluteFilePath.replace(/\\/g, '/')) { logger.info(`Reloading EveryLearn.md due to changes`); await syncData(); } }); }} satisfies Loader;
const everyLearn = defineCollection({ loader: everyLearnLoader, schema: z.object({ pubDate: z.coerce.date(), meta: z.string().optional(), }),});评论#
要给每一个日记加上评论区。暂时不打算做。
时间线(目录)#
本来想写一个日期组件,展示哪些天有内容产出。但是记录其实很稀疏,用这种方式索引内容效率太低了;于是打算复用已经写好的TOC(感觉专门写一个时间线没有必要?)
首先要准备toc的内容。目录要求简短,比如年份可以只保留后两位;且我希望年份为一级标题,具体日期为二级。所以只能自己构建:
const posts = (await getCollection("everyLearn")).sort( (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),);
const anchors: Array<MarkdownHeading & { index: number }> = [];let lastYear = "";for (let i = 0; i < posts.length; i++) { const post = posts[i]; const date = post.data.pubDate; const year = String(date.getFullYear()); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); if (year !== lastYear) { anchors.push({ depth: 1, slug: String(year), text: String(year), index: -1, }); lastYear = year; } anchors.push({ depth: 2, slug: post.id, text: `${year.slice(2)}/${month}/${day}${post.data.meta ? `: ${post.data.meta}` : ""}`, index: i, });}由于插入了年份,因此增加了 index 属性,用来从 posts 中索引具体内容。具体创建html时,只需要遍历 anchors 即可。
而CSS这里处理就比较随意了,简单用圆角矩形划分了一下范围。