| import PipelineStep from "../PipelineStep.js"; | ||
| import { TranslationContext } from "../context.js"; | ||
| import { SourceCodeAnalyzer } from "../../../services/source-analyzer.js"; | ||
| export default class ContextEnrichmentStep extends PipelineStep { | ||
| private analyzer; | ||
| private enabled; | ||
| constructor(analyzer: SourceCodeAnalyzer, enabled?: boolean); | ||
| execute(context: TranslationContext, next: () => Promise<void>): Promise<void>; | ||
| } |
| import PipelineStep from "../PipelineStep.js"; | ||
| export default class ContextEnrichmentStep extends PipelineStep { | ||
| analyzer; | ||
| enabled; | ||
| constructor(analyzer, enabled = true) { | ||
| super(); | ||
| this.analyzer = analyzer; | ||
| this.enabled = enabled; | ||
| } | ||
| async execute(context, next) { | ||
| if (this.enabled) { | ||
| try { | ||
| // Ensure analysis is complete before checking for context | ||
| await this.analyzer.ensureInitialized(); | ||
| const codeContext = this.analyzer.getContext(context.key); | ||
| if (codeContext) { | ||
| // Enrich the context meta | ||
| context.meta = { | ||
| ...context.meta, | ||
| code: codeContext, | ||
| }; | ||
| // Also append to the AI prompt context if it exists | ||
| // (Assuming existing translation prompt uses contextData) | ||
| context.contextData = { | ||
| ...(context.contextData || {}), | ||
| visualContext: { | ||
| component: codeContext.component, | ||
| file: codeContext.filePath, | ||
| comments: codeContext.comments, | ||
| props: codeContext.props, | ||
| snippet: codeContext.usageSnippet, | ||
| }, | ||
| }; | ||
| } | ||
| } | ||
| catch (error) { | ||
| // Non-blocking error | ||
| // console.warn(`Context enrichment failed for ${context.key}`, error); | ||
| } | ||
| } | ||
| await next(); | ||
| } | ||
| } |
| import { CodeContext } from "../types/context.js"; | ||
| export declare class SourceCodeAnalyzer { | ||
| private project; | ||
| private keyUsageMap; | ||
| private rootDir; | ||
| private initPromise; | ||
| constructor(rootDir?: string); | ||
| initialize(globPatterns?: string[]): Promise<void>; | ||
| ensureInitialized(): Promise<void>; | ||
| private processFile; | ||
| private processCallExpression; | ||
| private extractContext; | ||
| getContext(key: string): CodeContext | undefined; | ||
| } |
| import { Project, Node } from "ts-morph"; | ||
| import { log } from "../utils/logger.js"; | ||
| import path from "path"; | ||
| export class SourceCodeAnalyzer { | ||
| project; | ||
| keyUsageMap = new Map(); | ||
| rootDir; | ||
| initPromise = null; | ||
| constructor(rootDir = process.cwd()) { | ||
| this.rootDir = rootDir; | ||
| // Initialize with default settings, but don't add files yet for performance | ||
| this.project = new Project({ | ||
| skipAddingFilesFromTsConfig: true, | ||
| compilerOptions: { | ||
| allowJs: true, | ||
| jsx: 1, // Preserve | ||
| }, | ||
| }); | ||
| } | ||
| initialize(globPatterns = [ | ||
| "src/**/*.{ts,tsx}", | ||
| "app/**/*.{ts,tsx}", | ||
| "components/**/*.{ts,tsx}", | ||
| ]) { | ||
| if (this.initPromise) | ||
| return this.initPromise; | ||
| this.initPromise = (async () => { | ||
| await new Promise((resolve) => setTimeout(resolve, 0)); // Force async execution for race condition safety | ||
| try { | ||
| // Add source files | ||
| log(`Analyzing source code in ${this.rootDir}...`); | ||
| // Exclude node_modules explicitly just in case | ||
| this.project.addSourceFilesAtPaths(globPatterns); | ||
| const sourceFiles = this.project.getSourceFiles(); | ||
| log(`Found ${sourceFiles.length} source files for context analysis.`); | ||
| for (const sourceFile of sourceFiles) { | ||
| this.processFile(sourceFile); | ||
| } | ||
| log(`Source analysis complete. Context extracted for ${this.keyUsageMap.size} keys.`); | ||
| } | ||
| catch (error) { | ||
| console.warn(`Error during source analysis: ${error.message}`); | ||
| // Don't throw to prevent blocking the whole app, just log | ||
| } | ||
| })(); | ||
| return this.initPromise; | ||
| } | ||
| async ensureInitialized() { | ||
| if (this.initPromise) { | ||
| await this.initPromise; | ||
| } | ||
| } | ||
| processFile(sourceFile) { | ||
| sourceFile.forEachDescendant((node) => { | ||
| if (Node.isCallExpression(node)) { | ||
| this.processCallExpression(node, sourceFile.getFilePath()); | ||
| } | ||
| }); | ||
| } | ||
| processCallExpression(node, filePath) { | ||
| // Match t('key') or useTranslations().t('key') pattern | ||
| // This is a heuristic match | ||
| const expression = node.getExpression(); | ||
| // Check if expression is accessible | ||
| if (!expression) | ||
| return; | ||
| const expressionText = expression.getText(); | ||
| // Simple check for 't' function calls | ||
| if (expressionText.endsWith(".t") || expressionText === "t") { | ||
| const args = node.getArguments(); | ||
| if (args.length > 0 && Node.isStringLiteral(args[0])) { | ||
| const key = args[0].getLiteralText(); | ||
| this.extractContext(key, node, filePath); | ||
| } | ||
| } | ||
| } | ||
| extractContext(key, node, fullFilePath) { | ||
| const relativePath = path.relative(this.rootDir, fullFilePath); | ||
| const line = node.getStartLineNumber(); | ||
| // Extract component name | ||
| let componentName = undefined; | ||
| // Use getAncestors() to find the parent safely | ||
| const componentNode = node | ||
| .getAncestors() | ||
| .find((n) => Node.isFunctionDeclaration(n) || | ||
| Node.isClassDeclaration(n) || | ||
| Node.isVariableDeclaration(n)); | ||
| if (componentNode) { | ||
| if (Node.isFunctionDeclaration(componentNode) || | ||
| Node.isClassDeclaration(componentNode)) { | ||
| componentName = componentNode.getName(); | ||
| } | ||
| else if (Node.isVariableDeclaration(componentNode)) { | ||
| componentName = componentNode.getName(); | ||
| } | ||
| } | ||
| // Extract comments | ||
| // Look for leading comments on the statement | ||
| const statement = node | ||
| .getAncestors() | ||
| .find((n) => Node.isExpressionStatement(n) || | ||
| Node.isVariableStatement(n) || | ||
| Node.isJsxElement(n) || | ||
| Node.isJsxExpression(n)); | ||
| const comments = []; | ||
| const targetNode = statement || node; | ||
| const ranges = targetNode.getLeadingCommentRanges(); | ||
| for (const range of ranges) { | ||
| comments.push(range | ||
| .getText() | ||
| .replace(/^\/\/\s*/, "") | ||
| .replace(/^\/\*\s*/, "") | ||
| .replace(/\s*\*\/$/, "")); | ||
| } | ||
| // Get props (simple heuristic for JSX) | ||
| const props = {}; | ||
| const jsxElement = node | ||
| .getAncestors() | ||
| .find((n) => Node.isJsxOpeningElement(n) || Node.isJsxSelfClosingElement(n)); | ||
| if (jsxElement && | ||
| (Node.isJsxOpeningElement(jsxElement) || Node.isJsxSelfClosingElement(jsxElement))) { | ||
| jsxElement.getAttributes().forEach((attr) => { | ||
| if (Node.isJsxAttribute(attr)) { | ||
| // Safe name extraction | ||
| const nameNode = attr.getNameNode(); | ||
| const name = nameNode ? nameNode.getText() : "unknown"; | ||
| const initializer = attr.getInitializer(); | ||
| let value = initializer ? initializer.getText() : "true"; | ||
| // Strip quotes if string literal | ||
| if ((value.startsWith('"') && value.endsWith('"')) || | ||
| (value.startsWith("'") && value.endsWith("'"))) { | ||
| value = value.substring(1, value.length - 1); | ||
| } | ||
| props[name] = value; | ||
| } | ||
| }); | ||
| } | ||
| // Code Snippet (surrounding lines) | ||
| // Check for node.getParent() to avoid error if node is root (unlikely for call expression) | ||
| const parent = node.getParent(); | ||
| const snippet = parent ? parent.getText() : node.getText(); | ||
| const context = { | ||
| filePath: relativePath, | ||
| line, | ||
| component: componentName, | ||
| usageSnippet: snippet.length > 200 ? snippet.substring(0, 200) + "..." : snippet, | ||
| comments: comments.length > 0 ? comments : undefined, | ||
| props: Object.keys(props).length > 0 ? props : undefined, | ||
| }; | ||
| if (!this.keyUsageMap.has(key)) { | ||
| this.keyUsageMap.set(key, []); | ||
| } | ||
| this.keyUsageMap.get(key)?.push(context); | ||
| } | ||
| getContext(key) { | ||
| // Return duplicate functionality: prefer the first usage found or merge? | ||
| // For now return the first usage that has comments, or just the first usage. | ||
| const usages = this.keyUsageMap.get(key); | ||
| if (!usages || usages.length === 0) | ||
| return undefined; | ||
| // Prioritize usage with comments | ||
| return usages.find((u) => u.comments && u.comments.length > 0) || usages[0]; | ||
| } | ||
| } |
| export interface CodeContext { | ||
| filePath: string; | ||
| line: number; | ||
| component?: string; | ||
| usageSnippet?: string; | ||
| comments?: string[]; | ||
| props?: Record<string, string>; | ||
| } | ||
| export interface SourceAnalyzerConfig { | ||
| enabled?: boolean; | ||
| include?: string[]; | ||
| exclude?: string[]; | ||
| } |
| export {}; |
+1
-0
@@ -29,2 +29,3 @@ #!/usr/bin/env node | ||
| .option("--localesDir <dir>", "Localization files directory", defaultConfig.localesDir) | ||
| .option("--no-deep-context", "Disable deep context analysis (source code scanning)") | ||
| .option("--debug", "Enable debug mode with verbose logging", false) | ||
@@ -31,0 +32,0 @@ .option("--verbose", "Enable detailed diagnostic output", false); |
@@ -13,2 +13,8 @@ import { ConfigLayer } from "c12"; | ||
| /** | ||
| * Deep Context (Source Code Analysis) | ||
| * Scans source code for context (component name, comments, props) | ||
| * @default true | ||
| */ | ||
| deepContext?: boolean; | ||
| /** | ||
| * Filter by file extensions (e.g. ['.json', '.arb']) | ||
@@ -15,0 +21,0 @@ */ |
@@ -9,4 +9,6 @@ import ProgressTracker from "../utils/progress-tracker.js"; | ||
| import { ConfidenceSettings } from "./pipeline/steps/TranslationStep.js"; | ||
| import { SourceCodeAnalyzer } from "../services/source-analyzer.js"; | ||
| export interface OrchestratorOptions { | ||
| context: ContextConfig; | ||
| deepContext?: boolean; | ||
| progressOptions?: any; | ||
@@ -82,2 +84,3 @@ styleGuide?: any; | ||
| vectorStats: VectorMemoryStats; | ||
| sourceAnalyzer: SourceCodeAnalyzer; | ||
| confidenceSettings: ConfidenceSettings; | ||
@@ -84,0 +87,0 @@ private concurrencyLimit; |
@@ -25,2 +25,4 @@ import rateLimiter from "../utils/rate-limiter.js"; | ||
| import CacheWriteStep from "./pipeline/steps/CacheWriteStep.js"; | ||
| import ContextEnrichmentStep from "./pipeline/steps/ContextEnrichmentStep.js"; | ||
| import { SourceCodeAnalyzer } from "../services/source-analyzer.js"; | ||
| // Vector Memory Imports | ||
@@ -45,2 +47,4 @@ import { VectorStore } from "../services/vector-store.js"; | ||
| vectorStats; | ||
| // Source Code Analysis | ||
| sourceAnalyzer; | ||
| confidenceSettings; | ||
@@ -155,2 +159,22 @@ concurrencyLimit; | ||
| } | ||
| // Initialize Source Analyzer | ||
| if (options.sourceCodeAnalyzer) { | ||
| this.sourceAnalyzer = options.sourceCodeAnalyzer; | ||
| // Assumed initialized or self-initializing | ||
| } | ||
| else { | ||
| this.sourceAnalyzer = new SourceCodeAnalyzer(); | ||
| // Fire and forget initialization (or await if strict) | ||
| // We'll trust it catches up or is fast enough for the first few items, | ||
| // or we can await it if we want to block startup. | ||
| // For CLI responsiveness, we might want to await it only if deep context is enabled. | ||
| const deepContextEnabled = options.deepContext !== false; // Enabled by default | ||
| if (deepContextEnabled) { | ||
| this.sourceAnalyzer.initialize().catch((err) => { | ||
| if (this.advanced.debug) { | ||
| console.warn("Source analyzer init failed:", err.message); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| // Initialize Pipeline | ||
@@ -188,2 +212,5 @@ this.pipeline = new Pipeline(); | ||
| // 4. Translation | ||
| // 3.5 Context Enrichment (Deep Context) | ||
| const deepContextEnabled = this.options.deepContext !== false; | ||
| this.pipeline.use(new ContextEnrichmentStep(this.sourceAnalyzer, deepContextEnabled)); | ||
| this.pipeline.use(new TranslationStep(this.options, this.confidenceSettings)); | ||
@@ -190,0 +217,0 @@ // 4.5 Vector Cache Write (save new translations) |
@@ -12,2 +12,3 @@ import path from "path"; | ||
| import { LanguageProcessor } from "./language-processor.js"; | ||
| import { SourceCodeAnalyzer } from "./source-analyzer.js"; | ||
| /** | ||
@@ -155,2 +156,6 @@ * Service for handling file translation operations. | ||
| await uiManager.log(`Processing ${targetLanguages.length} languages with concurrency of ${languageConcurrency}`); | ||
| // Initialize shared SourceCodeAnalyzer | ||
| console.log("Initializing Locai Engine..."); | ||
| const sharedAnalyzer = new SourceCodeAnalyzer(); | ||
| sharedAnalyzer.initialize(); | ||
| // Create shared orchestrators array to collect review queues | ||
@@ -172,2 +177,3 @@ const orchestrators = []; | ||
| progressOptions, | ||
| sourceCodeAnalyzer: sharedAnalyzer, | ||
| }); | ||
@@ -174,0 +180,0 @@ orchestrators.push(orchestrator); |
+12
-11
| { | ||
| "name": "locai", | ||
| "version": "2.4.0", | ||
| "version": "2.5.0", | ||
| "type": "module", | ||
@@ -19,3 +19,3 @@ "main": "dist/core/orchestrator.js", | ||
| "build": "tsc", | ||
| "type-check": "tsc --noEmit", | ||
| "typecheck": "tsc --noEmit", | ||
| "lint": "eslint .", | ||
@@ -76,4 +76,4 @@ "lint:fix": "eslint . --fix", | ||
| "dependencies": { | ||
| "axios": "1.13.1", | ||
| "c12": "^3.3.2", | ||
| "axios": "1.13.2", | ||
| "c12": "^3.3.3", | ||
| "commander": "^14.0.2", | ||
@@ -86,21 +86,22 @@ "dot-properties": "^1.1.0", | ||
| "js-yaml": "^4.1.1", | ||
| "lru-cache": "^11.2.2", | ||
| "lru-cache": "^11.2.4", | ||
| "p-limit": "^7.2.0", | ||
| "prompts": "^2.4.2", | ||
| "ts-morph": "^27.0.2", | ||
| "vectra": "^0.11.1" | ||
| }, | ||
| "devDependencies": { | ||
| "@eslint/js": "^9.39.1", | ||
| "@eslint/js": "^9.39.2", | ||
| "@types/js-yaml": "^4.0.9", | ||
| "@types/node": "^25.0.1", | ||
| "@types/node": "^25.0.3", | ||
| "@types/prompts": "^2.4.9", | ||
| "@vitest/coverage-v8": "^4.0.15", | ||
| "eslint": "^9.39.1", | ||
| "@vitest/coverage-v8": "^4.0.16", | ||
| "eslint": "^9.39.2", | ||
| "eslint-config-prettier": "^10.1.8", | ||
| "globals": "^16.5.0", | ||
| "prettier": "^3.6.2", | ||
| "prettier": "^3.7.4", | ||
| "std-mocks": "^2.0.0", | ||
| "tsx": "^4.21.0", | ||
| "typescript": "^5.9.3", | ||
| "vitest": "^4.0.15" | ||
| "vitest": "^4.0.16" | ||
| }, | ||
@@ -107,0 +108,0 @@ "files": [ |
+5
-1
@@ -16,2 +16,4 @@ # Locai | ||
| > **Need a permanent setup?** Check out our [Configuration Guide](./docs/configuration.md) to create a `localize.config.js` file. | ||
| ## 📖 Documentation | ||
@@ -41,3 +43,5 @@ | ||
| - **Infinite Memory**: Vector-based caching. We "remember" every translation. If you translate the same (or similar) sentence again, we fetch it from local cache instantly. Zero cost, 100% consistency. | ||
| - **🧠 Smart Context Awareness**: Uses AI to understand the context of your strings for better translations. | ||
| - **🔬 Deep Context Analysis**: Scans your source code (AST) to extract component names, comments, and props for context-aware translations. | ||
| - **📚 Infinite Memory**: Uses vector embeddings to remember previous translations and maintain consistency. If you translate the same (or similar) sentence again, we fetch it from local cache instantly. Zero cost, 100% consistency. | ||
| - **Smart Sync**: We hash every key. Locai only translates **new** or **modified** keys. If you have 1000 keys and change 5, we only send 5 to the AI. | ||
@@ -44,0 +48,0 @@ - **Context Awareness**: Locai analyzes your content. It knows if a string is for a **Button** (keep it short), **Marketing** (make it punchy), or **Legal** (be precise). |
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 12 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 12 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
607666
2.06%165
3.77%15407
1.9%73
5.8%14
7.69%79
2.6%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
Updated
Updated
Updated