From 52775acd4d59eb70b49c29ee6a9ddf9d47d3ff6c Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 9 Jul 2025 20:30:24 +0400 Subject: [PATCH] chore(eslint): Extend eslint rules to error on `i18next/on no-literal-string` (#9616) Co-authored-by: openhands --- frontend/.eslintrc | 3 +- frontend/.husky/pre-commit | 2 +- frontend/package-lock.json | 25 + frontend/package.json | 2 +- .../scripts/check-unlocalized-strings.cjs | 740 ------------------ .../conversation-panel/budget-usage-text.tsx | 8 +- .../conversation-panel/conversation-card.tsx | 8 +- .../features/feedback/likert-scale.tsx | 2 +- .../llm-settings/reset-settings-modal.tsx | 41 - .../mcp-settings/mcp-config-editor.tsx | 2 +- .../features/settings/optional-tag.tsx | 10 +- .../settings/secrets-settings/secret-form.tsx | 7 +- .../features/settings/settings-switch.tsx | 5 +- .../shared/modals/confirmation-modal.tsx | 7 +- frontend/src/i18n/declaration.ts | 9 + frontend/src/i18n/translation.json | 144 ++++ frontend/src/routes/served-tab.tsx | 2 +- frontend/src/test/localization-fix.test.ts | 81 -- 18 files changed, 219 insertions(+), 879 deletions(-) delete mode 100755 frontend/scripts/check-unlocalized-strings.cjs delete mode 100644 frontend/src/components/features/settings/llm-settings/reset-settings-modal.tsx delete mode 100644 frontend/src/test/localization-fix.test.ts diff --git a/frontend/.eslintrc b/frontend/.eslintrc index 7bf4b7541c..c89d89c857 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -13,8 +13,9 @@ "plugin:react-hooks/recommended", "plugin:@tanstack/query/recommended", ], - "plugins": ["prettier", "unused-imports"], + "plugins": ["prettier", "unused-imports", "i18next"], "rules": { + "i18next/no-literal-string": "error", "unused-imports/no-unused-imports": "error", "prettier/prettier": ["error"], // Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871 diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit index f013a79f9a..c961cf71a1 100755 --- a/frontend/.husky/pre-commit +++ b/frontend/.husky/pre-commit @@ -1,7 +1,7 @@ # Run frontend checks echo "Running frontend checks..." cd frontend -npm run check-unlocalized-strings +npm run lint npm run check-translation-completeness npx lint-staged diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0e4d6ab47e..5bc2854ac3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -82,6 +82,7 @@ "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "^10.1.5", + "eslint-plugin-i18next": "^6.1.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.5.1", @@ -9121,6 +9122,20 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-i18next": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.2.tgz", + "integrity": "sha512-hvTmws4kouNHkk314+9MHNj+RQmsqrkejWhTXGlRC0j8H+EXq2qDRLe6UqIjrFZo7/ogyd4btuqsnKCBi8wHbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "lodash": "^4.17.21", + "requireindex": "~1.1.0" + }, + "engines": { + "node": ">=18.10.0" + } + }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", @@ -15117,6 +15132,16 @@ "node": ">=0.10.0" } }, + "node_modules/requireindex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", + "integrity": "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.5" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a261509058..9ccdcc1046 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,7 +70,6 @@ "lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}", "prepare": "cd .. && husky frontend/.husky", "typecheck": "react-router typegen && tsc", - "check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs", "check-translation-completeness": "node scripts/check-translation-completeness.cjs" }, "lint-staged": { @@ -107,6 +106,7 @@ "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "^10.1.5", + "eslint-plugin-i18next": "^6.1.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.5.1", diff --git a/frontend/scripts/check-unlocalized-strings.cjs b/frontend/scripts/check-unlocalized-strings.cjs deleted file mode 100755 index 7dea330a5b..0000000000 --- a/frontend/scripts/check-unlocalized-strings.cjs +++ /dev/null @@ -1,740 +0,0 @@ -#!/usr/bin/env node - -/** - * Pre-commit hook script to check for unlocalized strings in the frontend code - * This script is based on the test in __tests__/utils/check-hardcoded-strings.test.tsx - */ - -const path = require('path'); -const fs = require('fs'); -const parser = require('@babel/parser'); -const traverse = require('@babel/traverse').default; - -// Files/directories to ignore -const IGNORE_PATHS = [ - // Build and dependency files - "node_modules", - "dist", - ".git", - "test", - "__tests__", - ".d.ts", - "i18n", - "package.json", - "package-lock.json", - "tsconfig.json", - - // Internal code that doesn't need localization - "mocks", // Mock data - "assets", // SVG paths and CSS classes - "types", // Type definitions and constants - "state", // Redux state management - "api", // API endpoints - "services", // Internal services - "hooks", // React hooks - "context", // React context - "store", // Redux store - "routes.ts", // Route definitions - "root.tsx", // Root component - "entry.client.tsx", // Client entry point - "utils/scan-unlocalized-strings.ts", // Original scanner - "utils/scan-unlocalized-strings-ast.ts", // This file itself - "frontend/src/components/features/home/tasks/get-prompt-for-query.ts", // Only contains agent prompts -]; - -// Extensions to scan -const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]; - -// Attributes that typically don't contain user-facing text -const NON_TEXT_ATTRIBUTES = [ - "allow", - "className", - "i18nKey", - "testId", - "id", - "name", - "type", - "href", - "src", - "rel", - "target", - "style", - "onClick", - "onChange", - "onSubmit", - "data-testid", - "aria-labelledby", - "aria-describedby", - "aria-hidden", - "role", - "sandbox", -]; - -function shouldIgnorePath(filePath) { - return IGNORE_PATHS.some((ignore) => filePath.includes(ignore)); -} - -// Check if a string looks like a translation key -// Translation keys typically use dots, underscores, or are all caps -// Also check for the pattern with $ which is used in our translation keys -function isLikelyTranslationKey(str) { - return ( - /^[A-Z0-9_$.]+$/.test(str) || - str.includes(".") || - /[A-Z0-9_]+\$[A-Z0-9_]+/.test(str) - ); -} - -// Check if a string is a raw translation key that should be wrapped in t() -function isRawTranslationKey(str) { - // Check for our specific translation key pattern (e.g., "SETTINGS$GITHUB_SETTINGS") - // Exclude specific keys that are already properly used with i18next.t() in the code - const excludedKeys = [ - "STATUS$ERROR_LLM_OUT_OF_CREDITS", - "ERROR$GENERIC", - "GITHUB$AUTH_SCOPE", - ]; - - if (excludedKeys.includes(str)) { - return false; - } - - return /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str); -} - -// Specific technical strings that should be excluded from localization -const EXCLUDED_TECHNICAL_STRINGS = [ - "openid email profile", // OAuth scope string - not user-facing - "OPEN_ISSUE", // Task type identifier, not a UI string - "Merge Request", // Git provider specific terminology - "GitLab API", // Git provider specific terminology - "Pull Request", // Git provider specific terminology - "GitHub API", // Git provider specific terminology - "add-secret-form", // Test ID for secret form - "edit-secret-form", // Test ID for secret form - "search-api-key-input", // Input name for search API key - "noopener,noreferrer", // Options for window.open - ".openhands/microagents/", // Path to microagents directory - "STATUS$READY", - "STATUS$STOPPED", - "STATUS$ERROR", -]; - -function isExcludedTechnicalString(str) { - return EXCLUDED_TECHNICAL_STRINGS.includes(str); -} - -function isLikelyCode(str) { - // A string with no spaces and at least one underscore or colon is likely a code. - // (e.g.: "browser_interactive" or "error:") - if (str.includes(" ")) { - return false - } - if (str.includes(":") || str.includes("_")){ - return true - } - return false -} - -function isCommonDevelopmentString(str) { - - // Technical patterns that are definitely not UI strings - const technicalPatterns = [ - // URLs and paths - /^https?:\/\//, // URLs - /^\/[a-zA-Z0-9_\-./]*$/, // File paths - /^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/, // File extensions, class names - /^@[a-zA-Z0-9/-]+$/, // Import paths - /^#\/[a-zA-Z0-9/-]+$/, // Alias imports - /^[a-zA-Z0-9/-]+\/[a-zA-Z0-9/-]+$/, // Module paths - /^data:image\/[a-zA-Z0-9;,]+$/, // Data URLs - /^application\/[a-zA-Z0-9-]+$/, // MIME types - /^!\[image]\(data:image\/png;base64,$/, // Markdown image with base64 data - - // Numbers, IDs, and technical values - /^\d+(\.\d+)?$/, // Numbers - /^#[0-9a-fA-F]{3,8}$/, // Color codes - /^[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+$/, // Key-value pairs - /^mm:ss$/, // Time format - /^[a-zA-Z0-9]+\/[a-zA-Z0-9-]+$/, // Provider/model format - /^\?[a-zA-Z0-9_-]+$/, // URL parameters - /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, // UUID - /^[A-Za-z0-9+/=]+$/, // Base64 - - // HTML and CSS selectors - /^[a-z]+(\[[^\]]+\])+$/, // CSS attribute selectors - /^[a-z]+:[a-z-]+$/, // CSS pseudo-selectors - /^[a-z]+\.[a-z0-9_-]+$/, // CSS class selectors - /^[a-z]+#[a-z0-9_-]+$/, // CSS ID selectors - /^[a-z]+\s*>\s*[a-z]+$/, // CSS child selectors - /^[a-z]+\s+[a-z]+$/, // CSS descendant selectors - - // CSS and styling patterns - /^[a-z0-9-]+:[a-z0-9-]+$/, // CSS property:value - /^[a-z0-9-]+:[a-z0-9-]+;[a-z0-9-]+:[a-z0-9-]+$/, // Multiple CSS properties - ]; - - // File extensions and media types - const fileExtensionPattern = - /^\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|pdf|mp4|webm|ogg|mp3|wav|json|xml|csv|txt|md|html|css|js|jsx|ts|tsx)$/i; - if (fileExtensionPattern.test(str)) { - return true; - } - - // AI model and provider patterns - const aiRelatedPattern = - /^(AI|OpenAI|VertexAI|PaLM|Gemini|Anthropic|Anyscale|Databricks|Ollama|FriendliAI|Groq|DeepInfra|AI21|Replicate|OpenRouter|Azure|AWS|SageMaker|Bedrock|Mistral|Perplexity|Fireworks|Cloudflare|Workers|Voyage|claude-|gpt-|o1-|o3-)/i; - if (aiRelatedPattern.test(str)) { - return true; - } - - // CSS units and values - const cssUnitsPattern = - /\b\d+(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$|^(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$/; - const cssValuesPattern = - /(rgb|rgba|hsl|hsla|#[0-9a-fA-F]+|solid|absolute|relative|sticky|fixed|static|block|inline|flex|grid|none|auto|hidden|visible)/; - - if (cssUnitsPattern.test(str) || cssValuesPattern.test(str)) { - return true; - } - - // Check for CSS class strings with brackets (common in the codebase) - if ( - str.includes("[") && - str.includes("]") && - (str.includes("px") || - str.includes("rem") || - str.includes("em") || - str.includes("w-") || - str.includes("h-") || - str.includes("p-") || - str.includes("m-")) - ) { - return true; - } - - // Check for CSS class strings with specific patterns - if ( - str.includes("border-") || - str.includes("rounded-") || - str.includes("cursor-") || - str.includes("opacity-") || - str.includes("disabled:") || - str.includes("hover:") || - str.includes("focus-within:") || - str.includes("first-of-type:") || - str.includes("last-of-type:") || - str.includes("group-data-") - ) { - return true; - } - - // Check if it looks like a Tailwind class string - if (/^[a-z0-9-]+(\s+[a-z0-9-]+)*$/.test(str)) { - // Common Tailwind prefixes and patterns - const tailwindPrefixes = [ - "bg-", "text-", "border-", "rounded-", "p-", "m-", "px-", "py-", "mx-", "my-", - "w-", "h-", "min-w-", "min-h-", "max-w-", "max-h-", "flex-", "grid-", "gap-", - "space-", "items-", "justify-", "self-", "col-", "row-", "order-", "object-", - "overflow-", "opacity-", "z-", "top-", "right-", "bottom-", "left-", "inset-", - "font-", "tracking-", "leading-", "list-", "placeholder-", "shadow-", "ring-", - "transition-", "duration-", "ease-", "delay-", "animate-", "scale-", "rotate-", - "translate-", "skew-", "origin-", "cursor-", "select-", "resize-", "fill-", "stroke-", - ]; - - // Check if any word in the string starts with a Tailwind prefix - const words = str.split(/\s+/); - for (const word of words) { - for (const prefix of tailwindPrefixes) { - if (word.startsWith(prefix)) { - return true; - } - } - } - - // Check for Tailwind modifiers - const tailwindModifiers = [ - "hover:", "focus:", "active:", "disabled:", "visited:", "first:", "last:", - "odd:", "even:", "group-hover:", "focus-within:", "focus-visible:", "motion-safe:", - "motion-reduce:", "dark:", "light:", "sm:", "md:", "lg:", "xl:", "2xl:", - ]; - - for (const word of words) { - for (const modifier of tailwindModifiers) { - if (word.includes(modifier)) { - return true; - } - } - } - - // Check for CSS property combinations - const cssProperties = [ - "border", "rounded", "px", "py", "mx", "my", "p", "m", "w", "h", "flex", - "grid", "gap", "transition", "duration", "font", "leading", "tracking", - ]; - - // If the string contains multiple CSS properties, it's likely a CSS class string - let cssPropertyCount = 0; - for (const word of words) { - if ( - cssProperties.some( - (prop) => word === prop || word.startsWith(`${prop}-`), - ) - ) { - cssPropertyCount += 1; - } - } - - if (cssPropertyCount >= 2) { - return true; - } - } - - // Check for specific CSS class patterns that appear in the test failures - if ( - str.match( - /^(border|rounded|flex|grid|transition|duration|ease|hover:|focus:|active:|disabled:|placeholder:|text-|bg-|w-|h-|p-|m-|gap-|items-|justify-|self-|overflow-|cursor-|opacity-|z-|top-|right-|bottom-|left-|inset-|font-|tracking-|leading-|whitespace-|break-|truncate|shadow-|ring-|outline-|animate-|transform|rotate-|scale-|skew-|translate-|origin-|first-of-type:|last-of-type:|group-data-|max-|min-|px-|py-|mx-|my-|grow|shrink|resize-|underline|italic|normal)/, - ) - ) { - return true; - } - - // HTML tags and attributes - if ( - /^<[a-z0-9]+(?:\s[^>]*)?>.*<\/[a-z0-9]+>$/i.test(str) || - /^<[a-z0-9]+ [^>]+\/>$/i.test(str) - ) { - return true; - } - - // Check for specific patterns in suggestions and examples - if ( - str.includes("* ") && - (str.includes("create a") || - str.includes("build a") || - str.includes("make a")) - ) { - // This is likely a suggestion or example, not a UI string - return false; - } - - // Check for specific technical identifiers from the test failures - if ( - /^(download_via_vscode_button_clicked|open-vscode-error-|set-indicator|settings_saved|openhands-trace-|provider-item-|last_browser_action_error)$/.test( - str, - ) - ) { - return true; - } - - // Check for URL paths and query parameters - if ( - str.startsWith("?") || - str.startsWith("/") || - str.includes("auth.") || - str.includes("$1auth.") - ) { - return true; - } - - // Check for specific strings that should be excluded - if ( - str === "Cache Hit:" || - str === "Cache Write:" || - str === "ADD_DOCS" || - str === "ADD_DOCKERFILE" || - str === "Verified" || - str === "Others" || - str === "Feedback" || - str === "JSON File" || - str === "mt-0.5 md:mt-0" - ) { - return true; - } - - // Check for long suggestion texts - if ( - str.length > 100 && - (str.includes("Please write a bash script") || - str.includes("Please investigate the repo") || - str.includes("Please push the changes") || - str.includes("Examine the dependencies") || - str.includes("Investigate the documentation") || - str.includes("Investigate the current repo") || - str.includes("I want to create a Hello World app") || - str.includes("I want to create a VueJS app") || - str.includes("This should be a client-only app")) - ) { - return true; - } - - // Check for specific error messages and UI text - if ( - str === "All data associated with this project will be lost." || - str === "You will lose any unsaved information." || - str === - "This conversation does not exist, or you do not have permission to access it." || - str === "Failed to fetch settings. Please try reloading." || - str === - "If you tell OpenHands to start a web server, the app will appear here." || - str === - "Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API." || - str === - "Something went wrong while fetching settings. Please reload the page." || - str === - "To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." || - str === "Please push the latest changes to the existing pull request." - ) { - return true; - } - - // Check against all technical patterns - return technicalPatterns.some((pattern) => pattern.test(str)); -} - -function isLikelyUserFacingText(str) { - - // Basic validation - skip very short strings or strings without letters - if (!str || str.length <= 2 || !/[a-zA-Z]/.test(str)) { - return false; - } - - // Check if it's a specifically excluded technical string - if (isExcludedTechnicalString(str)) { - return false; - } - - // Check if it looks like a code rather than a key - if (isLikelyCode(str)) { - return false - } - - // Check if it's a raw translation key that should be wrapped in t() - if (isRawTranslationKey(str)) { - return true; - } - - // Check if it's a translation key pattern (e.g., "SETTINGS$BASE_URL") - // These should be wrapped in t() or use I18nKey enum - if (isLikelyTranslationKey(str) && /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str)) { - return true; - } - - // First, check if it's a common development string (not user-facing) - if (isCommonDevelopmentString(str)) { - return false; - } - - // Multi-word phrases are likely UI text - const hasMultipleWords = /\s+/.test(str) && str.split(/\s+/).length > 1; - - // Sentences and questions are likely UI text - const hasPunctuation = /[?!.,:]/.test(str); - const isCapitalizedPhrase = /^[A-Z]/.test(str) && hasMultipleWords; - const isTitleCase = hasMultipleWords && /\s[A-Z]/.test(str); - const hasSentenceStructure = /^[A-Z].*[.!?]$/.test(str); // Starts with capital, ends with punctuation - const hasQuestionForm = - /^(What|How|Why|When|Where|Who|Can|Could|Would|Will|Is|Are|Do|Does|Did|Should|May|Might)/.test( - str, - ); - - // Product names and camelCase identifiers are likely UI text - const hasInternalCapitals = /[a-z][A-Z]/.test(str); // CamelCase product names - - // Instruction text patterns are likely UI text - const looksLikeInstruction = - /^(Enter|Type|Select|Choose|Provide|Specify|Search|Find|Input|Add|Write|Describe|Set|Pick|Browse|Upload|Download|Click|Tap|Press|Go to|Visit|Open|Close)/i.test( - str, - ); - - // Error and status messages are likely UI text - const looksLikeErrorOrStatus = - /(failed|error|invalid|required|missing|incorrect|wrong|unavailable|not found|not available|try again|success|completed|finished|done|saved|updated|created|deleted|removed|added)/i.test( - str, - ); - - // Single word check - assume it's UI text unless proven otherwise - const isSingleWord = - !str.includes(" ") && str.length > 1 && /^[a-zA-Z]+$/.test(str); - - // For single words, we need to be more careful - if (isSingleWord) { - // Skip common programming terms and variable names - const isCommonProgrammingTerm = - /^(null|undefined|true|false|function|class|interface|type|enum|const|let|var|return|import|export|default|async|await|try|catch|finally|throw|new|this|super|extends|implements|instanceof|typeof|void|delete|in|of|for|while|do|if|else|switch|case|break|continue|yield|static|get|set|public|private|protected|readonly|abstract|implements|namespace|module|declare|as|from|with)$/i.test( - str, - ); - - if (isCommonProgrammingTerm) { - return false; - } - - // Skip common variable name patterns - const looksLikeVariableName = - /^[a-z][a-zA-Z0-9]*$/.test(str) && str.length <= 20; - - if (looksLikeVariableName) { - return false; - } - - // Skip common CSS values - const isCommonCssValue = - /^(auto|none|hidden|visible|block|inline|flex|grid|row|column|wrap|nowrap|center|start|end|stretch|cover|contain|fixed|absolute|relative|static|sticky|pointer|default|inherit|initial|unset)$/i.test( - str, - ); - - if (isCommonCssValue) { - return false; - } - - // Skip common file extensions - const isFileExtension = /^\.[a-z0-9]+$/i.test(str); - if (isFileExtension) { - return false; - } - - // Skip common abbreviations - const isCommonAbbreviation = - /^(id|src|href|url|alt|img|btn|nav|div|span|ul|li|ol|dl|dt|dd|svg|png|jpg|gif|pdf|doc|txt|md|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|toml|csv|mp3|mp4|wav|avi|mov|mpeg|webm|webp|ttf|woff|eot|otf)$/i.test( - str, - ); - - if (isCommonAbbreviation) { - return false; - } - - // If it's a single word that's not a programming term, variable name, CSS value, file extension, or abbreviation, - // it might be UI text, but we'll be conservative and return false - return false; - } - - // If it has multiple words, punctuation, or looks like a sentence, it's likely UI text - return ( - hasMultipleWords || - hasPunctuation || - isCapitalizedPhrase || - isTitleCase || - hasSentenceStructure || - hasQuestionForm || - hasInternalCapitals || - looksLikeInstruction || - looksLikeErrorOrStatus - ); -} - -function isInTranslationContext(path) { - // Check if the JSX text is inside a component - let current = path; - while (current.parentPath) { - if ( - current.isJSXElement() && - current.node.openingElement && - current.node.openingElement.name && - current.node.openingElement.name.name === "Trans" - ) { - return true; - } - current = current.parentPath; - } - return false; -} - -function scanFileForUnlocalizedStrings(filePath) { - // Skip suggestion content files as they contain special strings that are already properly localized - if (filePath.includes("utils/suggestions/") || filePath.includes("mocks/task-suggestions-handlers.ts")) { - return []; - } - - try { - const content = fs.readFileSync(filePath, "utf-8"); - const unlocalizedStrings = []; - - // Skip files that are too large - if (content.length > 1000000) { - console.warn(`Skipping large file: ${filePath}`); - return []; - } - - try { - // Parse the file - const ast = parser.parse(content, { - sourceType: "module", - plugins: ["jsx", "typescript", "classProperties", "decorators-legacy"], - }); - - // Traverse the AST - traverse(ast, { - // Find JSX text content - JSXText(jsxTextPath) { - const text = jsxTextPath.node.value.trim(); - if ( - text && - isLikelyUserFacingText(text) && - !isInTranslationContext(jsxTextPath) - ) { - unlocalizedStrings.push(text); - } - }, - - // Find string literals in JSX attributes - JSXAttribute(jsxAttrPath) { - const attrName = jsxAttrPath.node.name.name.toString(); - - // Skip technical attributes that don't contain user-facing text - if (NON_TEXT_ATTRIBUTES.includes(attrName)) { - return; - } - - // Skip styling attributes - if ( - attrName === "className" || - attrName === "class" || - attrName === "style" - ) { - return; - } - - // Skip data attributes and event handlers - if (attrName.startsWith("data-") || attrName.startsWith("on")) { - return; - } - - // Check the attribute value - const value = jsxAttrPath.node.value; - if (value && value.type === "StringLiteral") { - const text = value.value.trim(); - if (text && isLikelyUserFacingText(text)) { - unlocalizedStrings.push(text); - } - } - }, - - // Find string literals in code - StringLiteral(stringPath) { - // Skip if parent is JSX attribute (already handled above) - if (stringPath.parent.type === "JSXAttribute") { - return; - } - - // Skip if parent is import/export declaration - if ( - stringPath.parent.type === "ImportDeclaration" || - stringPath.parent.type === "ExportDeclaration" - ) { - return; - } - - // Skip if parent is object property key - if ( - stringPath.parent.type === "ObjectProperty" && - stringPath.parent.key === stringPath.node - ) { - return; - } - - // Skip if inside a t() call or Trans component - let isInsideTranslation = false; - let current = stringPath; - - while (current.parentPath && !isInsideTranslation) { - // Check for t() function call - if ( - current.parent.type === "CallExpression" && - current.parent.callee && - ((current.parent.callee.type === "Identifier" && - current.parent.callee.name === "t") || - (current.parent.callee.type === "MemberExpression" && - current.parent.callee.property && - current.parent.callee.property.name === "t")) - ) { - isInsideTranslation = true; - break; - } - - // Check for component - if ( - current.parent.type === "JSXElement" && - current.parent.openingElement && - current.parent.openingElement.name && - current.parent.openingElement.name.name === "Trans" - ) { - isInsideTranslation = true; - break; - } - - current = current.parentPath; - } - - if (!isInsideTranslation) { - const text = stringPath.node.value.trim(); - if (text && isLikelyUserFacingText(text)) { - unlocalizedStrings.push(text); - } - } - }, - }); - - return unlocalizedStrings; - } catch (error) { - console.error(`Error parsing file ${filePath}:`, error); - return []; - } - } catch (error) { - console.error(`Error reading file ${filePath}:`, error); - return []; - } -} - -function scanDirectoryForUnlocalizedStrings(dirPath) { - const results = new Map(); - - function scanDir(currentPath) { - const entries = fs.readdirSync(currentPath, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(currentPath, entry.name); - - if (!shouldIgnorePath(fullPath)) { - if (entry.isDirectory()) { - scanDir(fullPath); - } else if ( - entry.isFile() && - SCAN_EXTENSIONS.includes(path.extname(fullPath)) - ) { - const unlocalized = scanFileForUnlocalizedStrings(fullPath); - if (unlocalized.length > 0) { - results.set(fullPath, unlocalized); - } - } - } - } - } - - scanDir(dirPath); - return results; -} - -// Run the check -try { - const srcPath = path.resolve(__dirname, '../src'); - console.log('Checking for unlocalized strings in frontend code...'); - - // Get unlocalized strings using the AST scanner - const results = scanDirectoryForUnlocalizedStrings(srcPath); - - // If we found any unlocalized strings, format them for output and exit with error - if (results.size > 0) { - const formattedResults = Array.from(results.entries()) - .map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`) - .join('\n'); - - console.error(`Error: Found unlocalized strings in the following files:${formattedResults}`); - process.exit(1); - } - - console.log('✅ No unlocalized strings found in frontend code.'); - process.exit(0); -} catch (error) { - console.error('Error running unlocalized strings check:', error); - process.exit(1); -} diff --git a/frontend/src/components/features/conversation-panel/budget-usage-text.tsx b/frontend/src/components/features/conversation-panel/budget-usage-text.tsx index 57cd79e0a5..09aa57c7cc 100644 --- a/frontend/src/components/features/conversation-panel/budget-usage-text.tsx +++ b/frontend/src/components/features/conversation-panel/budget-usage-text.tsx @@ -17,8 +17,12 @@ export function BudgetUsageText({ return (
- ${currentCost.toFixed(4)} / ${maxBudget.toFixed(4)} ( - {usagePercentage.toFixed(2)}% {t(I18nKey.CONVERSATION$USED)}) + {t(I18nKey.CONVERSATION$BUDGET_USAGE_FORMAT, { + currentCost: `$${currentCost.toFixed(4)}`, + maxBudget: `$${maxBudget.toFixed(4)}`, + usagePercentage: usagePercentage.toFixed(2), + used: t(I18nKey.CONVERSATION$USED), + })}
); diff --git a/frontend/src/components/features/conversation-panel/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card.tsx index de70b49cd9..4635302c29 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card.tsx @@ -330,11 +330,15 @@ export function ConversationCard({
- Cache Hit: + + {t(I18nKey.CONVERSATION$CACHE_HIT)} + {metrics.usage.cache_read_tokens.toLocaleString()} - Cache Write: + + {t(I18nKey.CONVERSATION$CACHE_WRITE)} + {metrics.usage.cache_write_tokens.toLocaleString()} diff --git a/frontend/src/components/features/feedback/likert-scale.tsx b/frontend/src/components/features/feedback/likert-scale.tsx index d63a31d7c9..20456a49d2 100644 --- a/frontend/src/components/features/feedback/likert-scale.tsx +++ b/frontend/src/components/features/feedback/likert-scale.tsx @@ -207,7 +207,7 @@ export function LikertScale({ className={cn("text-xl transition-all", getButtonClass(rating))} aria-label={`Rate ${rating} stars`} > - ★ + {t(I18nKey.FEEDBACK$STAR_RATING)} ))} {/* Show selected reason inline with stars when submitted (only for ratings <= 3) */} diff --git a/frontend/src/components/features/settings/llm-settings/reset-settings-modal.tsx b/frontend/src/components/features/settings/llm-settings/reset-settings-modal.tsx deleted file mode 100644 index 85f4f80350..0000000000 --- a/frontend/src/components/features/settings/llm-settings/reset-settings-modal.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; -import { I18nKey } from "#/i18n/declaration"; -import { BrandButton } from "../brand-button"; - -interface ResetSettingsModalProps { - onReset: () => void; -} - -export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) { - const { t } = useTranslation(); - - return ( - -
-

{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}

-
- - Reset - - - - Cancel - -
-
-
- ); -} diff --git a/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx b/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx index 2bf040ef86..c02861059c 100644 --- a/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx +++ b/frontend/src/components/features/settings/mcp-settings/mcp-config-editor.tsx @@ -41,7 +41,7 @@ export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) { className="text-sm text-blue-400 hover:underline mr-3" onClick={(e) => e.stopPropagation()} > - Documentation + {t(I18nKey.COMMON$DOCUMENTATION)} (Optional); + const { t } = useTranslation(); + return ( + + {t(I18nKey.COMMON$OPTIONAL)} + + ); } diff --git a/frontend/src/components/features/settings/secrets-settings/secret-form.tsx b/frontend/src/components/features/settings/secrets-settings/secret-form.tsx index 9f7951bb22..b67e105f41 100644 --- a/frontend/src/components/features/settings/secrets-settings/secret-form.tsx +++ b/frontend/src/components/features/settings/secrets-settings/secret-form.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import React from "react"; import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; import { useCreateSecret } from "#/hooks/mutation/use-create-secret"; import { useUpdateSecret } from "#/hooks/mutation/use-update-secret"; import { SettingsInput } from "../settings-input"; @@ -151,7 +152,7 @@ export function SecretForm({ {mode === "add" && (