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

404 lines
16 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「标点」与 .claude/skills/human-copy)。只看源码里的字符串字面量、模板字符串与 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);
// 插值紧邻的半角括号:`${date}(${rel})` 在中文句子里,括号里多半是中文
push("插值旁的半角括号应为全角(括号内有中文时)", new RegExp(`${PH}\\(${PH}\\)`, "g"));
for (const m of text.matchAll(BANNED_WORDS)) out.push({ rule: `用词「${m[0]}」(见 human-copy)`, index: m.index });
return out;
}
// 用词:human-copy 规范里的客服腔、营销腔、AI 套话与「进行 + 动词」。只收误报低的写法:
// 「请」只拦祈使搭配(请先 / 请检查…),不碰「请求」「申请」;「一键」排除「同一键」;「进行」只拦后接动作名词。
const BANNED_WORDS = new RegExp(
[
"您",
"温馨提示",
"非常抱歉",
"敬请谅解",
"不便之处",
"(?<!同)一键",
"轻松",
"赋能",
"智能化",
"一站式",
"全新体验",
"无限可能",
"释放潜能",
"尽在掌握",
"成功地",
"其它",
"恭喜",
"太棒了",
"哎呀",
"别担心",
"无需担心",
"好消息是",
"请(?:先|检查|输入|重新|重试|选择|确认|稍后|填写|勿|务必|联系|注意|在|到|用)",
"进行(?:操作|处理|整理|下载|搜索|识别|删除|修改|设置|配置|校验|验证|检查|更新|同步|刷新|创建|安装|连接|测试|登录|保存|上传|备份|恢复|重试|清理)",
].join("|"),
"g",
);
// 术语:我们只负责整理(元数据、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);
}
if (node.quasis.some((q) => CJK_RE.test(q.value.raw)))
for (const e of node.expressions) for (const f of fragmentsOf(e)) checkFragment(f, src);
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;
}
/** 插值表达式里会被拼进外层中文模板的字面量(`${x ? `,…` : ""}` 里的 `,…`) */
function fragmentsOf(e) {
if (!e) return [];
if (e.type === "StringLiteral" || e.type === "TemplateLiteral") return [e];
if (e.type === "ConditionalExpression") return [...fragmentsOf(e.consequent), ...fragmentsOf(e.alternate)];
if (e.type === "LogicalExpression") return [...fragmentsOf(e.left), ...fragmentsOf(e.right)];
if (e.type === "TSAsExpression" || e.type === "ParenthesizedExpression") return fragmentsOf(e.expression);
return [];
}
/** 外层是中文模板时,插值片段开头的半角 , ; : 会紧贴中文 */
let fragIssues = [];
function checkFragment(f, src) {
const text = f.type === "StringLiteral" ? src.slice(f.start + 1, f.end - 1) : src.slice(f.quasis[0].start, f.quasis[0].end);
const m = /^\s*[,;:](?!\/\/)/.exec(text);
if (m) fragIssues.push({ start: f.start + 1, rule: "中文模板的插值片段以半角标点开头", excerpt: text.slice(0, 20) });
}
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 = [];
fragIssues = [];
const groups = collect(ast, src);
for (const fi of fragIssues) {
const line = lineOf(src, fi.start);
if (!allowed(lines, line)) issues.push({ line, rule: fi.rule, excerpt: fi.excerpt });
}
for (const g of groups) {
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);