mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
chore(eslint): Extend eslint rules to error on i18next/on no-literal-string (#9616)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
be0596abd6
commit
52775acd4d
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
25
frontend/package-lock.json
generated
25
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 <Trans> 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 <Trans> 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);
|
||||
}
|
||||
@ -17,8 +17,12 @@ export function BudgetUsageText({
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-neutral-400">
|
||||
${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),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -330,11 +330,15 @@ export function ConversationCard({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
|
||||
<span className="text-neutral-400">Cache Hit:</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_HIT)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_read_tokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-neutral-400">Cache Write:</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_write_tokens.toLocaleString()}
|
||||
</span>
|
||||
|
||||
@ -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)}
|
||||
</button>
|
||||
))}
|
||||
{/* Show selected reason inline with stars when submitted (only for ratings <= 3) */}
|
||||
|
||||
@ -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 (
|
||||
<ModalBackdrop>
|
||||
<div className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary">
|
||||
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
|
||||
<div className="w-full flex gap-2" data-testid="reset-settings-modal">
|
||||
<BrandButton
|
||||
testId="confirm-button"
|
||||
type="submit"
|
||||
name="reset-settings"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
testId="cancel-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={onReset}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
</a>
|
||||
<BrandButton
|
||||
type="button"
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function OptionalTag() {
|
||||
return <span className="text-xs text-tertiary-alt">(Optional)</span>;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="text-xs text-tertiary-alt">
|
||||
{t(I18nKey.COMMON$OPTIONAL)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" && (
|
||||
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
|
||||
<span className="text-sm">Value</span>
|
||||
<span className="text-sm">{t(I18nKey.FORM$VALUE)}</span>
|
||||
<textarea
|
||||
data-testid="value-input"
|
||||
name="secret-value"
|
||||
@ -168,7 +169,7 @@ export function SecretForm({
|
||||
|
||||
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Description</span>
|
||||
<span className="text-sm">{t(I18nKey.FORM$DESCRIPTION)}</span>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<input
|
||||
@ -190,7 +191,7 @@ export function SecretForm({
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton testId="submit-button" type="submit" variant="primary">
|
||||
{mode === "add" && t("SECRETS$ADD_SECRET")}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { StyledSwitchComponent } from "./styled-switch-component";
|
||||
|
||||
interface SettingsSwitchProps {
|
||||
@ -19,6 +21,7 @@ export function SettingsSwitch({
|
||||
isToggled: controlledIsToggled,
|
||||
isBeta,
|
||||
}: React.PropsWithChildren<SettingsSwitchProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
@ -44,7 +47,7 @@ export function SettingsSwitch({
|
||||
<span className="text-sm">{children}</span>
|
||||
{isBeta && (
|
||||
<span className="text-[11px] leading-4 text-[#0D0F11] font-[500] tracking-tighter bg-primary px-1 rounded-full">
|
||||
Beta
|
||||
{t(I18nKey.BADGE$BETA)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { ModalBackdrop } from "./modal-backdrop";
|
||||
|
||||
@ -12,6 +14,7 @@ export function ConfirmationModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBackdrop onClose={onCancel}>
|
||||
<div
|
||||
@ -27,7 +30,7 @@ export function ConfirmationModal({
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
>
|
||||
Cancel
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
testId="confirm-button"
|
||||
@ -36,7 +39,7 @@ export function ConfirmationModal({
|
||||
variant="primary"
|
||||
className="grow"
|
||||
>
|
||||
Confirm
|
||||
{t(I18nKey.BUTTON$CONFIRM)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -663,4 +663,13 @@ export enum I18nKey {
|
||||
API$TAVILY_KEY_EXAMPLE = "API$TAVILY_KEY_EXAMPLE",
|
||||
API$TVLY_KEY_EXAMPLE = "API$TVLY_KEY_EXAMPLE",
|
||||
SECRETS$CONNECT_GIT_PROVIDER = "SECRETS$CONNECT_GIT_PROVIDER",
|
||||
CONVERSATION$BUDGET_USAGE_FORMAT = "CONVERSATION$BUDGET_USAGE_FORMAT",
|
||||
CONVERSATION$CACHE_HIT = "CONVERSATION$CACHE_HIT",
|
||||
CONVERSATION$CACHE_WRITE = "CONVERSATION$CACHE_WRITE",
|
||||
FEEDBACK$STAR_RATING = "FEEDBACK$STAR_RATING",
|
||||
BUTTON$CONFIRM = "BUTTON$CONFIRM",
|
||||
FORM$VALUE = "FORM$VALUE",
|
||||
FORM$DESCRIPTION = "FORM$DESCRIPTION",
|
||||
COMMON$OPTIONAL = "COMMON$OPTIONAL",
|
||||
BROWSER$SERVER_MESSAGE = "BROWSER$SERVER_MESSAGE",
|
||||
}
|
||||
|
||||
@ -10607,5 +10607,149 @@
|
||||
"tr": "Gizli anahtarları yönetmek için bir Git sağlayıcısına bağlan",
|
||||
"de": "Git-Anbieter verbinden, um Geheimnisse zu verwalten",
|
||||
"uk": "Підключити провайдера Git для управління секретами"
|
||||
},
|
||||
"CONVERSATION$BUDGET_USAGE_FORMAT": {
|
||||
"en": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"ja": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"zh-CN": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"zh-TW": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"ko-KR": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"no": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"it": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"pt": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"es": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"ar": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"fr": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"tr": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"de": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"uk": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})"
|
||||
},
|
||||
"CONVERSATION$CACHE_HIT": {
|
||||
"en": "Cache Hit:",
|
||||
"ja": "キャッシュヒット:",
|
||||
"zh-CN": "缓存命中:",
|
||||
"zh-TW": "快取命中:",
|
||||
"ko-KR": "캐시 히트:",
|
||||
"no": "Cache-treff:",
|
||||
"it": "Cache Hit:",
|
||||
"pt": "Cache Hit:",
|
||||
"es": "Cache Hit:",
|
||||
"ar": "إصابة التخزين المؤقت:",
|
||||
"fr": "Cache Hit:",
|
||||
"tr": "Önbellek İsabeti:",
|
||||
"de": "Cache-Treffer:",
|
||||
"uk": "Попадання в кеш:"
|
||||
},
|
||||
"CONVERSATION$CACHE_WRITE": {
|
||||
"en": "Cache Write:",
|
||||
"ja": "キャッシュ書き込み:",
|
||||
"zh-CN": "缓存写入:",
|
||||
"zh-TW": "快取寫入:",
|
||||
"ko-KR": "캐시 쓰기:",
|
||||
"no": "Cache-skriving:",
|
||||
"it": "Cache Write:",
|
||||
"pt": "Cache Write:",
|
||||
"es": "Cache Write:",
|
||||
"ar": "كتابة التخزين المؤقت:",
|
||||
"fr": "Cache Write:",
|
||||
"tr": "Önbellek Yazma:",
|
||||
"de": "Cache-Schreibung:",
|
||||
"uk": "Запис в кеш:"
|
||||
},
|
||||
"FEEDBACK$STAR_RATING": {
|
||||
"en": "★",
|
||||
"ja": "★",
|
||||
"zh-CN": "★",
|
||||
"zh-TW": "★",
|
||||
"ko-KR": "★",
|
||||
"no": "★",
|
||||
"it": "★",
|
||||
"pt": "★",
|
||||
"es": "★",
|
||||
"ar": "★",
|
||||
"fr": "★",
|
||||
"tr": "★",
|
||||
"de": "★",
|
||||
"uk": "★"
|
||||
},
|
||||
"BUTTON$CONFIRM": {
|
||||
"en": "Confirm",
|
||||
"ja": "確認",
|
||||
"zh-CN": "确认",
|
||||
"zh-TW": "確認",
|
||||
"ko-KR": "확인",
|
||||
"no": "Bekreft",
|
||||
"it": "Conferma",
|
||||
"pt": "Confirmar",
|
||||
"es": "Confirmar",
|
||||
"ar": "تأكيد",
|
||||
"fr": "Confirmer",
|
||||
"tr": "Onayla",
|
||||
"de": "Bestätigen",
|
||||
"uk": "Підтвердити"
|
||||
},
|
||||
"FORM$VALUE": {
|
||||
"en": "Value",
|
||||
"ja": "値",
|
||||
"zh-CN": "值",
|
||||
"zh-TW": "值",
|
||||
"ko-KR": "값",
|
||||
"no": "Verdi",
|
||||
"it": "Valore",
|
||||
"pt": "Valor",
|
||||
"es": "Valor",
|
||||
"ar": "القيمة",
|
||||
"fr": "Valeur",
|
||||
"tr": "Değer",
|
||||
"de": "Wert",
|
||||
"uk": "Значення"
|
||||
},
|
||||
"FORM$DESCRIPTION": {
|
||||
"en": "Description",
|
||||
"ja": "説明",
|
||||
"zh-CN": "描述",
|
||||
"zh-TW": "描述",
|
||||
"ko-KR": "설명",
|
||||
"no": "Beskrivelse",
|
||||
"it": "Descrizione",
|
||||
"pt": "Descrição",
|
||||
"es": "Descripción",
|
||||
"ar": "الوصف",
|
||||
"fr": "Description",
|
||||
"tr": "Açıklama",
|
||||
"de": "Beschreibung",
|
||||
"uk": "Опис"
|
||||
},
|
||||
"COMMON$OPTIONAL": {
|
||||
"en": "(Optional)",
|
||||
"ja": "(オプション)",
|
||||
"zh-CN": "(可选)",
|
||||
"zh-TW": "(可選)",
|
||||
"ko-KR": "(선택사항)",
|
||||
"no": "(Valgfritt)",
|
||||
"it": "(Opzionale)",
|
||||
"pt": "(Opcional)",
|
||||
"es": "(Opcional)",
|
||||
"ar": "(اختياري)",
|
||||
"fr": "(Optionnel)",
|
||||
"tr": "(İsteğe bağlı)",
|
||||
"de": "(Optional)",
|
||||
"uk": "(Необов'язково)"
|
||||
},
|
||||
"BROWSER$SERVER_MESSAGE": {
|
||||
"en": "If you tell OpenHands to start a web server, the app will appear here.",
|
||||
"ja": "OpenHandsにWebサーバーの起動を指示すると、アプリがここに表示されます。",
|
||||
"zh-CN": "如果您告诉OpenHands启动Web服务器,应用程序将在此处显示。",
|
||||
"zh-TW": "如果您告訴OpenHands啟動Web伺服器,應用程式將在此處顯示。",
|
||||
"ko-KR": "OpenHands에게 웹 서버를 시작하라고 말하면 앱이 여기에 나타납니다.",
|
||||
"no": "Hvis du ber OpenHands om å starte en webserver, vil appen vises her.",
|
||||
"it": "Se dici a OpenHands di avviare un server web, l'app apparirà qui.",
|
||||
"pt": "Se você disser ao OpenHands para iniciar um servidor web, o aplicativo aparecerá aqui.",
|
||||
"es": "Si le dices a OpenHands que inicie un servidor web, la aplicación aparecerá aquí.",
|
||||
"ar": "إذا أخبرت OpenHands ببدء خادم ويب، فستظهر التطبيق هنا.",
|
||||
"fr": "Si vous demandez à OpenHands de démarrer un serveur web, l'application apparaîtra ici.",
|
||||
"tr": "OpenHands'e bir web sunucusu başlatmasını söylerseniz, uygulama burada görünecektir.",
|
||||
"de": "Wenn Sie OpenHands anweisen, einen Webserver zu starten, wird die App hier angezeigt.",
|
||||
"uk": "Якщо ви скажете OpenHands запустити веб-сервер, додаток з'явиться тут."
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ function ServedApp() {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-10">
|
||||
<span className="text-neutral-400 font-bold">
|
||||
If you tell OpenHands to start a web server, the app will appear here.
|
||||
{t(I18nKey.BROWSER$SERVER_MESSAGE)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
describe("Localization Fix Tests", () => {
|
||||
it("should not find any unlocalized strings in the frontend code", () => {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"../../scripts/check-unlocalized-strings.cjs",
|
||||
);
|
||||
|
||||
// Run the localization check script
|
||||
const result = execSync(`node ${scriptPath}`, {
|
||||
cwd: path.join(__dirname, "../.."),
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
// The script should output success message and exit with code 0
|
||||
expect(result).toContain(
|
||||
"✅ No unlocalized strings found in frontend code.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should properly detect user-facing attributes like placeholder, alt, and aria-label", () => {
|
||||
// This test verifies that our fix to include placeholder, alt, and aria-label
|
||||
// attributes in the localization check is working correctly by testing the regex patterns
|
||||
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"../../scripts/check-unlocalized-strings.cjs",
|
||||
);
|
||||
const scriptContent = fs.readFileSync(scriptPath, "utf8");
|
||||
|
||||
// Verify that these attributes are now being checked for localization
|
||||
// by ensuring they're not excluded from text extraction
|
||||
const nonTextAttributesMatch = scriptContent.match(
|
||||
/const NON_TEXT_ATTRIBUTES = \[(.*?)\]/s,
|
||||
);
|
||||
expect(nonTextAttributesMatch).toBeTruthy();
|
||||
|
||||
const nonTextAttributes = nonTextAttributesMatch![1];
|
||||
expect(nonTextAttributes).not.toContain('"placeholder"');
|
||||
expect(nonTextAttributes).not.toContain('"alt"');
|
||||
expect(nonTextAttributes).not.toContain('"aria-label"');
|
||||
|
||||
// Verify that the script contains the correct attributes that should be excluded
|
||||
expect(nonTextAttributes).toContain('"className"');
|
||||
expect(nonTextAttributes).toContain('"testId"');
|
||||
expect(nonTextAttributes).toContain('"href"');
|
||||
});
|
||||
|
||||
it("should not incorrectly flag CSS units as unlocalized strings", () => {
|
||||
// This test verifies that our fix to the CSS units regex pattern
|
||||
// prevents false positives like "Suggested Tasks" being flagged
|
||||
|
||||
const testStrings = [
|
||||
"Suggested Tasks",
|
||||
"No tasks available",
|
||||
"Select a branch",
|
||||
"Select a repo",
|
||||
"Custom Models",
|
||||
"API Keys",
|
||||
"Git Settings",
|
||||
];
|
||||
|
||||
// These strings should not be flagged as CSS units
|
||||
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)$/;
|
||||
|
||||
testStrings.forEach((str) => {
|
||||
expect(cssUnitsPattern.test(str)).toBe(false);
|
||||
});
|
||||
|
||||
// But actual CSS units should still be detected
|
||||
const actualCssUnits = ["10px", "2rem", "100vh", "px", "rem", "s"];
|
||||
actualCssUnits.forEach((unit) => {
|
||||
expect(cssUnitsPattern.test(unit)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user