批量修改不能只靠“全局替换后看一眼”。本例只把普通顶层二级标题“旧标题”改为“新标题”,同样文字出现在段落、YAML、代码块或链接中都不改。脚本先用解析器确认结构,再按原始行位置改动,最终生成副本。
环境、范围和输入
在新的 markdown-demo 目录使用 Node.js 20+,安装本例固定版本依赖:
npm init -y
npm install --save-exact --ignore-scripts [email protected] [email protected]
版本用于固定教学环境,不表示以后永远使用这些版本。保存锁文件。这里使用 markdown-it 的公开解析入口,不导入内部子路径。
建立 input/a.md:
---
title: "旧标题"
updated: "2026-10-01"
---
## 旧标题
正文里提到旧标题,不应该被替换。
```markdown
## 旧标题
```
[旧标题](https://example.com)
再建立 input/b.md,正文为“没有目标标题。”。目录可以有多篇 .md 文件,本例只处理直接子文件,不递归、不跟随目录链接。练习使用普通 UTF-8 文件;自定义 MDX 或不完整 frontmatter 先修复,不套本脚本。
完整脚本:解析、预演、核对输入再写副本
保存为 rename-heading.mjs:
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import MarkdownIt from 'markdown-it';
import matter from 'gray-matter';
const mode = process.argv[2] || 'plan';
if (!['plan', 'apply'].includes(mode)) throw new Error('用法: node rename-heading.mjs plan|apply');
const inputDir = path.resolve('input');
const outputDir = path.resolve('converted');
const planFile = path.resolve('markdown-plan.json');
const md = new MarkdownIt();
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
const names = fs.readdirSync(inputDir).filter(n => n.endsWith('.md')).sort();
const rows = [];
for (const name of names) {
const file = path.join(inputDir, name);
if (!fs.lstatSync(file).isFile()) throw new Error('仅处理普通文件: ' + name);
const buffer = fs.readFileSync(file);
const raw = buffer.toString('utf8');
if (!Buffer.from(raw).equals(buffer)) throw new Error('不是有效 UTF-8: ' + name);
const parsed = matter(raw);
const body = parsed.content;
if (!raw.endsWith(body)) throw new Error('无法保留原始头部: ' + name);
const prefix = raw.slice(0, raw.length - body.length);
const lines = body.match(/[^\n]*\n|[^\n]+$/g) || [];
const tokens = md.parse(body, {});
const changes = [];
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t.type !== 'heading_open' || t.tag !== 'h2' || t.level !== 0) continue;
const inline = tokens[i + 1];
if (inline.content !== '旧标题') continue;
const [start, end] = t.map;
const line = lines[start];
if (end !== start + 1 || !/^## 旧标题(?:\r?\n)?$/.test(line) ||
inline.children.length !== 1 || inline.children[0].type !== 'text') {
throw new Error('标题格式需人工处理: ' + name + ':' + (start + 1));
}
lines[start] = line.replace('旧标题', '新标题');
changes.push({ bodyLine: start + 1, before: '## 旧标题', after: '## 新标题' });
}
const after = prefix + lines.join('');
if (JSON.stringify(matter(after).data) !== JSON.stringify(parsed.data)) {
throw new Error('元数据变化: ' + name);
}
rows.push({ name, sourceHash: hash(buffer), changes, after });
}
const manifest = {
inputDir, rule: 'top-level plain ATX h2: 旧标题 -> 新标题',
files: rows.map(({ name, sourceHash, changes }) => ({ name, sourceHash, changes }))
};
if (mode === 'plan') {
fs.writeFileSync(planFile, JSON.stringify(manifest, null, 2) + '\n');
console.log(JSON.stringify(manifest, null, 2));
} else {
const saved = JSON.parse(fs.readFileSync(planFile, 'utf8'));
if (JSON.stringify(saved) !== JSON.stringify(manifest)) {
throw new Error('输入或规则已变化,请重新预演并检查');
}
// Existing output is deliberately refused; the input is never overwritten.
fs.mkdirSync(outputDir);
for (const row of rows) fs.writeFileSync(path.join(outputDir, row.name), row.after, { flag: 'wx' });
console.log('副本写入 converted;原 input 未改动');
}
它没有重新序列化全部 Markdown,也没有重新输出 YAML;原头部字节和非目标行保持原样。解析器负责识别 heading_open 的类型和行范围,只有已经确认是普通顶层二级标题的那一行才做替换。因此最后一处 replace 是结构定位后的局部操作,不是对整篇文章硬替换。
Setext 标题、带强调/链接的标题、块引用和列表中的标题都不在自动处理范围。恰好具有目标内容但格式不符合时,脚本会停止;没有命中的其他结构保持不动。它是明确规则的批处理示例,不是通用 Markdown 迁移器。
先看清单,再执行
node rename-heading.mjs plan
Get-Content -LiteralPath markdown-plan.json
node rename-heading.mjs apply
git diff --no-index -- input converted
预演应列出 a.md 的一个标题变更,b.md 的 changes 为空。清单保存源文件哈希;预演后输入增加、减少或内容变化,apply 都会停止,要求重新预演。不要只看变更数量,还要查看前后文字与文件名。
应用后 converted/a.md 的实际标题变成“新标题”,YAML title、正文句子、代码块和链接文字仍是“旧标题”;converted/b.md 与原文一致。git diff --no-index 的退出码 1 表示发现差异,并不自动代表脚本错误。
input 原文件从未被覆盖。converted 已存在时脚本拒绝执行,避免覆盖上次结果;保存旧结果后使用新的练习目录再执行。如果磁盘写入中途失败,副本目录可能不完整,原 input 仍在;不要把半份输出当成完成品。
还要验证哪些反例
复制练习目录,分别测试:a.md 没有目标标题、相同文字只在代码块、标题含格式、预演后改输入、重复执行 apply。检查未匹配内容是否原样保留,错误是否停止写入,而不是只确认正常样本能变出文件。
允许自动替换的是这次明确选择的结构。若需求改为更新 frontmatter 的 updated,应通过 YAML 字段修改并说明序列化格式变化;不能把本例标题脚本稍改字符串就宣称已经支持所有字段迁移。
放回真实项目之前
让 Codex 比较源与副本,解释每处差异并列出未覆盖的语法,再按真实项目的构建命令验证副本。构建可能有自定义插件和内部链接规则,解析成功不能证明业务页面正确。
只有确认本次变更符合需求后,再通过可审查的逐文件差异合入内容源;不要覆盖整个目录。这个练习不会部署网站,也不会替真实项目决定哪些标题应该改。
资料来源
- markdown-it 官方项目
使用 parse 与 token 类型、源行映射。
- gray-matter 官方项目
解析元数据与正文,不重排原 YAML。
下一步
继续阅读与你当前问题相关的教程:


