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/。

但文件名影响两件事:

  1. scripts/ensure-post-slugs.js 在 slug 缺失时,用 title(不是文件名)生成拼音 slug;
  2. scripts/format-post-meta.js 与 update-post-updated.js 会用文件路径做状态键。

2. Frontmatter 字段完整参数表

markdown
1234567891011121314
---
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)
titlestring是—文章标题min(1),空字符串会构建失败
slugstring否自动生成URL 标识,最终即 post.id正则 ^[a-z0-9]+(?:-[a-z0-9]+)*$,只允许小写字母/数字/单连字符;.nullish() 可省略或留空
indexnumber否0置顶权重。0 = 不置顶;>0 = 置顶,数值越大越靠前int().min(0),负数会构建失败
descriptionstring是—文章描述。用于 SEO、OG、列表摘要、文章页副标题min(1)
categorystring | string[]否'未分类'分类。传数组时只取归一化后的第 1 项normalizeList() 后取首项
tagsstring | string[]否''标签。字符串按逗号拆分normalizeList():trim → 去空 → 按 toLowerCase() 去重
publishedDate是—发布时间,建议格式 YYYY-MM-DD HH:mm:ss(无时区字符串按 UTC 解析)z.preprocess(parseDateAsUtc, z.date()),非法日期报错
updatedDate否—更新时间。开启 autoUpdatePostUpdated 时由脚本按 mtime 写入z.preprocess(parseDateAsUtc, z.date()).optional()
draftboolean否false草稿。仅生产构建过滤,dev 下可见boolean().default(false)
seriesstring否无(不填=不连载)系列名。同一主题 ≥3 篇、需按顺序阅读时填写;零散文章不填z.string().trim().optional(),空串视为无系列
series_ordernumber否—系列内阅读顺序(从 1 开始),仅配合 series 使用int().min(1);缺省按发布时间升序兜底排序

系列使用规则:系列是与分类/标签互不影响的可选属性。判定标准——同一主题已有 3 篇以上且需按顺序阅读(如连载教程)才建系列,零散文章不填。有 series 的文章会出现在 /series/ 系列页与独立的系列总览页;文章页会显示系列信息(头部一行 + 可折叠的系列目录),底部上一篇/下一篇在系列内按 series_order 相邻衔接。系列内排序规则:series_order 升序 → 发布时间升序兜底。

2.1 normalizeList() 的确切行为

ts
1234
// 输入字符串:按逗号拆分
"a, b, a"        → ['a', 'b']
// 输入数组
['Markdown', 'markdown', ' 教程 '] → ['Markdown', '教程']

处理步骤:split(',')(字符串时)→ trim() → 跳过空串 → 按 toLowerCase() 去重(保留首次出现的原始大小写)→ 输出。

2.2 category 传数组会怎样

yaml
1
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:02YAML 解析为字符串,补 Z 按 UTC 解析 → 时刻 2026-01-01T00:01:02Z(项目全站采用此格式;new-post.js 生成的即是 UTC)
published: 2026-01-01YAML 可能解析为 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):

步骤说明
1pinyin(title, { toneType: 'none', type: 'array', nonZh: 'consecutive', v: true }) → 中文转无声调拼音,非中文连续保留
2join(' ') → 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. 新建文章的三种方式

方式一:脚本(推荐)

bash
1
pnpm new-post my-first-post

生成 src/content/posts/my-first-post.md:

markdown
1234567891011
---
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 启动时会自动补全并写入文件。

方式三:子目录归类

text
123456
src/content/posts/
├── 教程/
│   ├── 入门.md
│   └── 进阶.md
└── 随笔/
    └── 2026-01.md

loader 的 pattern: '**/*.md' 会递归扫描。相对链接的解析也支持跨目录(见#8)。

4. Markdown 语法支持矩阵

语法支持实现说明
标题 #–######✅原生 + rehypeSlug + rehypeAutolinkHeadings自动加 id,悬停显示 # 锚点;左侧有层级色条
段落 / 换行✅remarkBreaks单个换行即换行(不需要行尾两空格)
强调 / 粗体 / 删除线✅GFM~单波浪删除线~ 也支持
无序 / 有序列表✅GFM—
任务列表✅GFMaccent-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 + remarkRehypeid 前缀为 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>:

ts
12345
const { 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> 里输出的是 &lt;p&gt;&lt;kbd&gt;Ctrl&lt;/kbd&gt;…。这是 @astrojs/rss 对 content 字段做 XML 转义的既定行为(已用最小用例实测确认,与 renderMarkdownHtml 无关——后者本身输出的仍是原生 HTML)。

受影响的表现:部分阅读器会按字面显示 <p> 标签;也有阅读器会自动反转义后正常渲染。 若你重度依赖 RSS 的富文本展示,需要自行把 content:encoded 改成 CDATA 包裹,例如:

ts
123
// 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 语法

markdown
12345678910111213141516171819
:::info 标题可选
容器内容,支持 markdown 语法。
:::

:::tip
不写标题时,标题默认显示类型名(如 `tip`)。
:::

:::warning
警告内容
:::

:::danger
危险内容
:::

:::details 点击展开
details 是唯一可折叠的容器,标题变成 `<summary>`。
:::

5.2 五种类型对照表

类型标识生成 class标题元素左边框色标题色变量
infomd-container md-container-info<p class="md-container-title">#6fa8dc(写死)--wp-info-title
tipmd-container md-container-tip同上#7ba05b(写死)--wp-tip-title
warningmd-container md-container-warning同上#d9a441(写死)--wp-warn-title
dangermd-container md-container-danger同上#c96a5b(写死)--wp-danger-title
detailsmd-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

ts
1
const CONTAINER_TYPES = new Set(['info', 'tip', 'warning', 'danger', 'details', 'success'])

第 2 步:src/styles/markdown.css(加样式)

css
12
.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(加标题色的变量)

css
12
: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 结构

html
123456789101112
<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说明
markupHTML/XML 基础(html / xml 也走它)
clikeC 类语法基础(被多数语言依赖)
javascriptJS
typescriptTS
jsonJSON
cssCSS
bashShell
yamlYAML
markdownMarkdown
pythonPython
javaJava
cC
cppC++
goGo
rustRust
sqlSQL
diffdiff(+/- 行着色)
iniINI
tomlTOML

加一门语言:在 rehypeCodeBlock.ts 顶部追加 import 'prismjs/components/prism-xxx'(注意 Prism 组件有依赖顺序,例如 prism-jsx 需要在 prism-markup + prism-javascript 之后)。

6.3 语言图标映射

langIconMap(源码第 45–77 行):

语言标识(支持别名)图标
javascript jsmdi-language-javascript
typescript tsmdi-language-typescript
jsonmdi-code-json
cssmdi-language-css3
html markupmdi-language-html5
xmlmdi-xml
bash shell sh zshmdi-bash
yaml yml ini tomlmdi-file-cog-outline
markdown mdmdi-language-markdown
python pymdi-language-python
javamdi-language-java
cmdi-language-c
cpp c++mdi-language-cpp
go golangmdi-language-go
rust rsmdi-language-rust
sqlmdi-database
diffmdi-file-compare
其他任意语言mdi-code-braces(通用图标)

加语言图标:在 langIconMap 里加映射即可(图标常量需已存在于 src/lib/icons.ts,否则 IconName 类型会报错)。

6.4 未知语言的处理

ts
1234
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 行:

ts
1
`…<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-size0.85rem
代码行高同上 --code-line-height1.7
行号栏宽度.code-gutter 的 min-width2.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 图片引用的三种写法

写法转换结果说明
![alt](../images/logo.webp)/content-images/{sha1前16位}.webp相对路径,跨目录用 ../
![alt](/avatar.webp)原样保留以 / 开头 → public/ 下的文件
![alt](https://example.com/a.png)原样保留外链图片

rehypeImages 会把每个 <img> 包成:

html
12345
<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'点击图片切换到下一张(而不是关闭)
bgOpacity0.85遮罩透明度
自定义 UIcustom-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-loadedimg.complete && naturalWidth > 0,或 load 事件取消动画,width: fit-content,图片淡入(opacity 0.35s)
is-errorerror 事件隐藏 <img>,容器高度降到 3rem

8. 相对链接自动转换

8.1 支持的写法

写法转换结果
[文本](./其他文章.md)/posts/{其他文章的slug}/
[文本](./其他文章.md#某标题)/posts/{slug}/#某标题
[文本](../posts/其他.md?query=1)/posts/{slug}/?query=1
![图](../images/logo.webp)/content-images/{hash}.webp
[不支持](../data/file.json)保持原样 + 构建期警告

8.2 解析流程(src/lib/md/rehypeRelativeLinks.ts)

text
12345678910
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 与标题文本)。

变量计算
hasTocheadings.length > 0
tocMinLevelMath.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_OFFSET80锚点滚动时距顶部的偏移(px),同时也是「当前标题」判定阈值基准(+16)
TOC_SNAP_RANGE80「吸附」范围:标题进入 [threshold, threshold+80] 区间时优先选中
tocScrollDelta 的 pad12侧栏自动滚动时距边缘的留白
侧栏宽度.toc-affix 的 w-5213rem
侧栏左偏移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)。

参考 03-主题与样式定制.md#53-常用排版参数调整位置

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. 内容创作完整流程示例

bash
123456789101112131415161718192021222324
# 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 定义的规范顺序:

js
12
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。