mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
chore(i18n): add missing en.json keys for translator, cli-tools, memory, onboarding
Adds 58 missing keys identified by the new dashboard audit script: - cliTools: 18 custom CLI builder keys (CustomCliCard) - translator: 24 keys covering stream transformer, live monitor, test bench - memory: 12 health/pagination/dialog keys - onboarding.tier: 8 keys for the tier tour walkthrough Also adds scripts/i18n/audit-dashboard-pages.mjs which scans all dashboard pages, reports t() calls referencing missing en.json keys, and flags candidate hardcoded JSX/attribute strings.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -153,3 +153,6 @@ http-client.private.env.json
|
||||
|
||||
# Feature-triage ephemeral artifact (regenerated each run)
|
||||
_ideia/_triage.json
|
||||
|
||||
# i18n audit artifact (generated by scripts/i18n/audit-dashboard-pages.mjs)
|
||||
scripts/i18n/_audit.json
|
||||
|
||||
284
scripts/i18n/audit-dashboard-pages.mjs
Normal file
284
scripts/i18n/audit-dashboard-pages.mjs
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Audit every dashboard page for:
|
||||
* 1. Hardcoded user-visible strings in JSX (any language)
|
||||
* 2. t("...") calls that reference keys NOT present in en.json
|
||||
*
|
||||
* Output: a JSON report at scripts/i18n/_audit.json + a human summary on stdout.
|
||||
*
|
||||
* Scope: src/app/(dashboard)/**\/*.tsx
|
||||
*/
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, dirname, relative, basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, "..", "..");
|
||||
const EN_JSON_PATH = join(REPO_ROOT, "src/i18n/messages/en.json");
|
||||
const DASHBOARD_ROOT = join(REPO_ROOT, "src/app/(dashboard)");
|
||||
const OUT_PATH = join(__dirname, "_audit.json");
|
||||
|
||||
const enJsonRaw = JSON.parse(readFileSync(EN_JSON_PATH, "utf8"));
|
||||
|
||||
/**
|
||||
* Deep-convert a parsed JSON object into a Map tree.
|
||||
* Using Map sidesteps prototype-pollution concerns when keys are
|
||||
* derived from source-code scraping (CWE-915).
|
||||
*/
|
||||
function toMapTree(value) {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(toMapTree);
|
||||
const m = new Map();
|
||||
for (const [k, v] of Object.entries(value)) m.set(k, toMapTree(v));
|
||||
return m;
|
||||
}
|
||||
const enJson = toMapTree(enJsonRaw);
|
||||
|
||||
/** Walk a directory recursively and return all .tsx files */
|
||||
function walkTsx(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walkTsx(full));
|
||||
else if (entry.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Walk a dotted path through the Map tree, returning the leaf or undefined */
|
||||
function walkPath(parts) {
|
||||
let cursor = enJson;
|
||||
for (const p of parts) {
|
||||
if (!(cursor instanceof Map) || !cursor.has(p)) return undefined;
|
||||
cursor = cursor.get(p);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/** Check if a dotted key exists, supporting dotted namespaces too */
|
||||
function keyExists(namespace, key) {
|
||||
const nsParts = namespace ? namespace.split(".") : [];
|
||||
const keyParts = key.split(".");
|
||||
return walkPath([...nsParts, ...keyParts]) !== undefined;
|
||||
}
|
||||
|
||||
/** Extract useTranslations("ns") and getTranslations("ns") calls */
|
||||
function extractNamespaces(source) {
|
||||
const namespaces = [];
|
||||
const reNs = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
|
||||
for (const m of source.matchAll(reNs)) {
|
||||
namespaces.push(m[1]);
|
||||
}
|
||||
// Without arg: bare, key must be fully qualified
|
||||
if (/(?:useTranslations|getTranslations)\s*\(\s*\)/.test(source)) namespaces.push(null);
|
||||
return namespaces;
|
||||
}
|
||||
|
||||
/** Extract every t("key", ...) and t.rich("key", ...) — capture the literal key only */
|
||||
function extractTCalls(source) {
|
||||
const out = new Set();
|
||||
// t("..."), t('...') — match only quoted string literals (skip template literals)
|
||||
const re = /\bt(?:Or[A-Z][A-Za-z]*)?(?:\.\w+)?\s*\(\s*["']([^"']+)["']/g;
|
||||
for (const m of source.matchAll(re)) out.add(m[1]);
|
||||
// translateOrFallback("key", fallback) — also t("key") style
|
||||
for (const m of source.matchAll(/translateOrFallback\s*\(\s*["']([^"']+)["']/g)) {
|
||||
out.add(m[1]);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find candidate hardcoded user-visible strings in JSX.
|
||||
*
|
||||
* Patterns we detect:
|
||||
* A. JSX text: >Some text< (must contain a letter, length >= 2)
|
||||
* B. JSX attrs: title="..." placeholder="..." aria-label="..." alt="..."
|
||||
* C. Toast/error literals: toast.error("...") toast.success("...")
|
||||
*
|
||||
* We DELIBERATELY skip:
|
||||
* - strings inside {t(...)} or {tOrFallback(...)}
|
||||
* - strings inside import statements
|
||||
* - strings inside CSS class names (className/style)
|
||||
* - URLs (start with /, http, mailto:, #)
|
||||
* - All-uppercase/snake constants (CONSTANT_VAR)
|
||||
* - pure-symbol strings (no letters)
|
||||
* - lonely-word JSX text that is a console.log / pino call argument
|
||||
*/
|
||||
// TS / JS noise that the naive regex picks up between two `>` chars
|
||||
const TS_TYPE_NOISE = new Set([
|
||||
"Promise",
|
||||
"Record",
|
||||
"Map",
|
||||
"Set",
|
||||
"Array",
|
||||
"ReadonlyArray",
|
||||
"Partial",
|
||||
"Pick",
|
||||
"Omit",
|
||||
"Required",
|
||||
"Readonly",
|
||||
"Awaited",
|
||||
"ReturnType",
|
||||
"Parameters",
|
||||
"void | Promise",
|
||||
"Promise | undefined",
|
||||
]);
|
||||
|
||||
function findHardcodedStrings(source) {
|
||||
const findings = [];
|
||||
const lines = source.split("\n");
|
||||
|
||||
// (A) JSX text between > and < — fragile but good enough for an audit pass
|
||||
let lineIdx = 0;
|
||||
for (const line of lines) {
|
||||
lineIdx++;
|
||||
// Skip obvious non-JSX lines (imports, single-line comments, JSDoc lines)
|
||||
if (
|
||||
/^\s*(import|export\s+(type|interface)|\/\/|\*|\/\*|type\s+\w+\s*=|interface\s+)/.test(line)
|
||||
)
|
||||
continue;
|
||||
// Skip lines that are *clearly* TypeScript signatures
|
||||
if (
|
||||
/:\s*(Promise|Record|Map|Set|Array|Partial|Pick|Omit|Required|Readonly|Awaited)\s*</.test(
|
||||
line
|
||||
)
|
||||
)
|
||||
continue;
|
||||
// Neutralise arrow functions and TS generics that confuse the regex
|
||||
const safe = line
|
||||
.replace(/=>/g, "__ARROW__")
|
||||
.replace(/<\/?\w[\w.-]*\s*\/?>/g, (m) => m) // keep JSX tags
|
||||
.replace(/<\w[\w.,?:|\s]*?>/g, "__GEN__"); // strip TS generics like <T> or <T, U>
|
||||
|
||||
let m;
|
||||
const re = />([^<>{}\n]+)</g;
|
||||
while ((m = re.exec(safe)) !== null) {
|
||||
const raw = m[1].trim();
|
||||
if (!raw) continue;
|
||||
if (!/[A-Za-zÀ-ÿ一-鿿Ѐ-ӿ--ۿ]/.test(raw)) continue;
|
||||
if (raw.length < 2) continue;
|
||||
if (raw.startsWith("{") || raw.endsWith("}")) continue;
|
||||
if (/^[A-Z0-9_]+$/.test(raw)) continue; // CONSTANT
|
||||
if (/^(https?:\/\/|\/|mailto:|#)/.test(raw)) continue; // URL/path
|
||||
if (/^[a-z][a-z_0-9]*$/.test(raw) && raw.length < 22) continue; // icon/id slug
|
||||
if (TS_TYPE_NOISE.has(raw)) continue;
|
||||
// operator/expression debris like "0 && foo.size", "x !== y"
|
||||
if (/(&&|\|\||==|!=|<=|>=|=>|\?\s*:|\?\.|\.\w+\()/.test(raw)) continue;
|
||||
// pure number or simple variable.member with no spaces
|
||||
if (/^[\w.]+$/.test(raw) && !raw.includes(" ")) continue;
|
||||
findings.push({ kind: "jsx-text", line: lineIdx, value: raw });
|
||||
}
|
||||
}
|
||||
|
||||
// (B) JSX attributes that are user-visible
|
||||
const attrRe = /\b(title|placeholder|aria-label|alt|label)\s*=\s*["'`]([^"'`{}\n]{2,})["'`]/g;
|
||||
lineIdx = 0;
|
||||
for (const line of lines) {
|
||||
lineIdx++;
|
||||
if (/^\s*(import|\/\/|\*|\/\*)/.test(line)) continue;
|
||||
let m;
|
||||
const re = new RegExp(attrRe.source, "g");
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const value = m[2].trim();
|
||||
if (!/[A-Za-zÀ-ÿ一-鿿]/.test(value)) continue;
|
||||
if (/^[a-z][a-z_0-9-]*$/.test(value) && value.length < 16) continue; // probably an id/key
|
||||
findings.push({ kind: `attr:${m[1]}`, line: lineIdx, value });
|
||||
}
|
||||
}
|
||||
|
||||
// (C) toast.* and Error() arguments that look like user copy (length > 5, has a space)
|
||||
lineIdx = 0;
|
||||
const callRe =
|
||||
/\b(toast\.(error|success|info|warn|warning|message))\s*\(\s*["'`]([^"'`]{3,})["'`]/g;
|
||||
for (const line of lines) {
|
||||
lineIdx++;
|
||||
let m;
|
||||
const re = new RegExp(callRe.source, "g");
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
findings.push({ kind: `toast:${m[2]}`, line: lineIdx, value: m[3].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Find t() calls whose key does NOT exist in en.json */
|
||||
function findMissingKeys(namespaces, tKeys) {
|
||||
const missing = [];
|
||||
for (const key of tKeys) {
|
||||
// A key may itself contain dots — handle namespace dotted into key
|
||||
let found = false;
|
||||
for (const ns of namespaces) {
|
||||
if (keyExists(ns, key)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Also accept fully qualified bare-namespace keys (like "common.foo" with no useTranslations)
|
||||
if (!found && key.includes(".")) {
|
||||
const [maybeNs, ...rest] = key.split(".");
|
||||
if (keyExists(maybeNs, rest.join("."))) found = true;
|
||||
}
|
||||
if (!found) missing.push(key);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
const files = walkTsx(DASHBOARD_ROOT);
|
||||
const report = [];
|
||||
|
||||
for (const file of files) {
|
||||
const rel = relative(REPO_ROOT, file);
|
||||
const src = readFileSync(file, "utf8");
|
||||
const namespaces = extractNamespaces(src);
|
||||
const tCalls = extractTCalls(src);
|
||||
const hardcoded = findHardcodedStrings(src);
|
||||
const missing = findMissingKeys(namespaces.length ? namespaces : [null], tCalls);
|
||||
// Only include files that have ANY user-visible content
|
||||
if (!namespaces.length && !tCalls.length && !hardcoded.length) continue;
|
||||
report.push({
|
||||
file: rel,
|
||||
namespaces,
|
||||
tCallCount: tCalls.length,
|
||||
missingKeys: missing,
|
||||
hardcodedCount: hardcoded.length,
|
||||
hardcoded,
|
||||
});
|
||||
}
|
||||
|
||||
writeFileSync(OUT_PATH, JSON.stringify(report, null, 2));
|
||||
|
||||
// Human summary
|
||||
let totalMissing = 0;
|
||||
let totalHardcoded = 0;
|
||||
console.log("# i18n audit — dashboard pages\n");
|
||||
console.log(`Scanned ${report.length} files with user-visible content.\n`);
|
||||
const onlyIssues = report
|
||||
.map((r) => ({ ...r, total: r.missingKeys.length + r.hardcoded.length }))
|
||||
.filter((r) => r.total > 0)
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
for (const r of onlyIssues) {
|
||||
totalMissing += r.missingKeys.length;
|
||||
totalHardcoded += r.hardcoded.length;
|
||||
console.log(`\n## ${r.file}`);
|
||||
console.log(` ns=${JSON.stringify(r.namespaces)} t() calls=${r.tCallCount}`);
|
||||
if (r.missingKeys.length) {
|
||||
console.log(` ✗ missing keys (${r.missingKeys.length}):`);
|
||||
for (const k of r.missingKeys) console.log(` - ${k}`);
|
||||
}
|
||||
if (r.hardcoded.length) {
|
||||
console.log(` ✗ hardcoded strings (${r.hardcoded.length}):`);
|
||||
for (const h of r.hardcoded.slice(0, 30)) {
|
||||
console.log(` L${h.line} [${h.kind}] ${JSON.stringify(h.value)}`);
|
||||
}
|
||||
if (r.hardcoded.length > 30) console.log(` ... (+${r.hardcoded.length - 30} more)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n# Summary`);
|
||||
console.log(`Files with issues: ${onlyIssues.length}`);
|
||||
console.log(`Missing keys total: ${totalMissing}`);
|
||||
console.log(`Hardcoded strings: ${totalHardcoded}`);
|
||||
console.log(`Report file: ${relative(REPO_ROOT, OUT_PATH)}`);
|
||||
@@ -1640,7 +1640,25 @@
|
||||
"mitmClientsTab": "MITM Clients",
|
||||
"customCliTab": "Custom CLI",
|
||||
"toolCategories": "Tool Categories",
|
||||
"visibleToolsCount": "{count} tools available"
|
||||
"visibleToolsCount": "{count} tools available",
|
||||
"customCliBuilderTitle": "OpenAI-compatible CLI builder",
|
||||
"customCliBuilderDescription": "Generate env vars and JSON snippets for any CLI or SDK that accepts an OpenAI-compatible base URL, API key, and model ID.",
|
||||
"customCliNoModels": "Connect at least one provider to populate the model selectors.",
|
||||
"customCliNameLabel": "CLI name",
|
||||
"customCliNamePlaceholder": "e.g. My Team CLI",
|
||||
"customCliDefaultModelLabel": "Default model",
|
||||
"customCliDefaultModelHelp": "Use any OmniRoute model ID or combo. Most OpenAI-compatible CLIs only need the /v1 base URL plus a model string.",
|
||||
"customCliKeyHelper": "For local installs OmniRoute can use sk_omniroute. In cloud mode, pick one of your management API keys.",
|
||||
"customCliAliasMappingsLabel": "Alias mappings",
|
||||
"customCliAliasMappingsHelp": "Optional helper aliases for wrapper scripts or config files that want stable shorthand names.",
|
||||
"customCliAddAlias": "Add alias",
|
||||
"customCliNoMappings": "No alias mappings yet. Add one if your wrapper or team scripts use stable short names.",
|
||||
"customCliAliasPlaceholder": "e.g. review",
|
||||
"customCliTargetModelLabel": "Target model",
|
||||
"customCliEndpointHintLabel": "How to wire the endpoint",
|
||||
"customCliEndpointHint": "Point any OpenAI-compatible client to the OmniRoute /v1 base URL. The raw chat completions endpoint is {endpoint}. Use the JSON block when the tool wants a provider object, or the env script when it reads OPENAI_* variables.",
|
||||
"customCliEnvBlockTitle": "Env / shell snippet",
|
||||
"customCliJsonBlockTitle": "Provider JSON block"
|
||||
},
|
||||
"combos": {
|
||||
"title": "Combos",
|
||||
@@ -2528,7 +2546,19 @@
|
||||
"episodic": "Episodic",
|
||||
"procedural": "Procedural",
|
||||
"semantic": "Semantic",
|
||||
"a": "A"
|
||||
"a": "A",
|
||||
"pipelineOk": "Pipeline OK ({latencyMs}ms)",
|
||||
"pipelineError": "Pipeline error",
|
||||
"healthUnknown": "Health unknown",
|
||||
"checkingHealth": "Checking…",
|
||||
"checkHealth": "Check health",
|
||||
"pageInfo": "Page {page} of {totalPages} ({total} total)",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"keyPlaceholder": "e.g. user.preferences.theme",
|
||||
"contentPlaceholder": "Value or JSON content to remember"
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
@@ -2825,7 +2855,23 @@
|
||||
"failedAddProvider": "Failed to add provider. Try again.",
|
||||
"connectionError": "Connection error. Please try again.",
|
||||
"provider": "Provider",
|
||||
"apiKeyHelp": "An API key is a password for AI services. Get one from your provider's website (e.g., platform.openai.com, console.anthropic.com)."
|
||||
"apiKeyHelp": "An API key is a password for AI services. Get one from your provider's website (e.g., platform.openai.com, console.anthropic.com).",
|
||||
"tier": {
|
||||
"subtitle": "OmniRoute organises providers into three tiers so routing prefers the most reliable, lowest-cost path first.",
|
||||
"tier1": {
|
||||
"label": "Premium clients",
|
||||
"description": "First-class CLIs with native auth flows and reasoning models."
|
||||
},
|
||||
"tier2": {
|
||||
"label": "Cost-optimised",
|
||||
"description": "Cheap, high-throughput providers used for everyday traffic."
|
||||
},
|
||||
"tier3": {
|
||||
"label": "Fallback & specialty",
|
||||
"description": "Locally-hosted or specialty endpoints used as fallbacks."
|
||||
},
|
||||
"configure": "Configure providers"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
@@ -4426,6 +4472,16 @@
|
||||
},
|
||||
"streaming": {
|
||||
"prompt": "Tell me a short story about a robot learning to paint."
|
||||
},
|
||||
"vision": {
|
||||
"system": "You are an assistant that describes images precisely.",
|
||||
"userPrompt": "What is shown in this image?",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/600px-PNG_transparency_demonstration_1.png"
|
||||
},
|
||||
"schemaCoercion": {
|
||||
"userPrompt": "Look up the weather for Tokyo using metric units and include the hourly breakdown.",
|
||||
"toolDescription": "Fetch weather for a city with structured options.",
|
||||
"cityDescription": "The city to query, e.g. 'Tokyo' or 'New York'."
|
||||
}
|
||||
},
|
||||
"openaiCompatibleLabel": "OpenAI Compatible",
|
||||
@@ -4471,7 +4527,33 @@
|
||||
"eventSourceTranslatorPage": "• Translator page (Chat Tester, Test Bench)",
|
||||
"eventSourceMainPipeline": "• Main request pipeline (CLI/IDE/API traffic)",
|
||||
"liveMonitorDescriptionPrefix": "Shows translation events as API calls flow through OmniRoute. Events come from the in-memory buffer (resets on restart). Use",
|
||||
"liveMonitorDescriptionSuffix": ", or external API calls to generate events."
|
||||
"liveMonitorDescriptionSuffix": ", or external API calls to generate events.",
|
||||
"streamTransformer": "Stream Transformer",
|
||||
"modeDescriptionStreamTransformer": "Run chat completions SSE streams through the Responses transformer.",
|
||||
"streamTransformerTitle": "Responses Stream Transformer",
|
||||
"streamTransformerDescription": "Paste a chat completions SSE stream, run it through OmniRoute's Responses transformer, and inspect the emitted response.* events before wiring a client.",
|
||||
"loadTextSample": "Load text sample",
|
||||
"loadToolSample": "Load tool-call sample",
|
||||
"transformToResponses": "Transform to Responses",
|
||||
"rawChatSseInput": "Raw chat completions SSE",
|
||||
"transformedResponsesSse": "Transformed Responses API SSE",
|
||||
"noResultsYet": "No results yet",
|
||||
"transformedEvents": "Transformed events",
|
||||
"uniqueEventTypes": "Unique event types",
|
||||
"inputLines": "Input lines",
|
||||
"outputLines": "Output lines",
|
||||
"transformedEventTimeline": "Transformed event timeline",
|
||||
"transformerTimelineHint": "Run the transformer to inspect emitted response.output_* events in order.",
|
||||
"eventType": "Event type",
|
||||
"eventPreview": "Preview",
|
||||
"comboRouted": "Combo routed",
|
||||
"uniqueEndpoints": "Unique endpoints",
|
||||
"routeDetails": "Route details",
|
||||
"comboBadge": "Combo",
|
||||
"routeEndpointLabel": "Endpoint",
|
||||
"routeConnectionLabel": "Connection",
|
||||
"scenarioVision": "Vision (image understanding)",
|
||||
"scenarioSchemaCoercion": "Schema coercion (structured output)"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Usage",
|
||||
|
||||
Reference in New Issue
Block a user