vue-i18n-customized-extractor
Version:
A CLI tool to extract text and i18n keys from Vue.js files.
958 lines (896 loc) • 79.8 kB
JavaScript
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 traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const { load } = require('cheerio');
const MagicString = require('magic-string');
function replace(targetDir, translationMappingPath, options = {}) {
// the first level key will be added when write $t() call to the file, eg. HomePage -> $t("HomePage[\"keyOne\"]")
// NOTES: should be the one in i18n config file
const curPageKey = options.translationPageKey; // this is for t call replacement
const pageKeys = options.pageKeys || []; // this is for customized component mapping
// whether it is processing the translationMapping for translation-as-whole-pointer
const isTranslationAsWholePointer = options.isTranslationAsWholePointer || false;
const excludeFiles = options.excludeFiles || [];
const configFilePath = path.resolve(process.cwd(), options.configFilePath);
const templateOnly = options.templateOnly;
const vueScriptMode = options.vueScriptMode || "Options";
// 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);
// Step 1: Process translationMapping
if (!fs.existsSync(translationMappingPath) || !translationMappingPath.endsWith('.json')) {
console.warn(`⚠️ Translation mapping file does not exist or is not a .json file: ${translationMappingPath}`);
return;
}
var originalTranslationMapping;
try {
originalTranslationMapping = JSON.parse(fs.readFileSync(translationMappingPath, 'utf-8'));
console.log(`✅ Successfully loaded translation mapping from ${translationMappingPath}`);
} catch (error) {
console.error(`❌ Failed to parse translation mapping file: ${translationMappingPath}`, error.message);
return;
}
const parsedTranslationMapping = parseTranslationMapping(originalTranslationMapping, isTranslationAsWholePointer);
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 {
const ignorePatterns = [
'**/node_modules/**',
...excludeFiles.flatMap(f =>
fs.existsSync(f) && fs.statSync(f).isDirectory()
? [f, `${f}/**`] // ignore the folder and everything under it
: [f] // ignore the file
),
];
console.log(`Processing folder: ${targetDir}`);
vueFiles = glob.sync(`${targetDir}/**/*.vue`, { ignore: ignorePatterns });
// If templateOnly is true, we only process .vue files
if(!templateOnly && !isTranslationAsWholePointer) {
jsFiles = glob.sync(`${targetDir}/**/*.{js,ts}`, { ignore: ignorePatterns });
}
}
console.log(`Found ${vueFiles.length} .vue files`);
console.log(`Found ${jsFiles.length} .js/.ts files`);
// Step 2: Process Vue files
vueFiles.forEach(file => {
replaceTextInVueFile(file, originalTranslationMapping, parsedTranslationMapping, customizedComponentsMapping, templateOnly, isTranslationAsWholePointer, curPageKey, vueScriptMode);
});
// Step 3: Process JS/TS files
jsFiles.forEach(file => {
replaceTextInJSFile(file, parsedTranslationMapping, curPageKey);
});
}
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 parseTranslationMapping(translationMapping, isTranslationAsWholePointer = false) {
const output = {};
// extract the placeholders from the key
if( isTranslationAsWholePointer ){
Object.entries(translationMapping)
.sort((a,b)=>{
const aNum = parseInt(a[0]);
const bNum = parseInt(b[0]);
return aNum - bNum;
}).forEach(([key, value]) => {
// 1. parse key
// NOTES: the key is definitely a string, so we can safely use it
const actualKey = key.split('~~')[1];
if (!actualKey) {
console.warn(`⚠️ Skipping invalid key for translate-as-whole-pointer: ${key}`);
return;
}
if(!output[actualKey]){
output[actualKey] = {};
}
output[actualKey]['key'] = actualKey;
const placeholdersInKey = extractPlaceholdersFromString(actualKey);
output[actualKey]['keyPlaceholders'] = placeholdersInKey;
// 2. parse value
if (typeof value === 'string') {
// If the value is a string, extract placeholders from it
const placeholdersInValue = extractPlaceholdersFromString(value);
if(!output[actualKey]['valuePlaceholders']){
output[actualKey]['valuePlaceholders'] = [];
}
output[actualKey]['valuePlaceholders'].push(...placeholdersInValue);
}else{
output[actualKey]['valuePlaceholders'] = [];
}
});
} else {
Object.entries(translationMapping).forEach(([key, value]) => {
// 1. parse key
// NOTES: the key is definitely a string, so we can safely use it
if(!output[key]){
output[key] = {};
}
output[key]['key'] = key;
const placeholdersInKey = extractPlaceholdersFromString(key);
output[key]['keyPlaceholders'] = placeholdersInKey;
// 2. parse value
if (typeof value === 'string') {
// If the value is a string, extract placeholders from it
const placeholdersInValue = extractPlaceholdersFromString(value);
output[key]['valuePlaceholders'] = placeholdersInValue;
}else{
output[key]['valuePlaceholders'] = [];
}
});
}
return output;
}
function extractPlaceholdersFromString(str) {
// Extract placeholders from a string,
// E.g. "Hello {name}" -> ["name"]
// E.g. "Hello {name} {age}" -> ["name", "age"]
// NOTES: already handle the case of ${}. E.g. "Hello ${name} {age}" -> ["name", "age"]
const matches = str.match(/\{([^}]+)\}|\$\{([^}]+)\}/g);
if (!matches) return [];
return matches.map(m => m.replace(/^\$\{/, '').replace(/^\{/, '').replace(/\}$/, '').trim());
}
function replaceTextInVueFile(filePath, originalTranslationMapping, parsedTranslationMapping, customizedComponentsMapping, templateOnly=false, isTranslationAsWholePointer, pageKey, vueScriptMode){
const content = fs.readFileSync(filePath, 'utf-8');
const { descriptor } = parse(content);
const extractedStringsOffsetAndReplacementCollection = [];
// 1. Process template
if (descriptor.template) {
const templateAst = compile(descriptor.template.content, { hoistStatic: false }).ast;
// Start of <template> content in the .vue file
const baseOffset = descriptor.template.loc.start.offset;
const matchedKeysRelativeOffsetAndReplacementCollection = [];
// get relative offsets and replacements from the template AST
walkTemplateAstAndRecordMatch(templateAst, matchedKeysRelativeOffsetAndReplacementCollection, originalTranslationMapping, parsedTranslationMapping, customizedComponentsMapping, isTranslationAsWholePointer, pageKey);
if (matchedKeysRelativeOffsetAndReplacementCollection.length > 0) {
// Convert relative offsets to absolute offsets based on the template's start offset
matchedKeysRelativeOffsetAndReplacementCollection.forEach(({ start, end, replacement }) => {
extractedStringsOffsetAndReplacementCollection.push({
start: start + baseOffset,
end: end + baseOffset,
replacement
});
});
}
}
// 2. Process script
// NOTES: If templateOnly is true, we only process the template, not the script
if ((descriptor.script || descriptor.scriptSetup) && !templateOnly && !isTranslationAsWholePointer) {
const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content;
// Start of <script> or <script setup> content in the .vue file
const baseOffset = descriptor.script?.loc.start.offset || descriptor.scriptSetup?.loc.start.offset || 0;
walkScriptContentInVueAndRecord(scriptContent, baseOffset, extractedStringsOffsetAndReplacementCollection, parsedTranslationMapping, pageKey, vueScriptMode);
}
// 3. use MagicString to replace the text based on extractedStringsOffsetAndReplacementCollection
if (extractedStringsOffsetAndReplacementCollection.length > 0) {
const magicString = new MagicString(content);
extractedStringsOffsetAndReplacementCollection.forEach(({ start, end, replacement }) => {
// console.log(`Replacing from ${start} to ${end} which is ${content.slice(start, end)} with "${replacement}"`);
magicString.overwrite(start, end, replacement);
});
// Write the modified content back to the file
fs.writeFileSync(filePath, magicString.toString(), 'utf-8');
console.log(`✅ Replaced text in ${filePath}`);
}
}
function walkTemplateAstAndRecordMatch(node, extractedStringsOffsetAndReplacementCollection, originalTranslationMapping, parsedTranslationMapping, customizedComponentsMapping, isTranslationAsWholePointer, pageKey) {
// console.log('walkTemplateAst node:', node.type, node.content);
if (!node) return;
switch (node.type) {
// ROOT
case 0:
// ELEMENT
case 1:
// IF_BRANCH
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') {
// If not processing translation-as-whole-pointer, handle :class extraction for translation mapping
if( !isTranslationAsWholePointer ) {
// Calculate the absolute start offset of the :class expression within the entire file
// check if anything need to be extracted from the :class expression,
// or any $t() calls in the :class expression
if (prop.exp?.content) {
// Trim whitespace/newlines first
const cleanExpr = prop.exp.content.trim();
const replacementResults = walkClassPropsArray(cleanExpr, parsedTranslationMapping, pageKey);
if (replacementResults.length > 0) {
const classExprStartOffset = prop.exp.loc.start.offset;
// If there are any replacements, add them to the collection with absolute offsets
replacementResults.forEach(replacementResult => {
extractedStringsOffsetAndReplacementCollection.push({
// absolute start and end offsets of the replacement in the file
start: classExprStartOffset + replacementResult.start,
end: classExprStartOffset + replacementResult.end,
replacement: replacementResult.replacement
});
});
}
}
}
return containsClassViaRegexOrAST(prop.exp?.content ?? "", 'translate-as-whole-pointer', 'AST');
}else if( prop.name === 'class') {
// console.log(`Processing class expression: ${prop.value.content}`);
return containsClassViaRegexOrAST(prop.value?.content ?? "", 'translate-as-whole-pointer');
}
});
if( hasClassPropNamedTranslateAsWholePointer ) {
// If the node has a class or :class prop named translate-as-whole-pointer:
// -> Process the node only if isTranslationAsWholePointer is true
if (isTranslationAsWholePointer) {
// Process the whole element as a single translation string
const {hasTCall, result, start, end} = processTranslationAsWholePointerClass(node);
if (!hasTCall && result) {
const possibleKeyBase = result.trim();
if(parsedTranslationMapping[possibleKeyBase]){
const replacement = getTranslationAsWholePointerReplacement(node, possibleKeyBase, originalTranslationMapping, parsedTranslationMapping[possibleKeyBase], pageKey);
extractedStringsOffsetAndReplacementCollection.push({
start,
end,
replacement
});
}
}
}
}else {
// If the node does not have a class or :class prop named translate-as-whole-pointer:
// -> Process the node only if isTranslationAsWholePointer is false
if( !isTranslationAsWholePointer ) {
if(customizedComponentsMapping?.[node.tag]){
// console.log(`Found customized component: ${node.tag}`);
// Process customized component
const outputOfPropsReplacements = processCustomizedComponent(node, parsedTranslationMapping, customizedComponentsMapping, pageKey);
extractedStringsOffsetAndReplacementCollection.push(...outputOfPropsReplacements);
}
}
// Recurse into children: deep dive into the AST
if (node.children && node.children.length) {
node.children.forEach(child => walkTemplateAstAndRecordMatch(child, extractedStringsOffsetAndReplacementCollection, originalTranslationMapping, parsedTranslationMapping, customizedComponentsMapping, isTranslationAsWholePointer, pageKey));
}
}
break;
}
// Text Call
case 12: {
// Process text call nodes, deep dive into the node's content property
walkTemplateAstAndRecordMatch(node.content, extractedStringsOffsetAndReplacementCollection, originalTranslationMapping, parsedTranslationMapping, customizedComponentsMapping, isTranslationAsWholePointer, pageKey);
break;
}
// v-for
case 11: {
// Recurse into children: deep dive into the AST
if (node.children && node.children.length) {
node.children.forEach(child => walkTemplateAstAndRecordMatch(child, extractedStringsOffsetAndReplacementCollection, originalTranslationMapping, parsedTranslationMapping, customizedComponentsMapping, isTranslationAsWholePointer, pageKey));
}
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 => {
if( !isTranslationAsWholePointer ) {
// Extract string literals from branch.condition
if (branch.condition && branch.condition.content) {
const replacementResults = walkVueIfCondition(branch.condition.content, parsedTranslationMapping, pageKey);
if (replacementResults.length > 0) {
const branchConditionExprStartOffset = branch.condition.loc.start.offset;
// If there are any replacements, add them to the collection with absolute offsets
replacementResults.forEach(replacementResult => {
extractedStringsOffsetAndReplacementCollection.push({
// absolute start and end offsets of the replacement in the file
start: branchConditionExprStartOffset + replacementResult.start,
end: branchConditionExprStartOffset + replacementResult.end,
replacement: replacementResult.replacement
});
});
}
}
}
// Recurse deeper into this branch
walkTemplateAstAndRecordMatch(branch, extractedStringsOffsetAndReplacementCollection, originalTranslationMapping, parsedTranslationMapping, customizedComponentsMapping, isTranslationAsWholePointer, pageKey)
});
}
break;
}
// COMPOUND_EXPRESSION: eg. "hi" + name, <div> hi {{ name }}</div>
case 8: {
if( !isTranslationAsWholePointer ) {
// Process COMPOUND_EXPRESSION nodes
const {hasTCall, result:results, start, end} = processCompoundExpression(node);
if(!hasTCall) {
results.forEach(res => {
const posiibleKey = res.trim();
const matchedKeyInfoInTranslationMapping = parsedTranslationMapping[posiibleKey];
if(matchedKeyInfoInTranslationMapping){
// If the possibleKey is found in the translation mapping,
extractedStringsOffsetAndReplacementCollection.push({
// absolute start and end offsets of the replacement in the file
start: start,
end: end,
replacement:`{{ ${buildTCall(pageKey, matchedKeyInfoInTranslationMapping.key, matchedKeyInfoInTranslationMapping.keyPlaceholders, matchedKeyInfoInTranslationMapping.valuePlaceholders, 'template')} }} `
});
}
// console.log("extractedStrings add 1:", res.trim());
// extractedStrings.add(res.trim());
});
}
}
break;
}
// INTERPOLATION
case 5: { // INTERPOLATION
if( !isTranslationAsWholePointer ) {
const results = processInterpolation(node);
results.forEach(({result, hasTCall, start, end}) => {
// console.log('Found INTERPOLATION: after 2', 'result:', result);
if (!hasTCall) {
// 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}`
const possibleKey = result.trim();
const matchedKeyInfoInTranslationMapping = parsedTranslationMapping[possibleKey];
if(matchedKeyInfoInTranslationMapping){
// If the possibleKey is found in the translation mapping,
extractedStringsOffsetAndReplacementCollection.push({
// absolute start and end offsets of the replacement in the file
start: start,
end: end,
replacement: buildTCall(pageKey, matchedKeyInfoInTranslationMapping.key, matchedKeyInfoInTranslationMapping.keyPlaceholders, matchedKeyInfoInTranslationMapping.valuePlaceholders, 'template')
});
}
// console.log("extractedStrings add 4:", result.trim());
// extractedStrings.add(result.trim());
}
}
});
}
break;
}
// TEXT node
case 2: {
if( !isTranslationAsWholePointer ) {
// console.log('Found TEXT node:', node.content);
// Process TEXT nodes
const text = node.content.trim();
if (text) {
const possibleKey = text.trim();
const matchedKeyInfoInTranslationMapping = parsedTranslationMapping[possibleKey];
if(matchedKeyInfoInTranslationMapping){
// If the possibleKey is found in the translation mapping,
extractedStringsOffsetAndReplacementCollection.push({
// absolute start and end offsets of the replacement in the file
start: node.loc.start.offset,
end: node.loc.end.offset,
replacement: `{{ ${buildTCall(pageKey, matchedKeyInfoInTranslationMapping.key, matchedKeyInfoInTranslationMapping.keyPlaceholders, matchedKeyInfoInTranslationMapping.valuePlaceholders, 'template')} }}`
});
}
// console.log("extractedStrings add 5:", text);
// extractedStrings.add(text);
}
}
break;
}
}
}
function walkClassPropsArray(cleanExpr, parsedTranslationMapping, pageKey) {
const outputOfExtractedStringsReplacementCollection = [];
// Parse expression string
try{
const prefix = 'const _cls = ';
const code = `${prefix}${cleanExpr};`;
const programAst = babelParser.parse(code, {
sourceType: 'module',
plugins: ['typescript']
});
const offsetShift = prefix.length;
// const exprAst = babelParser.parseExpression(cleanExpr);
// const programAst = {
// type: 'Program',
// body: [
// {
// type: 'ExpressionStatement',
// expression: exprAst,
// },
// ],
// sourceType: 'module',
// };
traverse(programAst, {
StringLiteral(path) {
// Skip object keys (these are the class names)
if (path.parent.type === 'ObjectProperty' && path.parent.key === path.node) {
// console.log(`Skipping class name key: ${path.node.value}`);
return;
}
// Skip string literals directly in array (static class names)
if (path.parent.type === 'ArrayExpression') {
// console.log(`Skipping static array string: ${path.node.value}`);
return;
}
// console.log("extractedStrings add 6:", path.node.value);
// extractedStrings.add(path.node.value);
const possibleKey = path.node.value.trim();
collectReplacementInfoForValuesInTag(path, possibleKey, parsedTranslationMapping, outputOfExtractedStringsReplacementCollection, pageKey, offsetShift);
},
TemplateLiteral(path) {
const quasis = path.node.quasis;
const expressions = path.node.expressions;
let reconstructed = '';
for (let i = 0; i < quasis.length; i++) {
reconstructed += quasis[i].value.cooked;
if (i < expressions.length) {
const exprCode = generate(expressions[i]).code;
reconstructed += '${' + exprCode + '}';
}
}
// console.log("extractedStrings add 7:", arg.value);
// extractedStrings.add(reconstructed);
const possibleKey = reconstructed.trim();
collectReplacementInfoForValuesInTag(path, possibleKey, parsedTranslationMapping, outputOfExtractedStringsReplacementCollection, pageKey, offsetShift);
},
// CallExpression(path) {
// Check for $t(...) calls
// const callee = path.node.callee;
// const arg = path.node.arguments[0];
// if(includeT){
// if (callee?.type === 'Identifier' && callee?.name === '$t') {
// if (arg && arg.type === 'StringLiteral') {
// // console.log("extractedStrings add 8:", arg.value);
// // extractedStrings.add(arg.value);
// extractedStrings.add(JSON.stringify(
// {
// result: arg.value,
// hasTCall: true
// }
// ));
// }
// // ✅ Handle TemplateLiteral in $t(),
// // eg. :class = "[
// // {'dummy-webinar-video-card':curTab == $t(`EducationCenter['Webinars']`) && test == $t(`EducationCenter['test val {name}']`, {name: 'test'})},
// // {'dummy-educational-video-card':curTab == 'Educational Videos'}
// // ]";
// // NOTES: different structure from other $t() CallExpression in the processJSContent function
// if (arg.type === 'TemplateLiteral' && arg.expressions.length === 0) {
// const raw = arg.quasis.map(q => q.value.cooked).join('');
// // console.log("extractedStrings add 9:", raw);
// // extractedStrings.add(raw);
// extractedStrings.add(JSON.stringify(
// {
// result: raw,
// hasTCall: true
// }
// ));
// }
// }else if(callee?.property?.type === 'Identifier' && callee?.property?.name === '$t'){
// // NOTES: in case, we still keep this logic which is the same as the one in processJSContent function
// // ✅ 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 10:", raw);
// // extractedStrings.add(raw);
// extractedStrings.add(JSON.stringify(
// {
// result: raw,
// hasTCall: true
// }
// ));
// }
// }
// }
// },
});
return outputOfExtractedStringsReplacementCollection;
} catch (err) {
console.warn(`Failed of walkClassPropsArray: ${cleanExpr}`, err);
}
}
function walkVueIfCondition(exprStr, parsedTranslationMapping, pageKey) {
const outputOfExtractedStringsReplacementCollection = [];
try {
const prefix = 'const _cls = ';
const code = `${prefix}${exprStr};`;
const programAst = babelParser.parse(code, {
sourceType: 'module',
plugins: ['typescript']
});
const offsetShift = prefix.length;
// const exprAst = babelParser.parseExpression(exprStr);
// const programAst = {
// type: 'Program',
// body: [
// {
// type: 'ExpressionStatement',
// expression: exprAst,
// },
// ],
// sourceType: 'module',
// };
traverse(programAst, {
StringLiteral(path) {
// console.log("extractedStrings add 11:", path.node.value);
// extractedStrings.add(path.node.value);
const possibleKey = path.node.value.trim();
collectReplacementInfoForValuesInTag(path, possibleKey, parsedTranslationMapping, outputOfExtractedStringsReplacementCollection, pageKey, offsetShift);
},
TemplateLiteral(path) {
const quasis = path.node.quasis;
const expressions = path.node.expressions;
let reconstructed = '';
for (let i = 0; i < quasis.length; i++) {
reconstructed += quasis[i].value.cooked;
if (i < expressions.length) {
const exprCode = generate(expressions[i]).code;
reconstructed += '${' + exprCode + '}';
}
}
// console.log("extractedStrings add 12:", reconstructed);
// extractedStrings.add(reconstructed);
const possibleKey = reconstructed.trim();
collectReplacementInfoForValuesInTag(path, possibleKey, parsedTranslationMapping, outputOfExtractedStringsReplacementCollection, pageKey, offsetShift);
},
// CallExpression(path) {
// // Check for $t(...) calls
// const callee = path.node.callee;
// const arg = path.node.arguments[0];
// if(includeT){
// if (callee?.type === 'Identifier' && callee?.name === '$t') {
// // console.log("extractedStrings add 7:", JSON.stringify(arg, null, 2));
// if (arg && arg.type === 'StringLiteral') {
// // console.log("extractedStrings add 13:", arg.value);
// // extractedStrings.add(arg.value);
// extractedStrings.add(JSON.stringify(
// {
// result: arg.value,
// hasTCall: true
// }
// ));
// }
// // ✅ 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 14:", raw);
// // extractedStrings.add(raw);
// extractedStrings.add(JSON.stringify(
// {
// result: raw,
// hasTCall: true
// }
// ));
// }
// }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 15:", raw);
// // extractedStrings.add(raw);
// extractedStrings.add(JSON.stringify(
// {
// result: raw,
// hasTCall: true
// }
// ));
// }
// }
// }
// },
});
return outputOfExtractedStringsReplacementCollection;
} catch (err) {
console.warn(`Failed to parse branch condition in walkVueIfBranches: ${exprStr}`, err);
}
}
function containsClassViaRegexOrAST(exprStr, targetClass, method = 'regex') {
// method can be 'regex' or 'AST'
// console.log('containsClassRegexOnly input:', exprStr, 'method:', method);
let hasClass = false;
if(method==="AST"){
try {
const cleanExpr = exprStr.trim();
const prefix = 'const _cls = ';
const code = `${prefix}${cleanExpr};`;
const programAst = babelParser.parse(code, {
sourceType: 'module',
plugins: ['typescript']
});
const offsetShift = prefix.length;
// NOTES: the way below cannot cover all dynamic class value formats.
// const exprAst = babelParser.parseExpression(cleanExpr);
// const programAst = {
// type: 'Program',
// body: [
// {
// type: 'ExpressionStatement',
// expression: exprAst,
// },
// ],
// sourceType: 'module',
// };
traverse(programAst, {
StringLiteral(path) {
// Check object keys
if (path.parent.type === 'ObjectProperty' && path.parent.key === path.node) {
if (path.node.value === targetClass) {
hasClass = true;
}
}
// Check array literals
if (path.parent.type === 'ArrayExpression') {
if (path.node.value === targetClass) {
hasClass = true;
}
}
}
});
} catch (err) {
console.warn(`Failed to parse expression: ${exprStr}`, err);
}
}else{
// Split on whitespace to get individual classes
const classList = exprStr.split(/\s+/);
if (classList.includes(targetClass)) {
hasClass = true;
}
}
return hasClass;
}
function processCustomizedComponent(node, parsedTranslationMapping, customizedComponentsMapping, pageKey){
const outputOfPropsReplacementCollection = [];
//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 && (/^`.*`\s*$/.test(propValue) || /^'.*'\s*$/.test(propValue))){
// If the prop value is a template literal or in format of 'xxx', check if it is in translation mapping
const possibleKey = propValue.slice(1,-1).trim();
const matchedKeyInfoInTranslationMapping = parsedTranslationMapping[possibleKey];
if(matchedKeyInfoInTranslationMapping) {
const start = prop.exp.loc.start.offset;
const end = prop.exp.loc.end.offset;
outputOfPropsReplacementCollection.push({
start: start,
end: end,
replacement: buildTCall(pageKey, matchedKeyInfoInTranslationMapping.key, matchedKeyInfoInTranslationMapping.keyPlaceholders, matchedKeyInfoInTranslationMapping.valuePlaceholders, 'templateTag')
});
}
// 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, replace value and prop name if has matched key in translation mapping
const possibleKey = propValueNode.content.trim();
const matchedKeyInfoInTranslationMapping = parsedTranslationMapping[possibleKey];
if(matchedKeyInfoInTranslationMapping) {
const start = prop.value.loc.start.offset;
const end = prop.value.loc.end.offset;
outputOfPropsReplacementCollection.push({
start: start,
end: end,
// wrap in quotes
replacement: `"${buildTCall(pageKey, matchedKeyInfoInTranslationMapping.key, matchedKeyInfoInTranslationMapping.keyPlaceholders, matchedKeyInfoInTranslationMapping.valuePlaceholders, 'templateTag')}"`
});
// since it is changed to be a $t() call, we need to replace prop name. eg. title = "Hi" -> :title = "$t('Hi')"
const propNameStart = prop.loc.start.offset;
// `prop.name.length` = length of 'title' = 5
const propNameEnd = propNameStart + prop.name.length;
outputOfPropsReplacementCollection.push({
start: propNameStart,
end: propNameEnd,
replacement: `:${prop.name}` // add ":" to the prop name
});
}
// results.add(propValueNode.content.trim());
}
}
});
return outputOfPropsReplacementCollection;
}
function processTranslationAsWholePointerClass(node) {
// 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, 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
childResult.forEach(res => {
result += res;
});
// result += childResult;
if (childHasTCall){
hasTCall = true;
}
}else if (child.content?.type === 5) { // Interpolation
const childResults = processInterpolation(child.content, 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, 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
childResult.forEach(res => {
result += res;
});
// result += childResult;
if (childHasTCall){
hasTCall = true;
}
}else if (child.type === 5) { // Interpolation
const childResults = processInterpolation(child, 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);
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,
start: node.loc.start.offset,
end: node.loc.end.offset
};
}
// 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, 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, isTranslationAsWholePointer, false);
// 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