项目专题 · 内容发布流程
准备内容元数据与站内链接
在发布前补齐博客与文档的必要元数据,并把可解析的本地 Markdown 链接规范为公开路由。
问题
进入仓库的 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).`);