当一个内容站运行一段时间之后,文章字段几乎一定会慢慢失控。
最常见的情况不是“没有字段”,而是“字段越来越多,但并不统一”:
- 老文章用
date - 新文章用
pubDate - 有的文件写
categories - 有的文件写
category - 有的文章带
permalink - 有的文章还保留着上一套系统遗留的
layout、slug、articleId
如果这时只是靠手改,短期能撑住,文件一多就会失去一致性。更稳的办法,是写一个专门的整理脚本,把旧内容批量迁到当前项目真正需要的字段上。
这类脚本没有统一的唯一名字,但常见叫法通常是这些:
- 内容规范化脚本
- frontmatter 迁移脚本
- 内容迁移脚本
- metadata normalization script
- migration script
它本质上不是页面运行代码,而是一种维护工具。你可以把它理解成“给内容做数据清洗和结构迁移”的小程序。
它解决的到底是什么问题
这类脚本最适合处理三种情况:
- 从旧博客、旧静态站、旧 CMS 批量迁内容到新项目
- 同一个仓库里,历史文章 frontmatter 长期不统一
- 内容 schema 已经定下来了,但旧文件还停留在上一套结构
比如一个项目现在真正需要的字段只有:
title:
description:
pubDate:
category:
draft:
但旧文件里可能同时混着这些:
layout: post
date: 2024-08-21
permalink: /blog/example
categories: 随笔
tags: [示例]
articleId: example
publishedAt: 2024-08-21
storyDate: 2024-08-22
这时就不应该继续“兼容一切”,而应该明确一件事:
当前项目最终要认哪一套字段。
只要这件事确定了,后面的迁移脚本就有了目标。
通用流程其实很固定
无论你是在 Astro、Jekyll、Hugo、Eleventy,还是任意一个 Markdown 内容项目里,这类脚本通常都遵循同一个流程:
- 定目标字段
- 读取旧文件 frontmatter
- 把旧字段映射到新字段
- 补缺失值
- 删除废字段
- 批量写回
- 留一个
--check或 dry-run 模式
真正重要的是第 1 步,而不是第 6 步。
如果目标字段没先定清楚,脚本只会把混乱自动化。
先定义“最终只保留什么”
迁移脚本最怕的是一边迁、一边犹豫。最稳的做法是先写下最终 schema。
例如:
const canonicalFields = ["title", "description", "pubDate", "category", "draft"];
这句话看起来简单,但它决定了后面所有行为:
- 哪些字段必须留下
- 哪些字段要被映射
- 哪些字段应该删除
- 哪些信息要从正文推断
一旦你已经明确当前项目不会再消费 layout、permalink、tags、articleId 这类字段,就不要继续留着它们“以防万一”。
“暂时保留”通常只会让 schema 重新发散。
第二步是映射,而不是照搬
迁移脚本的关键不是复制字段,而是把旧字段折叠到新结构里。
比如:
date或publishedAt统一折成pubDatecategories统一折成单一的categorydraft不存在时,按当前目录或当前批次默认补上
一个很常见的映射函数会长这样:
function pickPubDate(data, file) {
return data.pubDate
?? data.publishedAt
?? data.date
?? file.match(/\b(\d{4}-\d{2}-\d{2})\b/)?.[1];
}
这类函数的好处是很直接:
- 先认新字段
- 再向下兼容旧字段
- 最后才用文件名兜底
顺序本身就是一条策略。
缺字段时,不要立刻手工补
批量迁移最耗人的地方,通常不是字段删除,而是缺失值。
尤其是 description。
很多老文章会出现这几种情况:
- 没有描述
- 描述只有一个
.或... - 描述是上一代系统留下的占位内容
这时脚本不应该停下来等人工,而应该有一个自动补值策略。最常见的办法有三种:
- 从正文第一段提取摘要
- 从标题生成一个简单描述
- 给出明显的占位值,后续人工再改
如果内容量大,第一种通常最实用。
比如:
function pickDescription(rawDescription, title, body) {
const existing = sanitizeDescription(rawDescription);
if (existing) return existing;
const excerpt = bodyToPlainText(body).trim();
return shortenText(excerpt || title, 72);
}
这样做的目的不是生成“完美摘要”,而是先让 schema 不缺字段,再决定哪些文章值得二次精修。
删除废字段,才算真正完成迁移
很多人写迁移脚本时,只做“新增字段”,不做“删除废字段”。
这会留下一个后遗症:文件看起来好像已经规范了,但旧字段还在,后面的人又会忍不住继续使用它们。
所以真正的迁移不只是:
- 加上
pubDate - 加上
category
还应该包含:
- 删掉
layout - 删掉
permalink - 删掉
categories - 删掉不再被当前项目消费的历史字段
脚本最后写回文件时,最稳的方式不是“在原 frontmatter 上修补”,而是直接按目标字段重建一份新的 frontmatter。
例如:
const nextFrontmatter = [
"---",
`title: ${quoteYamlString(title)}`,
`description: ${quoteYamlString(description)}`,
`pubDate: ${pubDate}`,
`category: ${quoteYamlString(category)}`,
"draft: true",
"---",
].join("\n");
这比局部替换更可靠,因为你不会不小心漏掉旧字段。
一定要保留 --check
如果这类脚本只能“直接写”,后面维护会很难受。
真正适合长期保留的版本,一定要支持只检查、不写入,比如:
node scripts/normalize-content.mjs --check
它的作用至少有三个:
- 看看当前仓库还有多少文件不符合规则
- 在 CI 里阻止新旧格式混用
- 验证脚本是否幂等,也就是重复执行后不应该继续产生变化
幂等很重要。
一个好的规范化脚本,第一次运行可能会改 500 篇文件;第二次运行应该尽量是 0 变更。
如果第二次还持续改,通常说明脚本本身的映射顺序有问题,或者它没有优先认已经规范好的字段。
这种脚本到底该删还是该留
这也是很多项目里会反复出现的问题。
答案很实际:
- 如果这次迁移结束后,后面不会再导入旧内容,可以删
- 如果后面还可能继续导入旧文章、旧草稿、旧 CMS 数据,建议保留
- 如果你希望仓库更干净,但又不想彻底丢掉它,可以保留在
scripts/或tools/下,明确标注它是维护脚本
判断标准不是“它现在会不会被页面 import”,而是:
以后是否还有重复整理内容的可能。
所以它通常有两种身份:
- 一次性迁移工具
- 可重复使用的整理工具
这两种都合理,取决于项目还会不会继续迁内容。
如果换一个平台,思路会变吗
基本不会。
你把这套思路搬到别的项目时,真正要替换的通常只有三样东西:
- 目标 schema
- 旧字段到新字段的映射规则
- 读写 frontmatter 的具体实现
但脚本骨架往往几乎不变:
- 扫描文件
- 读取 frontmatter
- 转成对象
- 计算标准化后的字段
- 重建 frontmatter
- 写回或只检查
也就是说,这类脚本的“平台差异”主要在输入输出,核心逻辑其实很稳定。
如果以后还要跟别人解释,这样说最清楚
一句比较准确的话可以是:
“这是一个内容迁移/规范化脚本,用来把旧 frontmatter 映射到当前 schema,自动补齐缺失字段,删除废字段,并支持 check 模式。”
这个说法有几个好处:
- 它说明了这不是运行时代码
- 它说明了目标是当前 schema,而不是无限兼容历史
- 它说明了脚本既能批量写回,也能只做校验
如果要再短一点,可以直接说:
“这是一个 frontmatter migration script,不是站点功能代码,是内容维护工具。”
最后
内容项目里,代码经常比内容更容易保持整洁。
因为代码会被 lint、build、typecheck 反复约束;内容 frontmatter 如果没有明确规则,往往会在几年里悄悄长成另一套系统。
所以一旦你发现文章字段开始分叉,最省力的办法通常不是继续手改,而是尽快写一个规范化脚本,把当前项目真正需要的字段固定下来。
脚本不一定要永久保留,但“先定 schema,再做迁移,再保留 check 模式”这套方法,几乎可以复用到任何 Markdown 内容项目里。