UNPKG

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
// <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 };