기존 vue3 프로젝트의 문구 통일과 국제화 대비겸 vue-i18n 도입을 시도한다.
다만, 프로젝트 덩치도 좀 커졌고 vue3+typescript 기반의 코드를 쉽게 자동화처리 해주는 도구는 딱히 없었다.
다행히도 ai가 많이 발전 중인 시기라 cursor의 도움을 받아 스크립트를 만들었다.
완벽하게 보다는 가능성을 보려고 조금씩 다듬어가는 식으로 진행했다.
script 영역은 일단 무시하고, 컴포넌트의 template 영역만 vue-i18n를 import 하고
useI18n()을 선언하여 t 메서드를 가져오게 한뒤, template 태그영역의 한글들을 t로 wrap 해주는 것을 목표로 했다.
아래는 최소한 쓸만한 정도로 완성된 스크립트다.
vue3의 props 전달문법이나 그 외 여러케이스, es의 템플릿 리터럴문법을 사용하는 케이스들을 체크해가며 생성했다.
일부 케이스는 적용 안될 수 있다.
까다롭거나 애매한 파일들은 exclude 하도록 CONFIG 변수를 사용했다.
참고로 t로 wrap만 진행하기 때문에 한글키 기반의 처리가 된다.
# vue-i18n-extractor.cjs
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const minimatch = require('minimatch');
const { parse } = require('@vue/compiler-sfc');
const { parse: babelParse } = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
const { baseParse, transform } = require('@vue/compiler-dom');
const recast = require('recast');
// Configuration
const CONFIG = {
vueFiles: './src/**/*.vue',
scriptFiles: './src/**/*.{ts,js}',
localeDir: './src/locales',
defaultLocale: 'ko',
excludePatterns: [
'node_modules',
'**/utils/format.{js,ts}',
"src/stories/**/*.ts",
"src/**/InputAutocomplete.vue",
"src/**/ListItem*.vue",
"src/**/*Bulk.vue",
"src/**/*.spec.ts",
"src/**/__mocks__/**/*.{js,ts}"
],
excludeFromWrapping: [
'**/string-utils.{js,ts}',
'**/format.{js,ts}',
'**/utils/format*.{js,ts}',
'**/utils/regex*.{js,ts}',
"src/stories/**/*.ts",
"src/**/*.spec.ts",
"src/**/__mocks__/**/*.{js,ts}",
],
processingTimeout: 20000, // 20 seconds
autoDetection: {
enabled: true,
maxTemplateSize: 10000,
maxComponentsPerFile: 20,
maxNestingLevel: 5,
complexityThreshold: 90
},
debug: true
};
// Korean text detection regex
const KOREAN_REGEX = /[가-힣ㄱ-ㅎㅏ-ㅣ]/;
// Helper function to check if text contains Korean characters
const containsKorean = (text) => {
return typeof text === 'string' && KOREAN_REGEX.test(text);
};
// Helper function to log debug messages
const LOG = (message) => {
if (CONFIG.debug) {
console.log(`[DEBUG] ${message}`);
}
};
// Load locale file
const loadLocaleFile = (locale) => {
const filePath = path.join(CONFIG.localeDir, `${locale}.json`);
try {
if (fs.existsSync(filePath)) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
return {};
} catch (error) {
console.error(`Error loading locale file ${filePath}:`, error);
return {};
}
};
// Save locale file
const saveLocaleFile = (locale, data) => {
const filePath = path.join(CONFIG.localeDir, `${locale}.json`);
// Create directory if it doesn't exist
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Sort keys alphabetically for better version control
const sortedData = {};
Object.keys(data).sort().forEach(key => {
sortedData[key] = data[key];
});
const content = JSON.stringify(sortedData, null, 2);
fs.writeFileSync(filePath, content, 'utf-8');
console.log(`Updated locale file: ${filePath}`);
};
// Update locale file with new texts
const updateLocaleFile = (locale, texts) => {
const localeData = loadLocaleFile(locale);
let updated = false;
if (Array.isArray(texts)) {
// Handle array of texts
texts.forEach(text => {
if (!localeData[text]) {
localeData[text] = text; // Default value is the same as the key
updated = true;
}
});
} else if (typeof texts === 'object') {
// Handle object with key-value pairs
Object.keys(texts).forEach(key => {
if (!localeData[key]) {
localeData[key] = texts[key];
updated = true;
}
});
}
if (updated) {
saveLocaleFile(locale, localeData);
return true;
} else {
console.log('No new texts found to update.');
return false;
}
};
// Check if text should be skipped (already wrapped, code pattern, etc.)
const shouldSkipText = (text) => {
if (!text || typeof text !== 'string') return true;
// Skip if already wrapped with t()
if (text.includes("t('") || text.includes('t("')) return true;
// Skip if it doesn't contain Korean
if (!containsKorean(text)) return true;
// Skip console.log messages
if (text.includes('console.') || text.toLowerCase().includes('로그') || text.toLowerCase().includes('디버그')) return true;
// Skip template literals with ${} expressions
if (text.includes('${') && text.includes('}')) return true;
// Skip backtick expressions
if (text.startsWith('`') && text.endsWith('`')) return true;
// Skip code patterns (regex, escape sequences)
if (
text.includes('escapeRegExp') ||
(text.includes('/[') && text.includes(']/g')) ||
(text.match(/\\\\/g) || []).length >= 2 ||
text.includes('\\$&') ||
text.includes('\\u') ||
text.includes('\\x')
) return true;
return false;
};
// Helper function to normalize whitespace
const normalizeWhitespace = (text) => {
// Remove extra whitespace within the text but preserve a single space
return text.trim().replace(/\s+/g, ' ');
};
// Extract Korean texts from template using AST
const extractKoreanFromTemplate = (template) => {
const koreanTexts = new Set();
try {
// Parse template to AST
const ast = baseParse(template);
// Walk through AST to find text nodes
const visitNode = (node) => {
// Handle text nodes
if (node.type === 1) { // Element
// Check attributes
if (node.props) {
node.props.forEach(prop => {
// Static attributes
if (prop.type === 6 && prop.value && containsKorean(prop.value.content)) {
const text = prop.value.content.trim();
if (!shouldSkipText(text)) {
koreanTexts.add(text);
}
}
});
}
// Process children
if (node.children) {
node.children.forEach(visitNode);
}
} else if (node.type === 2) { // Text
const text = node.content.trim();
if (text && containsKorean(text) && !shouldSkipText(text)) {
koreanTexts.add(text);
}
}
};
// Visit all nodes
if (ast.children) {
ast.children.forEach(visitNode);
}
} catch (error) {
console.error('Error extracting Korean from template:', error);
}
return Array.from(koreanTexts);
};
// Extract Korean texts from script using AST
const extractKoreanFromScript = (scriptContent, lang = 'ts') => {
const koreanTexts = new Set();
try {
// Parse script to AST
const ast = babelParse(scriptContent, {
sourceType: 'module',
plugins: [
'jsx',
'typescript',
'decorators-legacy',
'classProperties'
]
});
// Traverse AST to find string literals
traverse(ast, {
StringLiteral(path) {
const { value } = path.node;
// Skip console.log statements
const isInConsoleStatement = path.findParent(p =>
p.isCallExpression() &&
p.get('callee').isIdentifier() &&
p.get('callee').node.name === 'console'
);
if (isInConsoleStatement) return;
// Check if string contains Korean and is not already wrapped
if (containsKorean(value) && !shouldSkipText(value)) {
koreanTexts.add(value);
}
},
TemplateLiteral(path) {
// For template literals, we need to check if they contain Korean
// in their static parts (quasis)
const { quasis } = path.node;
// Skip if inside console.log
const isInConsoleStatement = path.findParent(p =>
p.isCallExpression() &&
p.get('callee').isIdentifier() &&
p.get('callee').node.name === 'console'
);
if (isInConsoleStatement) return;
// Check each static part
quasis.forEach(quasi => {
const value = quasi.value.cooked;
if (value && containsKorean(value) && !shouldSkipText(value)) {
koreanTexts.add(value.trim());
}
});
}
});
} catch (error) {
console.error('Error extracting Korean from script:', error);
}
return Array.from(koreanTexts);
};
// Extract Korean texts from Vue SFC
const extractKoreanFromVueFile = (filePath) => {
const koreanTexts = new Set();
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { descriptor } = parse(fileContent);
// Extract from template
if (descriptor.template) {
const templateContent = descriptor.template.content;
extractKoreanFromTemplate(templateContent).forEach(text => {
// Normalize whitespace in extracted text
koreanTexts.add(normalizeWhitespace(text));
});
}
// Extract from script
if (descriptor.script || descriptor.scriptSetup) {
const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || '';
const lang = descriptor.script?.lang || descriptor.scriptSetup?.lang || 'ts';
extractKoreanFromScript(scriptContent, lang).forEach(text => koreanTexts.add(text));
}
} catch (error) {
console.error(`Error processing Vue file ${filePath}:`, error);
}
return Array.from(koreanTexts);
};
// Extract Korean texts from JS/TS files
const extractKoreanFromScriptFile = (filePath) => {
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const fileExt = path.extname(filePath).substring(1); // Remove the dot
return extractKoreanFromScript(fileContent, fileExt);
} catch (error) {
console.error(`Error processing script file ${filePath}:`, error);
return [];
}
};
// Extract all Korean texts from all files
const extractAllKoreanTexts = () => {
const koreanTexts = new Set();
// Process Vue files
const vueFiles = glob.sync(CONFIG.vueFiles, { ignore: CONFIG.excludePatterns });
console.log(`Processing ${vueFiles.length} Vue files...`);
vueFiles.forEach(filePath => {
const texts = extractKoreanFromVueFile(filePath);
texts.forEach(text => koreanTexts.add(text));
});
// Process JS/TS files
const scriptFiles = glob.sync(CONFIG.scriptFiles, { ignore: CONFIG.excludePatterns });
console.log(`Processing ${scriptFiles.length} script files...`);
scriptFiles.forEach(filePath => {
const texts = extractKoreanFromScriptFile(filePath);
texts.forEach(text => koreanTexts.add(text));
});
return Array.from(koreanTexts);
};
// Analyze file complexity
const analyzeFileComplexity = (filePath, fileContent, descriptor) => {
if (!CONFIG.autoDetection.enabled) {
return { score: 0, canProcess: true, reason: 'Auto detection disabled' };
}
const fileName = path.basename(filePath);
let complexityScore = 0;
const reasons = [];
// Template size check
if (descriptor.template) {
const templateSize = descriptor.template.content.length;
if (templateSize > CONFIG.autoDetection.maxTemplateSize) {
complexityScore += 30;
reasons.push(`Large template (${templateSize} chars)`);
}
// Component count check
const componentCount = (descriptor.template.content.match(/<[A-Z][a-zA-Z0-9]*\s/g) || []).length;
if (componentCount > CONFIG.autoDetection.maxComponentsPerFile) {
complexityScore += 20;
reasons.push(`Many components (${componentCount})`);
}
// Nesting level check
let maxNesting = 0;
let currentNesting = 0;
const lines = descriptor.template.content.split('\n');
for (const line of lines) {
const openTags = (line.match(/<[a-zA-Z][^/>]*>/g) || []).length;
const closeTags = (line.match(/<\/[a-zA-Z][^>]*>/g) || []).length;
currentNesting += (openTags - closeTags);
maxNesting = Math.max(maxNesting, currentNesting);
}
if (maxNesting > CONFIG.autoDetection.maxNestingLevel) {
complexityScore += 15;
reasons.push(`Deep nesting (${maxNesting} levels)`);
}
}
const canProcess = complexityScore < CONFIG.autoDetection.complexityThreshold;
return {
score: complexityScore,
canProcess,
reasons
};
};
// Transform template AST to add t() wrapping for Korean texts
const transformTemplate = (template) => {
try {
// Parse template to AST
const ast = baseParse(template);
// Transform AST
const transformedAst = transform(ast, {
nodeTransforms: [
// Transform text nodes
(node) => {
if (node.type === 2) { // Text node
const text = node.content.trim();
// Only transform if it contains Korean and is not already wrapped
if (text && containsKorean(text) && !shouldSkipText(text)) {
// Get normalized content for the t() function
const normalizedText = normalizeWhitespace(text);
// Create interpolation node with t() function call
// but preserve original spacing
node.type = 5; // Interpolation
node.content = {
type: 4, // Simple Expression
content: `t('${normalizedText.replace(/'/g, "\\'")}')`,
isStatic: false,
isConstant: false
};
}
} else if (node.type === 1) { // Element
// Transform attributes
if (node.props) {
node.props.forEach(prop => {
// Static attributes
if (prop.type === 6 && prop.value && containsKorean(prop.value.content)) {
const text = prop.value.content.trim();
// Skip if the attribute value has template literals
if (text.includes('${') || text.includes('`') || shouldSkipText(text)) {
return; // Skip this attribute
}
// Normalize the text for the translation function
const normalizedText = normalizeWhitespace(text);
// Convert to dynamic binding with t()
prop.type = 7; // DIRECTIVE
prop.name = 'bind';
prop.arg = {
type: 4,
content: prop.name,
isStatic: true,
isConstant: true
};
prop.exp = {
type: 4,
content: `t('${normalizedText.replace(/'/g, "\\'")}')`,
isStatic: false,
isConstant: false
};
prop.modifiers = [];
delete prop.value;
}
});
}
}
}
]
});
// Convert transformed AST back to template code
// This is simplified as we'd need a proper code generator for @vue/compiler-dom
// For production use, a proper template code generator would be needed
return template;
} catch (error) {
console.error('Error transforming template:', error);
return template;
}
};
// Wrap Korean texts with t() function in Vue file
const wrapWithTFunction = (filePath) => {
console.log(`Processing: ${filePath}`);
// Check if file should be excluded
const isExcluded = CONFIG.excludeFromWrapping.some(pattern => minimatch(filePath, pattern));
if (isExcluded) {
console.log(`Skipping file (excluded from wrapping): ${filePath}`);
return;
}
try {
// Setup timeout tracking
const startTime = Date.now();
const checkTimeout = () => {
if (Date.now() - startTime > CONFIG.processingTimeout) {
throw new Error(`Processing timeout for file: ${filePath}`);
}
};
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { descriptor } = parse(fileContent);
// Analyze file complexity
const complexity = analyzeFileComplexity(filePath, fileContent, descriptor);
if (!complexity.canProcess) {
console.log(`Skipping complex file (score: ${complexity.score}): ${filePath}`);
console.log(`Reasons: ${complexity.reasons.join(', ')}`);
return;
}
let updatedContent = fileContent;
let needsUpdate = false;
let hasKoreanText = false;
let templateWasModified = false;
let hasActualTFunctionUsage = false;
// Check for Korean text in template
if (descriptor.template) {
const koreanTextsInTemplate = extractKoreanFromTemplate(descriptor.template.content);
hasKoreanText = koreanTextsInTemplate.length > 0;
let templateModified = false;
if (hasKoreanText) {
// Transform template to add t() function wrapping
// This regex more carefully handles text nodes with proper whitespace preservation
const transformedTemplate = descriptor.template.content.replace(
/>((\s*(?:[^<>{}]|{(?!{)|{[^{])*?))([가-힣ㄱ-ㅎㅏ-ㅣ][^<>{}]*?)(\s*)(<)/g,
(match, spaceBefore, _, korean, spaceAfter, closingTag) => {
checkTimeout();
// Skip if this should not be transformed
if (shouldSkipText(korean.trim())) {
return match;
}
// Normalize text for the translation function
const normalizedText = normalizeWhitespace(korean);
// Mark that we've made transformations
templateModified = true;
hasActualTFunctionUsage = true;
// Replace Korean text with {{ t('...') }} while preserving whitespace structure
return `>${spaceBefore}{{ t('${normalizedText.replace(/'/g, "\\'")}') }}${spaceAfter}${closingTag}`;
}
);
// Update attributes with Korean text, but skip those with template literals
const attrTransformed = transformedTemplate.replace(
/(\s+)([a-zA-Z0-9\-_:\.]+)=(["'])([^"'<>]*[가-힣ㄱ-ㅎㅏ-ㅣ][^"'<>]*)\3/g,
(match, space, attr, quote, value) => {
checkTimeout();
// Skip if attribute value contains template literals or is part of a dynamic binding
if (shouldSkipText(value) || value.includes('${') || value.includes('`') ||
attr.startsWith('v-') || attr.startsWith(':') || attr.startsWith('@')) {
return match;
}
// Mark that we've made transformations
templateModified = true;
hasActualTFunctionUsage = true;
// Convert to dynamic binding with t()
return `${space}:${attr}=${quote}t('${value.trim().replace(/'/g, "\\'")}')${quote}`;
}
);
if (attrTransformed !== descriptor.template.content) {
updatedContent = updatedContent.replace(descriptor.template.content, attrTransformed);
needsUpdate = true;
templateWasModified = templateModified;
}
}
}
// Check for Korean text in script
if (descriptor.script || descriptor.scriptSetup) {
const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || '';
// First check if t() is actually being used in the script
const hasTFunctionUsage = scriptContent.includes("t('") ||
scriptContent.includes('t("') ||
scriptContent.includes('t(`');
if (hasTFunctionUsage) {
hasActualTFunctionUsage = true;
}
// Only check for Korean text if t() isn't already being used
if (!hasTFunctionUsage) {
const koreanTextsInScript = extractKoreanFromScript(scriptContent);
if (koreanTextsInScript.length > 0) {
hasKoreanText = true;
}
}
}
// Check if there's existing i18n imports
const hasExistingImports = fileContent.includes("import { useI18n } from 'vue-i18n'") &&
fileContent.includes("const { t } = useI18n()");
// Add useI18n import and t() declaration if Korean text was found
// or if template was modified to use t() functions
if ((hasKoreanText || templateWasModified) && hasActualTFunctionUsage) {
const contentWithImports = ensureI18nImports(descriptor, updatedContent);
if (contentWithImports !== updatedContent) {
updatedContent = contentWithImports;
needsUpdate = true;
}
} else if (hasExistingImports && !hasActualTFunctionUsage) {
// If there are existing i18n imports but no actual t() usage,
// consider removing them (only when defineAsyncComponent is present to avoid false positives)
if (fileContent.includes('defineAsyncComponent') && !updatedContent.includes("{{ t(") &&
!updatedContent.includes(":") && !updatedContent.includes("t(")) {
// Remove import { useI18n } from 'vue-i18n'
updatedContent = updatedContent.replace(/import\s*{\s*useI18n\s*}\s*from\s*['"]vue-i18n['"]\s*;\s*\n?/g, '');
// Remove const { t } = useI18n();
updatedContent = updatedContent.replace(/const\s*{\s*t\s*}\s*=\s*useI18n\(\)\s*;\s*\n?/g, '');
needsUpdate = true;
}
}
// Save updates if needed
if (needsUpdate) {
fs.writeFileSync(filePath, updatedContent, 'utf-8');
console.log(`Updated file: ${filePath}`);
} else {
console.log(`No changes needed for: ${filePath}`);
}
} catch (error) {
console.error(`Error wrapping t function in ${filePath}:`, error);
if (error.message.includes('timeout')) {
console.log(`File processing timed out. Skipping file: ${filePath}`);
}
}
};
// Ensure i18n imports and t() declaration exist
const ensureI18nImports = (descriptor, fileContent) => {
try {
let updatedContent = fileContent;
// Check if template uses t() function
const templateUsesTFunction = fileContent.includes("{{ t(") ||
fileContent.includes("v-if=\"t(") ||
fileContent.includes("v-show=\"t(") ||
fileContent.includes(":") && fileContent.includes("t(");
// If template doesn't use t(), don't add imports
if (!templateUsesTFunction) {
return fileContent;
}
if (descriptor.scriptSetup) {
const scriptContent = descriptor.scriptSetup.content;
let newScriptContent = scriptContent;
// Check if useI18n is already imported
const hasI18nImport = scriptContent.includes("import { useI18n } from 'vue-i18n'") ||
scriptContent.includes('import { useI18n } from "vue-i18n"');
// Check if t function is already declared
const hasTFunction = scriptContent.includes("= useI18n()") ||
scriptContent.includes("const { t }") ||
scriptContent.includes("const {t}");
// Add useI18n import if needed
if (!hasI18nImport) {
const importStatement = "import { useI18n } from 'vue-i18n';\n";
// Find the last import statement
const importLines = scriptContent.split('\n').filter(line => line.trim().startsWith('import '));
if (importLines.length > 0) {
const lastImport = importLines[importLines.length - 1];
const lastImportPos = scriptContent.indexOf(lastImport) + lastImport.length;
newScriptContent =
scriptContent.substring(0, lastImportPos) +
'\n' + importStatement +
scriptContent.substring(lastImportPos);
} else {
// Add import at the beginning
newScriptContent = importStatement + scriptContent;
}
}
// Add t function declaration if needed
if (!hasTFunction) {
newScriptContent = newScriptContent.trim() + '\n\nconst { t } = useI18n();\n';
}
// Update script content if modified
if (newScriptContent !== scriptContent) {
updatedContent = updatedContent.replace(scriptContent, newScriptContent);
}
} else if (descriptor.script) {
// For script section without setup attribute, we need to convert to script setup
const scriptContent = descriptor.script.content;
// If script already has t() usage but imports are missing, just add the imports
const scriptLang = descriptor.script.lang || 'ts';
// Add missing imports if needed, otherwise convert to script setup
// Simplified setup script that imports useI18n and declares t
const newScriptSetup = `<script setup lang="${scriptLang}">
import { useI18n } from 'vue-i18n';
${scriptContent.replace(/export default {[\s\S]*?}/g, '')}
const { t } = useI18n();
</script>`;
// Replace the entire script section
updatedContent = updatedContent.replace(/<script[\s\S]*?<\/script>/, newScriptSetup);
} else {
// Add a new script setup section if none exists
const newScriptSetup = `<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
`;
// Add at the beginning of the file
updatedContent = newScriptSetup + updatedContent;
}
return updatedContent;
} catch (error) {
console.error('Error updating I18n imports:', error);
return fileContent;
}
};
// Main execution function
const main = () => {
const command = process.argv[2] || '';
switch (command) {
case 'extract':
console.log('Extracting Korean texts...');
const koreanTexts = extractAllKoreanTexts();
// Update locale file with extracted texts (as array)
updateLocaleFile(CONFIG.defaultLocale, koreanTexts);
console.log(`\nExtracted ${koreanTexts.length} Korean texts to locale file.`);
if (koreanTexts.length > 0) {
console.log('\nExtracted texts (sample):');
koreanTexts.slice(0, 10).forEach(text => {
console.log(`- "${text}"`);
});
}
break;
case 't-wrap':
console.log('Wrapping Korean texts with t function...');
const vueFiles = glob.sync(CONFIG.vueFiles, { ignore: CONFIG.excludePatterns });
// Sort files by size for more efficient processing
const sortedFiles = vueFiles.sort((a, b) => {
try {
const statsA = fs.statSync(a);
const statsB = fs.statSync(b);
return statsA.size - statsB.size;
} catch (e) {
return 0;
}
});
console.log(`Total files to process: ${sortedFiles.length}`);
let processCount = 0;
let skipCount = 0;
sortedFiles.forEach(filePath => {
try {
wrapWithTFunction(filePath);
processCount++;
} catch (e) {
console.error(`Error processing file ${filePath}:`, e);
skipCount++;
}
});
console.log(`Wrapping completed. Processed: ${processCount}, Skipped: ${skipCount}`);
break;
case 'clean':
console.log('Cleaning locale file...');
const localeFile = loadLocaleFile(CONFIG.defaultLocale);
const cleanedLocale = {};
Object.keys(localeFile).forEach(key => {
if (!shouldSkipText(key)) {
cleanedLocale[key] = localeFile[key];
}
});
saveLocaleFile(CONFIG.defaultLocale, cleanedLocale);
console.log(`Cleaned locale file. Removed ${Object.keys(localeFile).length - Object.keys(cleanedLocale).length} invalid entries.`);
break;
case 'clean-imports':
console.log('Cleaning unnecessary i18n imports...');
const filesToCheck = glob.sync(CONFIG.vueFiles, { ignore: CONFIG.excludePatterns });
let cleanedCount = 0;
filesToCheck.forEach(filePath => {
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { descriptor } = parse(fileContent);
// Check if file has useI18n import and t declaration
const hasI18nImport = fileContent.includes("import { useI18n } from 'vue-i18n'");
const hasTDeclaration = fileContent.includes("const { t } = useI18n()");
// Only proceed if the file has both import and declaration
if (hasI18nImport && hasTDeclaration) {
// Check if t() is actually used in the template
const hasTemplateUsage = descriptor.template && (
descriptor.template.content.includes("{{ t(") ||
descriptor.template.content.includes(" t(") ||
descriptor.template.content.includes(":") && descriptor.template.content.includes("t(")
);
// Check if t() is used in script
const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || '';
const hasScriptUsage = scriptContent.includes("t('") ||
scriptContent.includes('t("') ||
scriptContent.includes('t(`');
// If t() is not used in either template or script, remove the imports
if (!hasTemplateUsage && !hasScriptUsage) {
console.log(`Removing unnecessary i18n imports from: ${filePath}`);
// Remove import { useI18n } from 'vue-i18n'
let updatedContent = fileContent.replace(/import\s*{\s*useI18n\s*}\s*from\s*['"]vue-i18n['"]\s*;\s*\n?/g, '');
// Remove const { t } = useI18n();
updatedContent = updatedContent.replace(/const\s*{\s*t\s*}\s*=\s*useI18n\(\)\s*;\s*\n?/g, '');
// Check if content actually changed
if (updatedContent !== fileContent) {
fs.writeFileSync(filePath, updatedContent, 'utf-8');
cleanedCount++;
}
}
}
} catch (error) {
console.error(`Error cleaning imports in ${filePath}:`, error);
}
});
console.log(`Cleaned imports in ${cleanedCount} files.`);
break;
case 'test':
console.log('Running tests...');
const testCases = [
"대량 생성",
"{{ props.prefix }} 대량 생성",
"계좌 예금주 파트너사 정보 : {{ props.transaction.partnerCompanyName }}",
"`${props.title} 삭제`",
"{{ `${configDateMonthState.year}년 ${configDateMonthState.month}월` }}",
"'{{ props.request.requestName }}' 요청 건을 취소",
"안녕하세요 {{ userStore.userName }} 님",
"{{ selectedCar?.carNumber ?? \"[차량없음]\" }}",
"console.log('한글 디버그 메시지')",
"console.error('오류가 발생했습니다')",
"한글 로그 메시지",
"`${formatEnumLocale(TaskCategoryDic, item.taskCategory)} ${formatNumber(item.containerCount)}개`"
];
console.log('\nTesting shouldSkipText:');
testCases.forEach(test => {
const result = shouldSkipText(test);
console.log(`- "${test}": ${result ? 'SKIP' : 'EXTRACT'}`);
});
break;
default:
console.log(`
Vue i18n extractor - AST-based tool for internationalization
Usage:
node vue-i18n-extractor-improved.cjs [command]
Commands:
extract Extract Korean texts from source files and add to locale file
t-wrap Wrap Korean texts with t() function in Vue files
clean Clean locale file (remove invalid entries)
clean-imports Remove unnecessary i18n imports from Vue files
test Run test cases
Examples:
node vue-i18n-extractor-improved.cjs extract
node vue-i18n-extractor-improved.cjs t-wrap
node vue-i18n-extractor-improved.cjs clean-imports
`);
}
};
main();
실제 아래 명령으로 사용하게 되면
node vue-i18n-extractor-improved.cjs t-wrap
아래처럼 한글이 t로 래핑된다.
특이사항은,
eslint rule을 사용중이라 import 순서도 체크중이기 때문에 i18n import는 최하단에 되도록 작성되어 있다.
const { t } 선언 부분도 일단은 진행하기 쉽게 script 최하단에 선언되도록 되었다.
두번째로, 래핑만 되면 쓸모가 없으니 locale 파일을 추출해야한다.
t로 래핑되었지만 locale이 정의되지 않은 한글을 추출하는 스크립트는 아래와 같다.
const fs = require('fs');
const path = require('path');
const t = require('@babel/types');
const { parse } = require('@vue/compiler-sfc');
const glob = require('glob');
const minimatch = require('minimatch');
const recast = require('recast');
// 설정 정보
const CONFIG = {
vueFiles: './src/**/*.vue',
scriptFiles: './src/**/*.{ts,js}',
localeDir: './src/locales',
defaultLocale: 'ko',
excludePatterns: [
'node_modules',
'**/utils/format.{js,ts}',
"src/stories/**/*.ts",
"src/**/*.spec.ts",
"src/**/__mocks__/**/*.{js,ts}"
],
// 디버깅 모드
debug: true
};
// 기본 언어 파일 로드
const loadLocaleFile = (locale) => {
const filePath = path.join(CONFIG.localeDir, `${locale}.json`);
try {
if (fs.existsSync(filePath)) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
return {};
} catch (error) {
console.error(`Error loading locale file ${filePath}:`, error);
return {};
}
};
// 언어 파일 저장
const saveLocaleFile = (locale, data) => {
const filePath = path.join(CONFIG.localeDir, `${locale}.json`);
const content = JSON.stringify(data, null, 2);
fs.writeFileSync(filePath, content, 'utf-8');
console.log(`Updated locale file: ${filePath}`);
};
// 한글 문자열 감지 (정규식)
const containsKorean = (text) => {
return /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(text);
};
// 주석 문자열인지 확인
const isComment = (text, content, index) => {
// 현재 위치 기준으로 라인 시작까지 역방향 검색
const lineStart = content.lastIndexOf('\n', index) + 1;
const lineBeforeText = content.substring(lineStart, index).trim();
// // 로 시작하거나 * 로 시작하는 경우 (한 줄 주석 또는 여러 줄 주석의 일부)
return lineBeforeText.startsWith('//') || lineBeforeText.startsWith('*');
};
// 코드의 중요 부분인지 확인 (변수명, 함수명 등)
const isCodeIdentifier = (text) => {
// 변수 선언 패턴 (const, let, var 등)
if (/^\s*(const|let|var|function)\s+/.test(text)) return true;
// 화살표 함수, 메서드 정의 부분
if (/=>\s*{/.test(text) || /\)\s*{/.test(text)) return true;
// import, export 구문
if (/import\s+/.test(text) || /export\s+/.test(text)) return true;
return false;
};
// 머스태시 표현식을 포함하고 있는지 확인
const containsMustache = (text) => {
return /\{\{.*?\}\}/.test(text);
};
// 템플릿 리터럴(백틱)과 문자열 보간을 포함하고 있는지 확인
const containsTemplateLiteral = (text) => {
// 백틱으로 감싸진 문자열 또는 ${} 형태의 표현식 확인
return /`[^`]*?(\${[^}]*?})[^`]*?`/.test(text) || /\${.*?}/.test(text);
};
// JS 표현식과 뒤섞인 텍스트인지 확인
const isTextMixedWithCode = (text) => {
if (!text) return false;
// 머스태시 표현식이 포함된 경우 (앞, 뒤, 중간 어디든)
if (containsMustache(text)) {
LOG(`Mixed with mustache: ${text}`);
return true;
}
// 템플릿 리터럴 포함된 경우
if (containsTemplateLiteral(text)) {
LOG(`Mixed with template literal: ${text}`);
return true;
}
// 템플릿 리터럴 또는 머스태시와 일반 텍스트가 혼합된 경우
if (text.includes('{{') || text.includes('}}') || text.includes('${') || text.includes('`')) {
LOG(`Contains mustache/template markers: ${text}`);
return true;
}
// 텍스트 앞뒤에 작은/큰따옴표가 있고 중간에 mustache 표현식을 인용하는 형태
// 예: '{{ props.request.requestName }}' 요청 건을 취소
if (/['"][^'"]*?\{\{.*?\}\}[^'"]*?['"]/.test(text)) {
LOG(`Contains quoted mustache: ${text}`);
return true;
}
// JS 조건 연산자 또는 안전한 네비게이션 연산자가 포함된 경우
// 예: {{ selectedCar?.carNumber ?? "[차량없음]" }}
if (text.includes('?.') || text.includes('??') || text.includes('||') ||
text.includes('&&') || text.includes('==') || text.includes('===') ||
text.includes('!=') || text.includes('!==')) {
LOG(`Contains JS operators: ${text}`);
return true;
}
return false;
};
// 머스태시 표현식 내부에 한글이 있는지 확인 (템플릿 리터럴 내부의 텍스트는 래핑하지 않음)
const hasMustacheWithKorean = (text) => {
const mustacheMatches = text.match(/\{\{.*?\}\}/g) || [];
return mustacheMatches.some(match => {
// Mustache 표현식 내부에 조건식이나 삼항 연산자가 있는 경우
if (match.includes('?') && match.includes(':')) {
return true;
}
// Mustache 내부에 JavaScript 표현식이 있는 경우
const innerContent = match.substring(2, match.length - 2).trim();
if (isJavaScriptExpression(innerContent)) {
return true;
}
// Mustache 내부에 한글이 있는 경우
return containsKorean(match);
});
};
// 자바스크립트 표현식인지 확인하는 함수
const isJavaScriptExpression = (text) => {
// 공백 제거
const trimmed = text.trim();
// 비어있는 경우 제외
if (!trimmed) return false;
// 숫자만 있는 경우 JS 표현식으로 간주
if (/^\d+(\.\d+)?$/.test(trimmed)) return true;
// 변수나 프로퍼티 접근 패턴 (someVar, obj.prop, items[0], etc.)
if (/^[a-zA-Z0-9_$]+(\.[a-zA-Z0-9_$]+|\[[^\]]+\])*$/.test(trimmed)) return true;
// 메소드 호출 (someFunc(), obj.method())
if (/\([^)]*\)/.test(trimmed)) return true;
// 일반적인 연산자 포함 (&&, ||, +, -, *, /, >, <, >=, <=, ===, !==, ==, !=)
if (/[+\-*/%&|^<>=!]=?|>{2,3}|<{2,3}/.test(trimmed)) return true;
// 조건 연산자 (? :) 포함
if (trimmed.includes('?') && trimmed.includes(':')) return true;
// 논리 연산자 (&&, ||, !, not, and, or) 포함
if (/(\|\||&&|!)/.test(trimmed)) return true;
// 산술 표현식 (1 + 2, x * y, etc.)
if (/[+\-*/%]/.test(trimmed) && /\s+/.test(trimmed)) return true;
// 비교 연산자 (>, <, >=, <=, ===, !==, ==, !=)
if (/[<>]=?|[!=]==?/.test(trimmed)) return true;
// 할당 연산자 (=, +=, -=, *=, /=, %=)
if (/[+\-*/%]?=/.test(trimmed)) return true;
// JS 키워드 패턴 확인
const jsKeywords = [
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', 'return',
'function', 'var', 'let', 'const', 'new', 'delete', 'typeof', 'instanceof', 'void',
'this', 'super', 'class', 'extends', 'import', 'export', 'default', 'try', 'catch',
'finally', 'throw', 'async', 'await', 'true', 'false', 'null', 'undefined'
];
// 키워드가 포함되어 있는지 확인 (단어 경계 고려)
const hasKeyword = jsKeywords.some(keyword => {
const regex = new RegExp(`\\b${keyword}\\b`);
return regex.test(trimmed);
});
if (hasKeyword) return true;
// 특수 패턴: length 속성 접근 또는 일반적인 JS 메소드 호출 패턴
if (/\.length\b/.test(trimmed)) return true;
if (/\.(map|filter|find|forEach|reduce|some|every|includes|indexOf|join|split|slice|push|pop|shift|unshift)\b/.test(trimmed)) return true;
return false;
};
// 유효한 번역 키인지 확인 (한글을 포함하고 코드 패턴이 아닌 경우)
const isValidTranslationKey = (key) => {
// 키가 비어있거나 t() 함수로 이미 래핑된 경우 제외
if (!key || key.includes("t('") || key.includes('t("')) {
return false;
}
// 한글이 없는 경우 제외
if (!containsKorean(key)) {
return false;
}
// console 로그 메시지인 경우 제외
if (key.includes('console.') || key.toLowerCase().includes('로그') || key.toLowerCase().includes('디버그')) {
return false;
}
// 머스태시 표현식이나 템플릿 리터럴과 혼합된 텍스트인 경우 제외
if (isTextMixedWithCode(key)) {
return false;
}
// 코드 패턴인 경우 제외
if (isCodePattern(key)) {
return false;
}
// JavaScript 표현식인 경우 제외
if (isJavaScriptExpression(key.trim())) {
return false;
}
return true;
};
// 단어 그룹이 의미상 함께 번역되어야 하는지 검사하는 함수 추가
const shouldGroupWords = (text) => {
if (!text || !text.trim()) return false;
// 상황이나 문맥에 따라 함께 번역되어야 하는 단어 그룹 패턴
const commonPatterns = [
/[가-힣]+\s+[가-힣]+/, // 두 한글 단어 사이에 공백이 있는 경우
/[가-힣]+\s+여부/, // '~~ 여부'와 같은 패턴
/[가-힣]+\s+상태/, // '~~ 상태'와 같은 패턴
/[가-힣]+\s+정보/, // '~~ 정보'와 같은 패턴
/[가-힣]+\s+목록/, // '~~ 목록'과 같은 패턴
/[가-힣]+\s+[가-힣]+\s+[가-힣]+/, // 세 단어로 이루어진 구
/[가-힣]+\s+관련\s+[가-힣]+/, // '~~ 관련 ~~'와 같은 패턴
];
return commonPatterns.some(pattern => pattern.test(text));
};
// 코드 패턴인지 확인하는 함수 (정규식, 이스케이프 시퀀스 등)
const isCodePattern = (text) => {
if (!text) return false;
// 'escapeRegExp' 함수나 escapeRegExp 호출을 포함하는 경우
if (text.includes('escapeRegExp') || text.includes('escape') && text.includes('Reg')) {
return true;
}
// 정규식 리터럴이 포함된 문자열
if (text.includes('/[') && text.includes(']/g')) {
return true;
}
// 이스케이프 문자가 많이 포함된 문자열 (특히 \\와 함께 있는 경우)
if ((text.match(/\\/g) || []).length >= 2) {
return true;
}
// 함수 내부에 정의된 정규식 패턴을 감지
if (/function\s+\w+\s*\([^)]*\)\s*{[\s\S]*?\/[\s\S]*?\/[gimuy]?[\s\S]*?}/.test(text)) {
return true;
}
// '\\$&'와 같은 특수한 정규식 대체 패턴 감지
if (text.includes('\\$&') || text.includes('\\$1') || text.includes('\\$2')) {
return true;
}
// 정규식 패턴이나 이스케이프 문자가 포함된 경우
if (text.includes('\\') && (
text.includes('[') ||
text.includes(']') ||
text.includes('/') ||
text.includes('*') ||
text.includes('+') ||
text.includes('?') ||
text.includes('^') ||
text.includes('$')
)) {
return true;
}
// 정규식 리터럴과 비슷한 패턴
if (/\/[^\s\/]+\/[gimuy]*/.test(text)) {
return true;
}
// 이스케이프 시퀀스가 포함된 경우
if (/\\[bfnrtv0'"]/.test(text) || /\\u[0-9a-fA-F]{4}/.test(text) || /\\x[0-9a-fA-F]{2}/.test(text)) {
return true;
}
return false;
};
// 스크립트를 전처리하여 정규식 처리 함수나 특수 코드 블록을 식별하고 제외
const preprocessScript = (content) => {
// 정규식 관련 함수나 코드 블록을 식별하기 위한 패턴
const problemPatterns = [
// escapeRegExp와 같은 정규식 처리 함수 전체 블록 감지
/function\s+\w*[eE]scape\w*\([^)]*\)\s*{[\s\S]*?return[\s\S]*?replace\([^)]*\\[^)]*\)[\s\S]*?}/g,
// 'escapeRegExp' 함수 전체와 그 주변 코드
/function\s+escapeRegExp\s*\([^)]*\)\s*{[\s\S]*?\/\[.*\\\]\/g[\s\S]*?}/g,
// replace 메서드와 정규식 패턴이 함께 사용된 블록
/\.replace\(\s*\/[^/]*\\[^/]*\/[gimuy]*\s*,\s*(['"`])\\[^'"`]*\1\s*\)/g,
// replace 메서드와 new RegExp가 함께 사용된 블록
/\.replace\(\s*new RegExp\([^)]*\\[^)]*\)[^)]*\)/g,
// 정규식 리터럴 자체를 포함하는 코드 줄
/.*\/\[[^\]]*\][^\n]*\/g.*\n/g,
// escape 문자열을 포함하는 변수 선언문
/const\s+\w+\s*=\s*.*escape.*\n/g
];
// 문제가 될 수 있는 패턴들을 식별하여 임시 마킹
let processedContent = content;
let markIndex = 0;
const markers = [];
// 각 문제 패턴에 대해 처리
problemPatterns.forEach(pattern => {
processedContent = processedContent.replace(pattern, (match) => {
const marker = `__CODEBLOCK_MARKER_${markIndex++}__`;
markers.push({ marker, code: match });
return marker;
});
});
return { processedContent, markers };
};
// 마킹된 코드 블록 복원
const restoreMarkedCode = (content, markers) => {
let restoredContent = content;
markers.forEach(({ marker, code }) => {
restoredContent = restoredContent.replace(marker, code);
});
return restoredContent;
};
// 속성의 한글 값 처리를 위한 개선된 함수
const processAttribute = (attr, value, quote) => {
// 바인딩이 필요하지 않는 속성 목록
const nonBindingAttributes = [
'class', 'style', 'id', 'name', 'type', 'key', 'ref', 'v-for', 'v-if', 'v-else', 'v-else-if',
'v-show', 'v-model', 'v-slot', 'v-once', 'v-pre', 'v-cloak', 'v-html', 'v-text'
];
// UI 관련 속성 (항상 바인딩 처리)
const uiAttributes = [
'label', 'placeholder', 'title', 'alt', 'aria-label', 'tooltip', 'hint', 'caption', 'description',
'text', 'message', 'error', 'warning', 'success', 'info', 'help', 'instructions'
];
// JavaScript 함수 표현식 확인 (화살표 함수, 일반 함수 등)
if (
value.includes('=>') ||
value.includes('function(') ||
value.match(/\(\s*\)\s*\{/) ||
value.match(/\([^)]*\)\s*=>/) || // 매개변수가 있는 화살표 함수 (param) =>
value.includes('callback') || // callback 관련 속성은 일반적으로 함수
attr.toLowerCase().includes('callback') || // callback이 포함된 속성명
attr.toLowerCase().includes('handler') || // handler가 포함된 속성명 (이벤트 핸들러)
attr.toLowerCase().includes('on-') || // Vue 이벤트 핸들러
isJavaScriptExpression(value) || // 자바스크립트 표현식 감지
(attr.startsWith(':') && value.includes('{') && value.includes('}')) // 객체 리터럴 바인딩
) {
return `${attr}=${quote}${value}${quote}`;
}
// UI 관련 속성이거나 nonBindingAttributes가 아닌 경우 바인딩 추가
if (uiAttributes.includes(attr) || !nonBindingAttributes.includes(attr)) {
return `:${attr}=${quote}t('${value}')${quote}`;
} else {
// 바인딩이 필요없는 속성은 그대로 유지
return `${attr}=${quote}${value}${quote}`;
}
};
// 템플릿에서 한글 텍스트 추출
const extractKoreanFromTemplate = (template) => {
const koreanTexts = [];
// 주석 제외
const templateWithoutComments = template.replace(/<!--[\s\S]*?-->/g, '');
// 머스태시 표현식과 같은 줄에 있는 텍스트는 모두 제외 (추출 대상에서 제외)
const nonMustacheLines = templateWithoutComments
.split('\n')
.filter(line => !line.includes('{{') && !line.includes('}}') && !line.includes('`'));
const cleanedTemplate = nonMustacheLines.join('\n');
// 정규식으로 텍스트 노드 추출
const textNodeRegex = />([^<]+)</g;
let match;
while ((match = textNodeRegex.exec(cleanedTemplate)) !== null) {
const text = match[1].trim();
// 머스태시 표현식이나 템플릿 리터럴이 포함된 경우 제외
if (text && !isTextMixedWithCode(text) && containsKorean(text) && isValidTranslationKey(text)) {
koreanTexts.push(text);
}
}
// 속성값에서 한글 추출 (t() 함수로 이미 래핑된 것은 제외)
const attrRegex = /(\w+)=["']([^"']+)["']/g;
while ((match = attrRegex.exec(cleanedTemplate)) !== null) {
const attrValue = match[2];
// 이미 t 함수로 래핑된 경우, 백틱이나 머스태시 표현식이 포함된 경우 제외
if (attrValue.startsWith("t('") || attrValue.startsWith('t("') || isTextMixedWithCode(attrValue)) {
continue;
}
if (containsKorean(attrValue) && isValidTranslationKey(attrValue)) {
koreanTexts.push(attrValue);
}
}
return koreanTexts;
};
// Script에서 한글 문자열 추출
const extractKoreanFromScript = (scriptContent) => {
const koreanTexts = [];
try {
// 스크립트 전처리하여 문제될 수 있는 패턴 마킹
const { processedContent, markers } = preprocessScript(scriptContent);
// 주석 제거 (한 줄 주석)
const noSingleLineComments = processedContent.replace(/\/\/.*$/gm, '');
// 여러 줄 주석 제거
const noComments = noSingleLineComments.replace(/\/\*[\s\S]*?\*\//g, '');
// console 관련 코드 제거 (console.log, console.error, console.warn 등)
const noConsoleStatements = noComments.replace(/console\.(log|error|warn|info|debug|trace)\s*\(\s*(['"`])((?:\\\2|(?!\2).)*?[가-힣]+(?:\\\2|(?!\2).)*?)\2\s*(?:,|\))/g, '');
// 정규식 리터럴 패턴을 찾아 미리 제거 (문자열 검색 전에)
const noRegexPatterns = noConsoleStatements.replace(/\/([^\/\n]+)\/[gimuy]*/g, '');
// 특수한 정규식 패턴을 추가로 필터링
const additionalFilters = [
// escapeRegExp 함수나 정규식 관련 코드 라인 필터링
/.*escape.*reg.*\n/gi,
/.*reg.*exp.*\n/gi,
/.*\[.*\\.*\].*\n/g,
/.*\\\\.*\n/g,
/.*\/\[.*\]\/.*\n/g,
// console 구문 전체 라인 필터링 (추가적인 안전장치)
/.*console\.(log|error|warn|info|debug|trace).*\n/g
];
let filteredContent = noRegexPatterns;
for (const pattern of additionalFilters) {
filteredContent = filteredContent.replace(pattern, '');
}
// 템플릿 리터럴을 미리 제거 (백틱으로 감싸진 문자열)
filteredContent = filteredContent.replace(/`[^`]*`/g, '');
// 문자열 리터럴 추출 정규식 - 백틱 문자열은 이미 제거했으므로 ' 와 " 만 검사
const stringLiteralRegex = /(['"])((?:\\\1|(?!\1).)*)\1/g;
let match;
while ((match = stringLiteralRegex.exec(filteredContent)) !== null) {
const quoteType = match[1]; // 따옴표 종류 (', ")
const text = match[2]; // 따옴표 안의 텍스트
// console 구문 내부의 텍스트인지 추가 확인
const matchPosition = match.index;
const lineStart = filteredContent.lastIndexOf('\n', matchPosition) + 1;
const lineEnd = filteredContent.indexOf('\n', matchPosition);
const line = filteredContent.substring(lineStart, lineEnd !== -1 ? lineEnd : filteredContent.length);
// 해당 라인에 console이 포함되어 있으면 스킵
if (line.includes('console.')) {
continue;
}
// 머스태시 표현식이나 템플릿 리터럴이 포함된 경우 제외
if (isTextMixedWithCode(text)) continue;
// 정규식 관련 문자열인지 추가 검사
if (text.includes('\\') || text.includes('[') && text.includes(']')) {
continue;
}
// 코드와 관련된 문자열인지 추가 검사
if (containsKorean(text) && isValidTranslationKey(text) && !isCodePattern(text)) {
koreanTexts.push(text);
}
}
} catch (error) {
console.error('Error extracting Korean from script:', error);
}
return koreanTexts;
};
// Vue SFC 파일에서 한글 텍스트 추출
const extractKoreanFromVueFile = (filePath) => {
const koreanTexts = [];
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { descriptor } = parse(fileContent);
// 템플릿에서 추출
if (descriptor.template) {
const templateContent = descriptor.template.content;
koreanTexts.push(...extractKoreanFromTemplate(templateContent));
}
// 스크립트에서 추출
if (descriptor.script || descriptor.scriptSetup) {
const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || '';
koreanTexts.push(...extractKoreanFromScript(scriptContent));
}
} catch (error) {
console.error(`Error processing Vue file ${filePath}:`, error);
}
return koreanTexts;
};
// JS/TS 파일에서 한글 텍스트 추출
const extractKoreanFromScriptFile = (filePath) => {
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
return extractKoreanFromScript(fileContent);
} catch (error) {
console.error(`Error processing script file ${filePath}:`, error);
return [];
}
};
// 추출 메인 함수
const extractAllKoreanTexts = () => {
const koreanTexts = new Set();
// Vue 파일에서 추출
const vueFiles = glob.sync(CONFIG.vueFiles, { ignore: CONFIG.excludePatterns });
vueFiles.forEach(filePath => {
const texts = extractKoreanFromVueFile(filePath);
texts.forEach(text => koreanTexts.add(text));
});
// JS/TS 파일에서 추출
const scriptFiles = glob.sync(CONFIG.scriptFiles, { ignore: CONFIG.excludePatterns });
scriptFiles.forEach(filePath => {
const texts = extractKoreanFromScriptFile(filePath);
texts.forEach(text => koreanTexts.add(text));
});
return Array.from(koreanTexts);
};
// 로케일 파일 업데이트
const updateLocaleFile = (locale, koreanTexts) => {
const localeData = loadLocaleFile(locale);
let updated = false;
koreanTexts.forEach(text => {
if (!localeData[text]) {
localeData[text] = text; // 기본적으로 같은 값으로 설정
updated = true;
}
});
if (updated) {
saveLocaleFile(locale, localeData);
} else {
console.log('No new texts found to update.');
}
};
/**
* 파일 복잡도를 분석하여 처리 가능 여부를 판단하는 함수
* @param {string} filePath - 분석할 파일 경로
* @param {string} fileContent - 파일 내용
* @returns {Object} 분석 결과 (점수와 처리 가능 여부)
*/
const analyzeFileComplexity = (filePath, fileContent, descriptor) => {
if (!CONFIG.autoDetection.enabled) {
return { score: 0, canProcess: true, reason: 'Auto detection disabled' };
}
// 파일명 추출
const fileName = path.basename(filePath);
let complexityScore = 0;
const reasons = [];
// 1. 템플릿 크기 체크
if (descriptor.template) {
const templateSize = descriptor.template.content.length;
if (templateSize > CONFIG.autoDetection.maxTemplateSize) {
complexityScore += 30;
reasons.push(`Large template (${templateSize} chars)`);
}
// 2. 컴포넌트 수 체크
const componentCount = (descriptor.template.content.match(/<[A-Z][a-zA-Z0-9]*\s/g) || []).length;
if (componentCount > CONFIG.autoDetection.maxComponentsPerFile) {
complexityScore += 20;
reasons.push(`Many components (${componentCount})`);
}
// 3. 중첩 레벨 체크 (HTML 태그 중첩 분석)
let maxNesting = 0;
let currentNesting = 0;
const lines = descriptor.template.content.split('\n');
for (const line of lines) {
// 여는 태그 카운트
const openTags = (line.match(/<[a-zA-Z][^/>]*>/g) || []).length;
// 닫는 태그 카운트
const closeTags = (line.match(/<\/[a-zA-Z][^>]*>/g) || []).length;
currentNesting += (openTags - closeTags);
maxNesting = Math.max(maxNesting, currentNesting);
}
if (maxNesting > CONFIG.autoDetection.maxNestingLevel) {
complexityScore += 15;
reasons.push(`Deep nesting (${maxNesting} levels)`);
}
// 4. 복잡한 vue 디렉티브 사용 체크
const directiveCount = (descriptor.template.content.match(/v-(if|else|else-if|for|show|bind|model|on|slot)/g) || []).length;
if (directiveCount > 20) {
complexityScore += 15;
reasons.push(`Many directives (${directiveCount})`);
}
// 5. 머스태시 표현식 복잡도 체크
const mustacheExpressions = descriptor.template.content.match(/\{\{[^}]+\}\}/g) || [];
let complexMustacheCount = 0;
for (const expr of mustacheExpressions) {
// 조건식, 삼항 연산자, 함수 호출 등이 포함된 복잡한 표현식 감지
if (expr.includes('?') || expr.includes(':') || expr.includes('(') || expr.includes('[')) {
complexMustacheCount++;
}
}
if (complexMustacheCount > 10) {
complexityScore += 10;
reasons.push(`Complex expressions (${complexMustacheCount})`);
}
// 6. 한글과 머스태시가 섞인 문자열 패턴 감지
const mixedKoreanMustache = (descriptor.template.content.match(/[가-힣]+[^<>]*?\{\{|\}\}[^<>]*?[가-힣]+/g) || []).length;
if (mixedKoreanMustache > 5) {
complexityScore += 15;
reasons.push(`Mixed Korean-Mustache patterns (${mixedKoreanMustache})`);
}
}
// 7. 스크립트 복잡도 체크
const scriptContent = descriptor.script?.content || descriptor.scriptSetup?.content || '';
if (scriptContent) {
// 복잡한 함수 정의가 많은 경우
const functionCount = (scriptContent.match(/function\s+\w+\s*\(|\)\s*=>\s*{/g) || []).length;
if (functionCount > 15) {
complexityScore += 10;
reasons.push(`Many functions (${functionCount})`);
}
}
// 파일 패턴 분석 (Member, Contract 등 특정 단어가 포함된 파일들은 더 복잡한 경향)
const complexPatterns = ['Member', 'Contract', 'Annual', 'Collector', 'Edit', 'Detail', 'Dashboard'];
for (const pattern of complexPatterns) {
if (fileName.includes(pattern)) {
complexityScore += 5;
reasons.push(`Complex file pattern (${pattern})`);
break;
}
}
const canProcess = complexityScore < CONFIG.autoDetection.complexityThreshold;
return {
score: complexityScore,
canProcess,
reasons
};
};
// Vue 파일에 t 함수 래핑 적용
const wrapWithTFunction = (filePath) => {
console.log(`Processing: ${filePath}`);
// 수정 제외 대상인지 확인
const isExcluded = CONFIG.excludeFromWrapping && CONFIG.excludeFromWrapping.some(pattern => {
return minimatch(filePath, pattern);
});
if (isExcluded) {
console.log(`Skipping file (excluded from wrapping): ${filePath}`);
return;
}
try {
// 타임아웃 설정
const startTime = Date.now();
const checkTimeout = () => {
if (Date.now() - startTime > CONFIG.processingTimeout) {
throw new Error(`Processing timeout for file: ${filePath}`);
}
};
const fileContent = fs.readFileSync(filePath, 'utf-8');
// 파일 내용에 정규식 리터럴이나 escapeRegExp 함수가 포함된 경우 스킵
if (fileContent.includes('escapeRegExp') ||
fileContent.includes('/[') && fileContent.includes(']/g') ||
fileContent.match(/function\s+\w*[eE]scape\w*\s*\(/) ||
(fileContent.match(/\\\\/g) || []).length > 5) {
console.log(`Skipping file with regex patterns: ${filePath}`);
return;
}
const { descriptor } = parse(fileContent);
// 파일 복잡도 분석하여 처리 가능 여부 판단
const complexity = analyzeFileComplexity(filePath, fileContent, descriptor);
if (!complexity.canProcess) {
console.log(`Skipping complex file (score: ${complexity.score}): ${filePath}`);
console.log(`Reasons: ${complexity.reasons.join(', ')}`);
return;
}
let updatedContent = fileContent;
let needsUpdate = false;
let hasKoreanText = false; // 실제 변환된 한글이 있는지 추적
// 템플릿 처리
if (descriptor.template) {
const templateContent = descriptor.template.content;
// 템플릿 크기 체크
if (CONFIG.debug) {
console.log(`Template size: ${templateContent.length} characters`);
}
let modifiedTemplateContent = templateContent;
// Vue 컴포넌트의 바인딩 속성 보호 (특히 함수 표현식)
const protectVueBindings = (content) => {
const bindingMarkers = [];
let markerIndex = 0;
// 1. 바인딩 속성(:로 시작하는)의 값 보호
const processedContent = content.replace(
/(:[a-zA-Z0-9\-_]+|v-bind:[a-zA-Z0-9\-_]+)=(["'])([^"']*?)\2/g,
(match, attr, quote, value) => {
// 콜백이나 함수 표현식인 경우만 마킹
if (value.includes('=>') ||
value.includes('function') ||
value.match(/\(\s*\)\s*\{/) ||
value.includes('{') && value.includes('}') ||
attr.toLowerCase().includes('callback') ||
attr.toLowerCase().includes('handler')) {
const marker = `__VUE_BINDING_${markerIndex++}__`;
bindingMarkers.push({ marker, original: match });
return marker;
}
return match;
}
);
return { processedContent, bindingMarkers };
};
// 마킹된 바인딩 복원
const restoreVueBindings = (content, markers) => {
let result = content;
markers.forEach(({ marker, original }) => {
result = result.replace(marker, original);
});
return result;
};
// Vue 바인딩 보호 (특히 함수 표현식)
const { processedContent: protectedTemplate, bindingMarkers } = protectVueBindings(modifiedTemplateContent);
modifiedTemplateContent = protectedTemplate;
// HTML 주석 처리를 위한 별도 함수 (주석은 건드리지 않음)
const preserveComments = (content) => {
const commentMarkers = [];
let markerIndex = 0;
// 주석을 마커로 대체
const withoutComments = content.replace(/<!--[\s\S]*?-->/g, (comment) => {
const marker = `__COMMENT_MARKER_${markerIndex++}__`;
commentMarkers.push({ marker, comment });
return marker;
});
return { withoutComments, commentMarkers };
};
// 마커를 다시 주석으로 복원
const restoreComments = (content, commentMarkers) => {
let result = content;
commentMarkers.forEach(({ marker, comment }) => {
result = result.replace(marker, comment);
});
return result;
};
// 주석 처리 - 모든 작업 전에 주석을 마커로 치환
const { withoutComments, commentMarkers } = preserveComments(modifiedTemplateContent);
modifiedTemplateContent = withoutComments;
try {
// 1. 템플릿 안의 한글 텍스트 처리 (>) 태그 내부의 텍스트
const koreanRegex = /(\s*>)([^<]*[가-힣]+[^<]*)(<\s*)/g;
modifiedTemplateContent = modifiedTemplateContent.replace(koreanRegex, (match, prefix, text, suffix) => {
checkTimeout(); // 타임아웃 체크
// 이미 {{ t('...') }} 형태로 래핑된 경우 제외
if (text.includes('{{') && text.includes('}}') && text.includes("t('")) {
return match;
}
// 코드 패턴이면 제외
if (isCodePattern(text)) {
return match;
}
// Mustache 표현식이 있는 경우 제외
if (containsMustache(text)) {
return match;
}
// 자바스크립트 표현식인 경우 제외
if (isJavaScriptExpression(text.trim())) {
return match;
}
// 한글을 포함하고 텍스트가 존재하는 경우 처리
if (text.trim() && containsKorean(text.trim())) {
// 괄호, 쉼표 등의 특수문자로 구분된 텍스트 블록 추출
const textBlocks = text.split(/([(),;:])/g).filter(block => block.trim());
let processedText = text;
let modified = false;
// 각 텍스트 블록에 대해 처리
for (let i = 0; i < textBlocks.length; i++) {
const block = textBlocks[i].trim();
// 한글이 포함된 블록만 처리
if (block && containsKorean(block) && isValidTranslationKey(block)) {
// 복합 단어 그룹 (공백으로 구분된 단어들) 처리
if (shouldGroupWords(block)) {
hasKoreanText = true;
modified = true;
// 원본 텍스트에서 해당 블록의 위치 찾기
const blockStart = processedText.indexOf(block);
if (blockStart !== -1) {
// 블록을 t 함수로 래핑
processedText =
processedText.substring(0, blockStart) +
`{{ t('${block}') }}` +
processedText.substring(blockStart + block.length);
}
}
// 단일 한글 단어 처리
else if (containsKorean(block)) {
// 추가 정규식 검사를 통해 한글 부분만 추출
const koreanPattern = /([^가-힣\s]*)([가-힣\s]+)([^가-힣\s]*)/g;
let koreanMatch;
while ((koreanMatch = koreanPattern.exec(block)) !== null) {
const [fullMatch, prefix, koreanText, suffix] = koreanMatch;
if (koreanText && koreanText.trim() && isValidTranslationKey(koreanText.trim())) {
hasKoreanText = true;
modified = true;
// 원본 텍스트에서 해당 한글 텍스트의 위치 찾기
const fullBlock = prefix + koreanText + suffix;
const blockStart = processedText.indexOf(fullBlock);
const koreanStart = blockStart + prefix.length;
if (blockStart !== -1) {
// 한글 부분만 t 함수로 래핑
processedText =
processedText.substring(0, koreanStart) +
`{{ t('${koreanText.trim()}') }}` +
processedText.substring(koreanStart + koreanText.length);
}
}
}
}
}
}
if (modified) {
return `${prefix}${processedText}${suffix}`;
}
// 이전 방식으로 처리 - 전체 문자열이 하나의 번역 키로 적합한 경우
if (isValidTranslationKey(text.trim())) {
hasKoreanText = true;
// 원본 텍스트의 공백과 줄바꿈 패턴 보존
const leadingSpaces = text.match(/^(\s*)/)[0];
const trailingSpaces = text.match(/(\s*)$/)[0];
return `${prefix}${leadingSpaces}{{ t('${text.trim()}') }}${trailingSpaces}${suffix}`;
}
}
return match;
});
checkTimeout(); // 단계별 타임아웃 체크
} catch (e) {
console.log(`Error in koreanRegex pattern for file ${filePath}: ${e.message}`);
// 오류 발생 시 원본 내용 유지
}
try {
// 2. 일반 속성의 한글 값 처리
const attributeRegex = /(\s+)([a-zA-Z0-9\-_:\.]+)=(["'])([^"']*[가-힣]+[^"']*)\3/g;
modifiedTemplateContent = modifiedTemplateContent.replace(attributeRegex, (match, spacing, attr, quote, value) => {
checkTimeout(); // 타임아웃 체크
// 이미 t('...') 형태로 래핑된 경우 제외
if (value.startsWith("t('") || value.startsWith('t("')) {
return match;
}
// JavaScript 함수 표현식 확인 (화살표 함수, 일반 함수 등)
if (
value.includes('=>') ||
value.includes('function(') ||
value.match(/\(\s*\)\s*\{/) ||
value.match(/\([^)]*\)\s*=>/) || // 매개변수가 있는 화살표 함수 (param) =>
value.includes('callback') || // callback 관련 속성은 일반적으로 함수
attr.toLowerCase().includes('callback') || // callback이 포함된 속성명
attr.toLowerCase().includes('handler') || // handler가 포함된 속성명 (이벤트 핸들러)
attr.toLowerCase().includes('on-') || // Vue 이벤트 핸들러
isJavaScriptExpression(value) || // 자바스크립트 표현식 감지
(attr.startsWith(':') && value.includes('{') && value.includes('}')) // 객체 리터럴 바인딩
) {
return match;
}
// 코드 패턴이면 제외
if (isCodePattern(value)) {
return match;
}
// Mustache 표현식이 있는 경우 제외
if (containsMustache(value)) {
return match;
}
// 한글을 포함하고 유효한 번역 키인 경우 래핑
if (containsKorean(value)) {
// 괄호, 쉼표 등의 특수문자로 구분된 텍스트 블록 추출
const textBlocks = value.split(/([(),;:])/g).filter(block => block.trim());
const processedValue = value;
let modified = false;
let translationText = value; // 기본값으로 전체 문자열 사용
// 단일 블록이고 shouldGroupWords 패턴에 맞는 경우
if (textBlocks.length === 1 && shouldGroupWords(value)) {
if (isValidTranslationKey(value)) {
hasKoreanText = true;
// 그대로 전체를 하나의 번역 키로 처리
if (attr.startsWith(':') || attr.startsWith('v-bind:')) {
const actualAttr = attr.replace(/^(:|v-bind:)/, '');
return `${spacing}${attr}=${quote}t('${value}')${quote}`;
} else {
// 일반 속성 - 바인딩 추가 여부 결정
return `${spacing}${processAttribute(attr, value, quote)}`;
}
}
}
// 여러 블록이 있는 경우, 각 블록 처리
if (textBlocks.length > 1) {
const koreanBlocks = [];
// 각 텍스트 블록에 대해 처리
for (let i = 0; i < textBlocks.length; i++) {
const block = textBlocks[i].trim();
// 한글이 포함된 블록만 처리
if (block && containsKorean(block) && isValidTranslationKey(block)) {
koreanBlocks.push(block);
// 블록이 "세척 여부"와 같은 단어 그룹인 경우
if (shouldGroupWords(block)) {
translationText = block; // 이 블록을 번역 키로 사용
break; // 가장 먼저 발견된 의미 있는 단어 그룹을 사용
}
}
}
// 단일 한글 블록이 발견된 경우
if (koreanBlocks.length === 1 && isValidTranslationKey(koreanBlocks[0])) {
translationText = koreanBlocks[0];
modified = true;
}
// 여러 한글 블록이 발견된 경우 - 전체 텍스트 사용하되 주석 추가
else if (koreanBlocks.length > 1) {
// 속성 값 전체가 번역 키로 적합한 경우
if (isValidTranslationKey(value)) {
hasKoreanText = true;
if (attr.startsWith(':') || attr.startsWith('v-bind:')) {
return `${spacing}${attr}=${quote}t('${value}') /* TO-CHECK: 복합 번역키 */${quote}`;
} else {
return `${spacing}:${attr}=${quote}t('${value}') /* TO-CHECK: 복합 번역키 */${quote}`;
}
}
return match; // 적합한 번역 키가 없으면 그대로 유지
}
}
// 최종적으로 번역 키가 유효한 경우 처리
if (translationText && isValidTranslationKey(translationText)) {
hasKoreanText = true;
// 이미 바인딩된 속성인지 확인
if (attr.startsWith(':') || attr.startsWith('v-bind:')) {
const actualAttr = attr.replace(/^(:|v-bind:)/, '');
return `${spacing}${attr}=${quote}t('${translationText}')${quote}`;
} else {
// 일반 속성 - 바인딩 추가 여부 결정
return `${spacing}${processAttribute(attr, translationText, quote)}`;
}
}
}
return match;
});
checkTimeout(); // 단계별 타임아웃 체크
} catch (e) {
console.log(`Error in attributeRegex pattern for file ${filePath}: ${e.message}`);
// 오류 발생 시 원본 내용 유지
}
try {
// 3. 혼합된 텍스트 패턴 처리 (머스태시 표현식 전후)
const mixedTextRegex = /(\s*>)([^<]*?[가-힣]+[^{<]*?)(\{\{[^}]+\}\})([^<]*?)(<\s*)/g;
modifiedTemplateContent = modifiedTemplateContent.replace(mixedTextRegex, (match, prefix, beforeMustache, mustache, afterMustache, suffix) => {
checkTimeout(); // 타임아웃 체크
// 머스태시 표현식 내부를 확인
const mustacheContent = mustache.substring(2, mustache.length - 2).trim();
// 머스태시 내용이 이미 t 함수를 포함하거나 JavaScript 표현식인 경우 제외
if (mustacheContent.includes("t(") || isJavaScriptExpression(mustacheContent)) {
return match;
}
// 전체 표현식이 JavaScript 코드인 경우 제외
const fullText = (beforeMustache + mustache + afterMustache).trim();
if (isJavaScriptExpression(fullText)) {
return match;
}
let result = match;
let changed = false;
// 머스태시 표현식 앞에 한글이 있는 경우
if (beforeMustache.trim() && containsKorean(beforeMustache)) {
// 괄호, 쉼표 등의 특수문자로 구분된 텍스트 블록 추출
const textBlocks = beforeMustache.split(/([(),;:])/g).filter(block => block.trim());
let processedBeforeMustache = beforeMustache;
let modified = false;
// 각 텍스트 블록에 대해 처리
for (let i = 0; i < textBlocks.length; i++) {
const block = textBlocks[i].trim();
// 한글이 포함된 블록만 처리
if (block && containsKorean(block) && isValidTranslationKey(block)) {
// 복합 단어 그룹 (공백으로 구분된 단어들) 처리
if (shouldGroupWords(block)) {
hasKoreanText = true;
modified = true;
changed = true;
// 원본 텍스트에서 해당 블록의 위치 찾기
const blockStart = processedBeforeMustache.indexOf(block);
if (blockStart !== -1) {
// 블록을 t 함수로 래핑
processedBeforeMustache =
processedBeforeMustache.substring(0, blockStart) +
`{{ t('${block}') }}` +
processedBeforeMustache.substring(blockStart + block.length);
}
}
// 단일 한글 단어 처리
else if (containsKorean(block)) {
// 추가 정규식 검사를 통해 한글 부분만 추출
const koreanPattern = /([^가-힣\s]*)([가-힣\s]+)([^가-힣\s]*)/g;
let koreanMatch;
while ((koreanMatch = koreanPattern.exec(block)) !== null) {
const [fullMatch, prefix, koreanText, suffix] = koreanMatch;
if (koreanText && koreanText.trim() && isValidTranslationKey(koreanText.trim())) {
hasKoreanText = true;
modified = true;
changed = true;
// 원본 텍스트에서 해당 한글 텍스트의 위치 찾기
const fullBlock = prefix + koreanText + suffix;
const blockStart = processedBeforeMustache.indexOf(fullBlock);
const koreanStart = blockStart + prefix.length;
if (blockStart !== -1) {
// 한글 부분만 t 함수로 래핑
processedBeforeMustache =
processedBeforeMustache.substring(0, koreanStart) +
`{{ t('${koreanText.trim()}') }}` +
processedBeforeMustache.substring(koreanStart + koreanText.length);
}
}
}
}
}
}
if (modified) {
result = `${prefix}${processedBeforeMustache}${mustache}`;
} else if (isValidTranslationKey(beforeMustache.trim())) {
// 전체 문자열이 하나의 번역 키로 적합한 경우
hasKoreanText = true;
changed = true;
// 원본 텍스트의 공백 패턴 보존
const leadingSpaces = beforeMustache.match(/^(\s*)/)[0];
result = `${prefix}${leadingSpaces}{{ t('${beforeMustache.trim()}') }}${mustache}`;
} else {
result = `${prefix}${beforeMustache}${mustache}`;
}
} else {
result = `${prefix}${beforeMustache}${mustache}`;
}
// 머스태시 표현식 뒤에 한글이 있는 경우도 동일한 방식으로 처리
if (afterMustache.trim() && containsKorean(afterMustache)) {
// 괄호, 쉼표 등의 특수문자로 구분된 텍스트 블록 추출
const textBlocks = afterMustache.split(/([(),;:])/g).filter(block => block.trim());
let processedAfterMustache = afterMustache;
let modified = false;
// 각 텍스트 블록에 대해 처리
for (let i = 0; i < textBlocks.length; i++) {
const block = textBlocks[i].trim();
// 한글이 포함된 블록만 처리
if (block && containsKorean(block) && isValidTranslationKey(block)) {
// 복합 단어 그룹 (공백으로 구분된 단어들) 처리
if (shouldGroupWords(block)) {
hasKoreanText = true;
modified = true;
changed = true;
// 원본 텍스트에서 해당 블록의 위치 찾기
const blockStart = processedAfterMustache.indexOf(block);
if (blockStart !== -1) {
// 블록을 t 함수로 래핑
processedAfterMustache =
processedAfterMustache.substring(0, blockStart) +
`{{ t('${block}') }}` +
processedAfterMustache.substring(blockStart + block.length);
}
}
// 단일 한글 단어 처리
else if (containsKorean(block)) {
// 추가 정규식 검사를 통해 한글 부분만 추출
const koreanPattern = /([^가-힣\s]*)([가-힣\s]+)([^가-힣\s]*)/g;
let koreanMatch;
while ((koreanMatch = koreanPattern.exec(block)) !== null) {
const [fullMatch, prefix, koreanText, suffix] = koreanMatch;
if (koreanText && koreanText.trim() && isValidTranslationKey(koreanText.trim())) {
hasKoreanText = true;
modified = true;
changed = true;
// 원본 텍스트에서 해당 한글 텍스트의 위치 찾기
const fullBlock = prefix + koreanText + suffix;
const blockStart = processedAfterMustache.indexOf(fullBlock);
const koreanStart = blockStart + prefix.length;
if (blockStart !== -1) {
// 한글 부분만 t 함수로 래핑
processedAfterMustache =
processedAfterMustache.substring(0, koreanStart) +
`{{ t('${koreanText.trim()}') }}` +
processedAfterMustache.substring(koreanStart + koreanText.length);
}
}
}
}
}
}
if (modified) {
result += `${processedAfterMustache}${suffix}`;
} else if (isValidTranslationKey(afterMustache.trim())) {
// 전체 문자열이 하나의 번역 키로 적합한 경우
hasKoreanText = true;
changed = true;
// 원본 텍스트의 공백 패턴 보존
const trailingSpaces = afterMustache.match(/(\s*)$/)[0];
result += `{{ t('${afterMustache.trim()}') }}${trailingSpaces}${suffix}`;
} else {
result += `${afterMustache}${suffix}`;
}
} else {
result += `${afterMustache}${suffix}`;
}
return changed ? result : match;
});
checkTimeout(); // 단계별 타임아웃 체크
} catch (e) {
console.log(`Error in mixedTextRegex pattern for file ${filePath}: ${e.message}`);
// 오류 발생 시 원본 내용 유지
}
try {
// 4. 속성 내 머스태시 표현식 주변의 한글 처리
const attrMustacheRegex = /(\s+)([a-zA-Z0-9\-_:\.]+)=(["'])([^"']*[가-힣]+[^"']*?)(\{\{[^}]+\}\})([^"']*?)(\3)/g;
modifiedTemplateContent = modifiedTemplateContent.replace(attrMustacheRegex, (match, spacing, attr, quote, beforeMustache, mustache, afterMustache, endQuote) => {
checkTimeout(); // 타임아웃 체크
// 바인딩 속성이거나 특별한 속성인 경우 제외
if (attr.startsWith(':') || attr.startsWith('@') || attr.startsWith('v-') || nonBindingAttributes.includes(attr)) {
return match;
}
// 머스태시 표현식 내부를 확인
const mustacheContent = mustache.substring(2, mustache.length - 2).trim();
// 머스태시 내용이 이미 t 함수를 포함하거나 JavaScript 표현식인 경우 제외
if (mustacheContent.includes("t(") || isJavaScriptExpression(mustacheContent)) {
return match;
}
// 전체 표현식이 JavaScript 코드인 경우 제외
const fullText = (beforeMustache + mustache + afterMustache).trim();
if (isJavaScriptExpression(fullText)) {
return match;
}
// 한글을 포함하고 유효한 번역 키인 경우 래핑
if (containsKorean(beforeMustache.trim()) && isValidTranslationKey(beforeMustache.trim())) {
hasKoreanText = true;
// 원본 텍스트의 공백과 줄바꿈 패턴 보존
const leadingSpaces = beforeMustache.match(/^(\s*)/)[0];
const trailingSpaces = afterMustache.match(/(\s*)$/)[0];
return `${spacing}${attr}=${quote}t('${beforeMustache.trim()}') /* TO-CHECK: 복합 번역키 */${quote}`;
}
return match;
});
checkTimeout(); // 단계별 타임아웃 체크
} catch (e) {
console.log(`Error in attrMustacheRegex pattern for file ${filePath}: ${e.message}`);
// 오류 발생 시 원본 내용 유지
}
// 모든 변경이 끝난 후 마커 복원 (순서 중요)
modifiedTemplateContent = restoreComments(modifiedTemplateContent, commentMarkers);
modifiedTemplateContent = restoreVueBindings(modifiedTemplateContent, bindingMarkers);
// 템플릿이 수정되었다면 업데이트
if (modifiedTemplateContent !== templateContent) {
updatedContent = updatedContent.replace(templateContent, modifiedTemplateContent);
needsUpdate = true;
}
}
// 이미 t 함수 import가 되어 있는지 확인
const hasI18nImport = fileContent.includes("import { useI18n } from 'vue-i18n'");
const hasTFunction = fileContent.includes("const { t } = useI18n()");
// t 함수 사용이 없는데 import가 있는 경우 -> import 제거
if (!hasKoreanText && (hasI18nImport || hasTFunction)) {
// import 제거
updatedContent = updatedContent.replace(/import\s*{\s*useI18n\s*}\s*from\s*['"]vue-i18n['"]\s*;\s*\n?/g, '');
// t 함수 선언 제거
updatedContent = updatedContent.replace(/const\s*{\s*t\s*}\s*=\s*useI18n\(\)\s*;\s*\n?/g, '');
needsUpdate = true;
}
// t 함수 사용이 있는데 import가 없는 경우 -> import 추가
if (hasKoreanText && (!hasI18nImport || !hasTFunction)) {
if (descriptor.scriptSetup) {
const scriptContent = descriptor.scriptSetup.content;
let newScriptContent = scriptContent;
// useI18n import 추가
if (!hasI18nImport) {
const importStatement = "import { useI18n } from 'vue-i18n';\n";
// 모든 import 문 찾기
const importLines = scriptContent.split('\n').filter(line => line.trim().startsWith('import '));
if (importLines.length > 0) {
// 마지막 import 문 찾기
const lastImportLine = importLines[importLines.length - 1];
const lastImportPosition = scriptContent.indexOf(lastImportLine) + lastImportLine.length;
// Type import 블록 감지 - import type { 으로 시작하는 경우
const isTypeImportBlock = lastImportLine.trim().startsWith('import type {') &&
!lastImportLine.includes('}');
if (isTypeImportBlock) {
// 멀티라인 타입 임포트 블록 처리 - 전체 블록 찾기
const remainingScript = scriptContent.substring(lastImportPosition);
let braceCount = 1; // 이미 여는 괄호 하나를 만남
let blockEndIndex = 0;
// 중괄호 균형을 맞춰서 블록 끝 찾기
for (let i = 0; i < remainingScript.length; i++) {
if (remainingScript[i] === '{') braceCount++;
if (remainingScript[i] === '}') braceCount--;
if (braceCount === 0) {
blockEndIndex = i;
break;
}
}
// 괄호 닫힌 후 from 구문 찾기
if (blockEndIndex > 0) {
const afterCloseBrace = remainingScript.substring(blockEndIndex);
const fromMatch = afterCloseBrace.match(/\s*from\s+['"]\S+['"]\s*;/);
if (fromMatch) {
// Type import 블록 끝의 위치 계산
const typeBlockEndPosition = lastImportPosition + blockEndIndex + fromMatch[0].length;
// 타입 임포트 블록 이후에 추가
newScriptContent =
scriptContent.substring(0, typeBlockEndPosition) +
'\n' + importStatement +
scriptContent.substring(typeBlockEndPosition);
} else {
// 블록 끝을 찾았지만 from 구문을 찾지 못한 경우
newScriptContent =
scriptContent.substring(0, lastImportPosition) +
'\n' + importStatement +
scriptContent.substring(lastImportPosition);
}
} else {
// 블록 끝을 찾지 못한 경우, 안전하게 일반 import 추가
newScriptContent =
scriptContent.substring(0, lastImportPosition) +
'\n' + importStatement +
scriptContent.substring(lastImportPosition);
}
} else {
// 일반 import 문 다음에 추가
newScriptContent =
scriptContent.substring(0, lastImportPosition) +
'\n' + importStatement +
scriptContent.substring(lastImportPosition);
}
} else {
// import 문이 없는 경우 스크립트 시작 부분에 추가
newScriptContent = importStatement + scriptContent;
}
}
// t 함수 추가 - 스크립트 최하단에 위치하도록 수정
if (!hasTFunction && !newScriptContent.includes('const { t } = useI18n()')) {
// t 함수 선언 제거 (혹시 잘못된 위치에 있는 경우 대비)
newScriptContent = newScriptContent.replace(/const\s*{\s*t\s*}\s*=\s*useI18n\(\)\s*;\s*\/\/\s*Internationalization\s*\n?/g, '');
newScriptContent = newScriptContent.replace(/const\s*{\s*t\s*}\s*=\s*useI18n\(\)\s*;\s*\n?/g, '');
// 스크립트 끝에 t 함수 추가
newScriptContent = newScriptContent.trim() + '\n\nconst { t } = useI18n();\n';
}
if (newScriptContent !== scriptContent) {
updatedContent = updatedContent.replace(scriptContent, newScriptContent);
needsUpdate = true;
}
} else if (descriptor.script && !descriptor.scriptSetup) {
// script 태그가 있지만 setup 속성이 없는 경우, script setup으로 변환
const scriptContent = descriptor.script.content;
const scriptLang = descriptor.script.lang || 'ts';
const newScriptSetup = `<script setup lang="${scriptLang}">\nimport { useI18n } from 'vue-i18n';\n\n${scriptContent.replace(/export default {[\s\S]*?}/g, '')}\n\nconst { t } = useI18n(); // Internationalization\n</script>`;
updatedContent = updatedContent.replace(/<script[\s\S]*?<\/script>/, newScriptSetup);
needsUpdate = true;
} else if (!descriptor.script && !descriptor.scriptSetup) {
// script가 없는 경우, 새로 추가
const newScriptSetup = `<script setup lang="ts">\nimport { useI18n } from 'vue-i18n';\n\nconst { t } = useI18n(); // Internationalization\n</script>\n\n`;
updatedContent = newScriptSetup + updatedContent;
needsUpdate = true;
}
}
if (needsUpdate) {
fs.writeFileSync(filePath, updatedContent, 'utf-8');
console.log(`Updated file: ${filePath}`);
} else {
console.log(`No changes needed for: ${filePath}`);
}
} catch (error) {
console.error(`Error wrapping t function in ${filePath}:`, error);
if (error.message.includes('timeout')) {
console.log(`File processing timed out. Skipping file: ${filePath}`);
}
}
};
// LOG 함수 추가: 디버그 모드일 때만 출력
const LOG = (message) => {
if (CONFIG.debug) {
console.log(`[DEBUG] ${message}`);
}
};
// 메인 실행 함수
const main = () => {
const command = process.argv[2] || '';
// 테스트 예제 실행
if (command === 'test') {
console.log('Running edge case tests...');
const testCases = [
"{{ props.prefix }} 대량 생성",
"계좌 예금주 파트너사 정보 : {{ props.transaction.partnerCompanyName }}",
"`${props.title} 삭제`",
"{{ `${configDateMonthState.year}년 ${configDateMonthState.month}월` }}",
"'{{ props.request.requestName }}' 요청 건을 취소",
"안녕하세요 {{ userStore.userName }} 님",
"{{ selectedCar?.carNumber ?? \"[차량없음]\" }}",
"대량 생성", // 정상 추출되어야 함
"요청 건을 취소", // 정상 추출되어야 함
"console.log('한글 디버그 메시지')", // 제외되어야 함
"console.error('오류가 발생했습니다')", // 제외되어야 함
"한글 로그 메시지" // 정상 추출되어야 함
];
console.log('\nTesting isTextMixedWithCode:');
testCases.forEach(test => {
const result = isTextMixedWithCode(test);
console.log(`- ${test}: ${result ? 'MIXED (will not extract)' : 'CLEAN (will extract)'}`);
});
console.log('\nTesting isValidTranslationKey:');
testCases.forEach(test => {
const result = isValidTranslationKey(test);
console.log(`- ${test}: ${result ? 'VALID KEY' : 'INVALID KEY'}`);
});
// 콘솔 구문 추가 테스트
console.log('\nTesting console statements:');
const consoleTestCases = [
"console.log('한글 로그 메시지')",
"console.error('오류가 발생했습니다')",
"console.warn('경고: 사용자 정보가 없습니다')",
"const message = '정상 한글 메시지'; console.log(message);",
"if (error) { console.error('처리 중 오류 발생: ' + error.message); }"
];
// 콘솔 구문의 문자열을 추출해서 isValidTranslationKey로 테스트
consoleTestCases.forEach(test => {
const matches = test.match(/(['"])((?:\\\1|(?!\1).)*)\1/g) || [];
const strings = matches.map(m => m.slice(1, -1));
console.log(`- ${test}:`);
strings.forEach(str => {
const result = isValidTranslationKey(str);
console.log(` * "${str}": ${result ? 'WILL BE EXTRACTED' : 'EXCLUDED'}`);
});
});
return;
}
switch (command) {
case 'extract':
console.log('Extracting Korean texts...');
const koreanTexts = extractAllKoreanTexts();
// 추출된 텍스트 필터링 (코드 혼합 텍스트 재확인)
const filteredTexts = koreanTexts.filter(text => !isTextMixedWithCode(text));
console.log('\nFiltered out mixed texts:');
koreanTexts.filter(text => isTextMixedWithCode(text)).forEach(text => {
console.log(`- Not extracted: "${text}"`);
});
updateLocaleFile(CONFIG.defaultLocale, filteredTexts);
console.log(`\nExtracted ${filteredTexts.length} Korean texts to locale file.`);
if (filteredTexts.length > 0) {
console.log('\nExtracted texts (sample):');
filteredTexts.slice(0, 10).forEach(text => {
console.log(`- "${text}"`);
});
}
break;
case 'clean':
console.log('Cleaning locale file...');
const localeFile = loadLocaleFile(CONFIG.defaultLocale);
const cleanedKeys = Object.keys(localeFile).filter(key => isValidTranslationKey(key));
const cleanedLocale = {};
cleanedKeys.forEach(key => {
cleanedLocale[key] = localeFile[key];
});
saveLocaleFile(CONFIG.defaultLocale, cleanedLocale);
console.log(`Cleaned locale file. Removed ${Object.keys(localeFile).length - cleanedKeys.length} invalid entries.`);
break;
default:
console.log('Unknown command. Use "extract" or "clean"');
}
};
main();
해당 스크립트를 아래와 같이 다시 실행하면 ko.json에 정의되지 않은 한글이 추출 된다.
물론 t-wrap 이후에 실행해야하니 참고..
node vue-i18n-extractor.cjs extract
아래처럼 상당한 양의 한글이 추출된다.
이렇게 1차적으로 스크립트 테스트를 했지만 이후 시간을 들여 수작업으로 영문키로 전환하기로 했기에, 추출된 ko.json은 드랍했다.
그래도 t-wrap 처리한 코드들은 그대로 사용할 수 있었기 때문에 t내부의 한글들만 영문키로 수정해주는 작업을 해주면 되어서 나름 시간이 단축 되었다.
'프로그래밍 > Vue.js' 카테고리의 다른 글
vuejs Render Function 이해하기 (1) | 2024.12.20 |
---|---|
vuejs Renderless Component 패턴 이해하기 (0) | 2024.12.20 |
vuejs 반응형 sprite canvas 만들기 (1) | 2023.07.19 |
vuejs 슬롯머신 만들기 (0) | 2023.05.22 |
vuejs 룰렛만들기 (0) | 2023.05.22 |