scripts/replace-i18n.js
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Version: 0.1.15
/**
* i18n Replace Script - oxc-parser based
*
* This script uses oxc-parser (Rust-based, ESTree-compatible) for fast AST parsing
* and magic-string for efficient source code manipulation.
*
* Required dependencies: oxc-parser, magic-string (auto-installed by AI Localizer extension)
*/
const { readdir, readFile, writeFile } = require('node:fs/promises');
const { existsSync, readdirSync } = require('node:fs');
const path = require('node:path');
const process = require('node:process');
// oxc-parser for fast Rust-based parsing
let parseSync;
try {
parseSync = require('oxc-parser').parseSync;
} catch (err) {
console.error('[i18n-replace] Warning: oxc-parser is not installed or is incompatible with this Node version.');
console.error('[i18n-replace] Skipping rewrite. No source files were modified.');
console.error('[i18n-replace] To enable oxc-based rewrite, install a compatible oxc-parser (e.g. npm install -D oxc-parser)');
console.error('[i18n-replace] or re-run the AI i18n "Configure Project i18n" command to switch to the Babel-based rewrite script.');
process.exit(0);
}
// magic-string for efficient source manipulation
let MagicString;
try {
MagicString = require('magic-string');
} catch (err) {
console.error('[i18n-replace] Warning: magic-string is not installed.');
console.error('[i18n-replace] Skipping rewrite. No source files were modified.');
console.error('[i18n-replace] To enable oxc-based rewrite, install magic-string (e.g. npm install -D magic-string)');
console.error('[i18n-replace] or re-run the AI i18n "Configure Project i18n" command to switch to the Babel-based rewrite script.');
process.exit(0);
}
// Import shared utilities
const { detectSrcRoot } = require('./lib/projectConfig');
const { getNamespaceFromFile } = require('./lib/stringUtils');
const { loadIgnorePatterns, shouldIgnoreAttribute, shouldTranslateText } = require('./lib/ignorePatterns');
const projectRoot = path.resolve(__dirname, '..');
const srcRoot = detectSrcRoot(projectRoot);
const outputDir = path.resolve(projectRoot, 'resources', 'js', 'i18n', 'auto');
let hasVueI18n = false;
try {
const pkgPath = path.resolve(projectRoot, 'package.json');
if (existsSync(pkgPath)) {
const pkg = require(pkgPath);
const deps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
if (deps['vue-i18n'] || deps['@intlify/vue-i18n']) {
hasVueI18n = true;
}
}
} catch {
}
// Load ignore patterns
const ignorePatterns = loadIgnorePatterns(projectRoot);
// ============================================================================
// AST Node Type Helpers
// ============================================================================
function isStringLiteral(node) {
if (!node) return false;
if (node.type === 'StringLiteral') return true;
if (node.type === 'Literal' && typeof node.value === 'string') return true;
return false;
}
function getStringValue(node) {
if (!node) return null;
if (node.type === 'StringLiteral') return node.value;
if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
return null;
}
function isTemplateLiteral(node) {
return node && node.type === 'TemplateLiteral';
}
function getJsxElementName(node) {
if (!node) return null;
if (node.type === 'JSXIdentifier') return node.name;
if (node.type === 'JSXMemberExpression') {
const objectName = getJsxElementName(node.object);
const propName = getJsxElementName(node.property);
if (objectName && propName) return `${objectName}.${propName}`;
return propName || objectName;
}
return null;
}
function inferKindFromJsxElementName(name) {
if (!name) return 'text';
const lower = name.toLowerCase();
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(lower)) return 'heading';
if (lower === 'label') return 'label';
if (lower === 'button' || name.endsWith('Button')) return 'button';
if (lower === 'a' || lower === 'link') return 'link';
if (lower === 'input' || lower === 'textarea' || lower === 'select') return 'placeholder';
return 'text';
}
function normalizeText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function isCommonShortText(text) {
const trimmed = String(text || '').trim();
if (!trimmed) return false;
const cleaned = trimmed.replace(/\s+/g, ' ').trim();
if (/[.!?]/.test(cleaned)) return false;
const words = cleaned.split(' ').filter(Boolean);
if (words.length === 0 || words.length > 2) return false;
if (cleaned.length > 24) return false;
if (/[\/_]/.test(cleaned)) return false;
return true;
}
function inferPlaceholderNameFromExpression(expr, index, usedNames = new Set()) {
if (!expr) return getUniqueName(`value${index + 1}`, usedNames);
let baseName;
if (expr.type === 'Identifier') {
baseName = expr.name;
} else if (expr.type === 'MemberExpression' && !expr.computed) {
const prop = expr.property;
if (prop && prop.type === 'Identifier') {
// For member expressions, include object name to avoid collisions
// e.g., user.firstName and profile.firstName -> userFirstName, profileFirstName
const objName = expr.object?.type === 'Identifier' ? expr.object.name : '';
baseName = objName ? `${objName}${prop.name.charAt(0).toUpperCase()}${prop.name.slice(1)}` : prop.name;
} else {
baseName = `value${index + 1}`;
}
} else if (expr.type === 'CallExpression') {
// For function calls like formatDate(date), use the function name
const callee = expr.callee;
if (callee?.type === 'Identifier') {
baseName = callee.name;
} else if (callee?.type === 'MemberExpression' && callee.property?.type === 'Identifier') {
baseName = callee.property.name;
} else {
baseName = `value${index + 1}`;
}
} else {
baseName = `value${index + 1}`;
}
return getUniqueName(baseName, usedNames);
}
/**
* Get a unique name by appending a number if the name is already used
*/
function getUniqueName(baseName, usedNames) {
if (!usedNames.has(baseName)) {
usedNames.add(baseName);
return baseName;
}
let counter = 2;
let uniqueName = `${baseName}${counter}`;
while (usedNames.has(uniqueName)) {
counter++;
uniqueName = `${baseName}${counter}`;
}
usedNames.add(uniqueName);
return uniqueName;
}
/**
* Build pattern and placeholder info from template literal
*/
function buildPatternAndPlaceholders(tpl, code) {
if (!tpl || !tpl.quasis) return { pattern: '', placeholders: [] };
const parts = [];
const placeholders = [];
const usedNames = new Set();
for (let i = 0; i < tpl.quasis.length; i++) {
const quasi = tpl.quasis[i];
const cooked = quasi.value?.cooked ?? quasi.cooked ?? '';
parts.push(cooked);
if (tpl.expressions && i < tpl.expressions.length) {
const expr = tpl.expressions[i];
const name = inferPlaceholderNameFromExpression(expr, i, usedNames);
// Get the original expression code
const exprCode = code.slice(expr.start, expr.end);
placeholders.push({ name, code: exprCode });
parts.push(`{${name}}`);
}
}
return { pattern: parts.join(''), placeholders };
}
/**
* Get text pattern from node
*/
function getTextPattern(node, code) {
if (!node) return null;
if (isStringLiteral(node)) return getStringValue(node);
if (isTemplateLiteral(node)) {
if (!node.expressions || node.expressions.length === 0) {
return node.quasis.map(q => q.value?.cooked ?? q.cooked ?? '').join('');
}
return buildPatternAndPlaceholders(node, code).pattern;
}
return null;
}
// ============================================================================
// Translation Key Map
// ============================================================================
function buildKeyMapFromTranslations(translations) {
const map = new Map();
function walk(node, pathSegments) {
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
for (const [key, value] of Object.entries(node)) {
const nextPath = [...pathSegments, key];
if (typeof value === 'string') {
if (!shouldTranslateText(value, ignorePatterns)) continue;
if (nextPath.length < 3) continue;
const namespace = nextPath.slice(0, -2).join('.');
const kind = nextPath[nextPath.length - 2];
const keyId = `${namespace}|${kind}|${value}`;
const fullKey = nextPath.join('.');
if (!map.has(keyId)) {
map.set(keyId, fullKey);
}
// Also register a generic "text" alias so that minor kind
// mismatches (e.g. button vs text) during rewrite don't prevent
// us from finding an existing translation. This only affects the
// internal keyMap used by the rewrite script; the actual
// translation JSON structure (namespace.kind.slug) is unchanged.
if (kind !== 'text') {
const textAliasId = `${namespace}|text|${value}`;
if (!map.has(textAliasId)) {
map.set(textAliasId, fullKey);
}
}
} else {
walk(value, nextPath);
}
}
}
walk(translations, []);
return map;
}
// ============================================================================
// AST Walker with Parent Tracking
// ============================================================================
function walk(node, visitors, parent = null, parentKey = null, ancestors = []) {
if (!node || typeof node !== 'object') return;
const visitor = visitors[node.type];
if (visitor) {
visitor(node, parent, parentKey, ancestors);
}
const newAncestors = [...ancestors, node];
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'loc' || key === 'range' || key === 'start' || key === 'end') {
continue;
}
const child = node[key];
if (Array.isArray(child)) {
for (let i = 0; i < child.length; i++) {
walk(child[i], visitors, node, key, newAncestors);
}
} else if (child && typeof child === 'object' && child.type) {
walk(child, visitors, node, key, newAncestors);
}
}
}
// ============================================================================
// File Collection
// ============================================================================
async function collectSourceFiles(dir, out) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (['node_modules', 'vendor', '.git', 'storage', 'bootstrap', 'public'].includes(entry.name)) {
continue;
}
await collectSourceFiles(entryPath, out);
} else if (entry.isFile()) {
if (/\.(tsx|ts|jsx|js)$/i.test(entry.name)) {
out.push(entryPath);
}
}
}
}
async function collectVueFiles(dir, out) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (['node_modules', 'vendor', '.git', 'storage', 'bootstrap', 'public'].includes(entry.name)) {
continue;
}
await collectVueFiles(entryPath, out);
} else if (entry.isFile()) {
if (/\.vue$/i.test(entry.name)) {
out.push(entryPath);
}
}
}
}
// ============================================================================
// Import Management
// ============================================================================
/**
* Check if file has i18n import and find position to insert if needed
* Also detects directive strings like "use client", "use strict", "use server"
*/
function analyzeImports(ast, code) {
let hasI18nImport = false;
let hasTConflict = false;
let lastImportEnd = 0;
let firstImportStart = -1;
let directiveEnd = 0;
// Check for directive prologue (must be at the very start of the file)
const body = ast.body || [];
for (const node of body) {
// Directive strings are ExpressionStatements with string literals
if (node.type === 'ExpressionStatement') {
const expr = node.expression;
if (expr && (expr.type === 'StringLiteral' ||
(expr.type === 'Literal' && typeof expr.value === 'string'))) {
const value = expr.value || '';
// Common directive strings
if (['use strict', 'use client', 'use server'].includes(value)) {
directiveEnd = Math.max(directiveEnd, node.end);
continue;
}
}
}
// Once we hit a non-directive statement, stop checking
break;
}
walk(ast, {
ImportDeclaration(node) {
if (firstImportStart === -1) {
firstImportStart = node.start;
}
lastImportEnd = node.end;
const source = node.source?.value;
for (const spec of (node.specifiers || [])) {
if (spec.type === 'ImportSpecifier' && spec.local?.name === 't') {
if (source === '@/i18n') {
hasI18nImport = true;
} else {
hasTConflict = true;
}
}
}
}
});
return { hasI18nImport, hasTConflict, lastImportEnd, firstImportStart, directiveEnd };
}
/**
* Generate import statement string for regular JS/TS files
*/
function generateImportStatement() {
return "import { t } from '@/i18n';\n";
}
/**
* Generate import statement for Vue script sections
* Uses useI18n if vue-i18n is detected, otherwise falls back to @/i18n
*/
function generateVueImportStatement() {
if (hasVueI18n) {
return "import { useI18n } from 'vue-i18n';\n";
}
return "import { t } from '@/i18n';\n";
}
/**
* Check if script section already has useI18n import or t function setup
*/
function hasVueI18nSetup(scriptContent) {
// Check for useI18n import
if (/import\s*\{[^}]*useI18n[^}]*\}\s*from\s*['"]vue-i18n['"]/.test(scriptContent)) {
return true;
}
// Check for const { t } = useI18n() pattern
if (/const\s*\{[^}]*\bt\b[^}]*\}\s*=\s*useI18n\s*\(\s*\)/.test(scriptContent)) {
return true;
}
// Check for existing t import from @/i18n
if (/import\s*\{[^}]*\bt\b[^}]*\}\s*from\s*['"]@\/i18n['"]/.test(scriptContent)) {
return true;
}
return false;
}
// ============================================================================
// Replacement Generation
// ============================================================================
/**
* Generate t() call code for a simple string
*/
function generateTCall(fullKey) {
return `t('${fullKey}')`;
}
/**
* Generate t() call code with placeholders
*/
function generateTCallWithPlaceholders(fullKey, placeholders) {
if (!placeholders || placeholders.length === 0) {
return generateTCall(fullKey);
}
const params = placeholders.map(p => `${p.name}: ${p.code}`).join(', ');
return `t('${fullKey}', { ${params} })`;
}
// ============================================================================
// File Processing
// ============================================================================
// Simple state machine states for Vue template rewriting
const VUE_STATE = {
TEXT: 'TEXT',
TAG_NAME: 'TAG_NAME',
TAG_SPACE: 'TAG_SPACE',
ATTR_NAME: 'ATTR_NAME',
ATTR_VALUE_START: 'ATTR_VALUE_START',
ATTR_VALUE: 'ATTR_VALUE',
TAG_CLOSE: 'TAG_CLOSE',
COMMENT: 'COMMENT',
SCRIPT: 'SCRIPT',
STYLE: 'STYLE',
};
/**
* Extract the main <template> section from a Vue SFC, handling nested
* <template> slots by tracking depth instead of relying on a single
* non-greedy regex.
*/
function extractVueTemplateRange(code) {
if (!code || typeof code !== 'string') return null;
const openMatch = code.match(/^[\s\S]*?<template(\s[^>]*)?>|<template(\s[^>]*)?>/i);
if (!openMatch) return null;
const fullStart = openMatch.index;
const innerStart = fullStart + openMatch[0].length;
let depth = 1;
let pos = innerStart;
const len = code.length;
while (pos < len && depth > 0) {
const nextOpen = code.indexOf('<template', pos);
const nextClose = code.indexOf('</template>', pos);
if (nextClose === -1) {
break;
}
if (nextOpen !== -1 && nextOpen < nextClose) {
const afterOpen = code[nextOpen + 9];
if (!afterOpen || /[\s>\/]/.test(afterOpen)) {
depth += 1;
}
pos = nextOpen + 9;
} else {
depth -= 1;
if (depth === 0) {
const innerEnd = nextClose;
const fullEnd = nextClose + 11; // '</template>'.length
return { fullStart, fullEnd, innerStart, innerEnd };
}
pos = nextClose + 11;
}
}
// Fallback: greedy match for last </template>
const greedy = code.match(/<template[^>]*>([\s\S]*)<\/template>/i);
if (!greedy || typeof greedy.index !== 'number') return null;
const gFullStart = greedy.index;
const gInnerStart = gFullStart + greedy[0].length - greedy[1].length - 11;
const gInnerEnd = gInnerStart + greedy[1].length;
const gFullEnd = gInnerEnd + 11;
return { fullStart: gFullStart, fullEnd: gFullEnd, innerStart: gInnerStart, innerEnd: gInnerEnd };
}
/**
* Rewrite the inner Vue template content by walking tags/text with a
* lightweight state machine. For each plain-text node, look up the
* corresponding translation key and, if found, wrap it in {{$t('key')}}.
*/
function rewriteVueTemplate(template, namespace, keyMap) {
if (!template || typeof template !== 'string') return template;
let state = VUE_STATE.TEXT;
let pos = 0;
let textStart = 0;
let tagName = '';
let currentTag = '';
let attrName = '';
let attrValue = '';
let attrQuote = '';
const tagStack = [];
const len = template.length;
let out = '';
let lastEmitPos = 0;
const getCurrentParentTag = () =>
(tagStack.length > 0 ? tagStack[tagStack.length - 1] : null);
function maybeRewriteTextSegment(start, end) {
if (end <= start) return;
const rawText = template.slice(start, end);
if (!rawText || !rawText.trim()) return;
const parentTag = getCurrentParentTag();
const kind = inferKindFromJsxElementName(parentTag || 'div');
function rewritePlainTextFragment(fragment) {
if (!fragment || !fragment.trim()) return fragment;
// Idempotency guard: skip if already contains $t() or t() calls
if (/\{\{\s*\$?t\s*\(/.test(fragment)) return fragment;
const cleaned = normalizeText(fragment);
if (!cleaned) return fragment;
// Skip if text looks like a translation key (dot-separated path starting with capital)
if (/^[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$/.test(cleaned)) return fragment;
const nsForKey = isCommonShortText(cleaned) ? 'Commons' : namespace;
let keyId = `${nsForKey}|${kind}|${cleaned}`;
let fullKey = keyMap.get(keyId);
// Fallback: allow generic "text" alias if kind-specific key is missing.
if (!fullKey && kind !== 'text') {
keyId = `${nsForKey}|text|${cleaned}`;
fullKey = keyMap.get(keyId);
}
if (!fullKey) return fragment;
const leadingSpaceMatch = fragment.match(/^\s*/);
const trailingSpaceMatch = fragment.match(/\s*$/);
const leadingSpace = leadingSpaceMatch ? leadingSpaceMatch[0] : '';
const trailingSpace = trailingSpaceMatch ? trailingSpaceMatch[0] : '';
const escapedKey = String(fullKey).replace(/'/g, "\\'");
return `${leadingSpace}{{$t('${escapedKey}')}}${trailingSpace}`;
}
// Handle Vue mustache expressions specially so we can rewrite
// string literals inside expressions (e.g. ternaries) to $t() calls.
if (rawText.includes('{{') && rawText.includes('}}')) {
const mustacheRegex = /\{\{([^}]+)\}\}/g;
let resultText = '';
let lastIndex = 0;
let hasChange = false;
let match;
while ((match = mustacheRegex.exec(rawText)) !== null) {
const before = rawText.slice(lastIndex, match.index);
if (before) {
resultText += rewritePlainTextFragment(before);
}
const expr = match[1] || '';
let exprChanged = false;
const stringRegex = /(['"])([^'"\\]*(?:\\.[^'"\\]*)*)\1/g;
const rewrittenExpr = expr.replace(stringRegex, (m, quote, body, offset) => {
const candidate = (body || '').trim();
if (!candidate) return m;
// Idempotency guard: skip if this string is the argument of an existing $t() or t() call
// Check if there's a $t( or t( immediately before this match position
const beforeMatch = expr.slice(0, offset);
if (/\$?t\s*\(\s*$/.test(beforeMatch)) return m;
// Skip if text looks like a translation key (dot-separated path starting with capital)
if (/^[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$/.test(candidate)) return m;
const cleaned = normalizeText(candidate);
if (!cleaned) return m;
const nsForKey = isCommonShortText(cleaned) ? 'Commons' : namespace;
let keyId = `${nsForKey}|${kind}|${cleaned}`;
let fullKey = keyMap.get(keyId);
if (!fullKey && kind !== 'text') {
keyId = `${nsForKey}|text|${cleaned}`;
fullKey = keyMap.get(keyId);
}
if (!fullKey) return m;
const escapedKey = String(fullKey).replace(/'/g, "\\'");
exprChanged = true;
return `$t('${escapedKey}')`;
});
if (exprChanged) {
hasChange = true;
}
resultText += `{{${rewrittenExpr}}}`;
lastIndex = match.index + match[0].length;
}
const tail = rawText.slice(lastIndex);
if (tail) {
resultText += rewritePlainTextFragment(tail);
}
if (!hasChange) return;
out += template.slice(lastEmitPos, start);
out += resultText;
lastEmitPos = end;
return;
}
const rewritten = rewritePlainTextFragment(rawText);
if (rewritten === rawText) return;
out += template.slice(lastEmitPos, start);
out += rewritten;
lastEmitPos = end;
}
while (pos < len) {
const char = template[pos];
const nextChar = pos + 1 < len ? template[pos + 1] : '';
switch (state) {
case VUE_STATE.TEXT: {
if (char === '<' && template.slice(pos, pos + 4) === '<!--') {
maybeRewriteTextSegment(textStart, pos);
state = VUE_STATE.COMMENT;
pos += 4;
continue;
}
if (char === '<') {
maybeRewriteTextSegment(textStart, pos);
if (nextChar === '/') {
state = VUE_STATE.TAG_CLOSE;
pos += 2;
tagName = '';
} else if (/[a-zA-Z]/.test(nextChar)) {
state = VUE_STATE.TAG_NAME;
pos += 1;
tagName = '';
} else {
pos += 1;
}
continue;
}
pos += 1;
break;
}
case VUE_STATE.COMMENT: {
if (char === '-' && template.slice(pos, pos + 3) === '-->') {
pos += 3;
state = VUE_STATE.TEXT;
textStart = pos;
continue;
}
pos += 1;
break;
}
case VUE_STATE.TAG_NAME: {
if (/[a-zA-Z0-9_:-]/.test(char)) {
tagName += char;
pos += 1;
} else if (char === '>' || char === '/') {
currentTag = tagName;
const lowerTag = tagName.toLowerCase();
if (lowerTag === 'script') {
state = VUE_STATE.SCRIPT;
} else if (lowerTag === 'style') {
state = VUE_STATE.STYLE;
} else if (char === '/') {
if (template[pos + 1] === '>') pos += 2; else pos += 1;
state = VUE_STATE.TEXT;
textStart = pos;
} else {
tagStack.push(tagName);
pos += 1;
state = VUE_STATE.TEXT;
textStart = pos;
}
} else if (/\s/.test(char)) {
currentTag = tagName;
state = VUE_STATE.TAG_SPACE;
pos += 1;
} else {
pos += 1;
}
break;
}
case VUE_STATE.TAG_SPACE: {
if (/\s/.test(char)) {
pos += 1;
} else if (char === '>') {
const lowerTag = currentTag.toLowerCase();
if (lowerTag === 'script') {
state = VUE_STATE.SCRIPT;
pos += 1;
} else if (lowerTag === 'style') {
state = VUE_STATE.STYLE;
pos += 1;
} else {
tagStack.push(currentTag);
pos += 1;
state = VUE_STATE.TEXT;
textStart = pos;
}
} else if (char === '/') {
if (template[pos + 1] === '>') pos += 2; else pos += 1;
state = VUE_STATE.TEXT;
textStart = pos;
} else if (/[a-zA-Z@:#v]/.test(char)) {
state = VUE_STATE.ATTR_NAME;
attrName = char;
pos += 1;
} else {
pos += 1;
}
break;
}
case VUE_STATE.ATTR_NAME: {
if (/[a-zA-Z0-9_:@#.\-\[\]]/.test(char)) {
attrName += char;
pos += 1;
} else if (char === '=') {
state = VUE_STATE.ATTR_VALUE_START;
pos += 1;
} else if (/\s/.test(char)) {
state = VUE_STATE.TAG_SPACE;
pos += 1;
} else if (char === '>' || char === '/') {
if (char === '/') {
if (template[pos + 1] === '>') pos += 2; else pos += 1;
state = VUE_STATE.TEXT;
textStart = pos;
} else {
tagStack.push(currentTag);
pos += 1;
state = VUE_STATE.TEXT;
textStart = pos;
}
} else {
pos += 1;
}
break;
}
case VUE_STATE.ATTR_VALUE_START: {
if (char === '"' || char === "'") {
attrQuote = char;
attrValue = '';
state = VUE_STATE.ATTR_VALUE;
pos += 1;
} else if (/\s/.test(char)) {
pos += 1;
} else {
attrQuote = '';
attrValue = char;
state = VUE_STATE.ATTR_VALUE;
pos += 1;
}
break;
}
case VUE_STATE.ATTR_VALUE: {
if (attrQuote) {
if (char === attrQuote) {
// Vue attributes are rewritten via JS/TS rewrite; we only
// consume the value here.
state = VUE_STATE.TAG_SPACE;
pos += 1;
} else {
attrValue += char;
pos += 1;
}
} else {
if (/[\s>\/]/.test(char)) {
state = VUE_STATE.TAG_SPACE;
if (char === '>') {
tagStack.push(currentTag);
state = VUE_STATE.TEXT;
textStart = pos + 1;
}
pos += 1;
} else {
attrValue += char;
pos += 1;
}
}
break;
}
case VUE_STATE.TAG_CLOSE: {
if (char === '>') {
const closingTag = tagName.toLowerCase();
while (tagStack.length > 0) {
const top = tagStack.pop();
if (top.toLowerCase() === closingTag) break;
}
pos += 1;
state = VUE_STATE.TEXT;
textStart = pos;
tagName = '';
} else if (/[a-zA-Z0-9_:-]/.test(char)) {
tagName += char;
pos += 1;
} else {
pos += 1;
}
break;
}
case VUE_STATE.SCRIPT: {
if (char === '<' && template.slice(pos, pos + 9).toLowerCase() === '</script>') {
pos += 9;
state = VUE_STATE.TEXT;
textStart = pos;
continue;
}
pos += 1;
break;
}
case VUE_STATE.STYLE: {
if (char === '<' && template.slice(pos, pos + 8).toLowerCase() === '</style>') {
pos += 8;
state = VUE_STATE.TEXT;
textStart = pos;
continue;
}
pos += 1;
break;
}
default:
pos += 1;
}
}
if (state === VUE_STATE.TEXT && textStart < len) {
maybeRewriteTextSegment(textStart, len);
}
if (lastEmitPos === 0) {
return template;
}
if (lastEmitPos < len) {
out += template.slice(lastEmitPos);
}
return out;
}
async function processFile(filePath, keyMap) {
const code = await readFile(filePath, 'utf8');
const namespace = getNamespaceFromFile(filePath, srcRoot);
const ext = path.extname(filePath).toLowerCase();
const sourceFilename = path.basename(filePath);
let result;
try {
result = parseSync(sourceFilename, code, {
sourceType: 'module',
lang: ext === '.tsx' ? 'tsx' : ext === '.ts' ? 'ts' : ext === '.jsx' ? 'jsx' : 'js',
});
} catch (err) {
console.error(`[i18n-replace] Parse error in ${filePath}:`, err.message);
return { changed: false, skippedDueToConflict: false };
}
if (!result || !result.program) {
return { changed: false, skippedDueToConflict: false };
}
const ast = result.program;
const s = new MagicString(code);
// Analyze imports
const { hasI18nImport, hasTConflict, lastImportEnd, firstImportStart, directiveEnd } = analyzeImports(ast, code);
if (hasTConflict) {
return { changed: false, skippedDueToConflict: true };
}
// Track replacements to avoid overlapping edits
const replacements = [];
let needsImport = false;
// Helper to add a replacement
// Strategy: Allow nested replacements where one is fully contained in another
// When applying, only apply the innermost (smallest) replacements
const addReplacement = (start, end, newCode) => {
// Check for partial overlaps (but allow full containment)
for (const r of replacements) {
const isFullyContained = (start >= r.start && end <= r.end) || (r.start >= start && r.end <= end);
const hasOverlap = (start >= r.start && start < r.end) || (end > r.start && end <= r.end) ||
(r.start >= start && r.start < end) || (r.end > start && r.end <= end);
if (hasOverlap && !isFullyContained) {
return false; // Partial overlap, skip
}
// If the new range fully contains an existing one, keep the smaller one
if (r.start >= start && r.end <= end) {
// Existing range is inside new range - skip the new (larger) one
return false;
}
// If existing range fully contains the new one, we'll add it and filter later
}
replacements.push({ start, end, newCode });
needsImport = true;
return true;
};
// Helper to filter replacements to only keep innermost (smallest) ones
const filterToInnermostReplacements = () => {
// Sort by start position, then by size (smallest first)
replacements.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start;
return (a.end - a.start) - (b.end - b.start);
});
// Remove any replacement that fully contains another
const filtered = [];
for (const r of replacements) {
const isContainer = replacements.some(other =>
other !== r && other.start >= r.start && other.end <= r.end
);
if (!isContainer) {
filtered.push(r);
}
}
// Replace the array contents
replacements.length = 0;
replacements.push(...filtered);
};
// Helper to build replacement for string/template
const tryReplace = (node, kind) => {
if (isStringLiteral(node)) {
const text = normalizeText(getStringValue(node));
if (!shouldTranslateText(text, ignorePatterns)) return false;
// Idempotency guard: skip if text looks like a translation key
if (/^[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$/.test(text)) return false;
const keyId = `${namespace}|${kind}|${text}`;
const fullKey = keyMap.get(keyId);
if (!fullKey) return false;
return addReplacement(node.start, node.end, generateTCall(fullKey));
}
if (isTemplateLiteral(node)) {
const { pattern, placeholders } = buildPatternAndPlaceholders(node, code);
const text = normalizeText(pattern);
if (!shouldTranslateText(text, ignorePatterns)) return false;
// Idempotency guard: skip if text looks like a translation key
if (/^[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$/.test(text)) return false;
const keyId = `${namespace}|${kind}|${text}`;
const fullKey = keyMap.get(keyId);
if (!fullKey) return false;
return addReplacement(node.start, node.end, generateTCallWithPlaceholders(fullKey, placeholders));
}
return false;
};
// Process conditionals recursively
const processConditional = (node, kind) => {
if (!node) return false;
if (isStringLiteral(node) || isTemplateLiteral(node)) {
return tryReplace(node, kind);
}
if (node.type === 'ConditionalExpression') {
const a = processConditional(node.consequent, kind);
const b = processConditional(node.alternate, kind);
return a || b;
}
return false;
};
// Track current JSX element
let currentJsxElement = null;
const visitors = {
JSXElement(node) {
currentJsxElement = node;
},
JSXText(node, parent) {
const raw = node.value || '';
const hasLeadingSpace = /^\s/.test(raw);
const hasTrailingSpace = /\s$/.test(raw);
const text = normalizeText(raw);
if (!shouldTranslateText(text, ignorePatterns)) return;
const jsxParent = parent?.type === 'JSXElement' ? parent : currentJsxElement;
if (!jsxParent?.openingElement) return;
const elementName = getJsxElementName(jsxParent.openingElement.name);
const kind = inferKindFromJsxElementName(elementName);
const nsForKey = isCommonShortText(text) ? 'Commons' : namespace;
const keyId = `${nsForKey}|${kind}|${text}`;
const fullKey = keyMap.get(keyId);
if (!fullKey) return;
// Build replacement with preserved spacing
let replacement = `{${generateTCall(fullKey)}}`;
if (hasLeadingSpace) replacement = ' ' + replacement;
if (hasTrailingSpace) replacement = replacement + ' ';
addReplacement(node.start, node.end, replacement);
},
JSXExpressionContainer(node, parent) {
const expr = node.expression;
if (!expr || expr.type === 'JSXEmptyExpression') return;
const jsxParent = parent?.type === 'JSXElement' ? parent : currentJsxElement;
if (!jsxParent?.openingElement) return;
const elementName = getJsxElementName(jsxParent.openingElement.name);
const kind = inferKindFromJsxElementName(elementName);
// Only replace the expression inside the container, not the container itself
if (isStringLiteral(expr) || isTemplateLiteral(expr)) {
tryReplace(expr, kind);
} else if (expr.type === 'ConditionalExpression') {
processConditional(expr, kind);
}
},
JSXAttribute(node) {
const nameNode = node.name;
if (!nameNode || nameNode.type !== 'JSXIdentifier') return;
const attrName = nameNode.name;
const valueNode = node.value;
if (!valueNode) return;
if (shouldIgnoreAttribute(attrName, ignorePatterns)) return;
let kind = null;
if (attrName === 'placeholder') kind = 'placeholder';
else if (attrName === 'title') kind = 'title';
else if (attrName === 'alt') kind = 'alt';
else if (attrName === 'aria-label') kind = 'aria_label';
else if (attrName === 'label') kind = 'label';
if (!kind) return;
// Handle string literal value: placeholder="text" -> placeholder={t('key')}
if (isStringLiteral(valueNode)) {
const text = normalizeText(getStringValue(valueNode));
if (!shouldTranslateText(text, ignorePatterns)) return;
const nsForKey = isCommonShortText(text) ? 'Commons' : namespace;
const keyId = `${nsForKey}|${kind}|${text}`;
const fullKey = keyMap.get(keyId);
if (!fullKey) return;
addReplacement(valueNode.start, valueNode.end, `{${generateTCall(fullKey)}}`);
return;
}
// Handle expression container: placeholder={"text"} or placeholder={`text`}
if (valueNode.type === 'JSXExpressionContainer') {
const expr = valueNode.expression;
if (isStringLiteral(expr) || isTemplateLiteral(expr)) {
tryReplace(expr, kind);
} else if (expr?.type === 'ConditionalExpression') {
processConditional(expr, kind);
}
}
},
// Handle object properties (Property in ESTree, ObjectProperty in Babel)
Property(node) {
processObjectProperty(node, namespace, keyMap, code, tryReplace);
},
ObjectProperty(node) {
processObjectProperty(node, namespace, keyMap, code, tryReplace);
},
VariableDeclarator(node) {
const id = node.id;
const init = node.init;
if (!id || id.type !== 'Identifier' || !init) return;
const varName = id.name || '';
let kind = 'text';
if (/title/i.test(varName)) kind = 'heading';
else if (/label/i.test(varName)) kind = 'label';
else if (/placeholder/i.test(varName)) kind = 'placeholder';
tryReplace(init, kind);
},
AssignmentExpression(node) {
const left = node.left;
const right = node.right;
// document.title = ...
if (left?.type === 'MemberExpression' && !left.computed) {
if (left.object?.type === 'Identifier' && left.object.name === 'document' &&
left.property?.type === 'Identifier' && left.property.name === 'title') {
tryReplace(right, 'title');
return;
}
}
// Variable assignment
if (left?.type === 'Identifier') {
const varName = left.name || '';
let kind = 'text';
if (/title/i.test(varName)) kind = 'heading';
else if (/label/i.test(varName)) kind = 'label';
else if (/placeholder/i.test(varName)) kind = 'placeholder';
tryReplace(right, kind);
}
},
CallExpression(node) {
const callee = node.callee;
const args = node.arguments || [];
// toast.xxx(...) calls
if (callee?.type === 'MemberExpression' &&
callee.object?.type === 'Identifier' && callee.object.name === 'toast') {
if (args.length > 0) {
const first = args[0];
if (isStringLiteral(first) || isTemplateLiteral(first)) {
tryReplace(first, 'toast');
} else if (first?.type === 'ConditionalExpression') {
processConditional(first, 'toast');
}
}
}
},
};
walk(ast, visitors);
// Filter to only innermost replacements (for nested structures)
filterToInnermostReplacements();
// Apply replacements (sort by position descending to avoid offset issues)
replacements.sort((a, b) => b.start - a.start);
for (const r of replacements) {
s.overwrite(r.start, r.end, r.newCode);
}
// Add import if needed
if (needsImport && !hasI18nImport) {
const importStmt = generateImportStatement();
if (lastImportEnd > 0) {
// Insert after last import
s.appendRight(lastImportEnd, '\n' + importStmt.trim());
} else if (firstImportStart >= 0) {
// Insert before first import
s.prependLeft(firstImportStart, importStmt);
} else if (directiveEnd > 0) {
// Insert after directive string ("use client", "use strict", etc.)
s.appendRight(directiveEnd, '\n' + importStmt.trim());
} else {
// No imports, add at beginning
s.prepend(importStmt);
}
}
const changed = replacements.length > 0;
if (changed) {
await writeFile(filePath, s.toString(), 'utf8');
}
return { changed, skippedDueToConflict: false };
}
function processObjectProperty(node, namespace, keyMap, code, tryReplace) {
const keyNode = node.key;
const valueNode = node.value;
if (!valueNode) return;
let propName = null;
if (keyNode?.type === 'Identifier') {
propName = keyNode.name;
} else if (isStringLiteral(keyNode)) {
propName = getStringValue(keyNode);
}
if (!propName) return;
let kind = null;
if (propName === 'title') kind = 'heading';
else if (propName === 'description') kind = 'text';
else if (propName === 'cta') kind = 'button';
// Heuristic for label property
if (!kind && propName === 'label' && isStringLiteral(valueNode)) {
const valueText = getStringValue(valueNode) || '';
if (/\s/.test(valueText.trim())) {
kind = 'label';
}
}
if (!kind) return;
tryReplace(valueNode, kind);
}
function rewriteVueScriptSections(code, namespace, keyMap) {
const scriptRegex = /<script(\s[^>]*)?>([\s\S]*?)<\/script>/gi;
const blocks = [];
let match;
while ((match = scriptRegex.exec(code)) !== null) {
const fullMatch = match[0];
const openTagEnd = fullMatch.indexOf('>');
if (openTagEnd === -1) {
continue;
}
const contentStart = match.index + openTagEnd + 1;
const contentEnd = match.index + fullMatch.length - '</script>'.length;
const scriptContent = code.slice(contentStart, contentEnd);
if (!scriptContent.trim()) {
continue;
}
let result;
try {
result = parseSync('vue-script.ts', scriptContent, {
sourceType: 'module',
lang: 'ts',
});
} catch {
continue;
}
if (!result || !result.program) {
continue;
}
const ast = result.program;
const s = new MagicString(scriptContent);
const { hasI18nImport, hasTConflict, lastImportEnd, firstImportStart, directiveEnd } = analyzeImports(ast, scriptContent);
if (hasTConflict) {
continue;
}
const replacements = [];
let needsImport = false;
const addReplacement = (start, end, newCode) => {
for (const r of replacements) {
if ((start >= r.start && start < r.end) || (end > r.start && end <= r.end)) {
return false;
}
}
replacements.push({ start, end, newCode });
needsImport = true;
return true;
};
const tryReplace = (node, kind) => {
if (isStringLiteral(node)) {
const text = normalizeText(getStringValue(node));
if (!shouldTranslateText(text, ignorePatterns)) return false;
// Idempotency guard: skip if text looks like a translation key
if (/^[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$/.test(text)) return false;
const nsForKey = isCommonShortText(text) ? 'Commons' : namespace;
const keyId = `${nsForKey}|${kind}|${text}`;
const fullKey = keyMap.get(keyId);
if (!fullKey) return false;
return addReplacement(node.start, node.end, generateTCall(fullKey));
}
if (isTemplateLiteral(node)) {
const { pattern, placeholders } = buildPatternAndPlaceholders(node, scriptContent);
const text = normalizeText(pattern);
if (!shouldTranslateText(text, ignorePatterns)) return false;
// Idempotency guard: skip if text looks like a translation key
if (/^[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$/.test(text)) return false;
const nsForKey = isCommonShortText(text) ? 'Commons' : namespace;
const keyId = `${nsForKey}|${kind}|${text}`;
const fullKey = keyMap.get(keyId);
if (!fullKey) return false;
return addReplacement(node.start, node.end, generateTCallWithPlaceholders(fullKey, placeholders));
}
return false;
};
const processConditional = (node, kind) => {
if (!node) return false;
if (isStringLiteral(node) || isTemplateLiteral(node)) {
return tryReplace(node, kind);
}
if (node.type === 'ConditionalExpression') {
const a = processConditional(node.consequent, kind);
const b = processConditional(node.alternate, kind);
return a || b;
}
return false;
};
const visitors = {
Property(node) {
processObjectProperty(node, namespace, keyMap, scriptContent, tryReplace);
},
ObjectProperty(node) {
processObjectProperty(node, namespace, keyMap, scriptContent, tryReplace);
},
VariableDeclarator(node) {
const id = node.id;
const init = node.init;
if (!id || id.type !== 'Identifier' || !init) return;
const varName = id.name || '';
let kind = 'text';
if (/title/i.test(varName)) kind = 'heading';
else if (/label/i.test(varName)) kind = 'label';
else if (/placeholder/i.test(varName)) kind = 'placeholder';
tryReplace(init, kind);
},
AssignmentExpression(node) {
const left = node.left;
const right = node.right;
if (left?.type === 'MemberExpression' && !left.computed) {
if (left.object?.type === 'Identifier' && left.object.name === 'document' &&
left.property?.type === 'Identifier' && left.property.name === 'title') {
tryReplace(right, 'title');
return;
}
}
if (left?.type === 'Identifier') {
const varName = left.name || '';
let kind = 'text';
if (/title/i.test(varName)) kind = 'heading';
else if (/label/i.test(varName)) kind = 'label';
else if (/placeholder/i.test(varName)) kind = 'placeholder';
tryReplace(right, kind);
}
},
CallExpression(node) {
const callee = node.callee;
const args = node.arguments || [];
if (callee?.type === 'MemberExpression' &&
callee.object?.type === 'Identifier' && callee.object.name === 'toast') {
if (args.length > 0) {
const first = args[0];
if (isStringLiteral(first) || isTemplateLiteral(first)) {
tryReplace(first, 'toast');
} else if (first?.type === 'ConditionalExpression') {
processConditional(first, 'toast');
}
}
}
},
};
walk(ast, visitors);
replacements.sort((a, b) => b.start - a.start);
for (const r of replacements) {
s.overwrite(r.start, r.end, r.newCode);
}
if (needsImport && !hasI18nImport) {
const importStmt = generateImportStatement();
if (lastImportEnd > 0) {
s.appendRight(lastImportEnd, '\n' + importStmt.trim());
} else if (firstImportStart >= 0) {
s.prependLeft(firstImportStart, importStmt);
} else if (directiveEnd > 0) {
s.appendRight(directiveEnd, '\n' + importStmt.trim());
} else {
s.prepend(importStmt);
}
}
if (replacements.length > 0) {
blocks.push({ start: contentStart, end: contentEnd, newCode: s.toString() });
}
}
if (blocks.length === 0) {
return { code, changed: false };
}
blocks.sort((a, b) => b.start - a.start);
let out = code;
for (const b of blocks) {
out = out.slice(0, b.start) + b.newCode + out.slice(b.end);
}
return { code: out, changed: true };
}
async function processVueFile(filePath, keyMap) {
let code = await readFile(filePath, 'utf8');
const namespace = getNamespaceFromFile(filePath, srcRoot);
let changed = false;
const scriptResult = rewriteVueScriptSections(code, namespace, keyMap);
code = scriptResult.code;
if (scriptResult.changed) {
changed = true;
}
const range = extractVueTemplateRange(code);
if (range) {
const { innerStart, innerEnd } = range;
const inner = code.slice(innerStart, innerEnd);
const rewritten = rewriteVueTemplate(inner, namespace, keyMap);
if (rewritten !== inner) {
code = code.slice(0, innerStart) + rewritten + code.slice(innerEnd);
changed = true;
}
}
if (!changed) {
return { changed: false };
}
await writeFile(filePath, code, 'utf8');
return { changed: true };
}
// ============================================================================
// Main
// ============================================================================
(async () => {
try {
// Load translations
let hadLocaleReadErrors = false;
async function readJsonSafe(p) {
try {
const raw = await readFile(p, 'utf8');
return JSON.parse(raw);
} catch (err) {
hadLocaleReadErrors = true;
console.error(`[i18n-replace] Failed to read/parse JSON: ${p}`);
console.error(err?.message || err);
return null;
}
}
function deepMerge(target, source) {
if (!source || typeof source !== 'object') return target;
for (const [k, v] of Object.entries(source)) {
if (v && typeof v === 'object' && !Array.isArray(v)) {
if (!target[k] || typeof target[k] !== 'object') target[k] = {};
deepMerge(target[k], v);
} else {
target[k] = v;
}
}
return target;
}
const groupedDir = path.resolve(outputDir, 'en');
let translations = {};
if (existsSync(groupedDir)) {
const stack = [groupedDir];
while (stack.length) {
const dir = stack.pop();
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) stack.push(full);
else if (entry.isFile() && entry.name.endsWith('.json')) {
const obj = await readJsonSafe(full);
if (obj && typeof obj === 'object') deepMerge(translations, obj);
}
}
}
} else {
const translationsPath = path.resolve(outputDir, 'en.json');
if (existsSync(translationsPath)) {
const parsed = await readJsonSafe(translationsPath);
if (parsed && typeof parsed === 'object') translations = parsed;
} else {
console.error(`[i18n-replace] No translations found at ${translationsPath} or ${groupedDir}`);
process.exit(1);
}
}
if (hadLocaleReadErrors) {
console.error('[i18n-replace] Aborting: one or more locale JSON files could not be parsed. No source files were modified.');
process.exit(1);
}
const keyMap = buildKeyMapFromTranslations(translations);
if (!existsSync(srcRoot)) {
console.error(`[i18n-replace] Source root not found: ${srcRoot}`);
process.exit(1);
}
// Collect files
const files = [];
await collectSourceFiles(srcRoot, files);
const vueFiles = [];
await collectVueFiles(srcRoot, vueFiles);
let changedCount = 0;
let conflictCount = 0;
// Process JS/TS files
for (const file of files) {
const rel = path.relative(projectRoot, file);
const { changed, skippedDueToConflict } = await processFile(file, keyMap);
if (changed) {
changedCount += 1;
console.log(`[i18n-replace] Updated ${rel}`);
} else if (skippedDueToConflict) {
conflictCount += 1;
console.warn(`[i18n-replace] Skipped ${rel} due to existing 't' import conflict.`);
}
}
// Process Vue files only when vue-i18n is present, since the rewrite uses $t(...) in templates.
if (hasVueI18n) {
for (const file of vueFiles) {
const rel = path.relative(projectRoot, file);
const { changed } = await processVueFile(file, keyMap);
if (changed) {
changedCount += 1;
console.log(`[i18n-replace] Updated Vue template ${rel}`);
}
}
} else if (vueFiles.length > 0) {
console.log('[i18n-replace] vue-i18n not detected; skipping Vue template rewrite to avoid inserting $t(...) without a global helper.');
}
console.log(`[i18n-replace] Completed. Updated ${changedCount} files. Skipped ${conflictCount} files due to conflicts.`);
} catch (error) {
console.error('[i18n-replace] Failed to replace translations.');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
})();