项目共有 6 个 Node 脚本(scripts/*.js),其中 3 个作为 Vite 插件在构建期自动执行,1 个在 astro build 之后执行,2 个是手动命令。

1. 脚本总览

脚本触发方式触发时机是否改写源文件产物
ensure-post-slugs.jsVite 插件(buildStart)构建 / dev 启动✅ 补写 slug.generated/post-index.json
generate-lqips.jsVite 插件(buildStart)构建 / dev 启动❌.generated/lqips.json
update-post-updated.jsVite 插件(buildStart,受配置开关控制)构建 / dev 启动✅ 写 updated.generated/post-updated.json
postbuild.jspnpm build 的第二段命令astro build 之后改写 dist/**/*.html内联脚本压缩 + modulepreload + pagefind 索引
new-post.jspnpm new-post [slug]手动✅ 新建文件src/content/posts/{slug}.md
format-post-meta.jspnpm format-post-meta手动✅ 重排 frontmatter—

三个脚本会改写你的 markdown 源文件

ensure-post-slugs、update-post-updated、format-post-meta 都会写文件。建议:

  • 先构建再提交(pnpm build && git commit),让自动改动一次进版本库;
  • 若 CI 环境是只读文件系统,请关闭 siteConfig.autoUpdatePostUpdated(默认已关闭),并确保所有文章已有 slug。

2. scripts/ensure-post-slugs.js

2.1 职责

  1. 扫描 src/content/posts/**/*.md;
  2. 对没有 slug 的文章,用 title 生成拼音 slug 并写回文件;
  3. 生成 .generated/post-index.json 供 rehypeRelativeLinks 使用。

2.2 参数

常量值说明
CONTENT_ROOTsrc/content内容根目录
POSTS_DIRsrc/content/posts扫描起点(递归)
INDEX_FILE.generated/post-index.json输出(目录不存在会自动创建)
SLUG_RE/^[a-z0-9]+(?:-[a-z0-9]+)*$/slug 合法性校验,与 content.config.ts 的正则一致

2.3 titleToSlug(title) 的 pinyin 参数

ts
123456
pinyin(String(title), {
  toneType: 'none',      // 不带声调:zhōng → zhong
  type: 'array',         // 返回数组,便于逐段处理
  nonZh: 'consecutive',  // 非中文连续保留成一段(英文单词不会被拆开)
  v: true,               // ü 转成 v(如 lü → lv)
})

随后:join(' ') → toLowerCase() → 非 [a-z0-9\s-] 变空格 → 空白与连续 - 折叠 → 去首尾 - → 空则 'post'。

2.4 查重与写入

情况处理
slug 已被其他文章占用依次尝试 {base}-1、{base}-2…
仍冲突追加 Date.now().toString(36).slice(-4)
slug 不合法(正则不过)直接置为 'post'
文件原本有 slug: 行(空值)原地替换该行
文件没有 slug: 行插到 title: 行的下一行
文件没有 title: 行插到开头 --- 之后
换行符检测到 \r\n 则沿用 CRLF

控制台输出:[post-slug] slug filled: 文件名 -> slug

2.5 post-index.json 结构

json
123
[
  { "file": "posts/Markdown语法样例.md", "slug": "markdown-yu-fa-yang-li", "draft": false }
]
字段说明
file相对 src/content 的路径(正斜杠,跨平台一致)
slug文章 slug
draft是否草稿(供相对链接在生成环境给出警告)

排序:按 file 字典序。内容未变化时不重写文件(对比后写入)。

手动运行:该文件没有直接运行入口(不像 generate-lqips.js 有 process.argv[1] 判断)。直接 node scripts/ensure-post-slugs.js 只会 import 模块——它仍然会执行模块顶层语句(mkdirSync 创建 .generated/ 目录),但不会扫描或改写任何文章。要单跑请调用工厂:

js
12
import { ensurePostSlugs } from './scripts/ensure-post-slugs.js'
ensurePostSlugs().buildStart()

3. scripts/generate-lqips.js

3.1 职责

扫描文章里的图片引用,采样 3 个主色,写入 .generated/lqips.json,供 rehypeImages 生成首屏渐变占位。

3.2 参数与环境变量

名称值说明
LQIP_FORCE环境变量,'1' 时强制全量默认增量(跳过已存在的 key)
IMG_SRC_RE/!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g匹配 ![alt](src "title")
远程图片超时8000ms(fetchBuffer 的 AbortController)超时即跳过该图
OUT_FILE.generated/lqips.json输出
支持扩展名.webp .png .jpg .jpeg .gif .svg .avif .ico .bmp其他类型不处理

3.3 处理流程

text
12345678910111213141516171819202122
1. 遍历 src/content/posts 下所有 .md

2. stripFencedCode()  去掉 ``` 围栏代码块(避免把示例里的图片语法当真)

3. stripInlineCode()  去掉 `行内代码`

4. 正则提取所有图片 src:
   - data: 开头        → 跳过
   - http(s):// 开头   → 作为远程 URL 目标(kind: 'url')
   - / 或 # 或 scheme  → 跳过(public 资源与锚点不采样)
   - 其他(相对路径)  → 解析成本地绝对路径,算 contentImageKey

5. 跳过 existing 里已有的 key(增量)

6. 逐个处理:
   - 本地文件 → sharp 直接读
   - 远程 URL → fetch(8s 超时)→ Buffer

7. sampleColors(): resize(2,2,{fit:'fill'}).removeAlpha().raw()
   → 取字节 0..3、3..6、9..12 三个像素的 RGB 十六进制拼接(共 18 位)

8. 过滤掉已不在 targets 里的旧 key,按 key 排序后写文件

3.4 输出格式

值是18 位无分隔符的小写十六进制字符串(3 个像素的 RGB 依次直接拼接,没有空格):

json
123
{
  "/content-images/883d9fc6f4cc516e.webp": "632d1b6a301b582816"
}

拆解:632d1b(第 1 像素)+ 6a301b(第 2 像素)+ 582816(第 4 像素)。

rehypeImages.ts 消费时按 18 位长度校验,切成 3 个颜色生成 linear-gradient(135deg, c1 0%, c2 50%, c3 100%)。

3.5 控制台输出与失败处理

输出含义
[lqip] processed N (ok M), total K entries处理了 N 张,成功 M 张,累计 K 条(N=本次待处理数,含失败)
[lqip] incremental hit, total K entries, nothing new无新增
[lqip] sampling failed, skipped (runtime falls back to gray gradient): …单张失败,运行时降级灰渐变
[lqip] generation failed, building without LQIP fallback skeleton: …整体异常,构建继续(不阻断)

3.6 常见操作

需求命令
新增图片后重算pnpm build(自动增量)
替换图片内容但路径不变$env:LQIP_FORCE=1; pnpm build(PowerShell)/ LQIP_FORCE=1 pnpm build(bash)
单跑脚本node scripts/generate-lqips.js(有直接运行入口)
完全关闭 LQIP在 astro.config.mjs 的 vite.plugins 中移除 generateLqips()(图片退化为灰白 shimmer)

4. scripts/update-post-updated.js

4.1 职责

按文章文件的 mtime 同步 frontmatter 的 updated 字段。仅在 buildStart 执行(构建、dev 启动各一次),dev 运行中保存文章不会触发,避免保存过程中的中间态污染。

4.2 开关

astro.config.mjs:

js
1
updatePostUpdated({ enabled: siteConfig.autoUpdatePostUpdated })

enabled 为 false 时函数返回 null,Vite 忽略该插件(当前默认关闭)。

4.3 参数

常量值
POSTS_DIRsrc/content/posts
STATE_FILE.generated/post-updated.json
时间戳格式YYYY-MM-DD HH:mm:ss(UTC 时间)

4.4 状态文件与幂等机制

json
123
{
  "MarkDown元数据说明.md": { "mtime": 1788447321747, "stamp": "2026-09-03 22:55:21" }
}

mtime 是毫秒时间戳,stamp 是同一时刻的 UTC 时间字符串(用 getUTC* 格式化,不依赖构建机时区;示例取自仓库真实的 .generated/post-updated.json)。

判断行为
状态里的 mtime 与当前文件 mtime 相同说明同步后文件没被再保存 → 跳过
mtime 不同读文件 → 生成新 stamp → 比较现有 updated → 不同则写入
现有 updated 已等于新 stamp只更新状态,不写文件
文件无 frontmatter记录状态并跳过
状态里有但文件已删除的条目buildStart 时清理

为什么用「mtime 快照」而不是把 mtime 改回去?

源码注释解释得很清楚:写入 updated 本身会刷新文件 mtime,所以需要记录「写入后的 mtime」来判断是否被再次保存。

不能写完再把 mtime 改回原值——Git for Windows 的索引按秒比对 mtime,恢复后字节数相同的时间戳变更会被 git 误判为「未修改」,导致改动提交不上去。

4.5 写入位置

情况处理
已有 updated: 行原地替换
无 updated: 行,有 draft: 行插到 draft: 之前(与 format-post-meta 的规范顺序一致)
无 updated: 也无 draft:追加到 frontmatter 末尾

换行符沿用原文件。

控制台输出:[post-updated] 文件名 -> 2026-09-15 20:49:56(每篇一行)、[post-updated] synced N posts(汇总)。

4.6 启用建议

谁建议
本地写作,用 git 管理可以开启,方便自动维护更新时间
CI / 容器化构建保持关闭(构建产物不应产生源文件改动,且 CI 上的文件系统可能只读)
手工维护 updated保持关闭

5. scripts/postbuild.js

5.1 职责(四件事,并行 + 串行结合)

text
123456
main()
 ├─ 并行:spawn('pagefind', ['--site', 'dist'])        ← 生成站内搜索索引
 └─ 串行:遍历 dist/**/*.html
            ├─ minifyHtml()      压缩内联脚本(esbuild)
            └─ injectPreloads()  注入 modulepreload
 └─ 串行:trimKatexFonts()                             ← 删除 dist/_astro 里 KaTeX 的 ttf/woff,只留 woff2

5.2 参数

常量 / 选项值说明
DIST_DIRpath.resolve(process.cwd(), 'dist')不存在时打印 [postbuild] dist/ not found, run astro build first 并 exitCode = 1
SCRIPT_RE/<script([^>]*)>([\s\S]*?)<\/script>/gi匹配所有 script 块
JS_TYPES'', text/javascript, application/javascript, module只有这些 type 会被压缩
shouldMinify含 src= → 跳过;type 不在 JS_TYPES → 跳过外链脚本与 JSON-LD(application/ld+json)不压缩
esbuild target'es2018'压缩目标语法版本
esbuild minifyWhitespacetrue—
esbuild minifySyntaxtrue—
esbuild minifyIdentifiersfalse不改标识符名(避免破坏内联脚本里的具名引用)
安全回退压缩结果含 </script → 放弃压缩,保留原文防止提前闭合标签
DEPS_RE/__vite__mapDeps=\(i,m=__vite__mapDeps,d=\(m\.f||\(m\.f=\[([^\]]*)\]\)/解析 Vite 生成的依赖映射
pagefind 命令pagefind --site dist,Windows 下 shell: true读取项目根 pagefind.yml
KATEX_LEGACY_FONT_RE/^KaTeX_.*\.(ttf|woff)$/字体裁剪只匹配 ttf / woff,.woff2 保留(现代浏览器只取 woff2)

5.3 输出统计

text
1234
[postbuild] minified N inline scripts, saved X KiB; injected M preload links (T/N HTML files touched)
[postbuild] trimmed K KaTeX font files (ttf/woff, woff2 kept), saved Y KiB
[postbuild] dist total A MiB (F files), _astro B KiB (G files)
[postbuild] top 5: <相对路径> <体积> | …
字段含义
N被压缩的内联脚本数量
X KiB累计节省字节
M注入的 modulepreload 链接数
T/N被改写 / 总 HTML 文件数
K / Y KiB被删除的 KaTeX ttf/woff 数量与节省字节;已裁剪过时打印 no ttf/woff to trim (already trimmed)(幂等)
A / Fdist/ 全部产物的总字节与文件数(含 Pagefind 索引、图片、public/music/ 音频等正式资源)
B / G其中 dist/_astro/ 哈希资源的字节与文件数
top 5全量产物里最大的 5 个文件(相对 dist/ 的路径 + 体积)

5.4 体积预算

项值
阈值BUDGET_BYTES = 60 * 1024 * 1024(60 MiB)
统计范围dist/ 全量产物,包含音频与图片(不做优化或排除)
超阈值行为console.warn 打印 budget exceeded: <总量> > 60.0 MiB (audio included) 并逐条列出 top 5;仅告警,不阻断构建(exit code 仍为 0)
观察方式每次 pnpm build 都会打印总量与 top 5,用于观察趋势、及时发现异常膨胀

预算实测与验证

V1.0.9 基线本地实测 dist total 6.3 MiB (122 files)、_astro 499.1 KiB (50 files)。触发告警的验证方式是临时把一个 58 MiB 文件放进 public/ 后构建:总量 64.3 MiB → 输出 budget exceeded 与 top 5 明细(首条即该文件),删除后重建恢复正常。计划书原写「6 MB 文件」是按更大的历史产物量估算,当前基线需约 54 MiB 以上才会越过 60 MiB 门槛。

5.5 失败处理

字体裁剪与 KaTeX 按需加载配套

文章页只在正文真的渲染出 .katex 时才动态 import('katex/dist/katex.min.css')(见 05-内容与Markdown.md)。官方 CSS 的 @font-face 按 woff2 → woff → ttf 排列,后两者只是老浏览器兜底,因此构建后直接删除,dist/_astro 里的 KaTeX 字体约 250 KiB。

5.4 失败处理

失败点行为
单个脚本压缩失败console.warn 并保留原脚本,继续
pagefind 启动失败打印 [postbuild] pagefind failed to start: 并 exitCode = 1
pagefind 非 0 退出打印 [postbuild] pagefind exited with code N 并 exitCode = 1
dist 不存在提示先跑 astro build,exitCode = 1

为什么需要 modulepreload 注入?

Vite 默认只对入口 chunk 输出 <link rel="modulepreload">,动态 import 的 chunk 依赖需要浏览器在运行时发现。injectPreloads 读取每个 module script 引用的 chunk,从里面的 __vite__mapDeps 数组解析出依赖列表,提前写进 <head>,从而减少瀑布式请求。

修改 pagefind.yml 后必须重新构建

pagefind 只在 postbuild.js 里被调用。改 pagefind.yml 后需要 pnpm build 才能反映到索引。

6. scripts/new-post.js

项说明
命令pnpm new-post [slug]
参数process.argv[2];缺省为 post-{YYYY-MM-DD}
输出路径src/content/posts/{slug}.md
已存在打印 already exists: <绝对路径>,process.exit(1)
目录mkdirSync(dir, { recursive: true })

模板:

markdown
1234567891011121314
---
title: "{slug}"
slug:
index: 0
description: "一句话摘要"
category: "未分类"
tags: []
# 连载(同一主题 3 篇以上、需按顺序阅读)时才填写,零散文章留空:
# series: "系列名"
# series_order: 1
published: {当前 UTC 时间,YYYY-MM-DD HH:mm:ss}
---

从这里开始写作。

成功后打印 created: <绝对路径>。

7. scripts/format-post-meta.js

项说明
命令pnpm format-post-meta
作用统一重排所有文章 frontmatter 的字段顺序
幂等顺序已正确时不写文件
输出formatted: <相对路径>(每篇)+ formatted N posts 或 all post meta already in canonical order

字段顺序规则

js
12
const ORDER = ['title', 'slug', 'index', 'description', 'category', 'tags', 'published']
const LAST  = ['updated', 'draft']

最终顺序 = ORDER 中存在的 → 其他未列出的自定义字段(保持原相对顺序)→ LAST 中存在的。

实现细节说明
字段识别^([A-Za-z_][\w-]*):,非字段行归入上一个字段(支持多行数组等)
重复 key合并到首次出现的位置
换行符沿用原文件
无 frontmatter跳过

8. 构建产物结构

text
12345678910111213141516171819202122232425262728293031
dist/
├── index.html                        # 首页(第 1 页)
├── 2/index.html 3/index.html …       # 首页分页(第 1 页除外)
├── 404.html
├── about/index.html
├── archive/index.html
├── categories/index.html
├── categories/{分类名}/index.html
├── tags/index.html
├── tags/{标签名}/index.html
├── friends/index.html
├── posts/{slug}/index.html
├── content-images/{sha1前16位}.{ext} # 文章图片
├── pagefind/                         # 搜索索引(postbuild 生成,以下为实测产物)
│   ├── pagefind.js                     # 检索入口(SearchModal 动态 import 的就是它)
│   ├── pagefind-ui.js  pagefind-ui.css  pagefind-highlight.js
│   ├── pagefind-modular-ui.js  pagefind-modular-ui.css
│   ├── pagefind-component-ui.js  pagefind-component-ui.css
│   ├── pagefind-entry.json  pagefind-worker.js  wasm.unknown.pagefind
│   ├── pagefind.{lang}_{hash}.pf_meta
│   ├── filter/    {lang}_{hash}.pf_filter
│   ├── fragment/  {lang}_{hash}.pf_fragment   # 注意:在子目录里
│   └── index/     {lang}_{hash}.pf_index      # 注意:在子目录里
├── rss.xml
├── robots.txt
├── sitemap-index.xml
├── sitemap-0.xml
├── _astro/                           # 带内容哈希的 JS/CSS
├── avatar.webp  logo.webp  favicon.ico
├── friends/logo.webp
└── music/                            # public/ 原样拷贝

8.1 缓存策略

本项目是纯静态产物,缓存由托管平台决定。全站只有 /_astro/ 与 /content-images/ 带内容哈希,其余资源路径固定——因此不能对所有资源用长缓存。

路径建议缓存头理由
/_astro/*public, max-age=2592000, immutable(30 天)文件名含内容哈希,升级后 URL 变化,可长期缓存
/content-images/*路由已自带 public, max-age=31536000, immutable(一年)见下方「缓存陷阱」
/pagefind/*public, max-age=604800(7 天)或随构建失效文件名含内容哈希,但索引整体随内容变化
图片 / 字体 / CSS / JS / XML / TXTpublic, max-age=604800(7 天)无哈希,需短缓存
*.htmlno-cache 或很短(如 max-age=0, must-revalidate)内容随每次发布变化,不能长缓存

上表是「按路径分层」的原则,具体在哪个平台怎么填,由该平台的缓存配置入口决定。

content-images 的缓存陷阱

/content-images/{hash} 中 hash 只基于相对路径,不基于内容。且路由响应的 Cache-Control 是 max-age=31536000, immutable(一年)。

后果:替换 src/content/images/logo.webp 的内容但路径不变时,URL 也不变,已访问过的浏览器会长期使用旧图。

规避方式:改文件名(如 logo-v2.webp),或清理部署平台缓存。

8.2 HTML 后处理的具体变化

postbuild.js 会改写每个 HTML 文件:

变化示例
内联脚本压缩<script>!function(){...}</script> 变成单行压缩版
注入 preload<head> 末尾追加 <link rel="modulepreload" href="/_astro/xxx.js">
不改外链脚本、application/ld+json、HTML/CSS 结构

9. 部署

9.1 环境要求

项要求
Node.js>= 22.14.0(package.json 的 engines)
包管理器pnpm(preinstall 脚本用 npx only-allow pnpm 强制),packageManager: pnpm@10.30.0
构建命令pnpm build
输出目录dist

9.2 各平台

平台配置
Cloudflare PagesBuild command: pnpm build;Output directory: dist;Node 版本设 22
Vercel / Netlify同上,注意安装 pnpm(corepack enable)
GitHub Pages需要设置 siteConfig.url 为 Pages 域名(含子路径时需 Astro 的 base 配置,项目未内置)
自托管(对象存储 / CDN / 自己的服务器)需要自行准备托管与缓存配置:把 dist/ 作为站点根目录,并配置「未知路径回落到 /404.html」

9.3 托管平台缓存与尾斜杠

更新说明.md 提到过一个真实问题:

在进行页面切换时发现了多次重复请求预取的问题,导致页面流量异常,经排查为预取请求与 Canonical URL 不一致造成的。将前端路由的缓存策略与托管平台(Cloudflare Pages)的 URL 规范严格对齐,现在已修复。

结论:全站 URL 统一使用尾斜杠形式(/posts/xxx/、/categories/xxx/)。localePath() 会自动补尾斜杠,但对含 . 的最后一段(如 rss.xml)不补(源码里 isFile 判断)。

若你换了托管平台且该平台默认去掉尾斜杠(或反之),需要保证 301 规则一致,否则 Swup 预取会产生大量重复请求。

10. 常用命令速查

命令实际执行副作用
pnpm dev / pnpm startastro dev触发已注册的 buildStart 插件:ensure-post-slugs(会补写 slug 到 md)、generate-lqips;update-post-updated 在默认 false 下未注册
pnpm buildastro build && node scripts/postbuild.js写 md 源文件 + 生成 .generated/ + pagefind 索引
pnpm previewastro preview—
pnpm checkastro checkAstro 组件与类型检查
pnpm type-checktsc --noEmit覆盖 src 与 scripts
pnpm new-post [slug]node scripts/new-post.js新建 md
pnpm format-post-metanode scripts/format-post-meta.js重排全部文章 frontmatter
pnpm formatbiome format --write ./src格式化 src
pnpm lintbiome check --write ./src检查并修复 src
pnpm format:allbiome check --write ./src ./scripts ./astro.config.mjs ./biome.json ./package.json ./tsconfig.json全项目检查修复
preinstallnpx only-allow pnpm自动执行,非 pnpm 安装会失败

推荐的本地工作流

bash
12345678
pnpm new-post my-post        # 1. 新建
# 2. 写作
pnpm format-post-meta        # 3. 元数据排序
pnpm dev                     # 4. 预览
pnpm check && pnpm type-check # 5. 检查
pnpm build                   # 6. 构建(补 slug / LQIP / 搜索索引)
pnpm preview                 # 7. 验证 dist
git add -A && git commit     # 8. 提交(先 build 再 commit)

11. 构建问题排查表

现象原因解决
Invalid slug / Zod 校验失败slug 含大写、下划线或中文改成小写 + 连字符,或直接留空让脚本生成
index 校验失败index 为负数或小数改成非负整数
published 校验失败日期格式不可解析改成 YYYY-MM-DD HH:mm:ss
目录里突然多了 slug: 行ensure-post-slugs 正常工作预期行为
updated 被自动改写autoUpdatePostUpdated: true设为 false,或接受该行为
[rel-link] … kept as-is 警告相对链接目标不存在检查 .md 路径与目标文章的 slug
[lqip] sampling failed远程图片不可达 / 本地文件缺失检查图片路径;不影响构建
[postbuild] pagefind failed to start未安装依赖或 PATH 问题pnpm install,确认 pagefind 可执行
搜索结果为空未执行 pnpm build(dev 下无索引)先构建;且文章需有 data-pagefind-body 容器
图片不显示图片不在 src/content/ 下,或用了绝对路径用相对路径(见 05 文档#7)
preinstall 报错用了 npm/yarnnpm i -g pnpm 后改用 pnpm install
构建后 git status 有大量改动三个脚本改写了 md预期行为,先 build 再 commit