#!/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/parser@7.29.9"; 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( [ "您", "温馨提示", "非常抱歉", "敬请谅解", "不便之处", "(? { 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);