草稿状态、导航与构建门禁:让文章安全进入站点
一篇文章为什么会“能打开”却不能发布
开发者把 Markdown 文件放进仓库,开发服务器能打开页面,通常会自然得出一个结论:文章已经完成。真正发布后,问题往往从另一个入口出现:侧栏里没有它,上一篇和下一篇跳到了错误系列,旧地址变成 404,搜索仍然返回已经撤下的页面,构建日志里有未解释的 warning,或者 CI 只检查了命令退出码,却没有打开关键页面。
这些现象不是同一个故障。文章至少同时存在于四个投影里:源文件投影决定内容和元数据,路由投影决定 URL,导航投影决定读者能否沿顺序抵达,构建投影决定浏览器实际收到的 HTML、脚本、图片和重定向。任何一层更新而其他层停留在旧状态,站点都会出现“局部正确、整体错误”。
先把这个判断放进日常动作里:新文章先以受控状态写入,先验证它自己能渲染;等标题、路径、链接、图片、构建和关键页面都稳定后,才把它加入公开目录。公开目录不是文件扫描结果,而是经过人工负责人与自动门禁共同确认的交付入口。
从安装入口建立可复现的站点骨架
新建 VuePress 站点时,官方安装指南要求 Node.js 20.9.0 或更高版本,并同时提供脚手架和手工安装两条路径;VuePress v2 当前仍以 RC 方式发布,配置和 API 可能在升级时出现小范围破坏性变化,因此版本必须锁在项目依赖文件里,而不是让 CI 每次拉取一个浮动标签。可以先阅读 VuePress v2 Getting Started 的安装和构建入口,再对照仓库中的 package.json 和 lockfile。本项目把 VuePress 核心、Vite bundler、主题与插件锁成一组已验证的精确版本;这些包的 RC 尾号不一定相同,兼容性应以各包的 peer dependency 和干净构建结果为准,升级时必须把整组依赖当成一个兼容单元验证。
在已有项目里,安装命令的意义不是“把某个包下载下来”,而是确定一组彼此匹配的运行对象:VuePress 核心读取 docs,Vite bundler 把 Markdown 和客户端代码编译成静态资源,主题消费导航和页面信息,插件在编译期生成额外数据或在浏览器端提供交互。Node 版本低于项目支持线时,最早出现的可能不是清晰的版本错误,而是 ESM、原生 API、构建 worker 或依赖安装阶段的连锁异常。
最小的空站点可以这样初始化。示例只使用官方文档给出的包管理方式;实际项目应改用锁文件中的已批准版本,不要把 @next 当成长期生产版本。
# 先确认运行时和包管理器,不要在错误目录初始化
node --version
npm --version
# 新项目的手工入口;现有项目不要重复初始化 package.json
npm install -D vuepress@next @vuepress/bundler-vite@next @vuepress/theme-default@next
mkdir docs
mkdir docs/.vuepress
npm pkg set scripts.docs:dev="vuepress dev docs"
npm pkg set scripts.docs:build="vuepress build docs"
npm run docs:dev预期是开发服务器监听本地地址,修改 Markdown 后页面热更新。这个结果只证明开发链路可以启动,不证明静态构建、侧栏和旧路由正确。要把入口固定成生产可重复的命令,再执行一次构建:
npm run docs:build
node --version
npm ls vuepress @vuepress/bundler-vite @vuepress/theme-default日志中应能看到构建完成、输出目录生成,以及依赖树没有把 VuePress v1 的主题或插件混进 v2。VuePress v2 使用 Vue 3、纯 ESM 配置,并且 v1 主题和插件不能直接复用;迁移指南 明确列出了 CommonJS 配置、enhanceApp、主题配置和插件 API 的变化。升级时如果仍看见 module.exports、字符串形式的旧插件配置或 v1 主题包,先停在依赖和配置层处理,不要用增加一个兼容脚本把警告压掉。
配置文件的查找顺序也会改变“你以为改了配置、实际没生效”的判断。VuePress 允许在工作目录使用 vuepress.config.ts/js/mjs,也允许在源目录 .vuepress 下使用 config.ts/js/mjs;配置指南 还说明了可以用 CLI 的 --config 指定另一份配置。团队应只保留一个明确入口,并在构建日志中输出实际使用的站点根目录和版本,避免本机从错误的临时配置启动。
frontmatter 字段要绑定真实消费者
frontmatter 是页面元数据,但字段名本身不产生发布能力。VuePress 核心会消费 title 和 description,主题或插件可能消费 article、timeline、sitemap、search,而 status、order 也可能只是项目自定义数据。当前仓库的导航顺序来自 docs/.vuepress/config/knowledge.ts,并不读取每篇文章的 order;status: "published" 也不会自动把页面加入侧栏。给字段赋予发布含义之前,必须找到实际读取它的配置、插件或检查脚本。
当前仓库把草稿目录从 pagePatterns 排除,并由 scripts/check-drafts.mjs 阻止正式文章目录中的 draft: true。因此状态要落成三个真实动作:正在写的文件放在草稿存放区并保留 draft: true;准备进入正式目录的文件先完成渲染和人工复核;公开文件移除阻断状态,再由导航配置显式挂载。不要只修改 status: "published" 就假定它已经发布,也不要只把文件名从 draft- 改成正式名称。是否生成页面、是否进入导航、是否进入站内搜索和 sitemap 是四个独立判断。
下面是一份适合正式页面的 frontmatter 骨架。它没有日历日期,避免把容易过期的日期当成技术版本;版本应写在正文第一次影响操作的地方,并以锁文件和官方兼容资料为准。
---
title: 文章标题
description: 用一句话说明读者能解决的工程问题
category:
- 工具效率
- 研发工具部署与使用手册
- 站点写作工具链
tags:
- VuePress
- 构建验证
status: "published"
author: 高谷深陵
article: true
timeline: false
index: true
sitemap: true
order: 2201
---反向实验更能说明状态的作用:在临时分支复制一份页面,把 draft: true 放进 docs/article,再执行 npm run check:drafts。预期命令以非零退出,并明确列出阻断文件;这不是页面渲染失败,而是发布前状态不合法。把同一文件移到草稿目录后再执行,预期阻断项消失。实验结束后删除临时文件,并用 git status --short 确认没有把测试页带进提交。如果仓库没有将某个目录排除在 pagePatterns 外,仍需检查构建是否会读取它,不能只凭目录名称推断不会产出页面。
状态字段还要与搜索和站点地图联动。受限、未审或已撤下内容即使通过 Markdown 构建,也不应进入公开索引;已经公开过的页面不能仅靠删除文件解决旧链接和缓存残留。SlimSearch 明确使用 search: false 排除单页,sitemap 插件则使用 sitemap: false 或全局 excludePaths;index: false 不能替代这两个字段。更重要的是,只要页面仍进入公开静态构建,知道 URL 的人就可能访问它,因此“内部预览”必须在独立且受认证的构建环境完成,不能把搜索隐藏当成访问控制。
文章顺序、侧栏和导航必须只有一个事实源
读者通过上一篇、下一篇和侧栏形成阅读路径。最容易出错的做法是同时维护文件名序号、frontmatter order、自动扫描目录、手写侧栏和顶部导航。五套顺序看上去都合理,但任何一次插入、重命名或归档都会产生差异:文章 A 的下一篇指向 B,侧栏把 C 放在 B 前面,顶部目录还保留旧地址,最终读者在同一页面看到三种路线。
更稳的方式是把内容身份和展示位置分开。文件名可以表达稳定 slug,文章配置保存标题、系列和顺序,侧栏从同一份结构生成,顶部导航只指向系列入口。当前仓库以 docs/.vuepress/config/knowledge.ts 保存领域、系列和文章树,navbar 与 sidebar 都从它生成;这里的 children 才是公开顺序事实源。对可自动生成的目录,自动扫描只负责发现文件,不能再由第二份手写列表补一遍同样的文章。
新增文章时先执行一个小实验:在不改导航的情况下构建页面,确认它能按预期生成路由;然后在本地临时分支加入侧栏项,检查该项的 link、标题和相邻项;最后撤销临时配置,确认未挂载的页面不会出现在公开侧栏。这里的关键不是“页面存在”,而是能证明“存在、可达、顺序正确、撤销后不残留”四个状态。
导航配置通常包含三层对象:领域入口、系列入口、文章链接。每一层都要使用稳定的绝对站点路径,文章文件重命名时先做路径映射,再改导航,不能让旧路径在配置里静默消失。一个最小结构可以写成:
export const siteWritingSeries = {
title: "站点写作工具链",
link: "/article/tool-efficiency/tool-deployment-usage/22-site-writing-toolchain/",
children: [
{
title: "草稿状态、导航与构建门禁",
link: "/article/tool-efficiency/tool-deployment-usage/22-site-writing-toolchain/draft-navigation-build-checks.html",
},
{
title: "搜索与知识维护",
link: "/article/tool-efficiency/tool-deployment-usage/22-site-writing-toolchain/search-and-knowledge-maintenance.html",
},
],
};配置中的链接和真实生成文件必须相互验证。若使用 foo.html,构建目录里应存在同名 HTML;若使用目录形式的 /foo/,静态托管服务器应能把它解析到 foo/index.html。两种形式不能由一组旧规则和一组新规则混用。检查脚本应将“配置声明的路由集合”和“构建产物中的路由集合”做差集,差集非空时阻断发布。
旧路由与重定向要先画出迁移关系
重命名文章、合并系列或从旧框架迁移时,旧 URL 是外部契约的一部分。书签、聊天记录、代码注释、搜索结果和站外引用都可能继续访问它。重定向配置不是为了掩盖错误链接,而是为已经存在的地址提供可监测的迁移入口;有长期站外引用的地址通常要长期保留映射,不能仅因短期无流量就删除。
VuePress 的 base 会影响站点部署在子路径时的 URL 生成;官方 Config Reference 说明了 base 必须以 / 开头和结尾,并会自动作用于许多路径选项。重定向目标应使用站点路径,不要把某个环境的域名硬编码进文章或配置。页面内链接、图片路径、导航链接和重定向目标都要在相同的 base 下测试。
一次迁移至少保留四列证据:旧路径、新路径、迁移原因、删除条件。新路径必须已经生成并通过 smoke 后,旧路径才能进入 redirect;旧路径的 redirect 连续稳定一段发布周期后,才讨论是否收缩映射。若目标再重定向到第三个地址,应直接把旧地址指向最终页面,避免多跳放大缓存、丢失查询参数和排障复杂度。
# 伪配置,仅展示关系;真实字段以使用的重定向插件版本为准
redirects:
"/article/legacy/site-writing.html":
to: "/article/tool-efficiency/tool-deployment-usage/22-site-writing-toolchain/draft-navigation-build-checks.html"
"/article/legacy/search.html":
to: "/article/tool-efficiency/tool-deployment-usage/22-site-writing-toolchain/search-and-knowledge-maintenance.html"这里要先区分两种实现。托管平台或反向代理可以直接返回 301、302、307 或 308;VuePress redirect 插件则会为映射生成 redirect HTML,并通过 meta refresh 和客户端跳转抵达目标,普通静态服务器往往仍对旧地址返回 200。于是“旧地址必须返回 3xx”不是通用断言,真正的不变量是旧地址能够抵达唯一、有效的最终页面,而且旧页面带 noindex 与 canonical,不形成重定向环。
正向实验访问新 URL,预期返回页面标题、文章海报和正文;反向实验同时用 curl -I 记录首个响应,再用浏览器或支持执行页面跳转的 smoke 确认最终 URL。若业务依赖查询参数或锚点,还要分别访问 ?from=legacy 和 #section,不能假定插件、CDN 和托管层都会原样保留。需要真正 HTTP 3xx、查询参数保留或边缘缓存控制时,应把规则放到托管层,并继续让构建检查验证目标路由存在。故意把目标改成不存在的页面时,构建期检查应能从路由集合差集中报错;若只能在浏览器里看到 404,说明门禁还停留在运行后。
迁移 VuePress v1 到 v2 时,不能只搬 Markdown。官方迁移指南列出 v1 插件和主题不兼容、CommonJS 配置不再支持、部分 frontmatter 和 CLI 选项改名或移除等变化。旧插件如果依赖 extendsCli、Webpack 专用 hook 或旧的 themeConfig 读取方式,即使文章路由生成了,也可能在页面渲染阶段失效。迁移清单必须把 frontmatter、组件、图片、导航、redirect 和搜索字段一起纳入。
死链和构建日志要提供可定位的证据
死链检查不能只扫描 Markdown 文本。内部链接可能来自 frontmatter、导航配置、组件 props、重定向表、上一篇/下一篇生成器和搜索结果;生成后的 HTML 还可能出现主题组件拼出的链接。最小可靠检查分两层:先在源文件和配置中做结构化链接检查,再对构建产物执行站点内 URL smoke。
构建日志也要区分三类信号。错误是页面无法生成或资源缺失,必须阻断;warning 是工具明确报告了兼容、未高亮语言、弃用 API 或资源体积风险,需要有归因;普通信息是构建阶段和产物位置,用于追踪。把 stderr 全部重定向到空文件或最后无条件 exit 0,会制造“构建通过”的假证据。
可以用一个小型检查脚本验证内部链接的基本形态。它不替代 VuePress 解析器,但能快速抓住绝对本机路径、空链接和错误域名;真实项目应让脚本读取 YAML frontmatter 和 TypeScript 配置,而不是用正则猜复杂语法。
$files = Get-ChildItem docs/article,docs/.vuepress -Recurse -File -Include *.md,*.ts,*.vue
$bad = foreach ($file in $files) {
$text = Get-Content -Raw -Encoding utf8 $file.FullName
if ($text -match 'file://|[A-Z]:\\|href=""|\]\(\s*\)') {
$file.FullName
}
}
if ($bad) {
$bad | ForEach-Object { Write-Error "疑似无效链接或本机路径: $_" }
exit 1
}
Write-Output "source link shape check passed"执行仓库已有的 npm run test:legacy-routes、npm run test:knowledge-nav、npm run test:sidebar-scope 和 npm run test:rendered-sidebar 时,要记录它们检查的是哪一层。配置断言通过,不代表渲染侧栏有正确的 href;渲染侧栏通过,也不代表旧地址、图片和搜索索引已经更新。每条检查都应能在失败日志里给出文件、路由或页面标题。
用关键页面 smoke 证明页面真的能被读者访问
静态构建是必要条件,不是充分条件。关键页面 smoke 至少要包含系列入口、一篇新增文章、上一篇和下一篇各一条、一个旧路由、一个图片 URL、一个搜索入口和一个不存在的 URL。smoke 的目标不是做完整浏览器回归,而是验证发布链上最容易断裂的接缝:返回状态、最终 URL、页面标题、关键 DOM、图片加载和控制台错误。
可以使用任意受控浏览器工具执行,也可以先用静态服务器和 curl 验证 HTTP 形态。下面的命令假设构建产物已经在 dist,端口只是本地临时值,不应写入公开链接。
npx http-server dist -p 4173
# 新页面、系列入口、旧路由和不存在页面分别记录首个响应
curl -sS -D - -o /dev/null http://127.0.0.1:4173/article/tool-efficiency/tool-deployment-usage/22-site-writing-toolchain/draft-navigation-build-checks.html
curl -sS -D - -o /dev/null http://127.0.0.1:4173/article/tool-efficiency/tool-deployment-usage/22-site-writing-toolchain/
curl -sS -D - -o /dev/null http://127.0.0.1:4173/article/legacy/site-writing.html
curl -sS -D - -o /dev/null http://127.0.0.1:4173/article/not-found.html预期的新页面和系列入口返回成功状态;旧路由可能是托管层 3xx,也可能是状态为 200 的 redirect HTML,必须按部署形态断言;不存在页面应返回 404,而不是状态为 200 的普通首页。若静态服务器把任意未知路径都回退到首页,HTTP 状态看起来正常,实际死链仍被掩盖,这时要检查服务端 fallback 规则和生成的 404 页面。curl 不执行 meta refresh 或页面脚本,所以插件型重定向必须再由浏览器验证最终 URL、canonical 和 noindex。
浏览器 smoke 还要检查页面是否出现重复 H1、文章海报是否为目标 SVG、侧栏是否包含当前文章、上一篇和下一篇是否属于同一阅读序列、搜索结果是否出现草稿或已归档内容。对移动视口再打开一次,避免导航文字溢出、侧栏遮挡正文或重定向提示卡住首屏。页面能在桌面上加载,不等于发布后的阅读路径完整。
反向实验可以制造一个稳定的坏页面:把文章路径中的一个大小写字母改掉,或把图片引用改成不存在的文件,重新构建并执行 smoke。预期构建或资源检查失败,页面里图片请求出现 404;修回路径后再构建,失败证据消失。这个实验比“正常页面能打开”更能证明门禁确实在观察关键资源。
CI 门禁要按职责分层
CI 不应该把所有责任压成一个 npm run build。适合分层的门禁包括:源文件结构检查、frontmatter 和草稿状态检查、标题层级检查、内部路由和旧路由检查、类型检查、构建告警归因、构建产物 smoke、关键页面浏览器检查。每一层失败时都应让责任人知道要改源文件、配置、依赖还是发布环境。
一个实用的流水线顺序如下:依赖安装使用 lockfile;先执行便宜的静态检查;再构建;构建完成后启动静态服务器做路由和页面 smoke;最后保存构建日志与版本信息。构建前清理旧产物很重要,否则上一次成功生成的 HTML 或 SVG 可能掩盖本次失败。仓库已有 prebuild 会先清理并执行草稿检查,新增门禁应复用这个入口,不要在另一个脚本里复制一套不同的规则。
name: docs-check
on:
pull_request:
permissions:
contents: read
jobs:
site:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
- run: npm ci
- run: npm run check:drafts
- run: npm run test:heading-hierarchy
- run: npm run test:knowledge-nav
- run: npm run test:legacy-routes
- run: npm run typecheck
- run: npm run build
- run: npm run test:rendered-sidebar
- run: npm run test:build-warnings这段配置的字段含义要结合项目事实解释:当前仓库没有 .nvmrc,所以示例显式选择满足 VuePress 要求的 Node 22;npm ci 让 lockfile 成为依赖输入;静态检查在构建前快速失败;test:rendered-sidebar 必须放在构建之后,因为它读取的是生成后的页面。GitHub 官方的 setup-node 已提供 v6 示例,并提醒 v5 以后 action 自身使用 Node 24、旧自托管 runner 需要先升级;团队若使用旧 runner,应先验证 runner 兼容性,而不是静默降级门禁。缓存只保存包管理器下载缓存,不代替 node_modules 或构建产物,也不能把私有 registry 凭证打进 cache key 和日志。
插件能力也属于 CI 边界。VuePress 插件可以在编译期扩展页面元数据、生成额外文件或注入客户端功能;官方插件指南提示插件的具体能力和多次使用规则要看插件文档。插件不是“安装后永远兼容”的黑盒:v1 插件不能直接视为 v2 插件,第三方主题还可能绑定某个 VuePress、Vue、bundler 或 Node 组合。升级插件时先看其 release notes、peer dependency 和迁移说明,再在干净依赖上构建。
发布职责要能回答“谁能放行、谁能撤回”
写作者负责内容、链接、代码块、图片引用和 frontmatter;系列维护者负责阅读顺序、侧栏关系、迁移映射和归档入口;构建维护者负责脚本、Node 基线、插件版本、构建告警和产物存储;发布负责人负责环境变量、静态托管、缓存刷新、重定向生效和上线后 smoke。多人协作时,发布通过不代表每个人都要审查所有层,但每层必须有明确 owner。
发布前先冻结路由映射。文章重命名、系列移动、主题升级和搜索插件升级不要在同一个变更里无证据合并。构建产物应带有提交标识、依赖锁文件摘要和构建日志摘要;日志中要隐藏令牌、代理凭证、真实内网地址和完整环境变量。审计需要的是“哪份源、哪套依赖、哪次构建生成了哪份产物”,不是一份包含敏感信息的完整终端录屏。
上线后要观察四类信号:新页面成功访问率、旧路由重定向命中和最终状态、资源 404、搜索或侧栏入口错误。若使用 CDN,缓存刷新和重定向缓存不是同一件事;HTML 更新了,旧的 JS 或索引文件仍可能被浏览器缓存。发布职责应包含缓存策略和验证动作,不能把“文件已经上传”当成读者已经看到新页面。
回滚要恢复一整组相互匹配的状态
构建失败时回滚通常只是修代码;发布后回滚则要同时考虑源版本、构建产物、导航、重定向、搜索索引和缓存。最稳妥的是保留上一份已验证的静态产物及其依赖信息,发生严重错误时把流量切回上一份产物,并继续保留必要的旧 URL 映射。不要只删除新文章,因为删除会把外部引用直接变成 404,搜索和缓存也可能继续保留旧页面。
一个安全的回滚顺序是:冻结继续发布;记录故障 URL 和时间窗口;切换到上一份产物;验证首页、系列入口、关键文章、旧路由、资源和 404 页面;检查 CDN 和浏览器缓存;把失败构建日志与恢复动作关联起来。若变更包含数据化的搜索索引,先让索引指向上一份可用文档集合,再清理新索引,避免页面和搜索结果短暂指向两套知识状态。
# 发布平台命令因环境不同而异,下面只表达回滚验证顺序
set -euo pipefail
test -f release-manifest.json
test -d previous-dist
serve-static previous-dist --port 4173 &
server_pid=$!
trap 'kill "$server_pid"' EXIT
curl --fail --silent http://127.0.0.1:4173/ > /dev/null
curl --fail --silent http://127.0.0.1:4173/article/tool-efficiency/tool-deployment-usage/22-site-writing-toolchain/ > /dev/null
test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1:4173/route-that-must-not-exist.html)" = "404"如果回滚只恢复 dist,没有恢复构建时使用的重定向表、站点 base、CDN header 或搜索索引版本,页面可能看起来恢复,链接仍然指向错误版本。发布清单应记录这些输入的版本或哈希,回滚时逐项核对。清理新产物前保留失败证据,便于定位是内容错误、插件兼容、托管 fallback 还是缓存失效。
长期治理从小指标开始,而不是靠“构建绿了”
站点质量至少需要一组趋势指标:草稿阻断次数、孤儿页面数量、侧栏配置与产物路由差异、旧路由命中量、重定向跳数、关键页面 smoke 失败数、构建 warning 分类数量、资源 404、构建耗时和产物体积。指标不需要一开始就接入复杂平台,CI artifact、构建日志和一个受控的 JSON 清单也能形成基线。
容量变化会反过来影响工具选择。文章数量增加后,全文索引、预取、客户端 JavaScript 和构建内存会一起增长;shouldPrefetch 对小站有帮助,但站点页面很多时,预取所有其他页面可能增加传输和缓存成本。VuePress 配置参考对 shouldPrefetch、cache、temp、dest 等字段都有明确行为说明,调整之前先观察产物和页面请求,而不是把所有优化项设成 true。
平台边界也要写进选型动作。VuePress 适合以 Markdown、Vue 组件、静态构建和插件扩展为主的 Vue 文档站;VitePress 的官方安装入口要求 Node.js 22 或更高,并且是 ESM-only,适合愿意接受更现代 Vite 约束的新站;Docusaurus 官方安装页要求 Node.js 20.0 或更高,使用 React 生态、docusaurus.config 和 sidebars 组织站点。选择替代平台时,不能只比较首页速度,要把 Node 基线、组件模型、frontmatter 语义、导航事实源、插件迁移、重定向和搜索索引全部放进迁移演练。可以分别阅读 VitePress Getting Started 和 Docusaurus Installation 核对运行时与目录边界。
稳定发布单元要满足一组可重复条件:页面能被正确构建、沿侧栏访问、由旧地址迁移、在关键页面 smoke 中通过,并且日志、权限、缓存和回滚证据齐全;后续插件升级、目录重排或框架迁移必须重新运行这条链。这样处理,文章不会因为“文件还在”而被误认为可读,也不会因为“首页能开”而掩盖导航和旧链接已经断裂。
