482f1962c1
Signed-off-by: Jack Amadeo <jackamadeo@squareup.com> Co-authored-by: block-open-source[bot] <201011344+block-open-source[bot]@users.noreply.github.com> Co-authored-by: block-open-source[bot] <1159699+block-open-source[bot]@users.noreply.github.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: tulsi <tulsi@block.xyz> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Bradley Axen <baxen@squareup.com> Co-authored-by: Alex Hancock <alexhancock@block.xyz> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com> Co-authored-by: Lifei Zhou <lifei@squareup.com>
255 lines
6.7 KiB
JavaScript
255 lines
6.7 KiB
JavaScript
import { readFileSync, readdirSync } from "node:fs";
|
|
import { extname, join, relative } from "node:path";
|
|
import ts from "typescript";
|
|
|
|
const CHECKED_PATHS = [
|
|
"src/app/ui",
|
|
"src/features/agents",
|
|
"src/features/chat/ui",
|
|
"src/features/home",
|
|
"src/features/projects",
|
|
"src/features/settings",
|
|
"src/features/skills",
|
|
"src/features/sidebar",
|
|
"src/features/status",
|
|
"src/features/sessions",
|
|
"src/shared/ui/ai-elements/code-block.tsx",
|
|
"src/shared/ui/ai-elements/environment-variables.tsx",
|
|
"src/shared/ui/ai-elements/message.tsx",
|
|
"src/shared/ui/ai-elements/plan.tsx",
|
|
"src/shared/ui/ai-elements/snippet.tsx",
|
|
"src/shared/ui/ai-elements/stack-trace.tsx",
|
|
"src/shared/ui/ai-elements/terminal.tsx",
|
|
"src/shared/ui/ai-elements/context.tsx",
|
|
"src/shared/ui/ai-elements/commit.tsx",
|
|
];
|
|
|
|
const EXCLUDED_PATH_SEGMENTS = ["__tests__"];
|
|
const EXCLUDED_FILE_MARKERS = [".test.", ".spec."];
|
|
const CHECKED_EXTENSIONS = new Set([".ts", ".tsx"]);
|
|
const TEXT_ATTRIBUTE_NAMES = new Set([
|
|
"aria-label",
|
|
"title",
|
|
"placeholder",
|
|
"alt",
|
|
]);
|
|
const TEXT_EXCLUDED_TAGS = new Set(["code", "pre", "kbd"]);
|
|
const IGNORE_COMMENT = "i18n-check-ignore";
|
|
|
|
function walkPath(targetPath) {
|
|
const entries = [];
|
|
let statEntries;
|
|
try {
|
|
statEntries = readdirSync(targetPath, { withFileTypes: true });
|
|
} catch {
|
|
if (CHECKED_EXTENSIONS.has(extname(targetPath))) {
|
|
return [targetPath];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
for (const entry of statEntries) {
|
|
const fullPath = join(targetPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
entries.push(...walkPath(fullPath));
|
|
continue;
|
|
}
|
|
|
|
if (CHECKED_EXTENSIONS.has(extname(entry.name))) {
|
|
entries.push(fullPath);
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function isExcluded(filePath) {
|
|
const rel = relative(".", filePath);
|
|
const normalizedRel = rel.replace(/\\/g, "/");
|
|
const pathSegments = normalizedRel.split("/");
|
|
|
|
return (
|
|
EXCLUDED_PATH_SEGMENTS.some((segment) => pathSegments.includes(segment)) ||
|
|
EXCLUDED_FILE_MARKERS.some((marker) => normalizedRel.includes(marker))
|
|
);
|
|
}
|
|
|
|
function collapseWhitespace(text) {
|
|
return text.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function isProbablyUserFacingText(text) {
|
|
if (!text) return false;
|
|
if (!/\p{L}/u.test(text)) return false;
|
|
if (/^https?:\/\//.test(text)) return false;
|
|
if (/^[./~@#][\w./-]+$/.test(text)) return false;
|
|
if (/^[A-Z0-9_:-]+$/.test(text)) return false;
|
|
if (/^[\w.-]+\.[A-Za-z]{2,}$/.test(text)) return false;
|
|
return true;
|
|
}
|
|
|
|
function getLineText(sourceText, sourceFile, lineIndex) {
|
|
if (lineIndex < 0) return "";
|
|
|
|
const lineStarts = sourceFile.getLineStarts();
|
|
if (lineIndex >= lineStarts.length) return "";
|
|
|
|
const lineStart = lineStarts[lineIndex];
|
|
const lineEnd =
|
|
lineIndex + 1 < lineStarts.length
|
|
? lineStarts[lineIndex + 1]
|
|
: sourceText.length;
|
|
|
|
return sourceText.slice(lineStart, lineEnd);
|
|
}
|
|
|
|
function hasIgnoreComment(sourceText, sourceFile, node) {
|
|
const start = node.getStart(sourceFile);
|
|
const { line } = sourceFile.getLineAndCharacterOfPosition(start);
|
|
|
|
return [line - 1, line].some((lineIndex) =>
|
|
getLineText(sourceText, sourceFile, lineIndex).includes(IGNORE_COMMENT),
|
|
);
|
|
}
|
|
|
|
function getParentTagName(node) {
|
|
if (ts.isJsxElement(node.parent)) {
|
|
return node.parent.openingElement.tagName.getText();
|
|
}
|
|
|
|
if (ts.isJsxSelfClosingElement(node.parent)) {
|
|
return node.parent.tagName.getText();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeJsxText(text) {
|
|
return collapseWhitespace(text);
|
|
}
|
|
|
|
function extractStringFromExpression(expression) {
|
|
if (!expression) return null;
|
|
|
|
if (ts.isStringLiteralLike(expression)) {
|
|
return expression.text;
|
|
}
|
|
|
|
if (ts.isNoSubstitutionTemplateLiteral(expression)) {
|
|
return expression.text;
|
|
}
|
|
|
|
if (ts.isTemplateExpression(expression)) {
|
|
let text = expression.head.text;
|
|
for (const span of expression.templateSpans) {
|
|
text += `{${span.expression.getText()}}${span.literal.text}`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function formatLocation(sourceFile, position) {
|
|
const { line, character } =
|
|
sourceFile.getLineAndCharacterOfPosition(position);
|
|
return `${sourceFile.fileName}:${line + 1}:${character + 1}`;
|
|
}
|
|
|
|
function collectViolations(filePath) {
|
|
const sourceText = readFileSync(filePath, "utf8");
|
|
const sourceFile = ts.createSourceFile(
|
|
filePath,
|
|
sourceText,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
);
|
|
const violations = [];
|
|
|
|
function report(node, kind, text) {
|
|
const normalizedText = collapseWhitespace(text);
|
|
if (!isProbablyUserFacingText(normalizedText)) return;
|
|
if (hasIgnoreComment(sourceText, sourceFile, node)) return;
|
|
|
|
violations.push({
|
|
location: formatLocation(sourceFile, node.getStart(sourceFile)),
|
|
kind,
|
|
text: normalizedText,
|
|
});
|
|
}
|
|
|
|
function visit(node) {
|
|
if (ts.isJsxText(node)) {
|
|
const tagName = getParentTagName(node);
|
|
if (tagName && TEXT_EXCLUDED_TAGS.has(tagName)) {
|
|
return;
|
|
}
|
|
|
|
const text = normalizeJsxText(node.getText(sourceFile));
|
|
report(node, "jsx-text", text);
|
|
}
|
|
|
|
if (ts.isJsxAttribute(node)) {
|
|
const attributeName = node.name.text;
|
|
if (TEXT_ATTRIBUTE_NAMES.has(attributeName) && node.initializer) {
|
|
if (ts.isStringLiteral(node.initializer)) {
|
|
report(
|
|
node.initializer,
|
|
`prop:${attributeName}`,
|
|
node.initializer.text,
|
|
);
|
|
}
|
|
|
|
if (ts.isJsxExpression(node.initializer)) {
|
|
const text = extractStringFromExpression(node.initializer.expression);
|
|
if (text) {
|
|
report(node.initializer, `prop:${attributeName}`, text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ts.isJsxExpression(node) && node.expression) {
|
|
if (ts.isJsxAttribute(node.parent)) {
|
|
return;
|
|
}
|
|
|
|
const text = extractStringFromExpression(node.expression);
|
|
if (text) {
|
|
report(node, "jsx-expression", text);
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
|
|
visit(sourceFile);
|
|
return violations;
|
|
}
|
|
|
|
const files = CHECKED_PATHS.flatMap(walkPath)
|
|
.filter((filePath) => !isExcluded(filePath))
|
|
.sort();
|
|
|
|
const violations = files.flatMap((filePath) => collectViolations(filePath));
|
|
|
|
if (violations.length > 0) {
|
|
console.error("i18n string check failed:");
|
|
for (const violation of violations) {
|
|
console.error(
|
|
` - ${violation.location} [${violation.kind}] ${JSON.stringify(violation.text)}`,
|
|
);
|
|
}
|
|
console.error("");
|
|
console.error(
|
|
`Wrap user-facing strings in translations or annotate a narrow exception with "${IGNORE_COMMENT}".`,
|
|
);
|
|
console.error(
|
|
"The current enforcement scope is intentionally limited to app areas already migrated to i18n.",
|
|
);
|
|
process.exit(1);
|
|
} else {
|
|
console.log("i18n string check passed.");
|
|
}
|