文章创作工作流与 Frontmatter
Mizuki 的文章创作以 src/content/posts/ 目录下的 Markdown/MDX 文件为核心:作者只需书写内容并在文件头部的 YAML frontmatter 中填写元数据,主题会在构建期通过 Astro Content Layer + Zod schema 校验这些字段,并自动完成草稿过滤、排序、置顶、上下篇派生、标签/分类聚合等处理。本文档完整覆盖这条"写文章 → 定义元数据 → 构建期处理"的创作工作流。
Purpose and Scope
本页覆盖以下内容:
- 文章集合(
posts)与规范文档集合(spec)的定义位置与加载方式; - Frontmatter 的完整字段参考(类型、默认值、语义),以
src/content.config.ts中的 Zod schema 为唯一事实来源; - 构建期的内容处理管线:草稿过滤、置顶/
priority排序、发布日期排序、prev*/next*内部字段派生; - 标签与分类列表的聚合逻辑,以及相关文章推荐所依赖的分词、Jaccard 相似度与标签 IDF 权重算法。
以下相关主题有意留给兄弟页面,本页仅点到为止:
- 文章 URL / permalink / alias 的路由解析细节 —— 参见路由与 URL 生成相关页面(实现位于
src/utils/url-utils.ts、src/utils/permalink-utils.ts、src/utils/post-url.ts); - 页面加密(
encrypted/password字段的运行时行为)—— 参见页面加密相关页面; - RSS/Atom Feed 的内容渲染 —— 参见 Feed 生成相关页面(
src/utils/feed-data.ts、src/utils/content-renderer.ts); - 主题构建配置(导航、评论系统等)—— 参见配置体系相关页面。
Overview
Mizuki 基于 Astro 的 Content Layer API 组织内容。创作者的全部"输入接口"只有一个:在 src/content/posts/ 下新建 *.md 或 *.mdx 文件并填写 frontmatter。之后的一切(校验、过滤、排序、聚合、推荐)都由构建期代码自动完成:
- 加载:
src/content.config.ts定义两个集合——posts(博客文章)与spec(规范文档)。posts使用自定义的postGlob加载器(src/loaders/post-loader.ts),spec使用 Astro 内置的glob加载器。 - 校验:每个文件的 frontmatter 都会被 Zod schema 解析;
title与published必填,其余字段均有默认值或可选,保证"最小 frontmatter"也能通过校验。 - 派生:
src/utils/content-utils.ts在页面获取数据时执行排序(置顶 →priority→ 发布日期倒序),并回写prevSlug/prevTitle/nextSlug/nextTitle四个内部字段,供文章详情页渲染上下篇导航。 - 聚合:标签与分类列表由全量文章的
tags/category字段统计而来;空分类会被归入 i18n 的"未分类"。 - 推荐:模块后半部分实现了基于标签 IDF 权重 + 标题/描述 Jaccard 相似度的相关文章推荐,支持中英文混合分词(
Intl.Segmenter,带正则回退)。
关键设计意图:让"写文章"这一动作保持零配置——frontmatter 的宽松默认值意味着作者可以只写 title + published,其余行为(评论开启、排序、聚合)全部自动获得合理默认;而高级能力(置顶、加密、自定义 permalink、多作者署名、许可证)通过可选字段渐进式启用。
Architecture
架构要点说明:
src/content.config.ts是唯一契约:两个集合(posts、spec)及其 schema 全部集中在这 55 行的文件里。posts走自定义postGlob加载器而非 Astro 内置glob,因为文章需要在加载阶段补充内部字段(schema 中注释为/* For internal use */的prevTitle/prevSlug/nextTitle/nextSlug与两个无默认值的必填布尔_publishedDateOnly、_updatedDateOnly——后者必须由加载器在解析日期时计算并注入,否则 Zod 校验会直接失败)。spec集合是刻意"无 schema"的对照组:schema: z.object({})表示规范文档不受任何 frontmatter 约束,体现了"文章强约束、文档零约束"的分层设计。content-utils.ts是消费侧唯一入口:所有页面(列表、详情、标签、分类、Feed)都通过该模块读取posts集合,保证草稿过滤与排序规则全局一致——任何页面都不会绕过draft过滤直接getCollection("posts")(模块内 4 处getCollection调用全部带同一条 PROD 过滤谓词)。
源码级实现:集合定义与加载
集合定义的完整源码如下(全文仅 55 行):
1import { defineCollection } from "astro:content";
2import { glob } from "astro/loaders";
3import { z } from "astro/zod";
4
5import { postGlob } from "./loaders/post-loader";
6
7const postsCollection = defineCollection({
8 loader: postGlob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
9 schema: z.object({
10 title: z.string(),
11 published: z.date(),
12 updated: z.date().optional(),
13 draft: z.boolean().optional().default(false),
14 description: z.string().optional().default(""),
15 image: z.string().optional().default(""),
16 tags: z.array(z.string()).optional().default([]),
17 category: z.string().optional().nullable().default(""),
18 lang: z.string().optional().default(""),
19 pinned: z.boolean().optional().default(false),
20 comment: z.boolean().optional().default(true),
21 priority: z.number().optional(),
22 author: z.string().optional().default(""),
23 sourceLink: z.string().optional().default(""),
24 licenseName: z.string().optional().default(""),
25 licenseUrl: z.string().optional().default(""),
26
27 /* Page encryption fields */
28 encrypted: z.boolean().optional().default(false),
29 password: z.string().optional().default(""),
30 passwordHint: z.string().optional().default(""),
31 hideHomeContent: z.boolean().optional(),
32
33 /* Posts alias */
34 alias: z.string().optional(),
35
36 /* Custom permalink - 自定义固定链接,优先级高于 alias */
37 permalink: z.string().optional(),
38
39 /* For internal use */
40 prevTitle: z.string().default(""),
41 prevSlug: z.string().default(""),
42 nextTitle: z.string().default(""),
43 nextSlug: z.string().default(""),
44 _publishedDateOnly: z.boolean(),
45 _updatedDateOnly: z.boolean(),
46 }),
47});
48const specCollection = defineCollection({
49 loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/spec" }),
50 schema: z.object({}),
51});
52export const collections = {
53 posts: postsCollection,
54 spec: specCollection,
55};Source: content.config.ts
逐段解读:
- 加载范围:
pattern: "**/*.{md,mdx}"+base: "./src/content/posts"—— 支持任意深度的子目录嵌套,post.id由加载器基于相对路径生成(排序时的同日期 tie-break 会用到post.id)。 - 必填字段只有两个:
title: z.string()与published: z.date()。缺任一项,Astro 构建会在 content 层校验阶段直接报错——这是"快速失败"设计,避免空标题/空日期的文章静默进入列表。 permalink优先于alias:源码注释明确写着"自定义固定链接,优先级高于 alias"。两者都是单值z.string().optional(),URL 解析优先级逻辑位于src/utils/url-utils.ts(细节见路由相关页面)。_publishedDateOnly/_updatedDateOnly没有默认值:它们是"必须由加载器供给"的标记字段(发布/更新日期是否只含日期部分),任何绕过postGlob直接写入posts集合的数据都会因缺少这两个字段而校验失败——这是把"加载器必须运行"固化为类型约束的做法。
关于 postGlob 加载器本身(src/loaders/post-loader.ts):它从 content.config.ts 导入并以上述参数调用,负责扫描目录、解析 frontmatter、注入内部字段。其内部实现不在本页采集范围内,详见源文件 post-loader.ts。
核心处理流程
草稿过滤与排序(getRawSortedPosts / getSortedPosts)
1// // Retrieve posts and sort them by publication date
2async function getRawSortedPosts() {
3 const allBlogPosts = await getCollection("posts", ({ data }) => {
4 return import.meta.env.PROD ? data.draft !== true : true;
5 });
6
7 const sorted = allBlogPosts.sort((a, b) => {
8 // 首先按置顶状态排序,置顶文章在前
9 if (a.data.pinned && !b.data.pinned) {
10 return -1;
11 }
12 if (!a.data.pinned && b.data.pinned) {
13 return 1;
14 }
15
16 // 如果置顶状态相同,优先按 Priority 排序(数值越小越靠前)
17 if (a.data.pinned && b.data.pinned) {
18 const priorityA = a.data.priority;
19 const priorityB = b.data.priority;
20 if (priorityA !== undefined && priorityB !== undefined) {
21 if (priorityA !== priorityB) {
22 return priorityA - priorityB;
23 }
24 } else if (priorityA !== undefined) {
25 return -1;
26 } else if (priorityB !== undefined) {
27 return 1;
28 }
29 }
30
31 // 否则按发布日期排序
32 return comparePublishedDatesDescending(
33 a.data.published,
34 b.data.published,
35 a.id,
36 b.id,
37 );
38 });
39 return sorted;
40}Source: content-utils.ts
设计意图:
- 草稿在开发模式下可见:
import.meta.env.PROD ? data.draft !== true : true意味着draft: true的文章仅在astro dev中出现,生产构建会将其剔除,作者可以"提交到仓库但不发布"。 - 排序规则分四层:非置顶在前被置顶覆盖;两篇都置顶时按
priority升序(数值小者靠前);未设置priority的置顶文章排在设置了priority的置顶文章之后;其余一律走comparePublishedDatesDescending(发布日期倒序,并以post.id作 tie-break 保证排序稳定、构建可复现)。注意priority只对置顶文章生效——这是刻意为之,避免普通文章破坏时间线语义。 Array.prototype.sort原地修改:getCollection每次返回新数组,因此原地排序安全;但模块内多处函数各自调用getRawSortedPosts(),每次调用都会重新排序(构建期一次性开销,可接受)。
排序完成后,getSortedPosts 会把上下篇信息回写到文章 data 上:
1export async function getSortedPosts() {
2 const sorted = await getRawSortedPosts();
3
4 for (let i = 1; i < sorted.length; i++) {
5 sorted[i].data.nextSlug = sorted[i - 1].id;
6 sorted[i].data.nextTitle = sorted[i - 1].data.title;
7 }
8 for (let i = 0; i < sorted.length - 1; i++) {
9 sorted[i].data.prevSlug = sorted[i + 1].id;
10 sorted[i].data.prevTitle = sorted[i + 1].data.title;
11 }
12
13 return sorted;
14}Source: content-utils.ts
由于排序为发布日期倒序,数组索引 i-1 是时间上更新的文章,因此被写为 next*("较新一篇"),i+1 被写为 prev*("较旧一篇")。这两个循环直接写入 schema 中标记为 "For internal use" 的字段——这正是 postGlob 加载器需要为这些字段提供空默认值的原因:校验在排序之前发生。
列表页专用数据(getSortedPostsList)
1export interface PostForList {
2 id: string;
3 data: CollectionEntry<"posts">["data"];
4 url?: string; // 预计算的文章 URL
5}
6export async function getSortedPostsList(): Promise<PostForList[]> {
7 const sortedFullPosts = await getRawSortedPosts();
8
9 // 初始化文章 ID 映射(用于 permalink 功能)
10 initPostIdMap(sortedFullPosts);
11
12 // delete post.body,并预计算 URL
13 const sortedPostsList = sortedFullPosts.map((post) => ({
14 id: post.id,
15 data: post.data,
16 url: getPostUrl(post),
17 }));
18
19 return sortedPostsList;
20}Source: content-utils.ts
列表页不需要文章正文。该函数通过 map 只保留 id、data、url 三个字段,丢弃体积庞大的 post.body(整个 Markdown 源文本),显著降低传给列表组件的数据量;同时调用 initPostIdMap 建立 permalink 所需的 id 索引(来自 @utils/permalink-utils),再把 URL 预计算一次(getPostUrl,来自 @utils/url-utils),避免列表页 N 篇文章反复调用 URL 生成逻辑。这体现了"数据瘦身 + 预计算"的构建期优化思路。
端到端时序
Frontmatter 字段完整参考
以 content.config.ts 的 Zod schema 为唯一事实来源:
| 字段 | 类型 | 必填 | 默认值 | 语义与用途 |
|---|---|---|---|---|
title | string | ✅ | — | 文章标题,列表/详情/SEO 使用 |
published | date | ✅ | — | 发布日期,排序主键(倒序) |
updated | date | ❌ | — | 更新日期,详情页展示"最后更新" |
draft | boolean | ❌ | false | 草稿标记;生产构建剔除,开发模式可见 |
description | string | ❌ | "" | 摘要,用于列表卡片与 meta 描述 |
image | string | ❌ | "" | 封面图路径 |
tags | string[] | ❌ | [] | 标签数组,驱动标签聚合与相关文章推荐 |
category | string | null | ❌ | "" | 分类;空/null 时归入 i18n "未分类" |
lang | string | ❌ | "" | 文章语言标记 |
pinned | boolean | ❌ | false | 置顶标记,排序最高优先级 |
comment | boolean | ❌ | true | 是否启用评论区(默认开启) |
priority | number | ❌ | — | 置顶文章内部排序,数值越小越靠前;仅对 pinned 文章生效 |
author | string | ❌ | "" | 作者署名(多作者场景) |
sourceLink | string | ❌ | "" | 原文/转载来源链接 |
licenseName | string | ❌ | "" | 许可证名称(如 CC BY 4.0) |
licenseUrl | string | ❌ | "" | 许可证链接 |
encrypted | boolean | ❌ | false | 页面加密开关(详见加密相关页面) |
password | string | ❌ | "" | 加密口令 |
passwordHint | string | ❌ | "" | 口令提示 |
hideHomeContent | boolean | ❌ | — | 首页隐藏正文内容 |
alias | string | ❌ | — | 文章别名,用于兼容旧 URL |
permalink | string | ❌ | — | 自定义固定链接,优先级高于 alias |
prevTitle / prevSlug | string | ❌ | "" | 内部字段:较旧一篇的标题/id(由排序派生,勿手写) |
nextTitle / nextSlug | string | ❌ | "" | 内部字段:较新一篇的标题/id(由排序派生,勿手写) |
_publishedDateOnly | boolean | ✅(内部) | — | 加载器注入:发布日期是否仅含日期 |
_updatedDateOnly | boolean | ✅(内部) | — | 加载器注入:更新日期是否仅含日期 |
最小合法 frontmatter 示例(可直接用于 src/content/posts/ 下的新文章):
1---
2title: 我的第一篇 Mizuki 文章
3published: 2025-01-01
4---进阶示例(置顶 + 加密 + 许可证 + 自定义 permalink):
1---
2title: 深入理解 Astro Content Layer
3published: 2025-06-01
4updated: 2025-06-10
5description: 一篇关于 Astro 内容层的深度解析
6tags: [Astro, 前端, 内容管理]
7category: 技术笔记
8lang: zh
9pinned: true
10priority: 1
11comment: false
12author: Lyra
13sourceLink: ""
14licenseName: CC BY 4.0
15licenseUrl: https://creativecommons.org/licenses/by/4.0/
16permalink: posts/deep-dive-astro-content-layer
17---(以上 YAML 为基于 schema 约束编写的字段用法示例,非仓库内实际文件。)
标签与分类聚合
1export interface Category {
2 name: string;
3 count: number;
4 url: string;
5}
6
7export async function getCategoryList(): Promise<Category[]> {
8 const allBlogPosts = await getCollection<"posts">("posts", ({ data }) => {
9 return import.meta.env.PROD ? data.draft !== true : true;
10 });
11 const count: Record<string, number> = {};
12 allBlogPosts.forEach((post: { data: { category: string | null } }) => {
13 if (!post.data.category) {
14 const ucKey = i18n(I18nKey.uncategorized);
15 count[ucKey] = count[ucKey] ? count[ucKey] + 1 : 1;
16 return;
17 }
18
19 const categoryName =
20 typeof post.data.category === "string"
21 ? post.data.category.trim()
22 : String(post.data.category).trim();
23
24 count[categoryName] = count[categoryName] ? count[categoryName] + 1 : 1;
25 });
26
27 const lst = Object.keys(count).sort((a, b) => {
28 return a.toLowerCase().localeCompare(b.toLowerCase());
29 });
30 ...
31}Source: content-utils.ts
要点:
- 未分类本地化:
category为空(""或null,schema 用.nullable()允许显式 null)的文章会被计入i18n(I18nKey.uncategorized),即界面语言下的"未分类",而不是出现空字符串分类。 - 分类名先
trim()再计数:防止技术与技术变成两个分类;随后按toLowerCase()+localeCompare做大小写不敏感的字典序排序,保证中英文混合分类名输出稳定。 getTagList()(同文件 content-utils.ts L88-L109)逻辑对称:遍历全量文章的tags数组建立countMap,同样按小写localeCompare排序后输出{ name, count }[]。区别在于标签没有"未分类"兜底——因为tags默认值是[],空标签不参与聚合。
相关文章推荐算法
content-utils.ts 后半部分实现了一套基于文本统计的相关文章推荐,包含三个协作函数:
分词(tokenize)
1function tokenize(text: string): Set<string> {
2 const tokens = new Set<string>();
3
4 const hasSegmenter =
5 typeof Intl !== "undefined" &&
6 "Segmenter" in Intl &&
7 typeof (Intl as unknown as { Segmenter?: unknown }).Segmenter ===
8 "function";
9
10 if (!hasSegmenter) {
11 const basicTokens = text
12 .toLowerCase()
13 .split(/[\s\p{P}]+/gu)
14 .filter(Boolean);
15 for (const t of basicTokens) {
16 tokens.add(t);
17 }
18 return tokens;
19 }
20
21 const segmenter = new (
22 Intl as unknown as {
23 Segmenter: new (
24 locale: string,
25 options: { granularity: string },
26 ) => {
27 segment: (
28 text: string,
29 ) => Iterable<{ segment: unknown; isWordLike: boolean | undefined }>;
30 };
31 }
32 ).Segmenter("zh", {
33 granularity: "word",
34 });
35 for (const { segment, isWordLike } of segmenter.segment(text)) {
36 if (!isWordLike) {
37 continue;
38 }
39 tokens.add((segment as string).toLowerCase());
40 }
41 return tokens;
42}Source: content-utils.ts
Intl.Segmenter 是原生 Intl API,Node 运行时的类型定义可能尚未包含它,因此代码通过 Intl as unknown as {...} 的双重断言访问,并在运行时探测 "Segmenter" in Intl。不支持时回退到 toLowerCase().split(/[\s\p{P}]+/gu) 的朴素分词——源码注释明确说明这是"以避免构建报错"的降级路径。两种路径都过滤标点/空白并统一小写,返回 Set<string> 天然去重。
相似度与标签权重
1function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
2 if (a.size === 0 && b.size === 0) {
3 return 0;
4 }
5 let intersection = 0;
6 for (const item of a) {
7 if (b.has(item)) {
8 intersection++;
9 }
10 }
11 const union = a.size + b.size - intersection;
12 return union === 0 ? 0 : intersection / union;
13}Source: content-utils.ts
computeTagIDF(content-utils.ts L225 起)按源码注释给出的公式计算:IDF(tag) = log(N / (1 + df(tag))),其中 N 为总文章数、df 为包含该标签的文章数。稀有标签权重高、常见标签权重低——这与信息检索中经典 IDF 的动机一致:"Astro" 这类专属标签比 "前端" 这类泛标签更能标识文章主题。分母 1 + df 保证 df=0 时不会除零。
推荐流程(模块内推荐函数,基于 content-utils.ts L302 起 的 getCollection 调用):读取全部非草稿文章 → 对每篇候选计算(标题+描述分词集合的 Jaccard 相似度)与(共享标签的 IDF 权重和)→ 综合打分排序 → 取前 N 篇作为相关文章。推荐算法的完整打分细节在本次采集范围之外,见源文件。
API Reference
以下为 src/utils/content-utils.ts 导出的公开函数(类型签名以源码为准):
getSortedPosts(): Promise<CollectionEntry<"posts">[]>
获取排序后的完整文章数组(含 body)。排序规则:置顶在前 → 置顶内部按 priority 升序 → 其余按发布日期倒序。副作用:回写每篇文章的 prevSlug/prevTitle/nextSlug/nextTitle 内部字段。
参数:无
返回:排序后的 posts 集合条目数组
消费方:文章详情页(上下篇导航)、Feed 生成
getSortedPostsList(): Promise<PostForList[]>
获取列表页专用的轻量数据。
返回:PostForList[],其中 PostForList = { id: string; data: ...; url?: string }
副作用:调用 initPostIdMap(sortedFullPosts) 初始化 permalink 的 id 映射;丢弃 post.body;预计算 url: getPostUrl(post)
getTagList(): Promise<Tag[]>
返回:Tag[] = { name: string; count: number }[],按标签名小写 localeCompare 排序
getCategoryList(): Promise<Category[]>
返回:Category[] = { name: string; count: number; url: string }[];空分类归入 i18n "未分类";url 由 getCategoryUrl(c) 生成
模块内部函数(私有)
| 函数 | 作用 |
|---|---|
getRawSortedPosts() | 草稿过滤 + 排序,不派生 prev/next;供 getSortedPosts/getSortedPostsList 复用 |
tokenize(text) | 中英文混合分词,Intl.Segmenter 优先、正则回退,返回去重 token 集合 |
jaccardSimilarity(a, b) | 计算两个 token 集合的 Jaccard 相似度 |
computeTagIDF(allPosts) | 计算每个标签的 IDF 权重:log(N / (1 + df)) |
失败模式、边界情况与并发
- Frontmatter 校验失败:
title或published缺失、类型不符(如published: "昨天")时,Astog/Zod 在构建期直接抛错并终止构建——错误会在构建日志中指向具体文件。_publishedDateOnly/_updatedDateOnly缺失同样会校验失败,但正常路径下由postGlob加载器注入,作者无需关心。 - 草稿泄露边界:
draft: true在astro dev下可见(便于预览),但在生产构建中被 4 处getCollection谓词一致剔除。若新增页面绕过content-utils.ts直接调用getCollection("posts")且不携带该谓词,草稿会在生产环境泄露——这是扩展时的强制约定。 - 排序稳定性:
comparePublishedDatesDescending以post.id作 tie-break(见 content-utils.ts L39-L44),同日期文章顺序确定,构建可复现,避免 CI 与本地构建产物 diff 噪音。 - 空集合/空分类边界:
category为""或null均走"未分类"分支;getTagList中空tags数组不产生条目;jaccardSimilarity对两个空集合显式返回 0;computeTagIDF用1 + df规避除零。 - 分词降级:在不支持
Intl.Segmenter的运行时,中文文本退化为整段切分(仅按空白/标点分割),推荐质量下降但构建不失败——这是显式的可用性优先取舍。 - 数据可变性:
getSortedPosts会原地修改getCollection返回对象的data字段(写入 prev/next)。Astro 的 Content Layer 每次调用返回独立数据副本,因此该写入不会跨请求/跨页面互相污染;但在同一构建中缓存CollectionEntry引用再复用的代码需意识到这些字段可能已被后续调用填充。
性能与运维注意事项
post.body瘦身:列表场景务必使用getSortedPostsList()而非getSortedPosts(),前者把整篇 Markdown 源文本从传给组件的数据中剥离。文章数量增大时,这是列表页内存与序列化开销的主要控制手段。- URL 预计算:
getPostUrl(post)在getSortedPostsList中每篇只调用一次,列表渲染不再重复计算路由。 - 重复排序成本:
getRawSortedPosts每次调用都会重新getCollection+ 排序。Astro 在单次构建中会缓存 content 层数据,因此主要成本在比较函数本身(O(n log n)),对数百篇文章量级无压力。 - IDF 全局依赖:
computeTagIDF需要全量文章统计,文章数增长时推荐计算量为 O(n²)(两两比较);这是构建期一次性开销,不进入运行时。 - 新增文章的运维流程:仅需
git add src/content/posts/xxx.md并推送;无数据库、无 CMS、无迁移——内容即文件,历史即 Git 历史。
扩展点
- 新增 frontmatter 字段:在 content.config.ts 的
postsCollection.schema中追加 Zod 字段(建议optional().default()以保持向后兼容),Astro 会在下次构建自动校验并生成类型;随后可在组件中直接读取post.data.新字段。 - 新增集合:仿照
specCollection,用defineCollection + glob+ 空或自定义 schema 定义,并加入collections导出对象。 - 调整排序规则:修改
getRawSortedPosts的比较器即可全局生效(列表、标签、分类、Feed 同源)。 - 自定义加载行为:
posts集合不使用 Astro 内置glob而使用postGlob(src/loaders/post-loader.ts),若需在加载期注入/变换字段(如默认分类、slug 规范化),扩展点即在该加载器。 - 推荐算法调优:调整
computeTagIDF公式权重或替换jaccardSimilarity,均不影响数据契约。
相关链接
- 集合与 schema 定义:content.config.ts
- 内容处理工具:content-utils.ts
- 文章加载器:post-loader.ts
- URL/固定链接解析:url-utils.ts、permalink-utils.ts、post-url.ts
- 日期比较工具:post-date-utils.ts
- Feed 数据与内容渲染:feed-data.ts、content-renderer.ts
- 主题总览与配置:README.md
路由与 permalink 解析、页面加密运行时行为、Feed 生成细节分别属于各自独立的目录页,本页不展开。