EchoBlog

整理首屏资源

8%
EchoBlog
返回文章
前端开发

从浏览器草稿到 Git 发布:CodePaper 的安全内容交接

复盘 CodePaper 如何把浏览器里的自由写作转换为符合 EchoBlog schema、默认不可索引且可经过 Git 审查的安全文章草稿。

从浏览器草稿到 Git 发布:CodePaper 的安全内容交接

浏览器里的编辑器适合自由写作,Git 仓库里的内容文件适合审查和发布。问题出现在两者之间:如果编辑器只导出正文,作者需要手工补齐十几个 Frontmatter 字段;如果编辑器直接写入正式内容目录,一次误操作就可能把半成品带进 sitemap、站内搜索和自动部署。

CodePaper 最初只提供便携 Markdown 导出。它能保住正文,却不能直接满足 EchoBlog 的文章 schema。真实写完一篇关于 Markdown 安全预览的技术文章后,这个缺口变得很具体:标题和标签之外,还缺少日期、作者、分类、封面、阅读时间、索引状态、SEO 摘要和规范地址。

这次实现没有把 CodePaper 扩展成 CMS,而是在浏览器与仓库之间增加一份安全、完整、仍然需要审查的 *.draft.md 文件。本文记录这条内容交接链路,并用本文自身完成一次从草稿到正式发布的验证。

先定义发布边界

安全草稿导出解决的是格式转换,不是发布权限。CodePaper 仍然只在当前浏览器保存正文,不持有 Git 凭证,也不调用服务端写文件接口。

完整链路被拆成五步:

TEXTpublishing-pipeline
浏览器本地草稿
  -> CodePaper 字段校验
  -> 下载 *.draft.md
  -> 人工复核并放入 content/articles
  -> 内容、链接、测试与构建门禁
  -> Git 提交和自动部署

每一步只承担一种职责。编辑器负责生成结构正确的候选文件,仓库负责审查差异,Nuxt Content 负责 schema,项目脚本负责更严格的业务规则,CI 才拥有部署权限。这样即使浏览器状态、输入字段或正文存在问题,也不会直接越过后续门禁。

Nuxt Content 的集合文档把 collection schema 定义为内容一致性与 TypeScript 类型的来源。EchoBlog 因此不在 CodePaper 中复制一套“差不多”的字段,而是按当前 content.config.ts 生成完整 Frontmatter,再交给同一份 schema 复核。

默认值必须偏向不发布

导出时最重要的两个字段不是标题,而是:

YAMLsafe-defaults
draft: true
indexable: false

draft: true 表示文件还没有通过人工复核,文件名也必须保留 .draft.md。生产环境会排除这类文件,因此即使它被提前放进内容目录,也不会成为正式页面。

indexable: false 是第二层保险。它不是 draft 的替代品,而是避免文件在错误改名、调试预览或发布状态调整时立刻进入搜索索引。Google 的 robots meta 文档说明,爬虫必须能够访问页面,才能读取 noindex 指令;因此 EchoBlog 不用 robots.txt 阻止这类页面,而是在页面元数据中输出 noindex, follow

这两个状态需要人工分别解除。正式发布时,作者必须明确把文件名改为普通 .md,再把 draft 改成 false;确认内容应该被搜索引擎发现后,才把 indexable 改成 true。显式操作比猜测作者意图更可靠。

用白名单连接站点分类

分类和标签不是任意字符串。EchoBlog 的 /tags/<slug>/categories/<slug>、首页计数和相关文章都依赖统一 taxonomy。如果 CodePaper 允许随意输入,“TypeScript”“typescript”和“TS”很容易变成三组内容。

导出逻辑只接受 shared/taxonomy.ts 已登记的分类与标签:

TypeScripttaxonomy-validation
const categoryDefinition = findCategoryDefinition(input.category)
const tagDefinitions = tags.map((tag) => findTagDefinition(tag))

if (!categoryDefinition) {
  throw new Error(`分类「${input.category}」未在站点注册表中登记`)
}

const unknownTag = tags.find((_, index) => !tagDefinitions[index])
if (unknownTag) {
  throw new Error(`标签「${unknownTag}」未在站点注册表中登记`)
}

界面使用选择控件展示注册表,而共享函数仍会再次检查输入。这不是多余重复:浏览器界面负责减少误操作,共享导出函数负责保护数据边界。OWASP 的输入校验指南也建议对有限集合采用 allowlist,并强调客户端校验不能替代可信边界上的校验。

别名会被归一为注册表里的正式展示名,重复标签会被去除。文章、项目、标签页和 DIY Tags 因此继续读取同一份事实来源,不需要再修正手工计数。

Frontmatter 完整不等于输入原样拼接

安全导出需要同时规范化结构化字段和自由正文。当前实现对输入做了几类处理:

输入处理方式目的
slug只允许小写字母、数字和单个连字符保持稳定路由与文件名
日期严格验证 YYYY-MM-DD 和真实日期拒绝会被 JavaScript 自动纠正的无效日期
封面只接受站内根路径或 HTTPS URL阻止相对路径逃逸和公开 HTTP
强调色只接受六位十六进制颜色避免任意 CSS 值进入主题
分类/标签taxonomy 白名单归一保持导航和计数一致
标题与摘要去除多余空白并限制长度保持列表、SEO 与 YAML 可控

YAML 字符串统一使用单引号,并把内容中的单引号转义为两个单引号,避免标题里的冒号、井号或引号破坏 Frontmatter。

正文也不能简单追加。用户可能导入一个已经带 Frontmatter 的文件,或者在正文第一行重复写了与元数据相同的一级标题。导出时会移除现有 Frontmatter,并在标题一致时去掉重复 H1,保留从二级标题开始的文章结构:

TypeScriptbody-normalization
if (/^---\s*\n/u.test(body)) {
  body = body.replace(/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/u, '').trim()
}

const firstHeading = body.match(/^#\s+(.+?)\s*(?:\n|$)/u)
if (firstHeading && normalizeCodePaperTitle(firstHeading[1]) === title) {
  body = body.slice(firstHeading[0].length).trim()
}

这让重新导入、修改和再次导出保持幂等,不会每经过一次工具就多一层元数据或标题。

稳定 slug 与草稿文件名

英文标题可以生成可读 slug,中文标题无法可靠自动转写时则使用带日期的确定性回退:

TypeScriptslug-fallback
const slug = createCodePaperBlogSlug(title, date)
// codepaper-safe-draft-handoff
// 或 codepaper-20260728

const filename = createCodePaperBlogFilename(slug)
// codepaper-safe-draft-handoff.draft.md

自动生成只是建议,导出前仍允许作者修改。校验要求整个 slug 匹配 ^[a-z0-9]+(?:-[a-z0-9]+)*$,并限制为 80 个字符。截断后会再次去除末尾连字符,避免边界长度生成不合法文件名。

slug 一旦发布就不应该随意改变。Nuxt Content 的 page collection 会根据文件路径生成页面路径,因此文件名既是内容标识,也是 canonical、站内链接和搜索引擎地址的一部分。

让门禁而不是界面决定能否发布

CodePaper 导出成功只表示候选文件结构合理,不能表示文章已经达到发布质量。草稿进入仓库后还需要执行:

Bashrelease-gates
pnpm validate:content
pnpm check:links
pnpm test
pnpm typecheck
pnpm build
pnpm check:budget

这些命令分别检查 Frontmatter、taxonomy、占位语、站内链接、回归逻辑、TypeScript、Nuxt 预渲染和页面资源预算。职责被拆开后,编辑器不需要知道所有发布细节;规则变化时,只需要让导出格式继续满足 schema,并让仓库门禁给出最终结论。

Git 暂存区是人工审查的最后一道边界。git add 官方文档明确说明该命令使用执行当时的工作树内容更新 index,后续修改不会自动进入提交。因此发布前需要查看 git diff、明确暂存目标文件,再确认提交里没有混入浏览器缓存、构建目录或私有资源。

用这篇文章验证完整工作流

本文不是为界面准备的短样例,而是本次功能的验收载荷。它包含长段落、表格、外链、YAML、TypeScript、Bash 和纯文本代码块,可以同时验证 CodePaper 写作能力与 EchoBlog 内容渲染。

实际流程如下:

  1. draft: trueindexable: false.draft.md 文件名生成安全草稿。
  2. 将草稿放入 content/articles/,运行内容校验,确认字段和 taxonomy 可以被项目接受。
  3. 人工复核标题、摘要、slug、链接、代码片段、阅读时间与发布范围。
  4. 改为普通 .md 文件,显式设置 draft: falseindexable: true
  5. 重新运行完整门禁,确认文章进入列表、归档、标签页、sitemap 和生产预渲染。
  6. 只暂存文章与相关文档,创建独立内容提交。

这次验证比单元测试多覆盖了一层真实边界:不是只断言导出字符串里存在字段,而是让生成结果进入实际内容数据库、路由、链接扫描和生产构建。

仍然保留的人工步骤

自动补齐字段不能替代编辑判断。以下内容仍然需要作者确认:

  • 摘要是否准确,而不是仅仅截取第一段。
  • 分类和标签是否表达文章主题,而不是为了增加曝光堆叠。
  • 封面是否与文章相关,并且已经压缩到合适体积。
  • 外部资料是否可信、可访问并真正支持正文结论。
  • 代码片段是否与当前仓库实现一致。
  • 文章是否值得索引,还是只应作为内部夹具保留。

因此当前实现不会直接向仓库写文件,也不会提供“导出并发布”按钮。只有在多作者、远程编辑或高频发布成为真实需求后,才有理由设计带身份、权限、版本与审计的服务端发布流程。

结论

内容工具最危险的捷径,是把“生成文件”和“允许发布”当成同一件事。CodePaper 现在能输出符合 EchoBlog schema 的完整文章草稿,但安全默认值、人工复核、Git 差异和构建门禁仍然保持独立。

这条链路没有消除人工操作,而是把人工判断集中在真正需要判断的地方:内容质量、公开范围和发布意图。格式补齐、白名单归一、文件命名和结构校验交给代码;是否让一篇文章进入生产环境,继续由可审查的 Git 工作流决定。