Skip to content

Commit 3ad9bc8

Browse files
authored
[FEAT] i18n 자동화 스크립트 도입 (#402)
* chore: 국제화 라이브러리 설치 * feat: i18next 초기화 및 언어 감지/HTTP 백엔드 설정 * feat: 국제화 라우팅 래퍼 생성 * feat: 중첩 라우팅 구조로 변경 및 국제화 라우팅 래퍼 적용 * chore: 필요한 바벨 설정 및 스크립트 추가 * feat: 파일 관련 유틸 생성 * feat: ATS 관련 및 국제화 훅 추가, t wrapper 유틸 생성 * feat: 한글 키를 기준으로 각 언어 JSON에 키 추가 유틸 생성 * feat: React 컴포넌트의 한글 텍스트 자동 변환 기능 구현 * fix: 테스트 파일과 스토리북 파일이 포함되는 문제 해결 * refactor: 컴포넌트 판별 로직을 분리하여 여러 형태에 대응할 수 있도록 수정 * feat: 템플릿 리터럴 자동 변환 및 키 추출 기능 구현 * refactor: 컴포넌트 내부에 있는 한글 문자열만 변환되도록 수정 * refactor: 복잡한 확장자 처리를 위한 라이브러리 사용으로 더이상 사용하지 않는 확장자 찾는 함수 삭제 * fix: ts-node 실행 오류 해결 및 tsx 기반으로 스크립트 전환 * refactor: 불필요한 주석 삭제
1 parent f2c222e commit 3ad9bc8

File tree

10 files changed

+1523
-138
lines changed

10 files changed

+1523
-138
lines changed

package-lock.json

Lines changed: 995 additions & 115 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,33 @@
1515
"storybook": "storybook dev -p 6006",
1616
"build-storybook": "storybook build",
1717
"test": "vitest",
18-
"chromatic": "npx chromatic --project-token=chpt_a6dc39eba6488b2"
18+
"chromatic": "npx chromatic --project-token=chpt_a6dc39eba6488b2",
19+
"i18n:transform": "tsx scripts/i18nTransform.ts"
1920
},
2021
"dependencies": {
2122
"@tanstack/eslint-plugin-query": "^5.62.9",
2223
"@tanstack/react-query": "^5.62.12",
2324
"axios": "^1.7.9",
2425
"clsx": "^2.1.1",
2526
"framer-motion": "^12.23.11",
27+
"i18next": "^25.5.2",
28+
"i18next-browser-languagedetector": "^8.2.0",
29+
"i18next-http-backend": "^3.0.2",
2630
"pako": "^2.1.0",
2731
"qrcode.react": "^4.2.0",
2832
"react": "^18.3.1",
2933
"react-dom": "^18.3.1",
3034
"react-ga4": "^2.1.0",
35+
"react-i18next": "^15.7.3",
3136
"react-icons": "^5.4.0",
3237
"react-router-dom": "^7.1.0",
3338
"vite-bundle-visualizer": "^1.2.1"
3439
},
3540
"devDependencies": {
41+
"@babel/generator": "^7.28.3",
42+
"@babel/parser": "^7.28.4",
43+
"@babel/traverse": "^7.28.4",
44+
"@babel/types": "^7.28.4",
3645
"@chromatic-com/storybook": "^3.2.2",
3746
"@eslint/js": "^9.15.0",
3847
"@storybook/addon-essentials": "^8.6.0",
@@ -46,7 +55,10 @@
4655
"@testing-library/jest-dom": "^6.6.3",
4756
"@testing-library/react": "^16.1.0",
4857
"@testing-library/user-event": "^14.5.2",
58+
"@types/babel__traverse": "^7.28.0",
59+
"@types/glob": "^8.1.0",
4960
"@types/jest": "^29.5.14",
61+
"@types/node": "^24.6.0",
5062
"@types/pako": "^2.0.3",
5163
"@types/react": "^18.3.12",
5264
"@types/react-dom": "^18.3.1",
@@ -64,6 +76,7 @@
6476
"eslint-plugin-react-refresh": "^0.4.14",
6577
"eslint-plugin-storybook": "^0.11.1",
6678
"eslint-plugin-tailwindcss": "^3.17.5",
79+
"glob": "^11.0.3",
6780
"globals": "^15.12.0",
6881
"jsdom": "^25.0.1",
6982
"msw": "^2.7.0",
@@ -76,6 +89,7 @@
7689
"stylelint-config-recommended": "^14.0.1",
7790
"stylelint-config-tailwindcss": "^0.0.7",
7891
"tailwindcss": "^3.4.16",
92+
"tsx": "^4.21.0",
7993
"typescript": "^5.7.2",
8094
"typescript-eslint": "^8.15.0",
8195
"vite": "^6.0.1",

scripts/i18nTransform.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as fs from 'fs/promises';
2+
import { glob } from 'glob';
3+
import { parseCode, transformAST, generateCode } from './utils/astUtils.ts';
4+
import { updateTranslationFiles } from './utils/translationUtils.ts';
5+
6+
async function processFile(filePath: string) {
7+
console.log(`\n파일 처리 중: ${filePath}`);
8+
const originalCode = await fs.readFile(filePath, 'utf-8');
9+
const ast = parseCode(originalCode);
10+
11+
const koreanKeys = transformAST(ast);
12+
if (koreanKeys.size === 0) {
13+
console.log('한글 텍스트를 찾지 못했습니다.');
14+
return;
15+
}
16+
17+
await updateTranslationFiles(koreanKeys);
18+
19+
const newCode = generateCode(ast);
20+
if (newCode !== originalCode) {
21+
await fs.writeFile(filePath, newCode, 'utf-8');
22+
console.log(`파일 업데이트 완료: ${filePath}`);
23+
} else {
24+
console.log('변경 사항이 없습니다.');
25+
}
26+
}
27+
28+
async function main() {
29+
const files = await glob('src/**/*.tsx', {
30+
ignore: ['src/**/*.test.tsx', 'src/**/*.stories.tsx'],
31+
});
32+
if (files.length === 0) {
33+
console.log('.tsx 파일을 찾지 못했습니다.');
34+
return;
35+
}
36+
37+
for (const file of files) {
38+
await processFile(file);
39+
}
40+
41+
console.log('\ni18n 변환 작업이 완료되었습니다.');
42+
}
43+
44+
main().catch(console.error);

scripts/utils/astUtils.ts

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import * as parser from '@babel/parser';
2+
import type { NodePath } from '@babel/traverse';
3+
import _traverse from '@babel/traverse';
4+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5+
const traverse = (_traverse as any).default;
6+
import _generate from '@babel/generator';
7+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8+
const generate = (_generate as any).default;
9+
import * as t from '@babel/types';
10+
11+
const KOREAN_REGEX = /[-]/;
12+
13+
/**
14+
* 코드 문자열을 파싱하여 AST로 변환
15+
*/
16+
export function parseCode(code: string) {
17+
return parser.parse(code, {
18+
sourceType: 'module',
19+
plugins: ['jsx', 'typescript'],
20+
});
21+
}
22+
23+
/**
24+
* 리액트 컴포넌트 함수인지 판별
25+
*/
26+
function isReactComponentFunction(path: NodePath): boolean {
27+
// 함수 선언문
28+
if (path.isFunctionDeclaration()) {
29+
return path.node.id?.name?.[0] === path.node.id?.name?.[0]?.toUpperCase();
30+
}
31+
32+
// 화살표 함수 표현식 또는 함수 표현식
33+
if (path.isArrowFunctionExpression() || path.isFunctionExpression()) {
34+
const parent = path.parentPath;
35+
36+
// 변수 선언문
37+
if (parent?.isVariableDeclarator()) {
38+
const varName = (parent.node.id as t.Identifier)?.name;
39+
return /^[A-Z]/.test(varName);
40+
}
41+
42+
// 합성 컴포넌트
43+
if (parent?.isAssignmentExpression()) {
44+
const left = parent.get('left');
45+
if (left.isMemberExpression()) {
46+
const property = left.get('property');
47+
if (property.isIdentifier()) {
48+
return /^[A-Z]/.test(property.node.name);
49+
}
50+
}
51+
}
52+
}
53+
54+
return false;
55+
}
56+
57+
/**
58+
* AST에서 한글 문자열 탐색 및 변환
59+
*/
60+
export function transformAST(ast: t.File) {
61+
const koreanKeys = new Set<string>();
62+
const componentsToModify = new Set<NodePath>();
63+
let hasUseTranslationImport = false;
64+
const simpleStringsToTransform: NodePath<t.StringLiteral | t.JSXText>[] = [];
65+
const templateLiteralsToTransform: {
66+
path: NodePath<t.TemplateLiteral>;
67+
i18nKey: string;
68+
objectProperties: t.ObjectProperty[];
69+
}[] = [];
70+
71+
// 1️. 한글 문자열 탐색 및 변환 대상 수집
72+
traverse(ast, {
73+
JSXText(path) {
74+
const value = path.node.value.trim();
75+
if (value && KOREAN_REGEX.test(value)) {
76+
const component = path.findParent((p) => isReactComponentFunction(p));
77+
if (component) {
78+
const parentT = path.findParent(
79+
(p) =>
80+
p.isCallExpression() &&
81+
p.get('callee').isIdentifier({ name: 't' }),
82+
);
83+
if (parentT) return;
84+
85+
simpleStringsToTransform.push(path);
86+
koreanKeys.add(value);
87+
componentsToModify.add(component);
88+
}
89+
}
90+
},
91+
StringLiteral(path) {
92+
const value = path.node.value.trim();
93+
if (
94+
value &&
95+
KOREAN_REGEX.test(value) &&
96+
path.parent.type !== 'ImportDeclaration' &&
97+
path.parent.type !== 'ExportNamedDeclaration' &&
98+
!(
99+
path.parent.type === 'ObjectProperty' && path.parent.key === path.node
100+
)
101+
) {
102+
const component = path.findParent((p) => isReactComponentFunction(p));
103+
if (component) {
104+
const parentT = path.findParent(
105+
(p) =>
106+
p.isCallExpression() &&
107+
p.get('callee').isIdentifier({ name: 't' }),
108+
);
109+
if (parentT) return;
110+
111+
simpleStringsToTransform.push(path);
112+
koreanKeys.add(value);
113+
componentsToModify.add(component);
114+
}
115+
}
116+
},
117+
TemplateLiteral(path) {
118+
const { quasis, expressions } = path.node;
119+
const hasKorean = quasis.some((q) => KOREAN_REGEX.test(q.value.raw));
120+
if (!hasKorean) return;
121+
122+
if (
123+
path.parent.type === 'CallExpression' &&
124+
t.isIdentifier(path.parent.callee) &&
125+
path.parent.callee.name === 't'
126+
) {
127+
return;
128+
}
129+
130+
const component = path.findParent((p) => isReactComponentFunction(p));
131+
if (!component) return;
132+
133+
let i18nKey = '';
134+
const objectProperties: t.ObjectProperty[] = [];
135+
136+
for (let i = 0; i < quasis.length; i++) {
137+
i18nKey += quasis[i].value.raw;
138+
if (i < expressions.length) {
139+
const expr = expressions[i];
140+
let placeholderName: string;
141+
142+
if (t.isIdentifier(expr)) {
143+
placeholderName = expr.name;
144+
} else if (
145+
t.isMemberExpression(expr) &&
146+
t.isIdentifier(expr.property)
147+
) {
148+
placeholderName = expr.property.name;
149+
} else {
150+
placeholderName = `val${i}`;
151+
}
152+
153+
let finalName = placeholderName;
154+
let count = 1;
155+
while (
156+
objectProperties.some(
157+
(p) => t.isIdentifier(p.key) && p.key.name === finalName,
158+
)
159+
) {
160+
finalName = `${placeholderName}${count++}`;
161+
}
162+
163+
i18nKey += `{{${finalName}}}`;
164+
objectProperties.push(
165+
t.objectProperty(
166+
t.identifier(finalName),
167+
expr,
168+
false,
169+
t.isIdentifier(expr) && finalName === expr.name,
170+
),
171+
);
172+
}
173+
}
174+
175+
koreanKeys.add(i18nKey);
176+
componentsToModify.add(component);
177+
templateLiteralsToTransform.push({ path, i18nKey, objectProperties });
178+
},
179+
ImportDeclaration(path) {
180+
if (path.node.source.value === 'react-i18next') {
181+
hasUseTranslationImport = true;
182+
}
183+
},
184+
});
185+
186+
// 2️. useTranslation import 추가
187+
if (koreanKeys.size > 0 && !hasUseTranslationImport) {
188+
const importDecl = t.importDeclaration(
189+
[
190+
t.importSpecifier(
191+
t.identifier('useTranslation'),
192+
t.identifier('useTranslation'),
193+
),
194+
],
195+
t.stringLiteral('react-i18next'),
196+
);
197+
ast.program.body.unshift(importDecl);
198+
}
199+
200+
// 3️. 각 컴포넌트에 const { t } = useTranslation() 추가
201+
componentsToModify.forEach((componentPath) => {
202+
const bodyPath = componentPath.get('body');
203+
if (Array.isArray(bodyPath) || !bodyPath.isBlockStatement()) return;
204+
205+
let hasHook = false;
206+
bodyPath.get('body').forEach((stmt) => {
207+
if (stmt.isVariableDeclaration()) {
208+
const declaration = stmt.node.declarations[0];
209+
if (
210+
declaration?.init?.type === 'CallExpression' &&
211+
t.isIdentifier(declaration.init.callee) &&
212+
declaration.init.callee.name === 'useTranslation'
213+
) {
214+
hasHook = true;
215+
}
216+
}
217+
});
218+
219+
if (!hasHook) {
220+
const hookDecl = t.variableDeclaration('const', [
221+
t.variableDeclarator(
222+
t.objectPattern([
223+
t.objectProperty(t.identifier('t'), t.identifier('t'), false, true),
224+
]),
225+
t.callExpression(t.identifier('useTranslation'), []),
226+
),
227+
]);
228+
bodyPath.unshiftContainer('body', hookDecl);
229+
}
230+
});
231+
232+
// 4️. 템플릿 리터럴 변환
233+
templateLiteralsToTransform.forEach(({ path, i18nKey, objectProperties }) => {
234+
const keyLiteral = t.stringLiteral(i18nKey);
235+
if (objectProperties.length > 0) {
236+
const interpolationObject = t.objectExpression(objectProperties);
237+
const tCall = t.callExpression(t.identifier('t'), [
238+
keyLiteral,
239+
interpolationObject,
240+
]);
241+
path.replaceWith(tCall);
242+
} else {
243+
const tCall = t.callExpression(t.identifier('t'), [keyLiteral]);
244+
path.replaceWith(tCall);
245+
}
246+
});
247+
248+
// 5️. 컴포넌트 내부 한글 텍스트 t()로 감싸기
249+
simpleStringsToTransform.forEach((path) => {
250+
const value =
251+
path.node.type === 'JSXText'
252+
? path.node.value.trim()
253+
: (path.node as t.StringLiteral).value;
254+
255+
const tCall = t.callExpression(t.identifier('t'), [t.stringLiteral(value)]);
256+
257+
if (path.isJSXText()) {
258+
path.replaceWith(t.jsxExpressionContainer(tCall));
259+
} else if (path.isStringLiteral()) {
260+
if (path.parent.type === 'JSXAttribute') {
261+
path.replaceWith(t.jsxExpressionContainer(tCall));
262+
} else {
263+
path.replaceWith(tCall);
264+
}
265+
}
266+
});
267+
268+
return koreanKeys;
269+
}
270+
271+
/**
272+
* AST를 코드 문자열로 다시 변환
273+
*/
274+
export function generateCode(ast: t.File) {
275+
const { code } = generate(ast, {
276+
retainLines: true,
277+
jsescOption: { minimal: true },
278+
});
279+
return code;
280+
}

0 commit comments

Comments
 (0)