Files
UHD_Media_Tool/scripts/check-copy-punctuation.mjs
T

343 lines
13 KiB
JavaScript
Raw 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 -S deno run -A
// 文案标点检查(规范见 docs/design/glossary.md「标点」)。只看源码里的字符串字面量、模板字符串与 JSX 文本,
// 不看注释、类型字面量、对象键、比较/匹配用的字符串(includes / split / === 等)和像正则的字符串。
//
// deno run -A scripts/check-copy-punctuation.mjs # 报告,warn 模式(退出码 0)
// deno run -A scripts/check-copy-punctuation.mjs --strict # 有违规退出码 1
// deno run -A scripts/check-copy-punctuation.mjs --fix [文件…] # 自动改能确定的项(标点、省略号、括号、引号、空格)
// deno run -A scripts/check-copy-punctuation.mjs --by-file # 按文件汇总违规数
//
// 例外:字符串所在行或上一行写 `copy-allow: 原因`;整个文件在前 5 行写 `copy-allow-file: 原因`。
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import { parse } from "npm:@babel/[email protected]";
const ROOT = new URL("..", import.meta.url).pathname;
const SCOPES = ["apps/web/app", "apps/server/src", "packages/contracts/src"];
const SKIP_DIR = /(^|\/)(node_modules|build|dist|\.react-router|__tests__|fixtures|routes\/dev)(\/|$)/;
const SKIP_FILE = /\.(test|spec)\.tsx?$|\.d\.ts$/;
const CJK = "\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff";
const CJK_RE = new RegExp(`[${CJK}]`);
const PH = ""; // 模板表达式 / JSX 表达式占位
const FULL = { ",": ",", ";": ";", ":": ":", "?": "?", "!": "!" };
const args = process.argv.slice(2);
const fix = args.includes("--fix");
const strict = args.includes("--strict");
const byFile = args.includes("--by-file");
const only = args.filter((a) => !a.startsWith("--"));
// ── 规则 ──
/** 返回违规列表 [{rule, index}] */
function findIssues(text) {
if (!CJK_RE.test(text)) return [];
const out = [];
const push = (rule, re) => {
for (const m of text.matchAll(re)) out.push({ rule, index: m.index });
};
push("中文旁用了半角标点", new RegExp(`[${CJK}][,;:?!]|[,;:?!](?=[${CJK}])`, "g"));
push("中文句末用了半角句号", new RegExp(`[${CJK}]\\.(?=\\s|$)`, "g"));
push("连续标点", /[,。;:?!、,;][,。;:?!、,;]|。。|!!|??/g);
push("中英文/数字之间缺空格", new RegExp(`[${CJK}][A-Za-z0-9]|[A-Za-z0-9][${CJK}]`, "g"));
push("省略号应为 …", /\.\.\.|……/g);
push("括号内有中文应用全角括号", new RegExp(`\\([^()]*[${CJK}][^()]*\\)`, "g"));
push("引号应为「」", /[“”]/g);
if (termScope) push("处理状态写「整理」不写「入库」(见 glossary)", TERM_RUKU);
return out;
}
// 术语:我们只负责整理(元数据、NFO、图片、归到目录),媒体服务器有没有扫到不归本工具判断,
// 所以表示处理状态的「已入库 / 未入库 / 入库方式 / 入库后…」一律写「整理」。只抓处理状态语境的搭配,
// 单独的「入库」(如搜索关键词)不算。
const TERM_RUKU =
/(已|未|部分|核验|验证|整理|登记|原地|复制|移动|最近|新|没有|没|不|补|按集|临时作品)入库|入库(失败|中|方式|后|版本|时间|文件|位置|状态|任务)/g;
// 其它线负责、术语替换待协调者合并的文件(清单见 .local/term-replacements.md);替换完成后删掉这一项。
const TERM_PENDING = new Set([]);
let termScope = true;
/** 能确定的自动修正;占位符 PH 视为非中文、非字母数字 */
function fixText(text) {
if (!CJK_RE.test(text)) return text;
let t = text;
t = t.replace(/……|\.\.\./g, "…");
t = t.replace(/“([^“”]*)”/g, "「$1」");
// 括号内含中文 → 全角,去掉括号内外紧贴的空格(由内向外)
const paren = new RegExp(`[ \\t]?\\(([^()]*[${CJK}][^()]*)\\)[ \\t]?`);
for (let i = 0; i < 20 && paren.test(t); i++) {
t = t.replace(paren, (m, inner, off, all) => {
const before = all[off - 1];
const after = all[off + m.length];
const lead = m.startsWith(" ") || m.startsWith("\t") ? (before === undefined || before === PH ? m[0] : "") : "";
const trail = /[ \t]$/.test(m) ? (after === undefined || after === PH ? m.at(-1) : "") : "";
return `${lead}(${inner.trim()})${trail}`;
});
}
// 中文旁的半角 , ; : ? ! → 全角,并去掉其后的空格
t = t.replace(new RegExp(`([${CJK})」])[ \\t]*([,;:?!])[ \\t]*`, "g"), (_m, a, p) => a + FULL[p]);
t = t.replace(new RegExp(`([,;:?!])[ \\t]*(?=[${CJK}(「])`, "g"), (_m, p) => FULL[p]);
// 中文后的半角句号(句末或后跟空白)→ 。
t = t.replace(new RegExp(`([${CJK})」])\\.(?=[ \\t]|$)[ \\t]*`, "g"), "$1。");
// 全角标点两侧不留空格
t = t.replace(/[ \t]+([,。;:?!、)」])/g, "$1").replace(/([,。;:?!、(「])[ \t]+/g, "$1");
// 中英文 / 数字之间加空格
t = t.replace(new RegExp(`([${CJK}])([A-Za-z0-9])`, "g"), "$1 $2");
t = t.replace(new RegExp(`([A-Za-z0-9])([${CJK}])`, "g"), "$1 $2");
return t;
}
// ── 源码遍历 ──
const MATCH_CALLS = new Set([
"includes",
"startsWith",
"endsWith",
"indexOf",
"lastIndexOf",
"split",
"replace",
"replaceAll",
"match",
"matchAll",
"search",
"test",
"has",
"get",
"delete",
"RegExp",
"querySelector",
"querySelectorAll",
"closest",
]);
/** 字面量用作数据(键、匹配、比较、类型)而非给人看的文字 */
function isDataUse(node, parent, key) {
if (!parent) return false;
switch (parent.type) {
case "TSLiteralType":
case "ImportDeclaration":
case "ExportNamedDeclaration":
case "ExportAllDeclaration":
case "SwitchCase":
case "TSEnumMember":
return true;
case "ObjectProperty":
case "ObjectMethod":
case "ClassProperty":
case "TSPropertySignature":
return key === "key";
case "MemberExpression":
case "OptionalMemberExpression":
return key === "property";
case "BinaryExpression":
return ["===", "!==", "==", "!=", "in"].includes(parent.operator);
case "CallExpression":
case "OptionalCallExpression":
case "NewExpression": {
const c = parent.callee;
const name = c.type === "Identifier" ? c.name : c.property?.name;
if (MATCH_CALLS.has(name)) return true;
if (name === "set" && parent.arguments[0] === node) return true;
return false;
}
default:
return false;
}
}
/** 像正则/代码的字符串(\d \( \. 等转义)整条跳过 */
const looksLikeRegex = (raw) => /\\[^nt"'`\\u]/.test(raw);
function collect(ast, src) {
/** @type {{start:number,end:number,text:string,kind:string}[][]} 每组 = 一段连续文字(模板/JSX 子节点按占位拼接) */
const groups = [];
const walk = (node, parent, key) => {
if (!node || typeof node.type !== "string") return;
if (node.type === "StringLiteral") {
const raw = src.slice(node.start + 1, node.end - 1);
if (!isDataUse(node, parent, key) && !looksLikeRegex(raw))
groups.push([{ start: node.start + 1, end: node.end - 1, text: raw }]);
return;
}
if (node.type === "TemplateLiteral") {
if (parent?.type !== "TaggedTemplateExpression" && !isDataUse(node, parent, key)) {
const parts = node.quasis.map((q) => ({ start: q.start, end: q.end, text: src.slice(q.start, q.end) }));
if (!parts.some((p) => looksLikeRegex(p.text))) groups.push(parts);
}
for (const e of node.expressions) walk(e, node, "expressions");
return;
}
if ((node.type === "JSXElement" || node.type === "JSXFragment") && node.children?.length) {
// 相邻 JSXText 与表达式按占位拼成一段(`剩余 {n} 项(共 {m})` 能整体判断)
let run = [];
const flush = () => {
if (run.some(Boolean)) groups.push(run);
run = [];
};
for (const ch of node.children) {
if (ch.type === "JSXText") run.push({ start: ch.start, end: ch.end, text: src.slice(ch.start, ch.end) });
else if (ch.type === "JSXExpressionContainer" && ch.expression.type !== "JSXEmptyExpression") run.push(null);
else flush();
}
flush();
}
for (const k of Object.keys(node)) {
if (k === "loc" || k === "leadingComments" || k === "trailingComments" || k === "innerComments") continue;
const v = node[k];
if (Array.isArray(v)) for (const c of v) walk(c, node, k);
else if (v && typeof v.type === "string") walk(v, node, k);
}
};
walk(ast.program, null, null);
return groups;
}
function lineOf(src, idx) {
let line = 1;
for (let i = 0; i < idx; i++) if (src.charCodeAt(i) === 10) line++;
return line;
}
function allowed(lines, line) {
return /copy-allow/.test(lines[line - 1] ?? "") || /copy-allow/.test(lines[line - 2] ?? "");
}
function processFile(file) {
const src = readFileSync(file, "utf8");
termScope = !TERM_PENDING.has(relative(ROOT, file));
if (/copy-allow-file/.test(src.split("\n").slice(0, 5).join("\n"))) return { issues: [], changed: false };
let ast;
try {
ast = parse(src, {
sourceType: "module",
plugins: file.endsWith(".tsx") ? ["typescript", "jsx"] : ["typescript"],
errorRecovery: true,
});
} catch (e) {
return { issues: [{ line: 0, rule: `解析失败: ${e.message}`, excerpt: "" }], changed: false };
}
const lines = src.split("\n");
const issues = [];
const edits = [];
for (const g of collect(ast, src)) {
const texts = g.filter(Boolean);
if (texts.length === 0) continue;
const composed = joinWithPH(g);
for (const is of findIssues(composed.text)) {
const loc = composed.locate(is.index);
if (!loc) continue;
const line = lineOf(src, loc);
if (allowed(lines, line)) continue;
issues.push({ line, rule: is.rule, excerpt: composed.text.slice(Math.max(0, is.index - 12), is.index + 14) });
}
if (fix) {
const firstLine = lineOf(src, texts[0].start);
if (allowed(lines, firstLine)) continue;
const fixed = fixText(composed.text);
if (fixed !== composed.text) {
const pieces = fixed.split(PH);
if (pieces.length === composed.slots.length)
composed.slots.forEach((p, i) => {
if (p && pieces[i] !== p.text) edits.push({ start: p.start, end: p.end, text: pieces[i] });
});
}
}
}
let changed = false;
if (fix && edits.length) {
let out = src;
for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
if (out !== src) {
writeFileSync(file, out);
changed = true;
}
}
return { issues, changed, edits: edits.length };
}
/**
* 把一组片段拼成带占位的文本。模板字面量:片段之间天然隔一个表达式;JSX:null 是表达式。
* slots 与按 PH 切分后的段一一对应(表达式位置为 null 段不会出现:相邻表达式之间插空文本段)。
*/
function joinWithPH(g) {
const slots = [];
const isJsx = g.includes(null);
if (isJsx) {
// JSX:文本段与表达式交替;两个表达式相邻时补一个空文本段
let lastWasText = false;
for (const p of g) {
if (p) {
slots.push(p);
lastWasText = true;
} else {
if (!lastWasText) slots.push(null);
lastWasText = false;
}
}
if (!lastWasText) slots.push(null);
} else slots.push(...g);
const text = slots.map((p) => (p ? p.text : "")).join(PH);
const offsets = [];
let acc = 0;
for (const p of slots) {
offsets.push(acc);
acc += (p ? p.text.length : 0) + 1;
}
return {
text,
slots,
locate(idx) {
for (let i = slots.length - 1; i >= 0; i--)
if (idx >= offsets[i])
return slots[i] ? slots[i].start + Math.min(idx - offsets[i], slots[i].text.length) : null;
return null;
},
};
}
function listFiles() {
if (only.length) return only.map((f) => join(process.cwd(), f));
const out = [];
const rec = (dir) => {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const rel = relative(ROOT, p);
if (SKIP_DIR.test(rel)) continue;
const st = statSync(p);
if (st.isDirectory()) rec(p);
else if (/\.tsx?$/.test(name) && !SKIP_FILE.test(name)) out.push(p);
}
};
for (const s of SCOPES) rec(join(ROOT, s));
return out.sort();
}
let total = 0;
let changedFiles = 0;
let editCount = 0;
const byRule = new Map();
for (const f of listFiles()) {
const r = processFile(f);
if (r.changed) {
changedFiles++;
editCount += r.edits;
}
const issues = fix ? processFile(f).issues : r.issues;
if (byFile && issues.length) console.log(`${String(issues.length).padStart(4)} ${relative(ROOT, f)}`);
for (const i of issues) {
total++;
byRule.set(i.rule, (byRule.get(i.rule) ?? 0) + 1);
if (!byFile && total <= 200)
console.log(`${relative(ROOT, f)}:${i.line} ${i.rule} …${i.excerpt.replaceAll(PH, "{…}")}…`);
}
}
if (!byFile && total > 200) console.log(`…另有 ${total - 200} 处未列出`);
if (fix) console.log(`已修改 ${changedFiles} 个文件、${editCount} 处字面量`);
console.log(
total
? `${strict ? "✗" : "⚠"} 文案标点:${total} 处(${[...byRule].map(([k, v]) => `${k} ${v}`).join(";")})`
: "✓ 文案标点:0 处",
);
if (strict && total) process.exit(1);