其它路负责的 web 文件里现有命中先加 copy-allow,文案由对应路改。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
375 lines
14 KiB
JavaScript
375 lines
14 KiB
JavaScript
#!/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);
|
||
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);
|
||
}
|
||
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);
|