Files
up/scripts/check-content.mjs
T

278 lines
9.3 KiB
JavaScript

#!/usr/bin/env node
import {
existsSync,
readFileSync,
readdirSync,
statSync,
} from "node:fs";
import { dirname, extname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
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"]);
const PUBLIC_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
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;
}
const isExternal = (target) =>
/^(https?:|mailto:|tel:|data:|\/\/)/i.test(target);
function stripOptionalTitle(target) {
return target.replace(/\s+["'].*$/, "").trim();
}
function routeToFile(route) {
const clean = route
.replace(/^\/+/, "")
.replace(/\/$/, "")
.replace(/\/index$/, "")
.replace(/\/README$/, "");
if (!clean) return join(DOCS, "README.md");
return join(DOCS, `${clean}.md`);
}
function resolveTarget(file, rawTarget) {
let target = stripOptionalTitle(rawTarget);
if (!target || isExternal(target) || target.startsWith("#")) return null;
const pathPart = target.split("#")[0].split("?")[0];
if (!pathPart) return null;
if (pathPart.startsWith("/")) return routeToFile(pathPart);
try {
return resolve(dirname(file), decodeURIComponent(pathPart));
} catch {
return resolve(dirname(file), pathPart);
}
}
function localTargetExists(path) {
if (existsSync(path)) return true;
if (!extname(path) && existsSync(`${path}.md`)) return true;
if (!extname(path) && existsSync(join(path, "README.md"))) return true;
return false;
}
const MARKDOWN_LINK = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
const HTML_HREF = /href\s*=\s*["']([^"']+)["']/gi;
const HTML_IMAGE = /<img\b([^>]*)>/gi;
function checkLinksAndAlt(file) {
const lines = readFileSync(file, "utf8").split("\n");
let inFence = false;
lines.forEach((line, index) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
return;
}
if (inFence) return;
const targets = [];
MARKDOWN_LINK.lastIndex = 0;
let match;
while ((match = MARKDOWN_LINK.exec(line))) {
if (match[1] === "!" && !match[2].trim()) {
addError(file, index + 1, "图片缺少有意义的 alt 文本");
}
targets.push(match[3]);
}
HTML_HREF.lastIndex = 0;
while ((match = HTML_HREF.exec(line))) targets.push(match[1]);
HTML_IMAGE.lastIndex = 0;
while ((match = HTML_IMAGE.exec(line))) {
const alt = match[1].match(/\balt\s*=\s*["']([^"']*)["']/i)?.[1];
if (!alt?.trim()) addError(file, index + 1, "HTML 图片缺少非空 alt 属性");
}
for (const target of targets) {
const resolved = resolveTarget(file, target);
if (resolved && !localTargetExists(resolved)) {
addError(
file,
index + 1,
`链接目标不存在: ${target} -> ${relative(ROOT, resolved)}`,
);
}
}
});
}
function parseFrontmatter(file) {
const text = readFileSync(file, "utf8");
const block = text.match(/^---\n([\s\S]*?)\n---\n/);
if (!block) return null;
const values = {};
for (const line of block[1].split("\n")) {
const match = line.match(/^([a-zA-Z][\w-]*):\s*(.+)$/);
if (match) values[match[1]] = match[2].trim().replace(/^['"]|['"]$/g, "");
}
return values;
}
function checkFrontmatter(file) {
if (file.endsWith("SUMMARY.md")) return;
const frontmatter = parseFrontmatter(file);
if (!frontmatter) {
addError(file, 1, "公开页面缺少 frontmatter");
return;
}
for (const key of ["title", "description", "updated"]) {
if (!frontmatter[key]) addError(file, 1, `frontmatter 缺少 ${key}`);
}
if (/\/(7-ai|1-ai-learning|2-ai-development-and-resource-layer)\.md$/.test(file)) {
if (!frontmatter.sources_checked) {
addError(file, 1, "AI 页面缺少 sources_checked");
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(frontmatter.sources_checked)) {
addError(file, 1, "sources_checked 必须使用 YYYY-MM-DD");
}
}
if (frontmatter.updated && !/^\d{4}-\d{2}-\d{2}$/.test(frontmatter.updated)) {
addError(file, 1, "updated 必须使用 YYYY-MM-DD");
}
if (frontmatter.description && frontmatter.description.length < 24) {
addError(file, 1, "description 过短,无法区分页面内容");
}
if (/\/(7-ai|1-ai-learning|2-ai-development-and-resource-layer)\.md$/.test(file) && frontmatter.sources_checked) {
const age = Date.now() - Date.parse(`${frontmatter.sources_checked}T00:00:00Z`);
const maxAge = 120 * 24 * 60 * 60 * 1000;
if (age > maxAge) addError(file, 1, "AI 产品资料超过 120 天未核验");
}
}
function checkBilingualParity(markdownFiles) {
const publicFiles = markdownFiles.filter(
(file) => file.startsWith(`${DOCS}/`) && !file.endsWith("SUMMARY.md"),
);
const chineseFiles = publicFiles.filter(
(file) => !file.startsWith(`${join(DOCS, "en")}/`) && file !== join(DOCS, "README.md"),
);
const englishFiles = publicFiles.filter(
(file) => file.startsWith(`${join(DOCS, "en")}/`) && file !== join(DOCS, "en/README.md"),
);
const specialZhToEn = new Map([
["threads/part-2/my-story.md", "threads/part-4/my-story.md"],
]);
const specialEnToZh = new Map(
[...specialZhToEn].map(([zh, en]) => [en, zh]),
);
for (const file of chineseFiles) {
const path = relative(DOCS, file);
const expected = join(DOCS, "en", specialZhToEn.get(path) || 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, specialEnToZh.get(path) || path);
if (!existsSync(expected)) addError(file, 1, `缺少中文对应页: ${relative(ROOT, expected)}`);
}
}
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", "过期优酷链接"],
["dopamine detox method", "把多巴胺排毒写成已验证方法"],
];
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 repositoryReadmeFromDocs(source) {
return source
.replace("中文 | [English](en/)", "中文 | [English](docs/en/README.md)")
.replace(/\]\((assets|threads|templates|reference)\//g, "](docs/$1/")
.replace(/src="\.\/assets\//g, 'src="./docs/assets/')
.replace(/\]\(projects\.md\)/g, "](docs/projects.md)")
.replace(/href="\.\/(threads\/[^\"]+|templates\/[^\"]+|projects)"/g, (_match, path) =>
`href="./docs/${path}.md"`,
);
}
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(", ")}`);
}
}
}
const markdownFiles = walk(ROOT, new Set([".md"]));
for (const file of markdownFiles) checkLinksAndAlt(file);
for (const file of markdownFiles.filter((path) => path.startsWith(`${DOCS}/`))) {
checkFrontmatter(file);
}
checkBilingualParity(markdownFiles);
checkStaleStrings(
markdownFiles.filter(
(file) =>
file.startsWith(`${DOCS}/`) ||
file === join(ROOT, "README.md") ||
file === join(ROOT, "SUMMARY.md"),
),
);
checkReadmeMirror();
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);