mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Engel Nyst <engel.nyst@gmail.com> Co-authored-by: tofarr <tofarr@gmail.com> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com> Co-authored-by: Robert Brennan <accounts@rbren.io> Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
741 lines
23 KiB
JavaScript
Executable File
741 lines
23 KiB
JavaScript
Executable File
#!/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);
|
|
}
|