Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
799 lines
21 KiB
JavaScript
799 lines
21 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { readFile, readdir } from "node:fs/promises";
|
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { dirname, join, relative } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import vm from "node:vm";
|
|
|
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
const workspaceRoot =
|
|
process.env.TUTTI_WORKSPACE_ROOT ?? join(scriptDirectory, "..", "..");
|
|
const locales = [
|
|
{ exportName: "en", file: "en.ts", locale: "en" },
|
|
{ exportName: "zhCN", file: "zh-CN.ts", locale: "zh-CN" }
|
|
];
|
|
const ignoredDirectories = new Set([
|
|
".git",
|
|
".turbo",
|
|
"dist",
|
|
"node_modules",
|
|
"out"
|
|
]);
|
|
const i18nModuleManifestExportNames = [
|
|
"tuttiI18nModule",
|
|
"agentGuiI18nModule",
|
|
"browserNodeI18nModule"
|
|
];
|
|
const i18nManifestSearchRoots = ["apps/desktop/src/shared/i18n", "packages"];
|
|
const localeResourceModules = discoverI18nModules();
|
|
const sourceRoots = [
|
|
"apps/desktop/src/main",
|
|
"apps/desktop/src/renderer/src",
|
|
...Array.from(
|
|
new Set(
|
|
localeResourceModules
|
|
.map((module) => module.sourceRoot)
|
|
.filter((value) => typeof value === "string" && value.length > 0)
|
|
)
|
|
)
|
|
];
|
|
const ignoredSourceFiles = new Set(
|
|
localeResourceModules.flatMap((module) =>
|
|
module.exportMode === "locale-object"
|
|
? Object.values(module.fileByLocale)
|
|
: [module.manifestPath]
|
|
)
|
|
);
|
|
const defaultLocale = locales[0].locale;
|
|
const stagedOnly = process.argv.includes("--staged");
|
|
const maxIssues = readMaxIssues(process.argv);
|
|
const ignoredPathSegments = ["/locales/", "/__tests__/", "/artifacts/"];
|
|
const ignoredFileSuffixes = [
|
|
".test.ts",
|
|
".test.tsx",
|
|
".spec.ts",
|
|
".spec.tsx",
|
|
".d.ts"
|
|
];
|
|
const textExtensions = [".ts", ".tsx", ".js", ".jsx"];
|
|
const uiAttributeNames = ["aria-label", "title", "alt", "placeholder"];
|
|
const uiObjectPropertyPattern =
|
|
/\b(ariaLabel|alt|buttonLabel|description|emptyText|helperText|label|message|placeholder|text|title|tooltip)\s*:\s*(["'`])([^"'`]+)\2/g;
|
|
const uiCallPattern =
|
|
/\b(setError|setInputError|setMessage|setTitle|setDescription|setWarning|setSuccess)\s*\(\s*(["'`])([^"'`]+)\2/g;
|
|
const toastCallPattern =
|
|
/\btoast\.(info|error|success|warning|message)\s*\(\s*(["'`])([^"'`]+)\2/g;
|
|
const i18nCallPatterns = [
|
|
/(?<!\.)\b(?:t|translate)\(\s*(["'`])([^"'`]+)\1/g,
|
|
/\b[\w$.]+\.t\(\s*(["'`])([^"'`]+)\1/g,
|
|
/\btranslator\.t\(\s*(["'`])([^"'`]+)\1/g,
|
|
/\btranslateMessage\(\s*(["'`])[^"'`]+\1\s*,\s*(["'`])([^"'`]+)\2/g
|
|
];
|
|
const allowedHardcodedCopy = new Set(["Tutti", "tutti"]);
|
|
const legacyKeyPattern = /(^|\.)(l\d+c\d+)$/;
|
|
|
|
const localeIssues = [];
|
|
const copyIssues = [];
|
|
const keyIssues = [];
|
|
const resources = loadLocaleResources();
|
|
const flattenedByLocale = flattenAllLocaleResources(resources);
|
|
const validKeys = flattenedByLocale.get(defaultLocale).keys;
|
|
const scopedValidKeysBySourceRoot = buildScopedValidKeysBySourceRoot(resources);
|
|
|
|
validateLocaleAlignment(flattenedByLocale);
|
|
|
|
const sourceFiles = stagedOnly
|
|
? listStagedSourceFiles()
|
|
: await listAllSourceFiles();
|
|
for (const file of sourceFiles) {
|
|
await inspectSourceFile(file);
|
|
}
|
|
|
|
const issues = [...localeIssues, ...copyIssues, ...keyIssues];
|
|
if (issues.length > 0) {
|
|
console.error(`Found ${issues.length} i18n issue(s):`);
|
|
for (const issue of issues.slice(0, maxIssues)) {
|
|
console.error(
|
|
`- [${issue.rule}] ${issue.file}:${issue.line} ${issue.message}`
|
|
);
|
|
}
|
|
if (issues.length > maxIssues) {
|
|
console.error(
|
|
`... truncated ${issues.length - maxIssues} additional issue(s). Increase with --max-issues.`
|
|
);
|
|
}
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(
|
|
`i18n check passed for ${locales.length} locale(s), ${validKeys.size} key(s), and ${sourceFiles.length} source file(s).`
|
|
);
|
|
}
|
|
|
|
function readMaxIssues(argv) {
|
|
const index = argv.indexOf("--max-issues");
|
|
if (index === -1) {
|
|
return 200;
|
|
}
|
|
|
|
const value = Number(argv[index + 1]);
|
|
if (!Number.isInteger(value) || value < 1) {
|
|
throw new Error("--max-issues requires a positive integer");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function loadLocaleResources() {
|
|
const result = new Map(locales.map((locale) => [locale.locale, []]));
|
|
|
|
for (const module of localeResourceModules) {
|
|
if (module.exportMode === "locale-object") {
|
|
for (const locale of locales) {
|
|
const relativePath = module.fileByLocale[locale.locale];
|
|
const resource = parseExportedObjectLiteral(
|
|
relativePath,
|
|
locale.exportName
|
|
);
|
|
if (!resource) {
|
|
continue;
|
|
}
|
|
|
|
result.get(locale.locale).push({
|
|
file: relativePath,
|
|
localValue: module.sourceRoot ? resource : undefined,
|
|
module: module.name,
|
|
sourceRoot: module.sourceRoot,
|
|
value: resource
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (module.exportMode === "scoped-locale-objects") {
|
|
const namespace = module.namespace;
|
|
if (!namespace) {
|
|
continue;
|
|
}
|
|
|
|
for (const locale of locales) {
|
|
const objectExportName = module.localeObjectByLocale[locale.locale];
|
|
const localeObject = parseExportedObjectLiteral(
|
|
module.manifestPath,
|
|
objectExportName
|
|
);
|
|
if (!localeObject) {
|
|
continue;
|
|
}
|
|
|
|
result.get(locale.locale).push({
|
|
file: module.manifestPath,
|
|
localValue: localeObject,
|
|
module: module.name,
|
|
sourceRoot: module.sourceRoot,
|
|
value: {
|
|
[namespace]: localeObject
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function discoverI18nModules() {
|
|
const manifests = [];
|
|
|
|
for (const root of i18nManifestSearchRoots) {
|
|
collectI18nModuleManifests(root, manifests);
|
|
}
|
|
|
|
return manifests.sort((left, right) =>
|
|
left.manifestPath.localeCompare(right.manifestPath)
|
|
);
|
|
}
|
|
|
|
function collectI18nModuleManifests(root, manifests) {
|
|
const absoluteRoot = join(workspaceRoot, root);
|
|
if (!existsSync(absoluteRoot)) {
|
|
return;
|
|
}
|
|
|
|
for (const entry of readdirSync(absoluteRoot, { withFileTypes: true })) {
|
|
const absolutePath = join(absoluteRoot, entry.name);
|
|
const relativePath = toPosixPath(relative(workspaceRoot, absolutePath));
|
|
|
|
if (entry.isDirectory()) {
|
|
if (!ignoredDirectories.has(entry.name)) {
|
|
collectI18nModuleManifests(relativePath, manifests);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isFile() || !relativePath.endsWith(".ts")) {
|
|
continue;
|
|
}
|
|
|
|
const source = readFileSync(absolutePath, "utf8");
|
|
for (const exportName of i18nModuleManifestExportNames) {
|
|
if (!source.includes(`export const ${exportName}`)) {
|
|
continue;
|
|
}
|
|
|
|
const manifest = parseExportedObjectLiteral(relativePath, exportName);
|
|
if (!manifest) {
|
|
continue;
|
|
}
|
|
|
|
manifests.push(normalizeI18nModuleManifest(relativePath, manifest));
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeI18nModuleManifest(relativePath, manifest) {
|
|
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
throw new Error(`Invalid i18n module manifest in ${relativePath}`);
|
|
}
|
|
|
|
if (manifest.exportMode === "locale-object") {
|
|
return {
|
|
exportMode: "locale-object",
|
|
fileByLocale: manifest.fileByLocale,
|
|
manifestPath: relativePath,
|
|
name: manifest.name,
|
|
sourceRoot: manifest.sourceRoot
|
|
};
|
|
}
|
|
|
|
if (manifest.exportMode === "scoped-locale-objects") {
|
|
return {
|
|
exportMode: "scoped-locale-objects",
|
|
localeObjectByLocale: manifest.localeObjectByLocale,
|
|
manifestPath: relativePath,
|
|
name: manifest.name,
|
|
namespace: manifest.namespace,
|
|
sourceRoot: manifest.sourceRoot
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unsupported i18n exportMode in ${relativePath}`);
|
|
}
|
|
|
|
function flattenAllLocaleResources(localeResources) {
|
|
const result = new Map();
|
|
|
|
for (const locale of locales) {
|
|
const resourcesForLocale = localeResources.get(locale.locale);
|
|
if (!resourcesForLocale) {
|
|
continue;
|
|
}
|
|
|
|
const leaves = new Map();
|
|
const files = [];
|
|
for (const resource of resourcesForLocale) {
|
|
files.push(resource.file);
|
|
flattenTranslationLeaves(resource.value, "", leaves, resource.file);
|
|
}
|
|
result.set(locale.locale, {
|
|
file: files[0] ?? "<unknown>",
|
|
files,
|
|
keys: new Set(leaves.keys()),
|
|
leaves
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function buildScopedValidKeysBySourceRoot(localeResources) {
|
|
const result = new Map();
|
|
const resourcesForDefaultLocale = localeResources.get(defaultLocale) ?? [];
|
|
|
|
for (const resource of resourcesForDefaultLocale) {
|
|
if (!resource.sourceRoot || !resource.localValue) {
|
|
continue;
|
|
}
|
|
|
|
const leaves = new Map();
|
|
flattenTranslationLeaves(resource.localValue, "", leaves, resource.file);
|
|
const scopedKeys = result.get(resource.sourceRoot) ?? new Set();
|
|
for (const key of leaves.keys()) {
|
|
scopedKeys.add(key);
|
|
}
|
|
result.set(resource.sourceRoot, scopedKeys);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function parseExportedObjectLiteral(
|
|
relativePath,
|
|
exportName,
|
|
seen = new Set()
|
|
) {
|
|
const absolutePath = join(workspaceRoot, relativePath);
|
|
if (!existsSync(absolutePath)) {
|
|
localeIssues.push({
|
|
file: relativePath,
|
|
line: 1,
|
|
message: `Missing i18n resource file ${relativePath}`,
|
|
rule: "locale-file-missing"
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const source = readFileSync(absolutePath, "utf8");
|
|
const importContext = createLocaleObjectImportContext(
|
|
relativePath,
|
|
source,
|
|
seen
|
|
);
|
|
const helperMatch = source.match(
|
|
new RegExp(
|
|
`(?:export\\s+)?const\\s+${exportName}\\s*=\\s*([A-Za-z_$][\\w$]*)\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)\\s*;`
|
|
)
|
|
);
|
|
if (helperMatch) {
|
|
return vm.runInNewContext(
|
|
`${helperMatch[1]}(${helperMatch[2]})`,
|
|
createI18nManifestVmContext()
|
|
);
|
|
}
|
|
|
|
const match = source.match(
|
|
new RegExp(
|
|
`(?:export\\s+)?const\\s+${exportName}\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*as\\s+const(?:\\s+satisfies\\s+[^;]+)?;`
|
|
)
|
|
);
|
|
|
|
if (!match) {
|
|
const plainObjectMatch = source.match(
|
|
new RegExp(
|
|
`(?:export\\s+)?const\\s+${exportName}\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*;`
|
|
)
|
|
);
|
|
if (!plainObjectMatch) {
|
|
localeIssues.push({
|
|
file: relativePath,
|
|
line: 1,
|
|
message: `Unable to parse locale export ${exportName}`,
|
|
rule: "locale-parse"
|
|
});
|
|
return null;
|
|
}
|
|
|
|
return vm.runInNewContext(`(${plainObjectMatch[1]})`, importContext);
|
|
}
|
|
|
|
return vm.runInNewContext(`(${match[1]})`, importContext);
|
|
}
|
|
|
|
function createLocaleObjectImportContext(relativePath, source, seen) {
|
|
const context = {};
|
|
const nextSeen = new Set(seen);
|
|
nextSeen.add(relativePath);
|
|
const importPattern = /^import\s+\{([^}]+)\}\s+from\s+["'](\.[^"']+)["'];/gm;
|
|
|
|
for (const match of source.matchAll(importPattern)) {
|
|
const importPath = toImportSourcePath(relativePath, match[2]);
|
|
if (nextSeen.has(importPath)) {
|
|
continue;
|
|
}
|
|
|
|
for (const specifier of match[1].split(",")) {
|
|
const trimmed = specifier.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
|
|
const [importedName, localName] = trimmed
|
|
.split(/\s+as\s+/)
|
|
.map((part) => part.trim());
|
|
const value = parseExportedObjectLiteral(
|
|
importPath,
|
|
importedName,
|
|
nextSeen
|
|
);
|
|
if (value) {
|
|
context[localName ?? importedName] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
function toImportSourcePath(relativePath, importSpecifier) {
|
|
const rawPath = toPosixPath(join(dirname(relativePath), importSpecifier));
|
|
return rawPath.endsWith(".ts") ? rawPath : `${rawPath}.ts`;
|
|
}
|
|
|
|
function createI18nManifestVmContext() {
|
|
return {
|
|
createLocaleObjectI18nModuleManifest(input) {
|
|
return {
|
|
exportMode: "locale-object",
|
|
...input
|
|
};
|
|
},
|
|
createScopedLocaleObjectsI18nModuleManifest(input) {
|
|
return {
|
|
exportMode: "scoped-locale-objects",
|
|
...input
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
function flattenTranslationLeaves(value, prefix, leaves, file) {
|
|
if (typeof value === "string") {
|
|
leaves.set(prefix, value);
|
|
return;
|
|
}
|
|
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
localeIssues.push({
|
|
file,
|
|
line: 1,
|
|
message: `Expected nested string object at ${prefix || "<root>"}`,
|
|
rule: "locale-shape"
|
|
});
|
|
return;
|
|
}
|
|
|
|
for (const [key, child] of Object.entries(value)) {
|
|
flattenTranslationLeaves(
|
|
child,
|
|
prefix ? `${prefix}.${key}` : key,
|
|
leaves,
|
|
file
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateLocaleAlignment(flattenedResources) {
|
|
const baseline = flattenedResources.get(defaultLocale);
|
|
if (!baseline) {
|
|
return;
|
|
}
|
|
|
|
for (const locale of locales.slice(1)) {
|
|
const candidate = flattenedResources.get(locale.locale);
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
|
|
for (const key of diffSets(baseline.keys, candidate.keys)) {
|
|
localeIssues.push({
|
|
file: candidate.file,
|
|
line: 1,
|
|
message: `Missing locale key ${key}`,
|
|
rule: "locale-key-missing"
|
|
});
|
|
}
|
|
|
|
for (const key of diffSets(candidate.keys, baseline.keys)) {
|
|
localeIssues.push({
|
|
file: candidate.file,
|
|
line: 1,
|
|
message: `Extra locale key ${key}`,
|
|
rule: "locale-key-extra"
|
|
});
|
|
}
|
|
|
|
for (const key of baseline.keys) {
|
|
if (!candidate.keys.has(key)) {
|
|
continue;
|
|
}
|
|
|
|
const baselinePlaceholders = extractPlaceholders(
|
|
baseline.leaves.get(key)
|
|
);
|
|
const candidatePlaceholders = extractPlaceholders(
|
|
candidate.leaves.get(key)
|
|
);
|
|
const missing = diffSets(baselinePlaceholders, candidatePlaceholders);
|
|
const extra = diffSets(candidatePlaceholders, baselinePlaceholders);
|
|
if (missing.length === 0 && extra.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
localeIssues.push({
|
|
file: candidate.file,
|
|
line: 1,
|
|
message: `Placeholder mismatch for ${key}: missing [${missing.join(", ")}], extra [${extra.join(", ")}]`,
|
|
rule: "locale-placeholder"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function extractPlaceholders(value) {
|
|
const placeholders = new Set();
|
|
for (const match of String(value ?? "").matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) {
|
|
placeholders.add(match[1]);
|
|
}
|
|
return placeholders;
|
|
}
|
|
|
|
function diffSets(left, right) {
|
|
return Array.from(left)
|
|
.filter((value) => !right.has(value))
|
|
.sort();
|
|
}
|
|
|
|
async function listAllSourceFiles() {
|
|
const files = [];
|
|
for (const root of sourceRoots) {
|
|
await walk(join(workspaceRoot, root), files);
|
|
}
|
|
return files.sort();
|
|
}
|
|
|
|
async function walk(directory, files) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const path = join(directory, entry.name);
|
|
const relativePath = toPosixPath(relative(workspaceRoot, path));
|
|
|
|
if (entry.isDirectory()) {
|
|
if (
|
|
!ignoredDirectories.has(entry.name) &&
|
|
!isIgnoredSourcePath(`${relativePath}/`)
|
|
) {
|
|
await walk(path, files);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (entry.isFile() && isTrackedSourceFile(relativePath)) {
|
|
files.push(relativePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
function listStagedSourceFiles() {
|
|
const output = execFileSync(
|
|
"git",
|
|
["diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
|
{
|
|
cwd: workspaceRoot,
|
|
encoding: "utf8"
|
|
}
|
|
);
|
|
|
|
return output
|
|
.split("\n")
|
|
.map((file) => file.trim())
|
|
.filter(Boolean)
|
|
.filter(isTrackedSourceFile)
|
|
.sort();
|
|
}
|
|
|
|
function isTrackedSourceFile(path) {
|
|
return (
|
|
sourceRoots.some((root) => path.startsWith(`${root}/`)) &&
|
|
hasTextExtension(path) &&
|
|
!isIgnoredSourcePath(path)
|
|
);
|
|
}
|
|
|
|
function isIgnoredSourcePath(path) {
|
|
return (
|
|
ignoredSourceFiles.has(path) ||
|
|
ignoredPathSegments.some((segment) => path.includes(segment)) ||
|
|
ignoredFileSuffixes.some((suffix) => path.endsWith(suffix))
|
|
);
|
|
}
|
|
|
|
function hasTextExtension(path) {
|
|
return textExtensions.some((extension) => path.endsWith(extension));
|
|
}
|
|
|
|
async function inspectSourceFile(relativePath) {
|
|
if (!existsSync(join(workspaceRoot, relativePath))) {
|
|
return;
|
|
}
|
|
|
|
const content = await readFile(join(workspaceRoot, relativePath), "utf8");
|
|
const lines = content.split(/\r?\n/);
|
|
|
|
inspectI18nKeyUsages(relativePath, lines);
|
|
inspectHardcodedCopy(relativePath, lines);
|
|
}
|
|
|
|
function inspectI18nKeyUsages(relativePath, lines) {
|
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
const line = stripLineComment(lines[lineIndex]);
|
|
|
|
for (const pattern of i18nCallPatterns) {
|
|
pattern.lastIndex = 0;
|
|
for (const match of line.matchAll(pattern)) {
|
|
const key = match[2] ?? match[3];
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
|
|
if (legacyKeyPattern.test(key)) {
|
|
keyIssues.push({
|
|
file: relativePath,
|
|
line: lineIndex + 1,
|
|
message: `Legacy/generated i18n key is not allowed: ${key}`,
|
|
rule: "i18n-key-semantic"
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
!validKeys.has(key) &&
|
|
!isValidScopedKeyForFile(relativePath, key)
|
|
) {
|
|
keyIssues.push({
|
|
file: relativePath,
|
|
line: lineIndex + 1,
|
|
message: `Referenced i18n key does not exist: ${key}`,
|
|
rule: "i18n-key-missing"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function isValidScopedKeyForFile(relativePath, key) {
|
|
for (const [
|
|
sourceRoot,
|
|
scopedKeys
|
|
] of scopedValidKeysBySourceRoot.entries()) {
|
|
if (relativePath.startsWith(`${sourceRoot}/`) && scopedKeys.has(key)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function inspectHardcodedCopy(relativePath, lines) {
|
|
const canContainJsx =
|
|
relativePath.endsWith(".tsx") || relativePath.endsWith(".jsx");
|
|
|
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
const line = stripLineComment(lines[lineIndex]);
|
|
if (lineHasIgnoreComment(lines, lineIndex) || isImportOrExportLine(line)) {
|
|
continue;
|
|
}
|
|
|
|
if (canContainJsx) {
|
|
inspectJsxText(relativePath, line, lineIndex + 1);
|
|
inspectJsxAttributes(relativePath, line, lineIndex + 1);
|
|
}
|
|
inspectPatternMatches(
|
|
relativePath,
|
|
line,
|
|
lineIndex + 1,
|
|
uiObjectPropertyPattern,
|
|
"hardcoded-ui-property"
|
|
);
|
|
inspectPatternMatches(
|
|
relativePath,
|
|
line,
|
|
lineIndex + 1,
|
|
uiCallPattern,
|
|
"hardcoded-ui-call"
|
|
);
|
|
inspectPatternMatches(
|
|
relativePath,
|
|
line,
|
|
lineIndex + 1,
|
|
toastCallPattern,
|
|
"hardcoded-toast-copy"
|
|
);
|
|
}
|
|
}
|
|
|
|
function inspectJsxText(relativePath, line, lineNumber) {
|
|
if (!line.includes("</")) {
|
|
return;
|
|
}
|
|
|
|
for (const match of line.matchAll(/>([^<>{}]+)</g)) {
|
|
const text = normalizeWhitespace(match[1]);
|
|
if (isReportableCopy(text)) {
|
|
copyIssues.push({
|
|
file: relativePath,
|
|
line: lineNumber,
|
|
message: `Hardcoded JSX text should use i18n: ${JSON.stringify(text)}`,
|
|
rule: "hardcoded-jsx-text"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function inspectJsxAttributes(relativePath, line, lineNumber) {
|
|
for (const name of uiAttributeNames) {
|
|
const pattern = new RegExp(`${name}=(["'])([^"']+)\\1`, "g");
|
|
for (const match of line.matchAll(pattern)) {
|
|
const text = normalizeWhitespace(match[2]);
|
|
if (isReportableCopy(text)) {
|
|
copyIssues.push({
|
|
file: relativePath,
|
|
line: lineNumber,
|
|
message: `Hardcoded ${name} should use i18n: ${JSON.stringify(text)}`,
|
|
rule: "hardcoded-ui-attribute"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function inspectPatternMatches(relativePath, line, lineNumber, pattern, rule) {
|
|
pattern.lastIndex = 0;
|
|
for (const match of line.matchAll(pattern)) {
|
|
const text = normalizeWhitespace(match[3]);
|
|
if (isReportableCopy(text)) {
|
|
copyIssues.push({
|
|
file: relativePath,
|
|
line: lineNumber,
|
|
message: `Hardcoded user-visible copy should use i18n: ${JSON.stringify(text)}`,
|
|
rule
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function isReportableCopy(value) {
|
|
const normalized = normalizeWhitespace(value);
|
|
if (!normalized || allowedHardcodedCopy.has(normalized)) {
|
|
return false;
|
|
}
|
|
if (isLikelyCodeLikeSnippet(normalized)) {
|
|
return false;
|
|
}
|
|
if (/\p{Script=Han}/u.test(normalized)) {
|
|
return true;
|
|
}
|
|
|
|
const words = normalized.match(/[A-Za-z]{2,}/g) ?? [];
|
|
if (words.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return words.some(
|
|
(word) =>
|
|
!/^(id|url|uri|api|ipc|ui|ux|os|ok|px|em|rem|rgb|rgba|env)$/i.test(word)
|
|
);
|
|
}
|
|
|
|
function isLikelyCodeLikeSnippet(value) {
|
|
return (
|
|
/^\/[A-Za-z0-9_:/.-]+$/.test(value) ||
|
|
/^[A-Za-z0-9_.<>:-]+\/[A-Za-z0-9_.<>:/-]+$/.test(value) ||
|
|
/^[a-z]+=[A-Za-z0-9_.:-]+$/.test(value) ||
|
|
/^[A-Z0-9_:-]+$/.test(value)
|
|
);
|
|
}
|
|
|
|
function normalizeWhitespace(value) {
|
|
return value.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function stripLineComment(line) {
|
|
const commentIndex = line.indexOf("//");
|
|
return commentIndex === -1 ? line : line.slice(0, commentIndex);
|
|
}
|
|
|
|
function lineHasIgnoreComment(lines, lineIndex) {
|
|
return [lines[lineIndex], lines[lineIndex - 1]].some((line) =>
|
|
line?.includes("i18n-check-ignore")
|
|
);
|
|
}
|
|
|
|
function isImportOrExportLine(line) {
|
|
const trimmed = line.trim();
|
|
return trimmed.startsWith("import ") || trimmed.startsWith("export type ");
|
|
}
|
|
|
|
function toPosixPath(path) {
|
|
return path.split("\\").join("/");
|
|
}
|