Files
up/scripts/check-content.mjs
Leap 离谱and你的名字 d442046074 Upgrade learning guide onboarding and maintenance (#204)
* Upgrade learning guide onboarding and maintenance

* Clarify audit publication status

---------

Co-authored-by: 你的名字 <you@example.com>
2026-10-19 13:08:05 +08:00

391 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import {
existsSync,
readFileSync,
readdirSync,
statSync,
} from "node:fs";
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { enNavigation, zhNavigation } from "../docs/.vitepress/navigation.mjs";
import {
frontmatterProblems,
bilingualMetadataProblems,
linkProblems,
navigationProblems,
parseFrontmatter as parseFrontmatterText,
} from "./lib/content-checks.mjs";
import { repositoryReadmeFromDocs } from "./lib/readme.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const DOCS = join(ROOT, "docs");
const errors = [];
const IGNORE_DIRS = new Set([
".git",
"node_modules",
"dist",
".cache",
"cache",
".codex-artifact-work",
"outputs",
"playwright-report",
"test-results",
".vitepress",
]);
const PUBLIC_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
const PUBLIC_ASSET_EXTENSIONS = new Set([...PUBLIC_IMAGE_EXTENSIONS, ".svg"]);
const TEXT_SOURCE_EXTENSIONS = new Set([".md", ".mjs", ".mts", ".ts", ".css"]);
const FORBIDDEN_TRACKED_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
function addError(file, line, message) {
errors.push({ file: relative(ROOT, file), line, message });
}
function walk(dir, extensions, output = []) {
for (const name of readdirSync(dir)) {
if (IGNORE_DIRS.has(name)) continue;
const path = join(dir, name);
const stat = statSync(path);
if (stat.isDirectory()) walk(path, extensions, output);
else if (extensions.has(extname(name).toLowerCase())) output.push(path);
}
return output;
}
function checkLinksAndAlt(file) {
for (const { line, message } of linkProblems(readFileSync(file, "utf8"), file, DOCS)) {
addError(file, line, message);
}
}
function parseFrontmatter(file) {
return parseFrontmatterText(readFileSync(file, "utf8"));
}
function checkFrontmatter(file) {
if (file.endsWith("SUMMARY.md")) return;
const text = readFileSync(file, "utf8");
const rawBlock = text.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
if (rawBlock) {
rawBlock.split(/\r?\n/).forEach((line, index) => {
const value = line.match(/^[a-zA-Z][\w-]*:\s*(.+)$/)?.[1]?.trim();
if (value && !/^['"]/.test(value) && /:\s/.test(value)) {
addError(file, index + 2, "frontmatter 含冒号的文本值必须使用引号");
}
});
}
for (const message of frontmatterProblems(text, relative(DOCS, file).split(sep).join("/"))) {
addError(file, 1, message);
}
}
function checkManualBookPager(file) {
const source = relative(DOCS, file).split(sep).join("/");
const isBookPage = /^(?:en\/)?threads\/part-[0-6]\//.test(source) || /^(?:en\/)?projects\.md$/.test(source);
if (!isBookPage) return;
const manualPager = /^(?:(?:上一篇|下一篇|下一部|返回首页)[::]|(?:Prev|Previous|Next|Next Part|Back to the home page):)/;
readFileSync(file, "utf8").split("\n").forEach((line, index) => {
if (manualPager.test(line.trim())) {
addError(file, index + 1, "主书稿正文不得手写上一篇/下一篇;连续阅读由统一页脚生成");
}
});
}
function checkPartOneClosing(file) {
const source = relative(DOCS, file).split(sep).join("/");
const match = source.match(/^(en\/)?threads\/part-1\/(0-cefr|grammar|[1-7]-.+)\.md$/);
if (!match) return;
const headings = [...readFileSync(file, "utf8").matchAll(/^## (.+)$/gm)].map((heading) => heading[1]);
const expected = match[1] ? /^Closing(?:[::]|$)/ : /^结语[::]/;
if (!expected.test(headings.at(-1) || "")) {
addError(file, 1, "第一部核心章节必须以双语结语收束,不能停在量表、来源或训练清单");
}
}
function checkKeyLiteraryClosing(file) {
const source = relative(DOCS, file).split(sep).join("/");
const expectedClosings = new Map([
["threads/part-2/my-story.md", /^结语:重来不是凯旋$/],
["en/threads/part-2/my-story.md", /^Closing: Starting Again Is Not a Triumph$/],
["threads/part-2/narrative-and-evidence.md", /^结语:让故事回到生活$/],
["en/threads/part-2/narrative-and-evidence.md", /^Closing: Let the Story Return to Life$/],
["threads/part-2/entrepreneurship.md", /^结语:让野心经过现实$/],
["en/threads/part-2/entrepreneurship.md", /^Closing: Let Ambition Pass Through Reality$/],
["threads/part-3/1-ai-learning.md", /^结语:把能力留在人身上$/],
["en/threads/part-3/1-ai-learning.md", /^Closing: Keep the Ability with the Person$/],
]);
const expected = expectedClosings.get(source);
if (!expected) return;
const headings = [...readFileSync(file, "utf8").matchAll(/^## (.+)$/gm)].map((heading) => heading[1]);
if (!expected.test(headings.at(-1) || "")) {
addError(file, 1, "关键故事与 AI 章节必须以指定文学结语收束,不能停在更新、目录或来源说明");
}
}
function checkEntrepreneurshipStyle(file) {
const source = relative(DOCS, file).split(sep).join("/");
if (source !== "threads/part-2/entrepreneurship.md") return;
const text = readFileSync(file, "utf8");
const contrastMarkers = ["不是", "而是", "真正"].reduce(
(total, marker) => total + (text.match(new RegExp(marker, "g")) || []).length,
0,
);
if (contrastMarkers > 8) {
addError(file, 1, "创业篇二元对举句式过密,应优先使用场景、动作与具体后果推进叙事");
}
}
function checkBilingualParity(markdownFiles) {
const publicFiles = markdownFiles.filter(
(file) => file.startsWith(`${DOCS}${sep}`) && !file.endsWith("SUMMARY.md"),
);
const chineseFiles = publicFiles.filter(
(file) => !file.startsWith(`${join(DOCS, "en")}${sep}`) && file !== join(DOCS, "README.md"),
);
const englishFiles = publicFiles.filter(
(file) => file.startsWith(`${join(DOCS, "en")}${sep}`) && file !== join(DOCS, "en/README.md"),
);
for (const file of chineseFiles) {
const path = relative(DOCS, file);
const expected = join(DOCS, "en", path);
if (!existsSync(expected)) addError(file, 1, `缺少英文对应页: ${relative(ROOT, expected)}`);
}
for (const file of englishFiles) {
const path = relative(join(DOCS, "en"), file);
const expected = join(DOCS, path);
if (!existsSync(expected)) addError(file, 1, `缺少中文对应页: ${relative(ROOT, expected)}`);
}
}
function headingShape(file) {
const shape = [];
let inFence = false;
for (const line of readFileSync(file, "utf8").split("\n")) {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = line.match(/^(#{1,6})\s+/);
if (match) shape.push(match[1].length);
}
return shape;
}
function checkHeadingParity(markdownFiles) {
const publicFiles = markdownFiles.filter(
(file) => file.startsWith(`${DOCS}${sep}`) && !file.endsWith("SUMMARY.md"),
);
const chineseFiles = publicFiles.filter(
(file) => !file.startsWith(`${join(DOCS, "en")}${sep}`) && file !== join(DOCS, "README.md"),
);
for (const file of chineseFiles) {
const path = relative(DOCS, file);
const expected = join(DOCS, "en", path);
if (!existsSync(expected)) continue;
const chineseShape = headingShape(file);
const englishShape = headingShape(expected);
if (chineseShape.join(",") !== englishShape.join(",")) {
addError(
file,
1,
`中英文标题层级不一致: ${chineseShape.join(",")} vs ${englishShape.join(",")}`,
);
}
}
}
function checkUpdatedParity(markdownFiles) {
const publicFiles = markdownFiles.filter(
(file) => file.startsWith(`${DOCS}${sep}`) && !file.endsWith("SUMMARY.md"),
);
const chineseFiles = publicFiles.filter(
(file) => !file.startsWith(`${join(DOCS, "en")}${sep}`) && file !== join(DOCS, "README.md"),
);
const pairs = [[join(DOCS, "README.md"), join(DOCS, "en/README.md")]];
for (const file of chineseFiles) {
const path = relative(DOCS, file);
pairs.push([file, join(DOCS, "en", path)]);
}
for (const [file, expected] of pairs) {
if (!existsSync(file) || !existsSync(expected)) continue;
const chinese = parseFrontmatter(file)?.updated;
const chineseFrontmatter = parseFrontmatter(file);
const englishFrontmatter = parseFrontmatter(expected);
const english = englishFrontmatter?.updated;
if (chinese && english && chinese !== english) {
addError(
file,
1,
`中英文稿件 updated 日期不一致: ${chinese} vs ${english}`,
);
}
for (const problem of bilingualMetadataProblems(readFileSync(file, "utf8"), readFileSync(expected, "utf8"))) {
addError(file, 1, problem);
}
}
}
const STALE_PATTERNS = [
["#/", "残留 Docsify hash 路由"],
["byoungd.github.io/up/#/", "残留旧 hash 站点地址"],
["byoungd.github.io/English-level-up-tips", "残留旧站点域名"],
["397865076", "公开个人 QQ 号码"],
["user.qzone.qq.com", "公开个人空间链接"],
["douyin-qr", "公开二维码资源"],
["2026-06 版", "过期的版本标签"],
["youtube.com/user/", "旧 YouTube 用户路由"],
["pan.baidu.com/s/1i5OLIIT", "失效网盘链接"],
["v.youku.com", "过期优酷链接"],
["zhuanlan.zhihu.com/p/444211376", "访问受限的旧知乎链接"],
["zhuanlan.zhihu.com/p/653380203", "访问受限的旧知乎链接"],
["10.1076/edre.7.1.403.3989", "已失效或错误的词汇研究 DOI"],
["10.1111/j.1467-9922.2011.00676.x", "指向其他文章的错误词汇研究 DOI"],
["scholarspace.manoa.hawaii.edu/items/dfe724c0-c66f-4afe-9164-d6c6a59585d4", "已替换的 ScholarSpace 词汇研究入口"],
["openaccess.wgtn.ac.nz/articles/journal_contribution/Unknown_vocabulary_density_and_reading_comprehension/12560354", "已替换的 Wellington 词汇研究入口"],
["Source (中文)", "英文页面混用中文来源标签"],
["dopamine detox method", "把多巴胺排毒写成已验证方法"],
["Lipu", "英文笔名拼写不一致"],
];
function checkStaleStrings(markdownFiles) {
for (const file of markdownFiles) {
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((line, index) => {
for (const [pattern, reason] of STALE_PATTERNS) {
if (line.includes(pattern)) addError(file, index + 1, `${reason}: "${pattern}"`);
}
});
}
}
function checkReadmeMirror() {
const source = readFileSync(join(DOCS, "README.md"), "utf8");
const expected = repositoryReadmeFromDocs(source);
const actual = readFileSync(join(ROOT, "README.md"), "utf8");
if (expected !== actual) {
addError(join(ROOT, "README.md"), 1, "未与 docs/README.md 同步;运行 npm run sync");
}
}
async function checkImageMetadata() {
const files = walk(join(DOCS, "assets"), PUBLIC_IMAGE_EXTENSIONS);
for (const file of files) {
let metadata;
try {
metadata = await sharp(file).metadata();
} catch (error) {
addError(file, 1, `无法检查图片元数据: ${error.message}`);
continue;
}
const found = ["exif", "iptc", "xmp"].filter((key) => metadata?.[key] != null);
if (found.length) {
addError(file, 1, `包含 EXIF/GPS 或描述元数据块: ${found.join(", ")}`);
}
}
}
function checkAttributionPaths() {
const file = join(ROOT, "ATTRIBUTIONS.md");
const lines = readFileSync(file, "utf8").split("\n");
const codeSpan = /`([^`]+)`/g;
lines.forEach((line, index) => {
codeSpan.lastIndex = 0;
let match;
while ((match = codeSpan.exec(line))) {
const candidate = match[1].trim();
if (!candidate.startsWith("docs/") || candidate.includes("*")) continue;
const path = resolve(ROOT, candidate.replace(/\/$/, ""));
if (!path.startsWith(`${ROOT}${sep}`) || !existsSync(path)) {
addError(file, index + 1, `归属表本地路径不存在: ${candidate}`);
}
}
});
}
function checkOrphanAssets() {
const assets = walk(join(DOCS, "assets"), PUBLIC_ASSET_EXTENSIONS);
const sourceFiles = walk(ROOT, TEXT_SOURCE_EXTENSIONS).filter((file) => {
if (file.startsWith(`${join(DOCS, "assets")}${sep}`)) return false;
const name = basename(file);
return name !== "ATTRIBUTIONS.md" && name !== "CHANGELOG.md";
});
const sourceText = sourceFiles.map((file) => readFileSync(file, "utf8"));
for (const asset of assets) {
const name = basename(asset);
if (!sourceText.some((text) => text.includes(name))) {
addError(asset, 1, `公共资产未在正文、配置或构建脚本中引用: ${name}`);
}
}
}
function checkTrackedSystemFiles() {
let tracked;
try {
tracked = execFileSync("git", ["ls-files", "-z"], { cwd: ROOT });
} catch (error) {
addError(join(ROOT, ".gitignore"), 1, `无法读取 Git 跟踪清单: ${error.message}`);
return;
}
for (const file of tracked.toString("utf8").split("\0")) {
if (!file || !FORBIDDEN_TRACKED_NAMES.has(basename(file))) continue;
addError(join(ROOT, file), 1, `系统元数据文件不应被 Git 跟踪: ${file}`);
}
}
// Generated worksheet files are downloadable payloads, not VitePress manuscript
// pages; their exact content is validated by sync-worksheets.mjs --check.
const markdownFiles = walk(ROOT, new Set([".md"])).filter(
(file) => !file.startsWith(`${join(DOCS, "public", "downloads", "worksheets")}${sep}`),
);
for (const file of markdownFiles) checkLinksAndAlt(file);
for (const file of markdownFiles.filter((path) => path.startsWith(`${DOCS}${sep}`))) {
checkFrontmatter(file);
checkManualBookPager(file);
checkPartOneClosing(file);
checkKeyLiteraryClosing(file);
checkEntrepreneurshipStyle(file);
}
checkBilingualParity(markdownFiles);
checkHeadingParity(markdownFiles);
checkUpdatedParity(markdownFiles);
for (const message of navigationProblems(zhNavigation, enNavigation, DOCS)) {
addError(join(DOCS, ".vitepress/navigation.mjs"), 1, message);
}
checkStaleStrings(
markdownFiles.filter(
(file) =>
file.startsWith(`${DOCS}${sep}`) ||
file === join(ROOT, "README.md") ||
file === join(ROOT, "SUMMARY.md"),
),
);
checkReadmeMirror();
checkAttributionPaths();
checkOrphanAssets();
checkTrackedSystemFiles();
await checkImageMetadata();
if (!errors.length) {
console.log(`✓ 内容校验通过:${markdownFiles.length} 个 Markdown 文件,双语、链接、元数据与隐私检查正常。`);
process.exit(0);
}
console.error(`✗ 发现 ${errors.length} 个问题:\n`);
for (const error of errors) {
const location = error.line ? `${error.file}:${error.line}` : error.file;
console.error(` ${location} ${error.message}`);
}
process.exit(1);