问题

进入仓库的 Markdown 可能带有文件开头 BOM、缺少 frontmatter,或缺少集合需要的默认字段。文章之间的链接也可能仍指向本地 Markdown 文件,而不是站点公开路由。

这些问题属于不同责任:前者决定内容能否按集合结构被识别,后者决定站内链接能否从本地文件关系转换为公开页面关系。

目的

准备流程先整理文件头与元数据,再解析链接。这样链接脚本面对的是已经落在 blog 或 docs 集合中的文件,能够按真实文件路径和路由 slug 建立索引。

两支脚本都保留只检查、不直接改写的入口:元数据脚本使用 --dry-run,链接脚本默认运行 check 模式。

解决方案

prepare-content.mjs 读取内容时先移除开头 BOM,再判断是否已有 frontmatter。没有 frontmatter 的博客会得到标题、空描述、当天日期、空标签与空专题;文档会得到标题、空描述、当天日期、未分类类别与空标签。已有博客 frontmatter 只在缺少 topic 时补上空专题字段。

convert-markdown-links.mjs 递归索引 blog 与 docs 文件,优先按相对文件路径解析链接,也能按 slug 回退匹配,并保留标题锚点。check 模式报告待转换与无法解析的本地 Markdown 链接;--apply 写入已解析的公开路由,无法解析的链接仍会被报告。

脚本代码展示

prepare-content.mjs

import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { formatLocalDate, hasFrontmatter, isValidCompletionDate, normalizeFrontmatter, splitMarkdownSource } from '../src/shared/content/frontmatter.mjs';
import { parse } from 'yaml';

const root = process.cwd();
const isDryRun = process.argv.includes('--dry-run');

const today = formatLocalDate(new Date());

const targets = [
	{
		directory: join(root, 'src', 'content', 'blog'),
		collection: 'blog',
	},
	{
		directory: join(root, 'src', 'content', 'docs'),
		collection: 'docs',
	},
];

const ensureDirectory = (directory) => {
	if (!existsSync(directory)) {
		mkdirSync(directory, { recursive: true });
	}
};

const collectMarkdownFiles = (directory) => {
	const entries = readdirSync(directory, { withFileTypes: true });
	const files = [];

	for (const entry of entries) {
		const fullPath = join(directory, entry.name);

		if (entry.isDirectory()) {
			files.push(...collectMarkdownFiles(fullPath));
			continue;
		}

		if (entry.isFile() && entry.name.endsWith('.md')) {
			files.push(fullPath);
		}
	}

	return files;
};

const removeBom = (content) => content.replace(/^\uFEFF/, '');

for (const target of targets) {
	ensureDirectory(target.directory);

	for (const file of collectMarkdownFiles(target.directory)) {
		const stats = statSync(file);
		if (!stats.isFile()) {
			continue;
		}

		const original = readFileSync(file, 'utf8');
		const normalized = removeBom(original);
		const nextContent = normalizeFrontmatter(normalized, {
			fileName: file,
			pubDate: today,
			collection: target.collection,
		});
		if (target.collection === 'blog' && !isValidCompletionDate(parse(splitMarkdownSource(nextContent).frontmatter)?.completion_date)) {
			throw new TypeError(`${relative(root, file)}: completion_date 必须是 YYYY-MM-DD 的有效完成日期。`);
		}

		if (hasFrontmatter(normalized)) {
			if (nextContent !== normalized) {
				if (isDryRun) {
					console.log(`[dry-run] Would update frontmatter fields: ${relative(root, file)}`);
					continue;
				}

				writeFileSync(file, nextContent, 'utf8');
				console.log(`Updated frontmatter fields: ${relative(root, file)}`);
			}

			continue;
		}

		if (isDryRun) {
			console.log(`[dry-run] Would add frontmatter: ${relative(root, file)}`);
			continue;
		}

		writeFileSync(file, nextContent, 'utf8');
		console.log(`Added frontmatter: ${relative(root, file)}`);
	}
}

convert-markdown-links.mjs

import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { createContentIndex, normalizeMarkdownLinks } from '../src/shared/content/links.mjs';

const root = process.cwd();
const contentRoot = join(root, 'src', 'content');
const targets = [
	{ collection: 'blog', directory: join(contentRoot, 'blog') },
	{ collection: 'docs', directory: join(contentRoot, 'docs') },
];

const mode = process.argv.includes('--apply') ? 'apply' : 'check';

const toPosixPath = (path) => path.split(sep).join('/');

const collectMarkdownFiles = (directory) => {
	if (!existsSync(directory)) {
		return [];
	}

	const files = [];
	const entries = readdirSync(directory, { withFileTypes: true });

	for (const entry of entries) {
		const fullPath = join(directory, entry.name);

		if (entry.isDirectory()) {
			files.push(...collectMarkdownFiles(fullPath));
			continue;
		}

		if (entry.isFile() && entry.name.endsWith('.md')) {
			files.push(fullPath);
		}
	}

	return files;
};

const contentFiles = targets.flatMap(({ collection, directory }) =>
	collectMarkdownFiles(directory).map((file) => ({
		collection,
		path: toPosixPath(relative(root, file)),
	})));
const contentIndex = createContentIndex(contentFiles);

const changes = [];
const unresolvedLinks = [];

for (const entry of contentFiles) {
	const file = join(root, entry.path);
	const stats = statSync(file);
	if (!stats.isFile()) {
		continue;
	}

	const original = readFileSync(file, 'utf8');
	const result = normalizeMarkdownLinks(original, entry.path, contentIndex);
	const next = result.content;
	changes.push(...result.changes.map(({ href, target }) => `${entry.path}: ${href} -> ${target}`));
	unresolvedLinks.push(...result.unresolvedLinks.map((href) => `${entry.path}: unresolved local Markdown link: ${href}`));

	if (next !== original && mode === 'apply') {
		writeFileSync(file, next, 'utf8');
	}
}

if (changes.length === 0 && unresolvedLinks.length === 0) {
	console.log('Markdown links are already normalized.');
	process.exit(0);
}

for (const change of changes) {
	console.log(change);
}

if (unresolvedLinks.length > 0) {
	console.error('Unresolved local Markdown links:');
	for (const link of unresolvedLinks) {
		console.error(`- ${link}`);
	}
}

if (mode === 'check') {
	if (changes.length > 0) {
		console.error('Markdown links need conversion. Run npm run links:apply.');
	}

	if (unresolvedLinks.length > 0) {
		console.error('Some local Markdown links do not match content files. Add the target files or update the links.');
	}

	process.exit(1);
}

if (unresolvedLinks.length > 0) {
	console.error('Some local Markdown links do not match content files. Add the target files or update the links.');
	process.exit(1);
}

console.log(`Converted ${changes.length} markdown link(s).`);