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.");