1. 文章目录与文件约定
| 项 | 约定 |
|---|---|
| 存放位置 | src/content/posts/**/*.md(支持子目录,loader 的 pattern: '**/*.md') |
| 文件格式 | 仅 .md(不支持 .mdx,未来计划) |
| URL 来源 | frontmatter 的 slug(最终成为 post.id),与文件名无关 |
| 图片位置 | 任意位置,只要在 src/content/ 下即可(通过 /content-images/{hash}.ext 路由访问) |
| 派生数据 | .generated/post-index.json(由构建脚本生成,不要手改) |
文件名不是 URL:
src/content/posts/我的第一篇文章.md的 URL 由 frontmatter 里的slug决定。若 slug 是my-first-post,URL 就是/posts/my-first-post/。但文件名影响两件事:
scripts/ensure-post-slugs.js在 slug 缺失时,用title(不是文件名)生成拼音 slug;scripts/format-post-meta.js与update-post-updated.js会用文件路径做状态键。
2. Frontmatter 字段完整参数表
---
title: "示例文章"
slug: shi-li-wen-zhang
index: 0
description: "一句话摘要,会用于 SEO 与列表摘要"
category: "示例"
tags: [Markdown, 教程]
# 连载(同一主题 3 篇以上、需按顺序阅读)才填写,零散文章留空:
# series: "系列名"
# series_order: 1
published: 2026-01-01 00:01:02
updated: 2026-01-01 00:01:03
draft: false
---
| 字段 | 类型 | 必填 | 默认值 | 说明 | 校验规则(src/content.config.ts) |
|---|---|---|---|---|---|
title | string | 是 | — | 文章标题 | min(1),空字符串会构建失败 |
slug | string | 否 | 自动生成 | URL 标识,最终即 post.id | 正则 ^[a-z0-9]+(?:-[a-z0-9]+)*$,只允许小写字母/数字/单连字符;.nullish() 可省略或留空 |
index | number | 否 | 0 | 置顶权重。0 = 不置顶;>0 = 置顶,数值越大越靠前 | int().min(0),负数会构建失败 |
description | string | 是 | — | 文章描述。用于 SEO、OG、列表摘要、文章页副标题 | min(1) |
category | string | string[] | 否 | '未分类' | 分类。传数组时只取归一化后的第 1 项 | normalizeList() 后取首项 |
tags | string | string[] | 否 | '' | 标签。字符串按逗号拆分 | normalizeList():trim → 去空 → 按 toLowerCase() 去重 |
published | Date | 是 | — | 发布时间,建议格式 YYYY-MM-DD HH:mm:ss(无时区字符串按 UTC 解析) | z.preprocess(parseDateAsUtc, z.date()),非法日期报错 |
updated | Date | 否 | — | 更新时间。开启 autoUpdatePostUpdated 时由脚本按 mtime 写入 | z.preprocess(parseDateAsUtc, z.date()).optional() |
draft | boolean | 否 | false | 草稿。仅生产构建过滤,dev 下可见 | boolean().default(false) |
series | string | 否 | 无(不填=不连载) | 系列名。同一主题 ≥3 篇、需按顺序阅读时填写;零散文章不填 | z.string().trim().optional(),空串视为无系列 |
series_order | number | 否 | — | 系列内阅读顺序(从 1 开始),仅配合 series 使用 | int().min(1);缺省按发布时间升序兜底排序 |
系列使用规则:系列是与分类/标签互不影响的可选属性。判定标准——同一主题已有 3 篇以上且需按顺序阅读(如连载教程)才建系列,零散文章不填。有
series的文章会出现在/series/系列页与独立的系列总览页;文章页会显示系列信息(头部一行 + 可折叠的系列目录),底部上一篇/下一篇在系列内按series_order相邻衔接。系列内排序规则:series_order升序 → 发布时间升序兜底。
2.1 normalizeList() 的确切行为
// 输入字符串:按逗号拆分
"a, b, a" → ['a', 'b']
// 输入数组
['Markdown', 'markdown', ' 教程 '] → ['Markdown', '教程']
处理步骤:split(',')(字符串时)→ trim() → 跳过空串 → 按 toLowerCase() 去重(保留首次出现的原始大小写)→ 输出。
2.2 category 传数组会怎样
category: [技术, 前端]
归一化得到 ['技术', '前端'],然后 transform(v => normalizeList(v)[0] ?? '未分类') → 最终 category = '技术'。第二项及之后会被静默丢弃,不要依赖多分类。
2.3 日期格式的坑
published / updated 在 content.config.ts 里经 parseDateAsUtc() 处理(无时区字符串补 Z 按 UTC 解析,YAML 裸日期已是 Date 对象则直通)。以下写法的解析结果:
| 写法 | 结果 |
|---|---|
published: 2026-01-01 00:01:02 | YAML 解析为字符串,补 Z 按 UTC 解析 → 时刻 2026-01-01T00:01:02Z(项目全站采用此格式;new-post.js 生成的即是 UTC) |
published: 2026-01-01 | YAML 可能解析为 Date 对象,schema 直通,时刻为 UTC 00:00(等价于按 UTC 解析) |
published: "2026/01/01" | 依赖引擎实现,不推荐 |
建议统一用 YYYY-MM-DD HH:mm:ss(不加引号),与 scripts/new-post.js 生成的模板格式一致。脚本生成的 published 取的是当前 UTC 时间(如东八区本地 08:01:02 会写成 00:01:02);站内显示统一按 UTC(formatDate / monthDay 用 dayjs.utc() 格式化),所有访问者看到一致的时间,不随时区变化。
2.4 slug 自动生成规则
scripts/ensure-post-slugs.js 的 titleToSlug(title):
| 步骤 | 说明 |
|---|---|
| 1 | pinyin(title, { toneType: 'none', type: 'array', nonZh: 'consecutive', v: true }) → 中文转无声调拼音,非中文连续保留 |
| 2 | join(' ') → toLowerCase() |
| 3 | 非 [a-z0-9\s-] 字符替换为空格 |
| 4 | 空白折叠为 -,连续 - 折叠为单个,去首尾 - |
| 5 | 空结果回退为 'post' |
去重:若 slug 已被占用,依次尝试 {base}-1、{base}-2…;仍冲突则追加时间戳后缀(Date.now().toString(36).slice(-4))。
「示例」→ slug 是什么?
title: "Markdown语法样例"→markdown-yu-fa-yang-li(见现有文章)。中文与英文混排时英文原样保留。
3. 新建文章的三种方式
方式一:脚本(推荐)
pnpm new-post my-first-post
生成 src/content/posts/my-first-post.md:
---
title: "my-first-post"
slug:
index: 0
description: "一句话摘要"
category: "未分类"
tags: []
published: 2026-01-01 12:00:00
---
从这里开始写作。
| 行为 | 说明 |
|---|---|
| 参数 | process.argv[2],缺省为 post-{YYYY-MM-DD} |
| 已存在 | 打印 already exists: <path> 并 exit 1,不覆盖 |
| 目录 | mkdirSync(dir, { recursive: true }),目录不存在会自动创建 |
slug | 留空,构建时由 ensurePostSlugs 自动填充 |
published | 取当前 UTC 时间 |
方式二:手动创建
在 src/content/posts/ 下新建 .md,粘贴上面的模板。slug 可以留空,pnpm dev / pnpm build 启动时会自动补全并写入文件。
方式三:子目录归类
src/content/posts/
├── 教程/
│ ├── 入门.md
│ └── 进阶.md
└── 随笔/
└── 2026-01.md
loader 的 pattern: '**/*.md' 会递归扫描。相对链接的解析也支持跨目录(见#8)。
4. Markdown 语法支持矩阵
| 语法 | 支持 | 实现 | 说明 |
|---|---|---|---|
标题 #–###### | ✅ | 原生 + rehypeSlug + rehypeAutolinkHeadings | 自动加 id,悬停显示 # 锚点;左侧有层级色条 |
| 段落 / 换行 | ✅ | remarkBreaks | 单个换行即换行(不需要行尾两空格) |
| 强调 / 粗体 / 删除线 | ✅ | GFM | ~单波浪删除线~ 也支持 |
| 无序 / 有序列表 | ✅ | GFM | — |
| 任务列表 | ✅ | GFM | accent-color: var(--wp-accent) |
| 表格 | ✅ | GFM + rehypeTableWrap | 外包 .table-wrapper 支持横向滚动;首列 white-space: nowrap |
| 链接 | ✅ | 原生 + rehypeExternalLinks | 站外链接自动 target="_blank"(按 host 判断,同 host 不加) |
| 图片 | ✅ | rehypeImages | 包 .img-frame + a.img-lightbox;首图 eager,其余 lazy;注入 LQIP 渐变 |
引用块 > | ✅ | 原生 | 左侧 3px 主题色边框 + 4% 主题色底 |
| 行内代码 / 代码块 | ✅ | rehypeCodeBlock(Prism) | 带语言图标、行号、复制按钮;19 个 Prism 语法组件 |
| 水平线 | ✅ | 原生 | --- / *** / ___ |
| 脚注 | ✅ | GFM + remarkRehype | id 前缀为 post-(不是默认的 user-content-) |
| 数学公式 | ✅ | remarkMath + rehypeKatex | 行内 $…$、块级 $$…$$;KaTeX 的 CSS 按需加载(见下) |
| Emoji 短代码 | ✅ | remarkEmoji | :smile: → 😄 |
| 自动链接 | ✅ | GFM | <https://…>、www.example.com |
| 内联 HTML | ✅ 页面 / ❌ RSS | 由 Astro 的 markdown 处理器保留 raw 节点 | 页面正常输出原生标签;RSS 会被 XML 转义,见下方警告 |
| 提示容器 | ✅ | remarkContainers(自定义) | :::info / tip / warning / danger / details |
| MDX / 组件嵌入 | ❌ | — | 见 AboutPage 的「未来计划」 |
| 缩进式代码块(4 空格) | ❌ | — | 见下方说明 |
数学公式的 CSS 按需加载
katex/dist/katex.min.css不再随文章页静态引入。PostPage.astro在页面初始化时判断document.querySelector('.katex'),只有正文真的渲染出公式才把 KaTeX 的 CSS 注入<head>:ts12345const { default: css } = await import('katex/dist/katex.min.css?inline') const style = document.createElement('style') style.dataset.katexCss = '1' // 换页(Swup)后不重复注入 style.textContent = css document.head.appendChild(style)这里用
?inline取 CSS 文本而不是直接import '…css',是因为astro.config.mjs的build.inlineStylesheets: 'always'会把动态 import 出来的 CSS 资源一并内联进页面 HTML 并删掉资源文件——直接 import 会让公式页额外发出一个 404 的样式请求;取文本放进按需加载的 JS 分片里,才是真正的按需下载。因此:无公式的文章页不会下载任何 KaTeX 资源;含公式的页面多下载一个 JS 分片 + woff2 字体(构建期由
postbuild.js裁掉 ttf/woff,见 06-构建与脚本.md#5)。自查方式:DevTools → Network 过滤
katex;或performance.getEntriesByType('resource')里搜索katex。
内联 HTML 可用,但 RSS 里会被 XML 转义
页面渲染:✅ 正常。 已实测 dist/posts/markdown-yu-fa-yang-li/index.html 中 <kbd>Ctrl</kbd>、<span style="color:red;">红色文字</span> 都是以原生标签输出的(Astro 的 markdown 处理器内部开启了 allowDangerousHtml 并把 raw 节点保留到最终 HTML)。
RSS:⚠️ HTML 会被转义。 <content:encoded> 里输出的是 <p><kbd>Ctrl</kbd>…。这是 @astrojs/rss 对 content 字段做 XML 转义的既定行为(已用最小用例实测确认,与 renderMarkdownHtml 无关——后者本身输出的仍是原生 HTML)。
受影响的表现:部分阅读器会按字面显示 <p> 标签;也有阅读器会自动反转义后正常渲染。 若你重度依赖 RSS 的富文本展示,需要自行把 content:encoded 改成 CDATA 包裹,例如:
// src/pages/rss.xml.ts —— 自己拼 XML,而不是交给 @astrojs/rss
const escaped = html.replace(/]]>/g, ']]]]><![CDATA[>')
const customData = `<content:encoded><![CDATA[${escaped}]]></content:encoded>`
内联 HTML 的安全边界
Markdown 里写什么 HTML 就会输出什么(无净化)。不要在文章里粘贴来源不可信的内联 HTML/<script>——astro.config 的 rehypePlugins 里没有 rehype-sanitize。
不要用缩进创建代码块!
Markdown语法样例.md 明确提示:「请勿使用缩进 4 个空格或一个制表符来创建代码块,因为不会显示。」
原因是 remarkBreaks 把单换行转为 <br>,缩进块在解析阶段的边界行为与预期不符。请统一用围栏代码块 ```。
5. 提示容器(remarkContainers)
5.1 语法
:::info 标题可选
容器内容,支持 markdown 语法。
:::
:::tip
不写标题时,标题默认显示类型名(如 `tip`)。
:::
:::warning
警告内容
:::
:::danger
危险内容
:::
:::details 点击展开
details 是唯一可折叠的容器,标题变成 `<summary>`。
:::
5.2 五种类型对照表
| 类型标识 | 生成 class | 标题元素 | 左边框色 | 标题色变量 |
|---|---|---|---|---|
info | md-container md-container-info | <p class="md-container-title"> | #6fa8dc(写死) | --wp-info-title |
tip | md-container md-container-tip | 同上 | #7ba05b(写死) | --wp-tip-title |
warning | md-container md-container-warning | 同上 | #d9a441(写死) | --wp-warn-title |
danger | md-container md-container-danger | 同上 | #c96a5b(写死) | --wp-danger-title |
details | md-container md-container-details | <summary class="md-container-title"> | var(--wp-text-tertiary) | 默认文字色 |
5.3 解析规则(src/lib/md/remarkContainers.ts)
| 规则 | 实现 |
|---|---|
| 开启标记 | 行首 :::类型 + 可选标题(正则 ^:::([a-zA-Z]+)[ \t]*(.*)$) |
| 合法类型白名单 | CONTAINER_TYPES = new Set(['info','tip','warning','danger','details']) |
| 结束标记 | 单独一行 ::: |
| 未闭合 | 不报错,降级:内容按普通段落展开,不生成容器 |
| 类型不在白名单 | 视为普通段落,保留原样文本 |
| 标题缺省 | 显示类型名(title || type) |
| 连续行合并 | mergeLineRuns() 把相邻行用 <br> 连接成一个段落,块级节点(列表、代码块)原样保留 |
5.4 增加一个自定义容器类型(如 :::success)
需要改三个文件:
第 1 步:src/lib/md/remarkContainers.ts
const CONTAINER_TYPES = new Set(['info', 'tip', 'warning', 'danger', 'details', 'success'])
第 2 步:src/styles/markdown.css(加样式)
.post-content .md-container-success { border-left-color: #4a9d6a; }
.post-content .md-container-success .md-container-title { color: var(--wp-success-title); }
第 3 步:src/styles/main.css(加标题色的变量)
:root { --wp-success-title: #2f7048; }
.dark { --wp-success-title: #7bc39a; }
改
remarkContainers后需要重启 dev它在
astro.config.mjs里被静态 import 进 markdown 处理器,改动后 Vite 不会热更新 markdown 配置,请重启pnpm dev。
6. 代码块实现与可调参数
6.1 生成的 HTML 结构
<div class="code-block">
<div class="code-header">
<span class="code-lang"><svg …/>python</span>
<button class="code-copy" type="button" onclick="window.__blogCopyCode(this)">
<svg …/><span>复制</span>
</button>
</div>
<div class="code-body">
<div class="code-gutter"><span>1</span><span>2</span>…</div>
<pre class="code-pre"><code class="language-python">…token 化的 HTML…</code></pre>
</div>
</div>
6.2 支持的语言(Prism 语法)
src/lib/md/rehypeCodeBlock.ts 顶部 import 了这些 Prism 组件:
| Prism 语法 key | 说明 |
|---|---|
markup | HTML/XML 基础(html / xml 也走它) |
clike | C 类语法基础(被多数语言依赖) |
javascript | JS |
typescript | TS |
json | JSON |
css | CSS |
bash | Shell |
yaml | YAML |
markdown | Markdown |
python | Python |
java | Java |
c | C |
cpp | C++ |
go | Go |
rust | Rust |
sql | SQL |
diff | diff(+/- 行着色) |
ini | INI |
toml | TOML |
加一门语言:在 rehypeCodeBlock.ts 顶部追加 import 'prismjs/components/prism-xxx'(注意 Prism 组件有依赖顺序,例如 prism-jsx 需要在 prism-markup + prism-javascript 之后)。
6.3 语言图标映射
langIconMap(源码第 45–77 行):
| 语言标识(支持别名) | 图标 |
|---|---|
javascript js | mdi-language-javascript |
typescript ts | mdi-language-typescript |
json | mdi-code-json |
css | mdi-language-css3 |
html markup | mdi-language-html5 |
xml | mdi-xml |
bash shell sh zsh | mdi-bash |
yaml yml ini toml | mdi-file-cog-outline |
markdown md | mdi-language-markdown |
python py | mdi-language-python |
java | mdi-language-java |
c | mdi-language-c |
cpp c++ | mdi-language-cpp |
go golang | mdi-language-go |
rust rs | mdi-language-rust |
sql | mdi-database |
diff | mdi-file-compare |
| 其他任意语言 | mdi-code-braces(通用图标) |
加语言图标:在 langIconMap 里加映射即可(图标常量需已存在于 src/lib/icons.ts,否则 IconName 类型会报错)。
6.4 未知语言的处理
function normalizeLanguage(raw) {
const cleaned = raw.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '')
return Prism.languages[cleaned] ? cleaned : '' // 不在 Prism 里则返回空串
}
| 情况 | 结果 |
|---|---|
| 已知语言 | 正常高亮,<code class="language-{lang}"> |
未知语言(如 ```foobar) | langClass 变为 language-text,内容走 Prism.util.encode() 纯转义,不高亮 |
无语言标识(```) | 语言标签显示 text,同上 |
语言含额外信息(如 ```js:title=foo) | langIcon() 会按 [\s:{\[] 截断取首段作为图标 key |
6.5 服务端代码块的「硬编码中文复制按钮」
rehypeCodeBlock.ts里写死了「复制」源码第 110 行:
ts1`…<button class="code-copy" …>${iconSvg('mdi-content-copy')}<span>复制</span></button>…`这个
复制是构建期硬编码的中文。PostPage.astro在客户端用window.__blogCopyIdleHtml/__blogCopyDoneHtml(由 i18n 的post.copy/post.copied生成)覆盖它,所以最终显示是正确的。但有一个副作用:代码块在 JS 加载前短暂显示「复制」。若你的站点非中文且在意这个 FOUC,请把
rehypeCodeBlock.ts的复制按钮做成不含文本的空按钮(保留图标),完全交给客户端填充。
6.6 可调参数
| 想改什么 | 位置 | 当前值 |
|---|---|---|
| 代码字体大小 | markdown.css .code-body 的 --code-font-size | 0.85rem |
| 代码行高 | 同上 --code-line-height | 1.7 |
| 行号栏宽度 | .code-gutter 的 min-width | 2.8em |
| 是否显示行号 | 删掉 rehypeCodeBlock.ts 中 buildLineNumbers() 的调用与 .code-gutter 那段 HTML | 显示 |
| 复制成功提示时长 | PostPage.astro 的 setTimeout(..., 2000) | 2000ms |
| 代码块圆角 | markdown.css .code-block { border-radius } | 12px |
| 代码块头部背景 | markdown.css .code-header { background } | 亮色 rgba(255,255,255,.35) / 暗色 rgba(255,255,255,.03) |
7. 图片处理与 LQIP 占位
7.1 图片引用的三种写法
| 写法 | 转换结果 | 说明 |
|---|---|---|
 | /content-images/{sha1前16位}.webp | 相对路径,跨目录用 ../ |
 | 原样保留 | 以 / 开头 → public/ 下的文件 |
 | 原样保留 | 外链图片 |
rehypeImages 会把每个 <img> 包成:
<span class="img-frame" style="--img-lqip:linear-gradient(135deg,#aabbcc 0%,#ddeeff 50%,#112233 100%)">
<a class="img-lightbox" href="{src}" title="…">
<img src="{src}" alt="…" loading="lazy" decoding="async" />
</a>
</span>
| 细节 | 规则 |
|---|---|
| 首图 | loading="eager" fetchpriority="high"(每个文件只有第一张) |
| 其余图 | loading="lazy" decoding="async" |
data: 开头的 src | 跳过包装,不处理 |
title 属性 | 存在则同时写到 <img> 与 <a> |
7.2 LQIP(主色渐变占位)
| 环节 | 实现 |
|---|---|
| 采样 | scripts/generate-lqips.js:sharp(input).resize(2,2,{fit:'fill'}).removeAlpha().raw() 取 4 像素,再用第 1、2、4 个像素(偏移 0、3、9 字节)拼成 18 位十六进制 |
| 存储 | .generated/lqips.json:{ "/content-images/883d9fc6f4cc516e.webp": "632d1b6a301b582816" }(18 位无分隔符十六进制,3 个像素直接拼接) |
| 消费 | rehypeImages.ts 读取(带 mtime 缓存),生成 --img-lqip 的 linear-gradient(135deg, c1 0%, c2 50%, c3 100%) |
| 兜底 | 取色失败 → 无 --img-lqip → CSS 回退灰白 shimmer(markdown.css 的 .img-frame:not(.is-loaded)) |
| 强制重算 | LQIP_FORCE=1 pnpm build(PowerShell:$env:LQIP_FORCE=1; pnpm build) |
| 增量 | 默认只处理 .generated/lqips.json 里没有的 key,已存在的跳过(改了图片文件内容但路径不变时不会重算,需 LQIP_FORCE=1) |
7.3 图片路由 /content-images/[file]
src/pages/content-images/[file].ts 是一个 prerender = true 的静态路由:
| 项 | 说明 |
|---|---|
| 输入 | params.file = contentImageName(rel) = sha1(rel).slice(0,16) + ext |
| 枚举 | getStaticPaths() 递归遍历 src/content/ 下所有图片文件(9 种扩展名) |
| 输出 | 文件二进制 + Content-Type |
| 缓存 | 生产 public, max-age=31536000, immutable;开发 public, max-age=3600 |
| 未找到 | 404 |
支持的图片扩展名:.webp .png .jpg .jpeg .gif .svg .avif .ico .bmp
为什么用 hash 而不是原文件名?
好处是避免中文文件名 URL 编码问题、天然去重、可长缓存(文件内容变了路径不变,但通过重新构建会有新 hash… 注意:hash 只基于相对路径,不基于内容。改了图片内容但路径不变时 hash 不变,浏览器可能命中长缓存。此时需要清理缓存或改名)。
7.4 灯箱(PhotoSwipe)
由 PostPage.astro 的 initPhotoswipe() 初始化:
| 参数 | 值 | 说明 |
|---|---|---|
gallery | '.post-content' | 图库容器 |
children | 'a.img-lightbox' | 可点击项 |
showHideAnimationType | 'zoom' | 缩放动画 |
imageClickAction | 'next' | 点击图片切换到下一张(而不是关闭) |
bgOpacity | 0.85 | 遮罩透明度 |
| 自定义 UI | custom-caption(order 9) | 底部居中白字显示 {宽} × {高} |
| 尺寸获取 | 优先 img.naturalWidth,否则等 load;最多等 2500ms | |
| 尺寸写入 | a.dataset.pswpWidth / pswpHeight(已写过则跳过) | |
| 懒初始化 | 通过 requestIdleCallback(超时 1000ms)或双 rAF + setTimeout 降级 | |
| 依赖加载 | import('photoswipe/lightbox') + import('photoswipe') 动态引入(已加入 optimizeDeps.include) |
7.5 图片骨架状态机
| class | 时机 | 样式 |
|---|---|---|
| (初始) | — | background: var(--wp-surface-hover) + LQIP 渐变 + img-shimmer 动画 |
is-loaded | img.complete && naturalWidth > 0,或 load 事件 | 取消动画,width: fit-content,图片淡入(opacity 0.35s) |
is-error | error 事件 | 隐藏 <img>,容器高度降到 3rem |
8. 相对链接自动转换
8.1 支持的写法
| 写法 | 转换结果 |
|---|---|
[文本](./其他文章.md) | /posts/{其他文章的slug}/ |
[文本](./其他文章.md#某标题) | /posts/{slug}/#某标题 |
[文本](../posts/其他.md?query=1) | /posts/{slug}/?query=1 |
 | /content-images/{hash}.webp |
[不支持](../data/file.json) | 保持原样 + 构建期警告 |
8.2 解析流程(src/lib/md/rehypeRelativeLinks.ts)
1. 只处理 isRelativeSrc(href) 的链接(非 http(s)/非 //、非 /、非 #、非 data:)
2. 用 [?#] 拆分 pathPart 与 suffix(后缀会拼回结果)
3. resolveContentPath(pathPart, filePath)
→ 以 src/content/ 为根做安全拼接,遇到 .. 出界返回 null(并警告)
4. 查 .generated/post-index.json:
- 命中 → 用 entry.slug
- 未命中但扩展名是 .md/.markdown → 直接读文件 frontmatter 取 slug
→ 输出 /posts/{slug}/{suffix}
5. 若是图片扩展名 → resolveContentImage → /content-images/{hash}.ext
6. 其他 → 保持原样 + 警告
8.3 构建期警告一览
| 警告文本 | 触发条件 |
|---|---|
relative link escapes src/content/, kept as-is | ../ 超出了 src/content/ 根 |
links to a draft post which is not built in production | 链接到 draft: true 的文章,且当前是生产构建 |
target post not found, kept as-is | .md 目标文件不存在或没有可用 slug |
unsupported relative link target, kept as-is | 目标既不是文章也不是已知图片类型 |
为什么链接失效只警告不报错?
因为预取(Swup preload)会请求该 URL 返回 404,但不影响页面正常显示。项目选择「构建期警告 + 保留原样」的宽松策略。请留意构建输出,及时修正。
9. 文章页面的 TOC(目录)
9.1 数据来源
render(post) 返回的 headings(Astro 内置,来自 rehypeSlug 生成的 id 与标题文本)。
| 变量 | 计算 |
|---|---|
hasToc | headings.length > 0 |
tocMinLevel | Math.min(...headings.map(h => h.depth)),无标题时回退 2 |
| 缩进 | padding-left: ${12 + (h.depth - tocMinLevel) * 14}px |
9.2 两种呈现
| 形态 | 触发条件 | DOM |
|---|---|---|
| 桌面侧边固定 | xl(≥1280px)显示(hidden xl:block) | #toc-affix > .toc-affix > .toc-affix-list |
| 移动端弹窗 | 点右下 #toc-fab 打开 | #toc-modal |
| 右下按钮 | ScrollControls.css 里 @media (min-width: 1280px) { .toc-btn { display: none } } | #toc-fab |
9.3 可调参数(PostPage.astro 内常量)
| 常量 | 值 | 作用 |
|---|---|---|
TOC_OFFSET | 80 | 锚点滚动时距顶部的偏移(px),同时也是「当前标题」判定阈值基准(+16) |
TOC_SNAP_RANGE | 80 | 「吸附」范围:标题进入 [threshold, threshold+80] 区间时优先选中 |
tocScrollDelta 的 pad | 12 | 侧栏自动滚动时距边缘的留白 |
| 侧栏宽度 | .toc-affix 的 w-52 | 13rem |
| 侧栏左偏移 | PostPage.css 的 left: calc(50% + 26rem) | 贴住玻璃卡片右缘:外框 max-w-[832px] 的一半 = 416px = 26rem |
| 侧栏顶部 | .toc-affix { top: 96px } | — |
| 侧栏最大高度 | max-height: calc(100vh - 190px) | — |
改内容区宽度要同步 TOC
BaseLayout的<main class="max-w-[832px]">与PostPage.css的left: calc(50% + 26rem)是耦合的。832px是玻璃卡片外框宽度(正文内容 800px + 左右px-4各 16px ),而26rem = 416px = 832 / 2——侧栏是fixed定位,50%从视口中线起算,偏移半个外框宽度即贴住卡片右缘。换算公式:内容宽改为
W时,外框 =W + 32,侧栏偏移 =calc(50% + (W + 32) / 2 px)。例如内容 900px → 外框 932px → 偏移 466px ≈calc(50% + 29rem)。
9.4 高亮与滚动逻辑
| 环节 | 实现 |
|---|---|
| 监听 | window 的 scroll(passive)+ requestAnimationFrame 节流 |
| 算法 | 遍历 .post-content 内 h1..h6:记录最后一个「顶部已越过 threshold」的标题(currentByPassed);同时找 [threshold, threshold+80] 区间内最近的标题(snapId);优先 snapId,否则 currentByPassed,再否则第一个标题 |
| 高亮 | 给所有 .toc-link 切换 toc-active 类(PostPage.css 定义样式:主题色 + 左侧边框 + 7%/10% 主题色背景) |
| 自动滚动 | tocScrollDelta() 计算需要滚动的像素,list.scrollTop += delta |
| 点击跳转 | 拦截 a.toc-link 点击 → smoothScrollToHeading() → history.replaceState 更新 hash → 关闭移动端弹窗 |
| 锚点容错 | findAnchorTarget(id) 依次尝试:原始 id → decodeURIComponent(id) → encodeURIComponent(id) |
上表「点击跳转」只针对同页目录链接。跨页面的锚点链接(如 markdown 相对链接带
#)由BaseLayout的点击委托接管:SPA 导航到页顶 → 加载后按同样的 80px 偏移平滑滚动(含直接打开#深链接场景),详见 07-页面与路由.md#84。
10. 内容创作完整流程示例
# 1. 新建文章
pnpm new-post my-post
# 2. 编辑 src/content/posts/my-post.md
# 填 title / description / category / tags / published
# slug 留空,构建时自动生成
# 3. 元数据排序(可选,但推荐)
pnpm format-post-meta
# → 输出:formatted N posts 或 all post meta already in canonical order
# 4. 本地预览
pnpm dev
# → 访问 http://localhost:4321
# 5. 构建(会补 slug、生成 LQIP、生成搜索索引)
pnpm build
# 6. 检查构建警告(相对链接、LQIP 取色失败等)
# 7. 预览产物
pnpm preview
# 8. 提交(先 build 再 commit,让 slug/updated 改动一起进版本库)
git add -A && git commit -m "post: my-post"
pnpm format-post-meta 的字段顺序
scripts/format-post-meta.js 定义的规范顺序:
order = ['title', 'slug', 'index', 'description', 'category', 'tags', 'published']
last = ['updated', 'draft']
最终顺序:order 中存在的字段(按序)→ 其他未列出的自定义字段(保持原相对顺序)→ last 中存在的字段(按序)。
| 特性 | 说明 |
|---|---|
| 幂等 | 若顺序已正确则跳过(不写文件),输出 all post meta already in canonical order |
| 换行符保留 | 检测原文件是 \r\n 还是 \n 并沿用 |
| 多行值支持 | 按 ^([A-Za-z_][\w-]*): 识别字段起始,后续非字段行归入当前字段 |
| 不改内容 | 只重排,不修改值 |
加了自定义 frontmatter 字段(如
cover)后,它会落在「其他字段」区;想固定位置请把它加入ORDER或LAST。