vue-i18n-customized-extractor
Version:
A CLI tool to extract text and i18n keys from Vue.js files.
990 lines (919 loc) • 48.1 kB
JavaScript
// <div> Hi {{ name }}</div> -> "Hi { name }"
// <div> {{ `Hei ${name} literal`}}</div> -> "`Hei ${name} literal`"
// <div> KK{{$t("HiKKT { name }", {name: name})}} </div> -> if includeT: "HiKKT { name }"; if not includeT: skip it
// <div> {{$t("Hello")}} </div> -> if includeT: "Hello"; if not includeT: skip it
// <div> {{$t("Hi { name }", {name: name})}} </div> -> if includeT: "Hi { name }"; if not includeT: skip it
// <div> {{ name }}</div> -> skip it
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const { parse } = require('@vue/compiler-sfc');
const { compile } = require('@vue/compiler-dom');
const babelParser = require('@babel/parser');
const { has, is } = require('@babel/traverse/lib/path/introspection');
const traverse = require('@babel/traverse').default;
const harcode_skip_string_collection = [
//value of 'typeof' in Vue.js
'string',
'object',
'MM/DD/YYYY'
];
function run(targetDir, options = {}) {
const includeT = options.includeT || false;
const pageKeys = options.pageKeys || [];
const configFilePath = path.resolve(process.cwd(), options.configFilePath);
const templateOnly = options.templateOnly;
// NOTES: If the config file is not provided, it will use the default config file path: vue-i18n-customized-extractor.config.cjs
// NOTES: If no config file is found, it will use the default config file data: null
var configFileData = null;
if(fs.existsSync(configFilePath)){
configFileData = require(configFilePath);
}
const customizedComponentsMapping = parseCustomizedComponentMapping(configFileData?.customizedComponentsTranslationMap, pageKeys);
const extractedStrings = new Set();
const extractedStringsOfTranslationAsWholePointer = new Set();
var vueFiles = [];
var jsFiles = [];
const stat = fs.statSync(targetDir);
// Step 1: Find files
if (stat.isFile()) {
console.log(`Processing single file: ${targetDir}`);
if (targetDir.endsWith('.vue')) {
vueFiles = [targetDir];
} else if (!templateOnly && (targetDir.endsWith('.js') || targetDir.endsWith('.ts'))) {
jsFiles = [targetDir];
} else {
console.warn(`⚠️ Skipping unsupported file: ${targetDir}`);
return; // Nothing to do
}
} else {
console.log(`Processing folder: ${targetDir}`);
vueFiles = glob.sync(`${targetDir}/**/*.vue`, { ignore: '**/node_modules/**' });
// If templateOnly is true, we only process .vue files
if(!templateOnly){
jsFiles = glob.sync(`${targetDir}/**/*.{js,ts}`, { ignore: '**/node_modules/**' });
}
}
console.log(`Found ${vueFiles.length} .vue files`);
console.log(`Found ${jsFiles.length} .js/.ts files`);
// Step 2: Process Vue files
vueFiles.forEach(file => processVueFile(file, extractedStrings, extractedStringsOfTranslationAsWholePointer, includeT, customizedComponentsMapping, templateOnly));
// Step 3: Process JS/TS files
jsFiles.forEach(file => processJSContent(file, extractedStrings, includeT));
// Step 4: Output JSON
const outputOfExtractedStrings = {};
Array.from(extractedStrings).sort().forEach(str => {
const strTrimmed = str.trim();
// Check if the string is a bracket notation (e.g. TestPage['Hi {name}'])
// **NOTES: Since each page keeps its own translation key-value mapping,
// **NOTES: normally if we extracted exsiting $t() calls, we can assume it will be something like: $t("TestPage['Hi {name}']")
// **NOTES: In extractedStrings, we already extracted the string without the $t() call, so it will be like: "TestPage['Hi {name}']"
// **NOTES: If the string is in bracket notation, we will parse it to be: eg. TestPage: { "Hi {name}": "Hi {name}" }
const parsedResult = parseBracketNotation(strTrimmed);
if (parsedResult) {
// If the string is in bracket notation, parse it to be:
// eg. TestPage: { "Hi {name}": "Hi {name}" }
const { objectName: pageKey, keyName:pageTranslateValue } = parsedResult;
if(pageTranslateValue && !isUrl(pageTranslateValue) && !harcode_skip_string_collection.includes(pageTranslateValue)){
// NOTES: doesn't check isLikelyVariableLike(pageTranslateValue) here, because if parsedResult has value, most likely it is a key extracted from a $t() call when includeT is true
if(outputOfExtractedStrings[pageKey]){
// If the pageKey already exists, add the new key-value pair
// NTOES: replace(/\$\{(.*?)\}/g, (_, key) => `{${key}}`) is used to replace ${key} with {key} for consistency. ONLY value.
outputOfExtractedStrings[pageKey][pageTranslateValue] = pageTranslateValue.replace(/\$\{(.*?)\}/g, (_, key) => `{${key}}`);
}else{
// If the pageKey does not exist, create a new object
// replace(/\$\{(.*?)\}/g, (_, key) => `{${key}}`) is used to replace ${key} with {key} for consistency. ONLY value.
outputOfExtractedStrings[pageKey] = { [pageTranslateValue]: pageTranslateValue.replace(/\$\{(.*?)\}/g, (_, key) => `{${key}}`) };
}
}
}else{
// If the string is not in bracket notation, and NOT a URL -> add it to the output
if(strTrimmed && !isUrl(strTrimmed) && !isLikelyVariableLike(strTrimmed) && !harcode_skip_string_collection.includes(strTrimmed)){
outputOfExtractedStrings[strTrimmed] = strTrimmed.replace(/\$\{(.*?)\}/g, (_, key) => `{${key}}`);
}
}
});
if (!fs.existsSync('src/locales')) {
fs.mkdirSync('src/locales');
}
fs.writeFileSync('src/locales/en.json', JSON.stringify(outputOfExtractedStrings, null, 2));
// Step 5: Output extractedStringsOfTranslationAsWholePointer, if any
if(extractedStringsOfTranslationAsWholePointer.size > 0) {
const outputOfExtractedStringsOfTranslationAsWholePointer = {};
Array.from(extractedStringsOfTranslationAsWholePointer).sort().forEach(strObj => {
// Check if the string is a bracket notation (e.g. TestPage['Hi {name}'])
// **NOTES: Since each page keeps its own translation key-value mapping,
// **NOTES: normally if we extracted exsiting $t() calls, we can assume it will be something like: $t("TestPage['Hi {name}']")
// **NOTES: In extractedStrings, we already extracted the string without the $t() call, so it will be like: "TestPage['Hi {name}']"
// **NOTES: If the string is in bracket notation, we will parse it to be: eg. TestPage: { "Hi {name}": "Hi {name}" }
const {result: str, hasTCall} = strObj;
// NOTES: since we already filter the extracted output in previous steps, we only meet hasTCall string if includeT is true
if( hasTCall && includeT) {
const extractedTKeys = extractAllTKeysFromString(str);
extractedTKeys.forEach(tKey => {
if(!outputOfExtractedStringsOfTranslationAsWholePointer["Existing $t() calls' keys for translate-as-whole-pointer"]){
outputOfExtractedStringsOfTranslationAsWholePointer["Existing $t() calls' keys for translate-as-whole-pointer"] = {};
}
const parsedResult = parseBracketNotation(tKey);
if (parsedResult) {
// If the string is in bracket notation, parse it to be:
// eg. TestPage: { "Hi {name}": "Hi {name}" }
const { objectName: pageKey, keyName:pageTranslateValue } = parsedResult;
const pageTranslateValueTrimmed = pageTranslateValue.trim();
if(outputOfExtractedStringsOfTranslationAsWholePointer["Existing $t() calls' keys for translate-as-whole-pointer"][pageKey]){
// If the pageKey already exists, add the new key-value pair
outputOfExtractedStringsOfTranslationAsWholePointer["Existing $t() calls' keys for translate-as-whole-pointer"][pageKey][pageTranslateValueTrimmed] = pageTranslateValueTrimmed;
}else{
// If the pageKey does not exist, create a new object
outputOfExtractedStringsOfTranslationAsWholePointer["Existing $t() calls' keys for translate-as-whole-pointer"][pageKey] = { [pageTranslateValueTrimmed]: pageTranslateValueTrimmed };
}
}else{
// If the string is not in bracket notation, add it to the output
outputOfExtractedStringsOfTranslationAsWholePointer["Existing $t() calls' keys for translate-as-whole-pointer"][tKey] = tKey;
}
})
}else {
const parsedResult = parseBracketNotation(str);
if (parsedResult) {
// If the string is in bracket notation, parse it to be:
// eg. TestPage: { "Hi {name}": "Hi {name}" }
const { objectName: pageKey, keyName:pageTranslateValue } = parsedResult;
const pageTranslateValueTrimmed = pageTranslateValue.trim();
if(outputOfExtractedStringsOfTranslationAsWholePointer[pageKey]){
// If the pageKey already exists, add the new key-value pair
outputOfExtractedStringsOfTranslationAsWholePointer[pageKey][pageTranslateValueTrimmed] = pageTranslateValueTrimmed;
}else{
// If the pageKey does not exist, create a new object
outputOfExtractedStringsOfTranslationAsWholePointer[pageKey] = { [pageTranslateValueTrimmed]: pageTranslateValueTrimmed };
}
}else{
// If the string is not in bracket notation, add it to the output
outputOfExtractedStringsOfTranslationAsWholePointer[str.trim()] = str.trim();
}
}
fs.writeFileSync('src/locales/en-TranslationAsWholePointer.json', JSON.stringify(outputOfExtractedStringsOfTranslationAsWholePointer, null, 2));
});
}
console.log(`✅ Extracted ${extractedStrings.size} strings → src/locales/en.json`);
}
function parseCustomizedComponentMapping(customizedComponentsTranslationMap, pageKeys){
if (!customizedComponentsTranslationMap){
// If no customized components mapping is provided, return null
return null;
}else{
// If has customized components mapping, parse it based on the pageKeys
const parsedMapping = {};
if(pageKeys?.length > 0) {
// If pageKeys is provided, filter the customizedComponentsTranslationMap based on the pageKeys
pageKeys.forEach(pageKey => {
const mappingOfCurPageKey = customizedComponentsTranslationMap[pageKey];
if(mappingOfCurPageKey){
Object.keys(mappingOfCurPageKey).forEach(componentName => {
const propsToExtract = mappingOfCurPageKey[componentName];
if(parsedMapping[componentName]){
// If the component already exists, add the new props to extract
parsedMapping[componentName] = new Set([...parsedMapping[componentName], ...propsToExtract]);
}else{
// If the component does not exist, create a new set of props to extract
parsedMapping[componentName] = new Set(propsToExtract);
}
});
}
});
}else{
// If no pageKeys is provided, parse the whole customizedComponentsTranslationMap
Object.keys(customizedComponentsTranslationMap).forEach( key => {
if(Array.isArray(customizedComponentsTranslationMap[key])){
// If The value is an array, it means the key is a component name and the value is an array of props to extract
const propsToExtract = customizedComponentsTranslationMap[key];
if(parsedMapping[key]){
// If the component already exists, add the new props to extract
parsedMapping[key] = new Set([...parsedMapping[key], ...propsToExtract]);
}else{
// If the component does not exist, create a new set of props to extract
parsedMapping[key] = new Set(propsToExtract);
}
}else{
const mappingOfCurPageKey = customizedComponentsTranslationMap[key];
if(mappingOfCurPageKey){
Object.keys(mappingOfCurPageKey).forEach(componentName => {
const propsToExtract = mappingOfCurPageKey[componentName];
if(parsedMapping[componentName]){
// If the component already exists, add the new props to extract
parsedMapping[componentName] = new Set([...parsedMapping[componentName], ...propsToExtract]);
}else{
// If the component does not exist, create a new set of props to extract
parsedMapping[componentName] = new Set(propsToExtract);
}
});
}
}
});
}
return parsedMapping;
}
}
function processVueFile(filePath, extractedStrings, extractedStringsOfTranslationAsWholePointer, includeT, customizedComponentsMapping, templateOnly = false) {
const content = fs.readFileSync(filePath, 'utf-8');
const { descriptor } = parse(content);
// 1. Process template
if (descriptor.template) {
const templateAst = compile(descriptor.template.content, { hoistStatic: false }).ast;
fs.writeFileSync('src/locales/structure.json', JSON.stringify(templateAst,null, 2));
walkTemplateAst(templateAst, extractedStrings, extractedStringsOfTranslationAsWholePointer, includeT, customizedComponentsMapping);
}
// 2. Process script
// NOTES: If templateOnly is true, we only process the template, not the script
if ((descriptor.script || descriptor.scriptSetup) && !templateOnly) {
const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content;
processJSContent(scriptContent, extractedStrings, includeT);
}
}
function walkTemplateAst(node, extractedStrings, extractedStringsOfTranslationAsWholePointer, includeT, customizedComponentsMapping, fromType = 0) {
// console.log('walkTemplateAst node:', node.type, node.content);
if (!node) return;
switch (node.type) {
// ROOT
case 0:
// ELEMENT
case 1:
case 10:{
const hasClassPropNamedTranslateAsWholePointer = node.props?.some(prop => {
// Check if the prop is a class or :class (type: 7) and contains translate-as-whole-pointer
// NOTES: ":class" need to be parsed as JSON array, so we need to replace single quotes with double quotes and use JSON.parse() to get the array of classes
if( prop.type === 7 && prop.rawName === ':class') {
return containsClassRegexOnly(prop.exp?.content ?? "", 'translate-as-whole-pointer');
}else if( prop.name === 'class') {
return containsClassRegexOnly(prop.value?.content ?? "", 'translate-as-whole-pointer');
}
});
if( hasClassPropNamedTranslateAsWholePointer ) {
// Process the whole element as a single translation string
const {hasTCall, result} = processTranslationAsWholePointerClass(node, includeT);
// console.log('processTranslationAsWholePointerClass result:', result, 'hasTCall:', hasTCall);
if (((hasTCall && includeT) || !hasTCall) && result) {
extractedStringsOfTranslationAsWholePointer.add({result, hasTCall});
}
}else if(customizedComponentsMapping?.[node.tag]){
// Process customized component
const results = processCustomizedComponent(node, includeT, customizedComponentsMapping);
// console.log('Found customized component:', node.tag, 'with results:', results, extractedStrings);
results.forEach(result => {
// console.log("extractedStrings add 1:", result);
extractedStrings.add(result);
});
}else {
// Recurse into children: deep dive into the AST
if (node.children && node.children.length) {
node.children.forEach(child => walkTemplateAst(child, extractedStrings, extractedStringsOfTranslationAsWholePointer, includeT, customizedComponentsMapping, node.type));
}
}
break;
}
// If v-if, v-else-if, v-else
case 9: {
// Recurse into branches: deep dive into the AST for each if branch
if (node.branches && node.branches.length) {
node.branches.forEach(branch => walkTemplateAst(branch, extractedStrings, extractedStringsOfTranslationAsWholePointer, includeT, customizedComponentsMapping, 9));
}
}
// COMPOUND_EXPRESSION: eg. "hi" + name, <div> hi {{ name }}</div>
case 8: {
// Process COMPOUND_EXPRESSION nodes
const {hasTCall, result} = processCompoundExpression(node, includeT);
if((hasTCall && includeT) || !hasTCall) {
result.forEach(res => {
// console.log("extractedStrings add 1:", res.trim());
extractedStrings.add(res.trim());
});
}
break;
}
// INTERPOLATION
case 5: { // INTERPOLATION
const results = processInterpolation(node, includeT);
results.forEach(({result, hasTCall}) => {
// console.log('Found INTERPOLATION: after 2', 'result:', result);
if (hasTCall) {
if (includeT) {
//NOTES: under this condition, the result the key of the $t() call: eg. {{ $t("Hello {name}") }} -> "Hello {name}"
// console.log("extractedStrings add 3:", result.trim());
extractedStrings.add(result.trim());
}
}else{
// if not a $t() call:
// check if the content is a { name } or similar
// if so -> skip it!
// **NOTES: normally for {{ name }}, we translate the assigned value for name in the script, not the template
// if not -> extract by adding in extractedStrings
if(!/^\{\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*\}$/.test(result)){
//NOTES: only possible result is a template literal, e.g. `Hello ${name}`
// console.log("extractedStrings add 4:", result.trim());
extractedStrings.add(result.trim());
}
}
});
break;
}
// TEXT node
case 2: {
// console.log('Found TEXT node:', node.content);
// Process TEXT nodes
const text = node.content.trim();
if (text) {
// console.log("extractedStrings add 5:", text);
extractedStrings.add(text);
}
break;
}
}
}
function containsClassRegexOnly(input, targetClass) {
// console.log('containsClassRegexOnly input:', input, 'targetClass:', targetClass);
if (typeof input !== 'string') return false;
const normalized = input.trim();
// Case 1: It's just a space-separated string (like class="...")
if (!normalized.includes('{') && !normalized.includes('[')) {
return normalized.split(/\s+/).includes(targetClass);
}
// Case 2: Looks like an array or object structure
// Match string items: 'class-a', "class-a", or `class-a`
const stringMatch = new RegExp(`[\\[\\s,]['"\`]${targetClass}['"\`]`, 'g');
if (stringMatch.test(normalized)) return true;
// Match object keys: { 'class-a': true }
const objectKeyMatch = new RegExp(`[{,]['"\`]${targetClass}['"\`]:`);
return objectKeyMatch.test(normalized);
}
function processCustomizedComponent(node, includeT, customizedComponentsMapping){
const results = new Set();
//get the all props need to be extracted from the customized component
const propsToExtract = customizedComponentsMapping[node.tag];
node.props?.forEach(prop =>{
if(prop.type === 7 && prop.rawName && propsToExtract?.has(prop.rawName.slice(1))){
//NOTES: rawName is the name of the prop, e.g. :title, :content, etc.
//NOTES: need to check the part after the ":", e.g. title, content, etc.
const propValue = prop.exp?.content;
// console.log('Found prop with value:', propValue);
const tKey = extractFirstTKeyFromString(propValue);
if(tKey) {
// If the prop value is a $t() call, add it to the results
if(includeT) {
results.add(tKey);
}
}else if(/^`.*`\s*$/.test(propValue) || /^'.*'\s*$/.test(propValue)){
// If the prop value is a template literal or in format of 'xxx', add it directly
results.add(propValue.slice(1,-1));
}
//else: if the prop is simply a variable, e.g. title, content, etc. -> skip it! We will translate it in the script, not the template
}else if( propsToExtract?.has(prop.name)) {
const propValueNode = prop.value;
if(propValueNode?.type === 2) {
// If the prop value is a text node, add it directly
results.add(propValueNode.content.trim());
}else if(propValueNode?.type === 5) {
// If the prop value is an interpolation, process it
const results = processInterpolation(propValueNode, includeT);
results.forEach(({result, hasTCall}) => {
if( ((hasTCall && includeT) || !hasTCall) && result) {
results.add(result.trim());
}
});
}else if(propValueNode?.type === 8) {
// If the prop value is a compound expression, process it
const { hasTCall, result } = processCompoundExpression(propValueNode, includeT);
if((hasTCall && includeT) || !hasTCall) {
result.forEach(res => {
results.add(res.trim());
});
}
}
}
});
return results;
}
function processTranslationAsWholePointerClass(node, includeT) {
// only need to check if the node type is 1;
// concate all text nodes in the children
// console.log('processTranslationAsWholePointerClass NODE:', node);
var result = '';
var hasTCall = false;
node.children?.forEach(child => {
// console.log('processTranslationAsWholePointerClass CHILD:', child);
if (typeof child === 'string') return; // skip raw spacing
if (child.type === 2) { // Text node
if(child.content) {
result += child.content;
}
// result += child.content;
} else if (child.type === 12) { // Text call
if ( child.content?.type === 2) {
if(child.content.content) {
result += child.content.content;
}
// result += child.content.content;
}else if (child.content?.type === 8) { // Compound expression
const { hasTCall:childHasTCall, result: childResult } = processCompoundExpression(child.content, includeT, true);
// console.log('type 12 processTranslationAsWholePointerClass type 8 childResult:', childResult);
//NOTES: normally the childResult will be an array of length 1, when run processCompoundExpression for translation-as-whole-pointer class
if((childHasTCall && includeT) || !childHasTCall) {
childResult.forEach(res => {
result += res;
});
}
// result += childResult;
if (childHasTCall){
hasTCall = true;
}
}else if (child.content?.type === 5) { // Interpolation
const childResults = processInterpolation(child.content, includeT, true);
// console.log('type 12 processTranslationAsWholePointerClass type 5 childResult:', childResults);
childResults.forEach(({hasTCall:childHasTCall, result: childResult}) => {
if(childResult) {
result += childResult;
}
// result += childResult;
if (childHasTCall){
hasTCall = true;
}
});
}
}else if(child.type === 8) { // Compound expression
const { hasTCall:childHasTCall, result: childResult } = processCompoundExpression(child, includeT, true);
// console.log('type 8 processTranslationAsWholePointerClass childResult:', childResult);
//NOTES: normally the childResult will be an array of length 1, when run processCompoundExpression for translation-as-whole-pointer class
if((childHasTCall && includeT) || !childHasTCall) {
childResult.forEach(res => {
result += res;
});
}
// result += childResult;
if (childHasTCall){
hasTCall = true;
}
}else if (child.type === 5) { // Interpolation
const childResults = processInterpolation(child, includeT, true);
// console.log('processTranslationAsWholePointerClass type 5 childResults:', childResults);
childResults.forEach(({hasTCall:childHasTCall, result: childResult}) => {
if(childResult) {
result += childResult;
}
// result += childResult;
if (childHasTCall){
hasTCall = true;
}
});
}else { // any other type
// we can recursively process its children
const {hasTCall:childHasTCall, result:childResult} = processTranslationAsWholePointerClass(child, includeT);
if (childResult) {
// Used for note the start of nest element content
result += "<";
result += childResult;
// Used for notes the end of nest element content
result += ">";
}
if (childHasTCall) {
hasTCall = true;
}
}
});
return {
hasTCall,
result
};
}
// type:8 -> Compound Epression eg. "hi" + name, <div> Hi {{ name }}</div>, <div> KK{{$t("HiKK { name }", {name: name})}} </div>, <div> {{button_name}} {{ name }}</div>,
function processCompoundExpression(node, includeT, isTranslationAsWholePointer = false) {
// var result = '';
var result = [];
var hasTCall = false;
node.children?.forEach(child => {
if (typeof child === 'string') return; // skip raw spacing
switch (child.type) {
case 2: // Text node in expression
// result += child.content.replace(/^'|'$/g, '');
result.push(child.content.replace(/^'|'$/g, ''));
break;
case 5: { // Interpolation: eg. {{ name }}
const childResults = processInterpolation(child, includeT, isTranslationAsWholePointer);
// console.log('processCompoundExpression childResults type 5:', childResults);
childResults.forEach(({hasTCall:childHasTCall, result: childResult}) => {
if( isTranslationAsWholePointer ){
// If isTranslationAsWholePointer: means we are processing a translation-as-whole-pointer class,
// so ANYWAY we need to add the result to the results array. For t calls, we will handle it later when add to extractedStringsOfTranslationAsWholePointer
result.push(childResult);
}else{
// If any childResult is hasTCall: we ONLY add t keys to the results array
// If NO childResult is hasTCall: we add all childResult to the results array
if( hasTCall ){
// NOTES: if hasTCall, means we have a $t() call in the previous parts.
// 1. If childHasTCall, means that childResult is a $t() call:
// -> we add it to the results array, when previous parts has a $t() call.
if( childHasTCall ){
result.push(childResult);
}
// 2. else : if no childHasTCall, means that childResult is not a $t() call,
// -> we do not add it to the results array, when previous parts has a $t() call.
}else{
//If NOT hasTCall, means we do not have a $t() call in the previous parts.
// 1. If current childResult has a $t() call,
// -> we reset the result to only include the current childResult, and will only add other t keys in later loop.
if( childHasTCall ){
result = [childResult];
}else{
// 2. If current childResult does not have a $t() call,
// -> we add it to the results array, when previous parts has no $t() call.
result.push(childResult);
}
}
}
//update hasTCall if childHasTCall is true, for next loop
if (childHasTCall) {
hasTCall = true;
}
});
break;
}
default:
// Unknown type — skip or handle if needed
break;
}
});
if(isTranslationAsWholePointer || !hasTCall) {
// If isTranslationAsWholePointer or not hasTCall, we need to return the whole content as it is
result = [result.join('')]; // join all parts together
}
return {
hasTCall,
result
};
}
// type: 5 -> Interpolation: eg. {{ name }},
function processInterpolation(node, includeT, isTranslationAsWholePointer = false) {
var results = [];
// var hasTCall = false;
const contentNode = node.content;
const rawContent = node.loc.source.trim();
if (contentNode.type === 4) {
// if not a template literal, but a simple expression like {{ name }}, or {{ $t("Hello") }}
//eg. {{ name }} -> return as '{ name }'
// expr: Parsed, trimmed version of what was inside {{ ... }} → no {{ }} anymore
const expr = contentNode.content;
// NOTES: under this condition, the expr is a simple expression, e.g. name, $t("Hello"), etc.
// NOTES: so if the expr is a $t() call, it will be only one $t() call, not multiple $t() calls
const extractedResultsByHandlingTernaryExpression = extractStringsViaHandlingTernaryExpression(expr);
// console.log('processInterpolation expr:', expr, 'extractedResultsByHandlingTernaryExpression:', extractedResultsByHandlingTernaryExpression);
if(extractedResultsByHandlingTernaryExpression.length === 0) {
// console.log('extractedResultsByHandlingTernaryExpression 1', expr);
const tKey = extractFirstTKeyFromString(expr);
var result = '';
var hasTCall = false;
// console.log('processInterpolation expr:', expr, tKey);
if(isTranslationAsWholePointer){
if (tKey) {
hasTCall = true;
}
result = rawContent.slice(1, -1); // return the whole content as it is
}else if (/^`.*`\s*$/.test(expr)) {
// if the content is a template literal (e.g. {{`Hello ${name}`}})
// → extract
// console.log('processInterpolation expr:', expr, 'tKey:', tKey);
if (tKey) {
hasTCall = true;
if (includeT) {
result = `${tKey}`;
}
} else {
result = `${expr.slice(1, -1)}`; // remove the backticks
}
}else{
// if the content is a simple expression (e.g. {{ name }})
// → return as '{ name }'
// console.log('processInterpolation expr:', expr, 'tKey:', tKey);
if (tKey) {
hasTCall = true;
if (includeT) {
result = `${tKey}`;
}
} else {
// replace {{ name }} with { name }, {{name}} with {name}
result = rawContent.slice(1, -1);
}
}
results.push({result, hasTCall});
}else{
// If the expr is a ternary expression, e.g. {{ name ? 'Hello' : 'Hi' }}
// or a complex expression, e.g. {{ $t("Hello {name}", {name: name}) }}
// we will extract the strings from the ternary expression
extractedResultsByHandlingTernaryExpression.forEach( ({content:expr, isLiteralLikeValue}) => {
// console.log('extractedResultsByHandlingTernaryExpression 2:', expr, 'isLiteralLikeValue:', isLiteralLikeValue);
if(isLiteralLikeValue) {
const tKey = extractFirstTKeyFromString(expr);
var result = '';
var hasTCall = false;
// console.log('extractedResultsByHandlingTernaryExpression:', expr, tKey);
if(isTranslationAsWholePointer){
if (tKey) {
hasTCall = true;
}
result = rawContent.slice(1, -1); // return the whole content as it is
}else if (/^`.*`\s*$/.test(expr)) {
// if the content is a template literal (e.g. {{`Hello ${name}`}})
// → already extracted as `Hello ${name}`
// console.log('processInterpolation expr:', expr, 'tKey:', tKey);
// if the expr is a $t() call, add $t() call key to the result
if (tKey) {
hasTCall = true;
if (includeT) {
result = `${tKey}`;
}
} else {
// if the expr is not a $t() call, just remove the backticks and return
result = `${expr.slice(1, -1)}`; // remove the backticks
}
}else{
// if the content is a simple expression (e.g. {{ name }})
// → return as '{ name }'
// console.log('processInterpolation expr:', expr, 'tKey:', tKey);
// console.log('extractedResultsByHandlingTernaryExpression: simple expression', expr, tKey, rawContent);
if (tKey) {
hasTCall = true;
if (includeT) {
result = `${tKey}`;
}
} else {
// replace {{ name }} with { name }, {{name}} with {name}
result = expr.slice(1, -1);
}
}
results.push({result, hasTCall});
}
});
}
}
// console.log('processInterpolation results:', node.content.content, results);
return results;
}
function extractFirstTKeyFromString(str) {
// NOTES: This function extracts the first string parameter from the first $t() call in a string.
// Check if the expression is a $t() call
const match = str.match(/\$t\s*\(\s*(['"`])((?:\\\1|.)*?)\1/);
return match ? match[2] : null;
}
function extractAllTKeysFromString(str) {
const regex = /\$t\s*\(\s*(['"`])((?:\\.|[^\\])*?)\1/g;
const matches = [];
let match;
while ((match = regex.exec(str)) !== null) {
matches.push(match[2]);
}
return matches;
}
function parseBracketNotation(str) {
const match = str.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\[\s*['"]([^'"]+)['"]\s*\]$/);
if (match) {
const objectName = match[1];
const keyName = match[2];
return { objectName, keyName };
}
return null; // not matching pattern
}
function extractStringsViaHandlingTernaryExpression(expression) {
const result = [];
// console.log('extractStringsViaHandlingTernaryExpression expression:', expression);
try {
const ast = babelParser.parseExpression(expression);
// console.log('Found ConditionalExpression:',expression, ast);
traverse({
type: 'File',
program: {
type: 'Program',
body: [{ type: 'ExpressionStatement', expression: ast }],
sourceType: 'module',
}
}, {
ConditionalExpression(path) {
// console.log('Found ConditionalExpression:',expression);
const { consequent, alternate } = path.node;
[consequent, alternate].forEach(part => {
// Skip raw variable identifiers (like button_name)
// Only extract if it's a literal-like value
const raw = expression.slice(part.start, part.end);
result.push({
content:raw,
isLiteralLikeValue: isLiteralLikeValue(part)
});
});
},
LogicalExpression(path) {
if (path.node.operator === '??') {
const right = path.node.right;
// console.log('Found LogicalExpression with ?? operator:',expression, isLiteralLikeValue(right));
// Skip raw variable identifiers (like button_name)
// Only extract if it's a literal-like value
const raw = expression.slice(right.start, right.end);
result.push({
content:raw,
isLiteralLikeValue: isLiteralLikeValue(right)
});
}
}
});
} catch (e) {
return [];
}
return result; // Return the original expression if no ternary found
}
// ✅ Only extract if it's a literal-like value
function isLiteralLikeValue(node) {
const output = (
node.type === 'StringLiteral' ||
node.type === 'NumericLiteral' ||
node.type === 'TemplateLiteral' ||
(node.type === 'CallExpression' &&
node.callee?.type === 'Identifier' &&
node.callee.name === '$t')
);
// console.log('isLiteralLikeValue node:', output);
return output;
}
function isLikelyVariableLike(str) {
// Trim to ignore whitespace issues
const trimmed = str.trim();
// if a single word eg. 'Hello', no spaces or punctuation
if (/\s|["'`.,;:]/.test(trimmed)) return false;
// Match patterns that are clearly variable-like
const output =(
/^[A-Z0-9_]+$/.test(trimmed) || // ALL_CAPS or ENUMS
/^[a-z_$][a-zA-Z0-9_$]*$/.test(trimmed) // camelCase, snake_case, $refs, _temp, var123
);
// console.log('isLikelyVariableLike str:', str, 'output:', output);
return output;
}
function processJSContent(code, extractedStrings, includeT) {
const ast = babelParser.parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx']
});
// fs.writeFileSync('src/locales/structure.json', JSON.stringify(ast,null, 2));
traverse(ast, {
StringLiteral(path) {
const parent = path.parent;
// Ignore imports
if (parent.type === 'ImportDeclaration') {
return;
}
if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier') {
return;
}
// Skip: window.open('https://example.com') and window.open('https://example.com', '_blank')
if (
parent.callee?.type === 'MemberExpression' &&
parent.callee?.object?.name === 'window' &&
parent.callee?.property?.name === 'open'
) {
return;
}
// Skip: importLibrary('places')
if (
parent.type === 'CallExpression' &&
parent.callee.type === 'MemberExpression' &&
parent.callee.property?.name === 'importLibrary'
) {
return;
}
//Skip import('some-module')
if (
parent.type === 'CallExpression' &&
parent.callee.type === 'Import'
) {
return;
}
// Ignore strings in console.log(...)
if (
parent.type === 'CallExpression' &&
parent.callee.type === 'MemberExpression' &&
parent.callee.object.name === 'console' &&
parent.callee.property.name === 'log'
) {
return;
}
// Skip emits: ['close']
if (
parent.type === 'ArrayExpression' &&
(
path.findParent(p =>
p.isObjectProperty() && p.node.key.name === 'emits'
)
)
) {
// console.log("Exclude emits: ['close']");
return;
}
// Skip $emit() or emit() calls
if (
parent.type === 'CallExpression' &&
(
(parent.callee.type === 'MemberExpression' &&
(parent.callee.property?.name === '$emit' || parent.callee.property?.name === 'emit')) || // this.emit / this.$emit
(parent.callee.type === 'Identifier' &&
(parent.callee.name === '$emit' || parent.callee.name === 'emit')) // just emit()
)
) {
return;
}
// Skip $refs usage
if(path.findParent(p => {
const node = p.node;
return (
node.type === 'MemberExpression' &&
(
// Matches: this.$refs['something']
(node.object?.type === 'MemberExpression' &&
node.object.object?.type === 'ThisExpression' &&
node.object.property?.name === '$refs') ||
// Matches: $refs['something']
(node.object?.type === 'Identifier' && node.object.name === '$refs')
)
);
})){
return;
}
// Skip mapState, mapActions usage
if (
path.findParent(p =>
p.isCallExpression() &&
['mapState', 'mapActions', 'mapGetters', 'mapMutations'].includes(p.node.callee.name)
)
) {
return;
}
// Skip: $t() call if includeT is false
if (!includeT && parent.callee?.property?.type === 'Identifier' && parent.callee?.property?.name === '$t') {
return;
}
// Otherwise, extract it
// console.log("extractedStrings add 6:", path.node.value);
extractedStrings.add(path.node.value);
},
CallExpression(path) {
const callee = path.node.callee;
const arg = path.node.arguments[0];
// console.log('Found $t() call 111111:', includeT, path.node.callee.property?.name, path.node.callee.property?.type, path.node.arguments[0]);
if(includeT){
if (callee?.type === 'Identifier' && callee?.name === '$t') {
if (arg && arg.type === 'StringLiteral') {
// console.log("extractedStrings add 7:", arg.value);
extractedStrings.add(arg.value);
}
}else if(callee?.property?.type === 'Identifier' && callee?.property?.name === '$t'){
// ✅ Handle TemplateLiteral with no interpolations, eg.`tkeyTestWithWholeLiteralKKKK ${this.$t("TestPage['tTkeyTestWithWholeLiteral']")}`;
if (arg.type === 'TemplateLiteral' && arg.expressions.length === 0) {
const raw = arg.quasis.map(q => q.value.cooked).join('');
// console.log("extractedStrings add 8:", raw);
extractedStrings.add(raw);
}
}
}
},
TemplateLiteral(path) {
const parent = path.parent;
// console.log('Found $t() call 111111:', path.node.expressions);
// Only extract if used in contexts like return, assignment, etc.
if (
parent.type === 'ReturnStatement' ||
parent.type === 'VariableDeclarator' ||
parent.type === 'ArrowFunctionExpression'
) {
// Check if it has any expressions (i.e. uses ${...})
if (path.node.expressions.length > 0) {
const { start, end } = path.node;
const raw = code.slice(start, end); // This preserves the original `${...}` syntax
const tKey = extractFirstTKeyFromString(raw);
if (tKey) {
// If the TemplateLiteral contains a $t() call, add it to the extracted strings
if(includeT) {
// console.log("extractedStrings add 9:", tKey);
extractedStrings.add(tKey);
}
} else {
// If it's just a template literal without $t(), add the raw content
// Remove the backticks
// console.log("extractedStrings add 10:", raw.slice(1, -1));
extractedStrings.add(raw.slice(1, -1)); // Remove the backticks
}
}
}
}
});
}
function isUrl(str) {
if (typeof str !== 'string') return false;
try {
const url = new URL(str);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
module.exports = { run };