Spaces:
Build error
Build error
| /** | |
| * 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", | |
| "alt", | |
| "placeholder", | |
| "rel", | |
| "target", | |
| "style", | |
| "onClick", | |
| "onChange", | |
| "onSubmit", | |
| "data-testid", | |
| "aria-label", | |
| "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 | |
| ]; | |
| 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 = | |
| /(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 all suggestion files as they contain special strings | |
| if (filePath.includes("suggestions")) { | |
| 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); | |
| } | |