348 lines
14 KiB
JavaScript
348 lines
14 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import vm from "node:vm";
|
|
import ts from "typescript";
|
|
|
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const webuiDir = path.resolve(scriptDir, "..");
|
|
const workspaceRoot = path.resolve(webuiDir, "..", "..");
|
|
|
|
const sourceRoots = fs.readdirSync(workspaceRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith("govoplan-"))
|
|
.map((entry) => path.join(workspaceRoot, entry.name, "webui", "src"))
|
|
.filter((sourceRoot) => fs.existsSync(sourceRoot));
|
|
|
|
const generatedCatalogs = sourceRoots
|
|
.map((sourceRoot) => path.join(sourceRoot, "i18n", "generatedTranslations.ts"))
|
|
.filter((file) => fs.existsSync(file));
|
|
|
|
function scanStructuralSourcePositions(roots) {
|
|
const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !file.endsWith("/i18n/generatedTranslations.ts")));
|
|
const findings = [];
|
|
for (const file of files) {
|
|
const source = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
|
|
function report(node, label) {
|
|
const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
findings.push(`${file}:${line + 1}:${character + 1} ${label} contains an i18n marker`);
|
|
}
|
|
|
|
function visit(node) {
|
|
if (isStringLiteralLike(node) && node.text.includes("i18n:") && isStructuralStringPosition(node, file) && !isAllowedStructuralStringPosition(node, file)) {
|
|
report(node, "structural string");
|
|
}
|
|
if (ts.isJsxAttribute(node)) {
|
|
const name = nodeName(node.name);
|
|
if ((isStructuralName(name) || isStructuralJsxValue(node)) && hasI18nLiteral(node.initializer)) report(node, `JSX ${name}`);
|
|
}
|
|
if (ts.isPropertyAssignment(node)) {
|
|
const name = nodeName(node.name);
|
|
if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer)) report(node, `object ${name}`);
|
|
if (hasI18nLiteral(node.name) && !isAllowedStructuralStringPosition(node.name, file)) report(node, "object key");
|
|
}
|
|
if (ts.isShorthandPropertyAssignment(node) && node.name.text.includes("i18n:")) report(node, "object key");
|
|
if (ts.isVariableDeclaration(node)) {
|
|
const name = nodeName(node.name);
|
|
if (isStructuralVariableName(name) && name !== "LEGACY_STORAGE_KEYS" && hasI18nLiteral(node.initializer)) report(node, `var ${name}`);
|
|
}
|
|
if (ts.isCallExpression(node) && isStructuralCall(node) && hasI18nLiteral(node.arguments[0])) {
|
|
report(node.arguments[0], `call ${node.expression.getText(source)}`);
|
|
}
|
|
if (ts.isCallExpression(node)) {
|
|
node.arguments.forEach((argument, index) => {
|
|
if (isStructuralCallArgument(node, index) && hasI18nLiteral(argument)) {
|
|
report(argument, `call ${node.expression.getText(source)} argument ${index + 1}`);
|
|
}
|
|
});
|
|
}
|
|
if (ts.isNewExpression(node) && node.expression.getText(source) === "CustomEvent" && hasI18nLiteral(node.arguments?.[0])) {
|
|
report(node.arguments[0], "new CustomEvent");
|
|
}
|
|
if (ts.isArrayLiteralExpression(node) && hasI18nLiteral(node) && isLookupArray(node)) {
|
|
report(node, "lookup array");
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
|
|
visit(source);
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
function scanGeneratedCatalogs(files) {
|
|
const findings = [];
|
|
for (const file of files) {
|
|
const translations = loadGeneratedTranslations(file);
|
|
for (const [language, dictionary] of Object.entries(translations)) {
|
|
for (const [key, value] of Object.entries(dictionary)) {
|
|
if (looksStructuralCatalogValue(String(value), key)) {
|
|
findings.push(`${file} ${language} ${key} has structural value ${JSON.stringify(value)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
function rgFiles(root) {
|
|
try {
|
|
const output = execFileSync("rg", ["--files", root], { encoding: "utf8" }).trim();
|
|
return output ? output.split("\n") : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function nodeName(name) {
|
|
if (!name) return "";
|
|
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
|
return name.getText();
|
|
}
|
|
|
|
function isStructuralName(name) {
|
|
if (!name) return false;
|
|
if (name === "aria-label" || name === "alt" || name === "placeholder" || name === "title") return false;
|
|
if (name.startsWith("data-")) return true;
|
|
return structuralNames.has(name) ||
|
|
name.endsWith("ClassName") ||
|
|
/(?:^|_)(?:id|ids|key|keys|slug|code|path|url|endpoint|route|scope|permission|capability|module|event|storage)(?:_|$)/i.test(name) ||
|
|
/(?:Id|IDs|Key|Keys|Slug|Code|Path|Url|URL|Endpoint|Route|Scope|Permission|Capability|Module|Event|Storage)$/.test(name);
|
|
}
|
|
|
|
function hasI18nLiteral(node) {
|
|
let found = false;
|
|
function visit(child) {
|
|
if (
|
|
(ts.isStringLiteral(child) || ts.isNoSubstitutionTemplateLiteral(child) || ts.isTemplateHead(child) || ts.isTemplateMiddle(child) || ts.isTemplateTail(child)) &&
|
|
child.text.includes("i18n:")
|
|
) {
|
|
found = true;
|
|
}
|
|
if (!found) ts.forEachChild(child, visit);
|
|
}
|
|
if (node) visit(node);
|
|
return found;
|
|
}
|
|
|
|
function isStringLiteralLike(node) {
|
|
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
|
|
}
|
|
|
|
function isStructuralStringPosition(node, file) {
|
|
const parent = node.parent;
|
|
if (!parent) return false;
|
|
if (ts.isPropertyAssignment(parent) && parent.name === node) return true;
|
|
if (ts.isElementAccessExpression(parent) && parent.argumentExpression === node) return true;
|
|
if (ts.isLiteralTypeNode(parent)) return true;
|
|
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) return true;
|
|
if (ts.isImportTypeNode(parent)) return true;
|
|
if (ts.isCallExpression(parent) && isStructuralCall(parent) && parent.arguments[0] === node) return true;
|
|
if (ts.isNewExpression(parent) && parent.expression.getText() === "CustomEvent" && parent.arguments?.[0] === node) return true;
|
|
return false;
|
|
}
|
|
|
|
function isAllowedStructuralStringPosition(node, file) {
|
|
if (file.endsWith("/utils/fieldHelp.ts") && ts.isPropertyAssignment(node.parent) && node.parent.name === node) return true;
|
|
return hasAncestorVariable(node, "LEGACY_STORAGE_KEYS");
|
|
}
|
|
|
|
function isStructuralVariableName(name) {
|
|
return /(?:^|_)(?:CLASS|CLASSNAME|STYLE|ID|KEY|SLUG|CODE|PATH|URL|ENDPOINT|ROUTE|SCOPE|PERMISSION|CAPABILITY|EVENT|STORAGE|ALGORITHM)(?:_|$)/i.test(name) ||
|
|
/(?:ClassName|Style|Id|ID|Key|Slug|Code|Path|Url|URL|Endpoint|Route|Scope|Permission|Capability|Event|Storage|Algorithm)$/.test(name);
|
|
}
|
|
|
|
function isStructuralCall(node) {
|
|
const expression = node.expression.getText();
|
|
return /(?:^|\.)(?:getItem|setItem|removeItem|addEventListener|removeEventListener|dispatchEvent)$/.test(expression);
|
|
}
|
|
|
|
function isStructuralCallArgument(node, index) {
|
|
const expressionName = callExpressionName(node);
|
|
if (isStructuralCall(node) && index === 0) return true;
|
|
if ((expressionName === "metadataText" || expressionName === "metadataFlag" || expressionName === "getText" || expressionName === "getBool") && index === 1) return true;
|
|
if (expressionName === "createAttachmentBasePath" && index === 0) return true;
|
|
if (expressionName === "updateNested" && index === 1) return true;
|
|
if ((expressionName === "hasScope" || expressionName === "hasAnyScope") && index === 1) return true;
|
|
if ((expressionName === "usePlatformModuleInstalled" || expressionName === "usePlatformUiCapability") && index === 0) return true;
|
|
return false;
|
|
}
|
|
|
|
function callExpressionName(node) {
|
|
const expression = node.expression;
|
|
if (ts.isIdentifier(expression)) return expression.text;
|
|
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
|
|
return expression.getText();
|
|
}
|
|
|
|
function isLookupArray(node) {
|
|
let parent = node.parent;
|
|
while (parent && (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent))) {
|
|
parent = parent.parent;
|
|
}
|
|
return Boolean(
|
|
parent &&
|
|
ts.isPropertyAccessExpression(parent) &&
|
|
parent.expression === node &&
|
|
["includes", "indexOf"].includes(parent.name.text) &&
|
|
ts.isCallExpression(parent.parent)
|
|
);
|
|
}
|
|
|
|
function isAlgorithmNameProperty(node) {
|
|
if (nodeName(node.name) !== "name") return false;
|
|
const objectLiteral = node.parent;
|
|
if (!ts.isObjectLiteralExpression(objectLiteral)) return false;
|
|
if (objectLiteral.properties.some((property) => ts.isPropertyAssignment(property) && ["hash", "namedCurve"].includes(nodeName(property.name)))) return true;
|
|
let parent = objectLiteral.parent;
|
|
while (parent && (ts.isConditionalExpression(parent) || ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent))) {
|
|
parent = parent.parent;
|
|
}
|
|
if (ts.isVariableDeclaration(parent)) return /Algorithm$/i.test(nodeName(parent.name));
|
|
if (ts.isPropertyAssignment(parent)) return /Algorithm$/i.test(nodeName(parent.name));
|
|
return false;
|
|
}
|
|
|
|
function isListOptionValueProperty(node) {
|
|
if (nodeName(node.name) !== "value") return false;
|
|
const objectLiteral = node.parent;
|
|
if (!ts.isObjectLiteralExpression(objectLiteral)) return false;
|
|
return objectLiteral.properties.some((property) => ts.isPropertyAssignment(property) && nodeName(property.name) === "label");
|
|
}
|
|
|
|
function isStructuralJsxValue(node) {
|
|
if (nodeName(node.name) !== "value") return false;
|
|
const parent = node.parent;
|
|
if (!ts.isJsxOpeningElement(parent) && !ts.isJsxSelfClosingElement(parent)) return false;
|
|
return parent.tagName.getText() === "option";
|
|
}
|
|
|
|
function hasAncestorVariable(node, variableName) {
|
|
let current = node.parent;
|
|
while (current) {
|
|
if (ts.isVariableDeclaration(current) && nodeName(current.name) === variableName) return true;
|
|
current = current.parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const structuralNames = new Set([
|
|
"action",
|
|
"actionId",
|
|
"algorithm",
|
|
"allOf",
|
|
"anyOf",
|
|
"apiKey",
|
|
"authMethod",
|
|
"capability",
|
|
"capabilityId",
|
|
"class",
|
|
"className",
|
|
"code",
|
|
"columnType",
|
|
"data-testid",
|
|
"endpoint",
|
|
"event",
|
|
"eventName",
|
|
"eventType",
|
|
"field",
|
|
"for",
|
|
"form",
|
|
"hash",
|
|
"href",
|
|
"htmlFor",
|
|
"iconName",
|
|
"id",
|
|
"kind",
|
|
"key",
|
|
"method",
|
|
"moduleId",
|
|
"nameAttribute",
|
|
"namedCurve",
|
|
"ownerModule",
|
|
"owner_module",
|
|
"path",
|
|
"permission",
|
|
"permissions",
|
|
"rel",
|
|
"requiredScopes",
|
|
"role",
|
|
"route",
|
|
"scope",
|
|
"scopeId",
|
|
"scopeType",
|
|
"slug",
|
|
"status",
|
|
"storageKey",
|
|
"style",
|
|
"target",
|
|
"to",
|
|
"tone",
|
|
"type",
|
|
"url",
|
|
"variant"
|
|
]);
|
|
|
|
function loadGeneratedTranslations(file) {
|
|
const source = fs.readFileSync(file, "utf8");
|
|
const output = ts.transpileModule(source, {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
|
|
}).outputText;
|
|
const sandbox = { exports: {}, module: { exports: {} }, require: () => ({}) };
|
|
vm.runInNewContext(output, sandbox, { filename: file });
|
|
return sandbox.exports.generatedTranslations ?? sandbox.module.exports.generatedTranslations ?? {};
|
|
}
|
|
|
|
const cssUnits = /^(?:-?\d+(?:\.\d+)?(?:px|r?em|vh|vw|vmin|vmax|%|s|ms)?|0)(?:\s+(?:-?\d+(?:\.\d+)?(?:px|r?em|vh|vw|vmin|vmax|%|s|ms)?|0)){0,3}$/i;
|
|
const cssFunction = /^(?:minmax|repeat|calc|var|clamp|rgba?|hsla?)\(/i;
|
|
const color = /^(?:#[0-9a-f]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\))$/i;
|
|
const cssIdentifier = /^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/;
|
|
const emailAddress = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
const emailAddressList = /^[^@\s;,]+@[^@\s;,]+\.[^@\s;,]+(?:\s*[;,]\s*[^@\s;,]+@[^@\s;,]+\.[^@\s;,]+)*$/;
|
|
const urlLike = /^(?:https?|webcal):\/\//i;
|
|
const packageSpecifier = /^@[a-z0-9-]+\/[a-z0-9-]+$/i;
|
|
const shellCommand = /^(?:systemctl|pg_dump|pg_restore|npm|pnpm|uv|pip|python)\b/;
|
|
const wildcardDomain = /^\*\.[a-z0-9.-]+$/i;
|
|
const technicalDottedIdentifier = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9][a-z0-9-]*)+$/;
|
|
const environmentReference = /^(?:env|secret):[A-Z0-9_]+$/;
|
|
const codeEllipsis = /^[a-z][a-z0-9_-]*-\.\.\.$/;
|
|
const globOrPathChoice = /^[\w./*-]+(?:\s+or\s+[\w./*-]+)+$/i;
|
|
const multiLineStructuralList = /^(?:[A-Za-z0-9_.:/-]+[*?]?(?:\n|$))+$/;
|
|
const cryptographicIdentifier = /^(?:ECDSA|Ed25519|RSASSA-PKCS1-v1_5|RSA-PSS|AES-GCM|SHA-\d+)$/;
|
|
const classStateTokens = new Set(["active", "subtle", "success", "neutral", "danger", "disabled", "compact", "hidden", "block", "inline"]);
|
|
const allowedStructuralLookingValues = new Set(["active tenant", "campaign-local settings", "govoplan-files"]);
|
|
const allowedStructuralLookingKeys = /(?:active_tenant|python_package_placeholder|manifest_signature_key|remote_entry|remote_manifest)/;
|
|
|
|
function looksStructuralCatalogValue(value, key) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || allowedStructuralLookingValues.has(trimmed) || allowedStructuralLookingKeys.test(key)) return false;
|
|
if (emailAddress.test(trimmed) || emailAddressList.test(trimmed) || cryptographicIdentifier.test(trimmed)) return true;
|
|
if (environmentReference.test(trimmed) || codeEllipsis.test(trimmed)) return true;
|
|
if (globOrPathChoice.test(trimmed) && /[/*.]/.test(trimmed)) return true;
|
|
if (urlLike.test(trimmed) || packageSpecifier.test(trimmed) || shellCommand.test(trimmed) || wildcardDomain.test(trimmed)) return true;
|
|
if (technicalDottedIdentifier.test(trimmed)) return true;
|
|
if (trimmed.includes("\n") && multiLineStructuralList.test(trimmed)) return true;
|
|
if (cssUnits.test(trimmed) || cssFunction.test(trimmed) || color.test(trimmed)) return true;
|
|
if (/^[.#][A-Za-z_][\w-]*(?:\s*[.#][A-Za-z_][\w-]*)*$/.test(trimmed)) return true;
|
|
|
|
const tokens = trimmed.split(/\s+/);
|
|
if (!tokens.every((token) => cssIdentifier.test(token))) return false;
|
|
if (!tokens.some((token) => token.includes("-") || token.startsWith("is-") || token.startsWith("has-") || classStateTokens.has(token))) return false;
|
|
if (value !== trimmed) return true;
|
|
if (tokens.length > 1 && tokens.every((token) => token === token.toLowerCase()) && tokens.some((token) => token.includes("-") || token.startsWith("is-") || token.startsWith("has-"))) return true;
|
|
if (tokens.length === 1 && (/^(?:is-|has-|with-)/.test(trimmed) || (trimmed.includes("-") && trimmed === trimmed.toLowerCase()))) return true;
|
|
return false;
|
|
}
|
|
|
|
const sourceFindings = scanStructuralSourcePositions(sourceRoots);
|
|
const catalogFindings = scanGeneratedCatalogs(generatedCatalogs);
|
|
const findings = [...sourceFindings, ...catalogFindings];
|
|
|
|
if (findings.length) {
|
|
console.error(findings.join("\n"));
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("No structural i18n markers found in source positions or generated catalogs.");
|