505 lines
15 KiB
JavaScript
505 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Fail when user-visible UI copy contains a Unicode em dash.
|
|
*
|
|
* The check covers locale values, rendered web source strings, backend mock-agent
|
|
* response strings, backend shared page catalogs, and published Markdown. Historical
|
|
* changelog content is intentionally excluded because the changelog is generated
|
|
* release history and must remain immutable.
|
|
* Comments are ignored in source files so this remains a copy check rather than
|
|
* a style check for developer prose.
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const EM_DASH = "\u2014";
|
|
|
|
const WEB_ROOT = path.resolve(import.meta.dirname, "..");
|
|
const REPO_ROOT = path.resolve(WEB_ROOT, "..", "..");
|
|
const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
const BACKEND_SOURCE_EXTENSIONS = new Set([".go"]);
|
|
const PUBLIC_DOC_EXTENSIONS = new Set([".md", ".mdx"]);
|
|
const IGNORED_DIRECTORIES = new Set(["dist", "e2e", "node_modules"]);
|
|
const CODE_STATE = "code";
|
|
const LINE_COMMENT_STATE = "line-comment";
|
|
const BLOCK_COMMENT_STATE = "block-comment";
|
|
const STRING_STATE = "string";
|
|
const REGEX_START_CHARACTERS = new Set("=([{,:;!?&|+-*%^~<>".split(""));
|
|
const REGEX_KEYWORDS = new Set([
|
|
"await",
|
|
"case",
|
|
"delete",
|
|
"do",
|
|
"else",
|
|
"in",
|
|
"instanceof",
|
|
"new",
|
|
"of",
|
|
"return",
|
|
"throw",
|
|
"typeof",
|
|
"void",
|
|
"while",
|
|
"yield",
|
|
]);
|
|
|
|
export function containsEmDash(value) {
|
|
return value.includes(EM_DASH) || /\\u2014/i.test(value);
|
|
}
|
|
|
|
function relativePath(file, root) {
|
|
return path.relative(root, file).split(path.sep).join("/");
|
|
}
|
|
|
|
function commentCharacter(character, state) {
|
|
return character === "\n"
|
|
? { output: character, nextState: state === LINE_COMMENT_STATE ? CODE_STATE : state }
|
|
: { output: " ", nextState: state };
|
|
}
|
|
|
|
function consumeLineComment(source, index) {
|
|
return { ...commentCharacter(source[index], LINE_COMMENT_STATE), nextIndex: index + 1 };
|
|
}
|
|
|
|
function consumeBlockComment(source, index) {
|
|
const closesComment = source[index] === "*" && source[index + 1] === "/";
|
|
if (closesComment) return { output: " ", nextIndex: index + 2, nextState: CODE_STATE };
|
|
return { ...commentCharacter(source[index], BLOCK_COMMENT_STATE), nextIndex: index + 1 };
|
|
}
|
|
|
|
function consumeString(source, index, quote, escaped) {
|
|
const character = source[index];
|
|
const closesString = !escaped && character === quote;
|
|
return {
|
|
output: character,
|
|
nextIndex: index + 1,
|
|
nextState: closesString ? CODE_STATE : STRING_STATE,
|
|
nextQuote: closesString ? "" : quote,
|
|
nextEscaped: closesString ? false : !escaped && character === "\\",
|
|
};
|
|
}
|
|
|
|
function previousToken(source, index) {
|
|
let cursor = index - 1;
|
|
while (cursor >= 0 && /\s/.test(source[cursor])) cursor -= 1;
|
|
const end = cursor;
|
|
while (cursor >= 0 && /[\w$]/.test(source[cursor])) cursor -= 1;
|
|
return source.slice(cursor + 1, end + 1);
|
|
}
|
|
|
|
function canStartRegex(source, index) {
|
|
if (source[index - 1] === "<") return false;
|
|
let cursor = index - 1;
|
|
while (cursor >= 0 && /\s/.test(source[cursor])) cursor -= 1;
|
|
if (cursor < 0) return true;
|
|
if (REGEX_START_CHARACTERS.has(source[cursor])) return true;
|
|
return REGEX_KEYWORDS.has(previousToken(source, index));
|
|
}
|
|
|
|
function consumeRegex(source, index) {
|
|
let inCharacterClass = false;
|
|
let escaped = false;
|
|
|
|
for (let cursor = index + 1; cursor < source.length; cursor += 1) {
|
|
const character = source[cursor];
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (character === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (character === "[") {
|
|
inCharacterClass = true;
|
|
continue;
|
|
}
|
|
if (character === "]") {
|
|
inCharacterClass = false;
|
|
continue;
|
|
}
|
|
if (character !== "/" || inCharacterClass) continue;
|
|
|
|
let end = cursor + 1;
|
|
while (/[a-z]/i.test(source[end] ?? "")) end += 1;
|
|
return {
|
|
output: source.slice(index, end).replace(/[^\n]/g, " "),
|
|
nextIndex: end,
|
|
nextState: CODE_STATE,
|
|
};
|
|
}
|
|
|
|
return { output: source[index], nextIndex: index + 1, nextState: CODE_STATE };
|
|
}
|
|
|
|
function consumeCode(source, index) {
|
|
const character = source[index];
|
|
const next = source[index + 1];
|
|
if (character === "/" && next === "/") {
|
|
return { output: " ", nextIndex: index + 2, nextState: LINE_COMMENT_STATE };
|
|
}
|
|
if (character === "/" && next === "*") {
|
|
return { output: " ", nextIndex: index + 2, nextState: BLOCK_COMMENT_STATE };
|
|
}
|
|
if (character === "/" && canStartRegex(source, index)) return consumeRegex(source, index);
|
|
if (["'", '"', "`"].includes(character)) {
|
|
return {
|
|
output: character,
|
|
nextIndex: index + 1,
|
|
nextState: STRING_STATE,
|
|
nextQuote: character,
|
|
nextEscaped: false,
|
|
};
|
|
}
|
|
return { output: character, nextIndex: index + 1, nextState: CODE_STATE };
|
|
}
|
|
|
|
const STATE_CONSUMERS = {
|
|
[LINE_COMMENT_STATE]: consumeLineComment,
|
|
[BLOCK_COMMENT_STATE]: consumeBlockComment,
|
|
};
|
|
|
|
function consumeState(source, index, state, quote, escaped) {
|
|
if (state === STRING_STATE) return consumeString(source, index, quote, escaped);
|
|
return (STATE_CONSUMERS[state] ?? consumeCode)(source, index);
|
|
}
|
|
|
|
/** Remove comments while retaining strings and source line positions. */
|
|
export function stripCommentsPreservingStrings(source) {
|
|
let output = "";
|
|
let state = CODE_STATE;
|
|
let quote = "";
|
|
let escaped = false;
|
|
|
|
for (let index = 0; index < source.length; ) {
|
|
const consumed = consumeState(source, index, state, quote, escaped);
|
|
output += consumed.output;
|
|
index = consumed.nextIndex;
|
|
state = consumed.nextState;
|
|
quote = consumed.nextQuote ?? "";
|
|
escaped = consumed.nextEscaped ?? false;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
export function findSourceViolations(source, file, root) {
|
|
const cleanedSource = stripCommentsPreservingStrings(source);
|
|
return cleanedSource
|
|
.split("\n")
|
|
.flatMap((line, index) =>
|
|
containsEmDash(line)
|
|
? [{ kind: "source", file: relativePath(file, root), line: index + 1 }]
|
|
: [],
|
|
);
|
|
}
|
|
|
|
const PUBLIC_DOC_COMMENT_MARKERS = [
|
|
{ start: "<!--", end: "-->" },
|
|
{ start: "{/*", end: "*/" },
|
|
];
|
|
|
|
function readBacktickRun(source, index) {
|
|
let end = index;
|
|
while (source[end] === "`") end += 1;
|
|
return source.slice(index, end);
|
|
}
|
|
|
|
function findBacktickRun(source, run, start) {
|
|
let cursor = source.indexOf(run, start);
|
|
while (cursor >= 0) {
|
|
const before = source[cursor - 1];
|
|
const after = source[cursor + run.length];
|
|
if (before !== "`" && after !== "`") return cursor;
|
|
cursor = source.indexOf(run, cursor + 1);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function readFence(line) {
|
|
const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
|
|
if (!match || (match[2][0] === "`" && match[3].includes("`"))) return null;
|
|
return { marker: match[2][0], length: match[2].length };
|
|
}
|
|
|
|
function isClosingFence(line, fence) {
|
|
const match = /^( {0,3})(`{3,}|~{3,})\s*$/.exec(line);
|
|
return Boolean(match && match[2][0] === fence.marker && match[2].length >= fence.length);
|
|
}
|
|
|
|
function findPublicDocComment(line, index) {
|
|
return PUBLIC_DOC_COMMENT_MARKERS.find(({ start }) => line.startsWith(start, index));
|
|
}
|
|
|
|
function maskPublicDocComment(line, index, endMarker) {
|
|
const end = line.indexOf(endMarker, index);
|
|
if (end < 0) {
|
|
return {
|
|
output: line.slice(index).replace(/[^\n]/g, " "),
|
|
nextIndex: line.length,
|
|
commentEnd: endMarker,
|
|
closed: false,
|
|
};
|
|
}
|
|
|
|
const endExclusive = end + endMarker.length;
|
|
return {
|
|
output: line.slice(index, endExclusive).replace(/[^\n]/g, " "),
|
|
nextIndex: endExclusive,
|
|
commentEnd: null,
|
|
closed: true,
|
|
};
|
|
}
|
|
|
|
function copyInlineCode(line, index, run) {
|
|
const end = findBacktickRun(line, run, index);
|
|
if (end < 0) {
|
|
return { output: line.slice(index), nextIndex: line.length, closed: false };
|
|
}
|
|
|
|
const endExclusive = end + run.length;
|
|
return { output: line.slice(index, endExclusive), nextIndex: endExclusive, closed: true };
|
|
}
|
|
|
|
function consumePublicDocToken(line, index, state) {
|
|
if (state.commentEnd) {
|
|
const consumed = maskPublicDocComment(line, index, state.commentEnd);
|
|
return {
|
|
...consumed,
|
|
inlineCodeRun: null,
|
|
done: !consumed.closed,
|
|
};
|
|
}
|
|
|
|
if (state.inlineCodeRun) {
|
|
const consumed = copyInlineCode(line, index, state.inlineCodeRun);
|
|
return {
|
|
...consumed,
|
|
commentEnd: null,
|
|
inlineCodeRun: consumed.closed ? null : state.inlineCodeRun,
|
|
done: !consumed.closed,
|
|
};
|
|
}
|
|
|
|
if (line[index] === "`") {
|
|
const run = readBacktickRun(line, index);
|
|
const consumed = copyInlineCode(line, index + run.length, run);
|
|
return {
|
|
output: line.slice(index, index + run.length) + consumed.output,
|
|
nextIndex: consumed.nextIndex,
|
|
commentEnd: null,
|
|
inlineCodeRun: consumed.closed ? null : run,
|
|
done: !consumed.closed,
|
|
};
|
|
}
|
|
|
|
const comment = findPublicDocComment(line, index);
|
|
if (comment) {
|
|
const consumed = maskPublicDocComment(line, index, comment.end);
|
|
return {
|
|
...consumed,
|
|
inlineCodeRun: null,
|
|
done: !consumed.closed,
|
|
};
|
|
}
|
|
|
|
return {
|
|
output: line[index],
|
|
nextIndex: index + 1,
|
|
commentEnd: null,
|
|
inlineCodeRun: null,
|
|
done: false,
|
|
};
|
|
}
|
|
|
|
function maskPublicDocLine(line, state) {
|
|
let output = "";
|
|
let cursor = 0;
|
|
let currentState = state;
|
|
|
|
while (cursor < line.length) {
|
|
const consumed = consumePublicDocToken(line, cursor, currentState);
|
|
output += consumed.output;
|
|
currentState = {
|
|
commentEnd: consumed.commentEnd,
|
|
inlineCodeRun: consumed.inlineCodeRun,
|
|
};
|
|
if (consumed.done) return { output, ...currentState };
|
|
cursor = consumed.nextIndex;
|
|
}
|
|
|
|
return { output, ...currentState };
|
|
}
|
|
|
|
function stripPublicDocCommentsPreservingLines(source) {
|
|
let output = "";
|
|
let fence = null;
|
|
let state = { commentEnd: null, inlineCodeRun: null };
|
|
|
|
for (const line of source.split("\n")) {
|
|
if (fence) {
|
|
output += line;
|
|
if (isClosingFence(line, fence)) fence = null;
|
|
} else {
|
|
const openingFence = state.commentEnd || state.inlineCodeRun ? null : readFence(line);
|
|
if (openingFence) {
|
|
output += line;
|
|
fence = openingFence;
|
|
state = { commentEnd: null, inlineCodeRun: null };
|
|
} else {
|
|
const masked = maskPublicDocLine(line, state);
|
|
output += masked.output;
|
|
state = { commentEnd: masked.commentEnd, inlineCodeRun: masked.inlineCodeRun };
|
|
}
|
|
}
|
|
output += "\n";
|
|
}
|
|
|
|
return output.slice(0, -1);
|
|
}
|
|
|
|
function containsPublicDocEmDash(value) {
|
|
return value.includes(EM_DASH);
|
|
}
|
|
|
|
export function findPublicDocViolations(source, file, root) {
|
|
return stripPublicDocCommentsPreservingLines(source)
|
|
.split("\n")
|
|
.flatMap((line, index) =>
|
|
containsPublicDocEmDash(line)
|
|
? [{ kind: "public-doc", file: relativePath(file, root), line: index + 1 }]
|
|
: [],
|
|
);
|
|
}
|
|
|
|
function listFiles(directory, predicate, files = []) {
|
|
if (!fs.existsSync(directory)) return files;
|
|
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
if (entry.isSymbolicLink()) continue;
|
|
const file = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!IGNORED_DIRECTORIES.has(entry.name)) listFiles(file, predicate, files);
|
|
} else if (predicate(file)) {
|
|
files.push(file);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function collectCatalogStrings(value, keyPath, file, root, violations) {
|
|
if (typeof value === "string") {
|
|
if (containsEmDash(value)) {
|
|
violations.push({ kind: "catalog", file: relativePath(file, root), key: keyPath });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item, index) =>
|
|
collectCatalogStrings(item, `${keyPath}[${index}]`, file, root, violations),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (value && typeof value === "object") {
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const childPath = keyPath ? `${keyPath}.${key}` : key;
|
|
collectCatalogStrings(child, childPath, file, root, violations);
|
|
}
|
|
}
|
|
}
|
|
|
|
function scanCatalogDirectory(directory, root, violations) {
|
|
for (const file of listFiles(directory, (candidate) => candidate.endsWith(".json"))) {
|
|
let catalog;
|
|
try {
|
|
catalog = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
} catch (error) {
|
|
throw new Error(`Unable to parse locale catalog ${file}: ${error.message}`);
|
|
}
|
|
collectCatalogStrings(catalog, "", file, root, violations);
|
|
}
|
|
}
|
|
|
|
export function scanPublicDocsEmDashViolations({
|
|
docsRoot = path.join(REPO_ROOT, "docs", "public"),
|
|
root = REPO_ROOT,
|
|
} = {}) {
|
|
const violations = [];
|
|
for (const file of listFiles(docsRoot, (candidate) =>
|
|
PUBLIC_DOC_EXTENSIONS.has(path.extname(candidate).toLowerCase()),
|
|
)) {
|
|
violations.push(...findPublicDocViolations(fs.readFileSync(file, "utf8"), file, root));
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
export function scanUiEmDashViolations({ repoRoot = REPO_ROOT, webRoot = WEB_ROOT } = {}) {
|
|
const violations = [];
|
|
const sourceRoots = ["components", "app", "src", "hooks", "lib"].map((directory) =>
|
|
path.join(webRoot, directory),
|
|
);
|
|
const catalogRoots = [
|
|
path.join(webRoot, "src", "locales"),
|
|
path.join(repoRoot, "apps", "backend", "internal", "i18n", "locales"),
|
|
];
|
|
|
|
for (const directory of catalogRoots) scanCatalogDirectory(directory, repoRoot, violations);
|
|
|
|
for (const directory of sourceRoots) {
|
|
for (const file of listFiles(directory, (candidate) => {
|
|
const extension = path.extname(candidate);
|
|
return SOURCE_EXTENSIONS.has(extension) && !/\.(test|spec)\.[^.]+$/.test(candidate);
|
|
})) {
|
|
violations.push(...findSourceViolations(fs.readFileSync(file, "utf8"), file, repoRoot));
|
|
}
|
|
}
|
|
|
|
const backendRenderedSourceRoots = [path.join(repoRoot, "apps", "backend", "cmd", "mock-agent")];
|
|
for (const directory of backendRenderedSourceRoots) {
|
|
for (const file of listFiles(directory, (candidate) => {
|
|
return (
|
|
BACKEND_SOURCE_EXTENSIONS.has(path.extname(candidate)) && !candidate.endsWith("_test.go")
|
|
);
|
|
})) {
|
|
violations.push(...findSourceViolations(fs.readFileSync(file, "utf8"), file, repoRoot));
|
|
}
|
|
}
|
|
|
|
violations.push(
|
|
...scanPublicDocsEmDashViolations({
|
|
docsRoot: path.join(repoRoot, "docs", "public"),
|
|
root: repoRoot,
|
|
}),
|
|
);
|
|
|
|
return violations;
|
|
}
|
|
|
|
function formatViolation(violation) {
|
|
const location = violation.key
|
|
? `${violation.file}:${violation.key}`
|
|
: `${violation.file}:${violation.line}`;
|
|
return ` ${location} (${violation.kind})`;
|
|
}
|
|
|
|
function main() {
|
|
const violations = scanUiEmDashViolations();
|
|
if (violations.length > 0) {
|
|
console.error(`✗ ${violations.length} public-copy em dash violation(s):\n`);
|
|
for (const violation of violations) console.error(formatViolation(violation));
|
|
console.error("\nUse a period, colon, comma, semicolon, or parentheses in user-facing copy.");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
console.log("✓ no em dashes in public copy or locale values.");
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main();
|
|
}
|