├── .gitignore
├── auto-i18n.config.json
├── src
├── type.ts
├── core
│ ├── index.ts
│ ├── type.d.ts
│ ├── transform.ts
│ ├── finder.ts
│ ├── twrapper.ts
│ └── insertion.ts
├── source
│ ├── plain.tsx
│ ├── HookComponentDemo.tsx
│ └── component.tsx
├── common
│ ├── language.ts
│ └── index.ts
├── loader
│ └── index.ts
├── __test__
│ ├── demo
│ │ └── index.ts
│ ├── extractor.test.ts
│ └── core.test.ts
├── index.ts
├── generator
│ └── index.ts
└── extractor
│ └── index.ts
├── babel.config.js
├── jest.config.js
├── tsconfig.json
├── bin
└── cli.js
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | coverage
--------------------------------------------------------------------------------
/auto-i18n.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "runType": "next"
3 | }
4 |
--------------------------------------------------------------------------------
/src/type.ts:
--------------------------------------------------------------------------------
1 | export type RunType = "next" | "react";
2 |
3 | export type KeyLanguage = "ko" | "en" | "ja" | "zh";
4 |
--------------------------------------------------------------------------------
/src/core/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./finder";
2 | export * from "./twrapper";
3 | export * from "./insertion";
4 | export * from "./transform";
5 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | "@babel/preset-env",
4 | "@babel/preset-typescript",
5 | "@babel/preset-react",
6 | ],
7 | };
8 |
--------------------------------------------------------------------------------
/src/source/plain.tsx:
--------------------------------------------------------------------------------
1 | const ERROR_MESSAGE = "잠시후 다시 시도해주세요";
2 | const review = {
3 | id: 1,
4 | name: "테스트",
5 | };
6 | const reviews = [
7 | {
8 | id: 1,
9 | name: "김X님",
10 | },
11 | {
12 | id: 2,
13 | name: "양X님",
14 | },
15 | ];
16 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: "ts-jest",
3 | testEnvironment: "node",
4 | transform: {
5 | "^.+\\.(js|jsx|ts|tsx)$": "babel-jest",
6 | },
7 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(js|jsx|ts|tsx)$",
8 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
9 | };
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "CommonJS",
5 | "lib": ["ESNext"],
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "declaration": true,
9 | "outDir": "dist",
10 | "rootDir": "src"
11 | },
12 | "include": ["src"],
13 | "exclude": ["src/source", "src/__test__"]
14 | }
15 |
--------------------------------------------------------------------------------
/src/core/type.d.ts:
--------------------------------------------------------------------------------
1 | import * as t from "@babel/types";
2 |
3 | export type PreHookContextNode =
4 | | t.FunctionDeclaration
5 | | t.FunctionExpression
6 | | t.ArrowFunctionExpression;
7 |
8 | export type HookContextNode = PreHookContextNode;
9 |
10 | export interface ExtractedText {
11 | text: string;
12 | isTWrapped: boolean;
13 | containerName: string;
14 | }
15 |
--------------------------------------------------------------------------------
/src/source/HookComponentDemo.tsx:
--------------------------------------------------------------------------------
1 | // src/demo/HookComponentDemo.tsx
2 | import React from "react";
3 |
4 | // 사용자 정의 훅 예제
5 | export const useCustomHook = () => {
6 | return "안녕하세요";
7 | };
8 |
9 | // 훅을 사용하는 함수형 컴포넌트 예제
10 | export const CustomComponent = () => {
11 | const value = useCustomHook();
12 | return
{value}
;
13 | };
14 |
15 | // 일반 함수 예제 (컴포넌트가 아님)
16 | export const helperFunction = () => {
17 | return 42;
18 | };
19 |
--------------------------------------------------------------------------------
/src/core/transform.ts:
--------------------------------------------------------------------------------
1 | import { RunType } from "../type";
2 | import { findHookContextNode } from "./finder";
3 | import { Insertion } from "./insertion";
4 | import { TWrapper } from "./twrapper";
5 | import * as t from "@babel/types";
6 |
7 | export function transform(
8 | ast: t.File,
9 | checkLanguage: (text: string) => boolean,
10 | runType: RunType
11 | ): {
12 | ast: t.File;
13 | isChanged: boolean;
14 | } {
15 | const hookContextNodes = findHookContextNode(ast);
16 |
17 | const wrapper = new TWrapper(hookContextNodes, checkLanguage);
18 |
19 | wrapper.wrap();
20 |
21 | const insertion = new Insertion(hookContextNodes, ast, runType);
22 |
23 | const isChanged = insertion.insert();
24 |
25 | return { ast, isChanged };
26 | }
27 |
--------------------------------------------------------------------------------
/src/common/language.ts:
--------------------------------------------------------------------------------
1 | import { KeyLanguage } from "../type";
2 |
3 | export function createLanguageCheckFunction(language: KeyLanguage) {
4 | switch (language) {
5 | case "ko":
6 | return containsKorean;
7 | case "ja":
8 | return containsJapanese;
9 | case "zh":
10 | return containsChinese;
11 | case "en":
12 | return containsEnglish;
13 | default:
14 | return () => false;
15 | }
16 | }
17 |
18 | function containsKorean(text: string): boolean {
19 | // [가-힣] 범위의 한글 문자를 찾는 정규식
20 | const koreanRegex = /[가-힣]/;
21 | return koreanRegex.test(text);
22 | }
23 |
24 | function containsJapanese(text: string): boolean {
25 | // 일본어(히라가나, 가타카나, CJK 한자) 범위를 포함하는 정규식
26 | const japaneseRegex = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/;
27 | return japaneseRegex.test(text);
28 | }
29 |
30 | // 중국어 검사
31 | function containsChinese(text: string): boolean {
32 | // 간단히 CJK 통합 한자 범위를 검사 (중국어, 일본어 한자를 포함)
33 | const chineseRegex = /[\u4E00-\u9FFF]/;
34 | return chineseRegex.test(text);
35 | }
36 |
37 | // 영어(알파벳) 검사
38 | function containsEnglish(text: string): boolean {
39 | // 기본 알파벳 범위만 검사 (A-Z, a-z)
40 | const englishRegex = /[A-Za-z]/;
41 | return englishRegex.test(text);
42 | }
43 |
--------------------------------------------------------------------------------
/bin/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const { program } = require("commander");
4 | const fs = require("fs");
5 | const path = require("path");
6 | const packageJson = require("../package.json");
7 | const { parse } = require("@babel/parser");
8 |
9 | const main = require(path.join(__dirname, "../dist/index.js")).main;
10 |
11 | const defaultOptions = {
12 | runType: "next",
13 | locales: ["ja_JP"],
14 | entry: "src",
15 | outputDir: "public/locales",
16 | enablePrettier: true,
17 | config: "./auto-i18n.config.json",
18 | outputFileName: "common.json",
19 | keyLanguage: "ko",
20 | };
21 |
22 | program
23 | .option("-c, --config ", "Configuration file (JSON)")
24 | .action((options) => {
25 | const configPath = path.resolve(options.config || defaultOptions.config);
26 |
27 | if (!fs.existsSync(configPath)) {
28 | console.error(`Config file not found: ${configPath}`);
29 | process.exit(1);
30 | }
31 |
32 | const configFileOptions = JSON.parse(fs.readFileSync(configPath));
33 |
34 | const runtimeOptions = {
35 | ...defaultOptions,
36 | ...configFileOptions,
37 | };
38 |
39 | console.log("🔧 Final Options:", runtimeOptions);
40 |
41 | main(runtimeOptions);
42 | });
43 |
44 | program.parse(process.argv);
45 |
--------------------------------------------------------------------------------
/src/loader/index.ts:
--------------------------------------------------------------------------------
1 | import * as fs from "fs/promises";
2 | import * as parser from "@babel/parser";
3 | import * as t from "@babel/types";
4 | import { globSync } from "fs";
5 | import { handleParseError } from "../common";
6 |
7 | interface File {
8 | ast: t.File;
9 | filepath: string;
10 | }
11 |
12 | export class Loader {
13 | private entry: string;
14 |
15 | constructor({ entry }: { entry: string }) {
16 | this.entry = entry;
17 | }
18 |
19 | async load(
20 | callback: (file: File) => void,
21 | options?: { onLoaded?: () => void }
22 | ): Promise {
23 | const { onLoaded } = options || {};
24 | const filePaths = this.getTargetFilePaths();
25 |
26 | try {
27 | const filePromises = filePaths.map(async (filePath) => {
28 | try {
29 | const file = await this.loadSourceFile(filePath);
30 | callback({ ast: file, filepath: filePath });
31 | } catch (error) {
32 | handleParseError(error, filePath);
33 | }
34 | });
35 |
36 | await Promise.all(filePromises);
37 | onLoaded?.();
38 | } catch (error) {
39 | console.error("Failed to load files:", error);
40 | throw error;
41 | }
42 | }
43 |
44 | private getTargetFilePaths(): string[] {
45 | return globSync(`${this.entry}/**/*.{js,jsx,ts,tsx}`);
46 | }
47 |
48 | private async loadSourceFile(filePath: string): Promise {
49 | const code = await fs.readFile(filePath, "utf8");
50 |
51 | return parser.parse(code, {
52 | sourceType: "module",
53 | plugins: ["typescript", "jsx"],
54 | });
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/__test__/demo/index.ts:
--------------------------------------------------------------------------------
1 | // 훅(Hook) 버전
2 |
3 | // 1. 선언형 훅 (FunctionDeclaration)
4 | export const hookDeclaration = `
5 | function useMyHook1() {
6 | return { value: 42 };
7 | }
8 | `;
9 |
10 | // 2. 표현식 훅 (FunctionExpression)
11 | export const hookExpression = `
12 | const useMyHook2 = function() {
13 | return { value: 42 };
14 | };
15 | `;
16 |
17 | // 3. 화살표 함수 훅 (ArrowFunctionExpression)
18 | export const hookArrow = `
19 | const useMyHook3 = () => {
20 | return { value: 42 };
21 | };
22 | `;
23 |
24 | // 컴포넌트(Component) 버전
25 |
26 | // 4. 선언형 컴포넌트 (FunctionDeclaration)
27 | export const componentDeclaration = `
28 | function MyComponent1() {
29 | return Hello, world!
;
30 | }
31 | `;
32 |
33 | // 5. 표현식 컴포넌트 (FunctionExpression)
34 | export const componentExpression = `
35 | const MyComponent2 = function() {
36 | return Hello, world!
;
37 | };
38 | `;
39 |
40 | // 6. 화살표 함수 컴포넌트 (ArrowFunctionExpression)
41 | export const componentArrow = `
42 | const MyComponent3 = () => {
43 | return Hello, world!
;
44 | };
45 | `;
46 |
47 | // 7. 그냥 함수
48 | export const helperFunction = `
49 | function helperFunction() {
50 | return 42;
51 | }
52 | `;
53 |
54 | export const allCasesDemo = `
55 | const user = { name: "Lee" };
56 | const isKorean = true;
57 |
58 | function AllCases() {
59 | // 일반 문자열 리터럴
60 | const greeting = "안녕하세요";
61 |
62 | // 템플릿 리터럴
63 | const template = \`안녕, \${user.name}!\`;
64 |
65 | // 삼항 연산자
66 | const message = isKorean ? "안녕" : "Hello";
67 |
68 | // JSX 리턴, JSX Attribute, JSX Text 모두 포함
69 | return (
70 |
71 | 반갑습니다.
72 | {template}
73 | {message}
74 |
75 | );
76 | }
77 | `;
78 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Loader } from "./loader";
2 | import * as core from "./core";
3 | import { handleParseError } from "./common";
4 | import { createLanguageCheckFunction } from "./common/language";
5 | import { Generator } from "./generator";
6 | import { Extractor } from "./extractor";
7 | import { ExtractedText } from "./core/type";
8 | import { RunType, KeyLanguage } from "./type";
9 |
10 | //TODO: 제외 경로 추가, ns 정의
11 | interface Options {
12 | runType: RunType;
13 | keyLanguage: KeyLanguage;
14 | locales: string[];
15 | outputDir: string;
16 | entry: string;
17 | enablePrettier: boolean;
18 | outputFileName: string;
19 | }
20 |
21 | export async function main(options: Options) {
22 | const {
23 | entry,
24 | locales,
25 | outputDir,
26 | runType,
27 | enablePrettier,
28 | outputFileName,
29 | keyLanguage,
30 | } = options;
31 |
32 | const loader = new Loader({
33 | entry: entry,
34 | });
35 |
36 | const generator = new Generator({
37 | enablePrettier: enablePrettier,
38 | });
39 |
40 | const extractedTexts: ExtractedText[] = [];
41 |
42 | // 추후 여러 언어 동적 할당
43 | await loader.load((file) => {
44 | try {
45 | const { ast: transformAst, isChanged } = core.transform(
46 | file.ast,
47 | createLanguageCheckFunction(keyLanguage),
48 | runType
49 | );
50 |
51 | if (isChanged) {
52 | generator.generate(transformAst, file.filepath);
53 | }
54 |
55 | extractedTexts.push(
56 | ...new Extractor(
57 | file.ast,
58 | createLanguageCheckFunction(keyLanguage),
59 | file.filepath
60 | ).extract()
61 | );
62 | } catch (error) {
63 | handleParseError(error, file.filepath);
64 | }
65 | });
66 |
67 | generator.generateJson(extractedTexts, locales, outputDir, outputFileName);
68 | }
69 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "i18nmatic",
3 | "version": "1.0.7",
4 | "description": "CLI tool for automating internationalization (i18n) code transformation and key extraction in React and Next.js projects",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "bin": {
8 | "auto-i18n": "./bin/cli.js"
9 | },
10 | "scripts": {
11 | "start": "node bin/cli.js",
12 | "build": "tsc",
13 | "prepublishOnly": "npm run build",
14 | "test": "jest",
15 | "prebuild": "rm -rf dist",
16 | "test:cov": "jest --coverage",
17 | "auto-i18n": "auto-i18n"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "https://github.com/seonghunYang/i18nmatic.git"
22 | },
23 | "homepage": "https://github.com/seonghunYang/i18nmatic#readme",
24 | "bugs": {
25 | "url": "https://github.com/seonghunYang/i18nmatic/issues"
26 | },
27 | "keywords": [
28 | "i18n",
29 | "internationalization",
30 | "react",
31 | "nextjs",
32 | "cli",
33 | "automation",
34 | "translation",
35 | "babel",
36 | "typescript"
37 | ],
38 | "author": "seonghunYang",
39 | "license": "MIT",
40 | "dependencies": {
41 | "@babel/parser": "^7.26.9",
42 | "@babel/traverse": "^7.26.9",
43 | "@babel/types": "^7.26.9",
44 | "commander": "^13.1.0",
45 | "glob": "^11.0.1",
46 | "prettier": "^3.5.3"
47 | },
48 | "devDependencies": {
49 | "@babel/core": "^7.26.9",
50 | "@babel/generator": "^7.26.9",
51 | "@babel/preset-env": "^7.26.9",
52 | "@babel/preset-react": "^7.26.3",
53 | "@babel/preset-typescript": "^7.26.0",
54 | "@types/babel__traverse": "^7.20.6",
55 | "@types/jest": "^29.5.14",
56 | "@types/node": "^22.13.8",
57 | "babel-jest": "^29.7.0",
58 | "jest": "^29.7.0",
59 | "ts-jest": "^29.2.6",
60 | "ts-node": "^10.9.2",
61 | "typescript": "^5.8.2"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/core/finder.ts:
--------------------------------------------------------------------------------
1 | import traverse, { NodePath } from "@babel/traverse";
2 | import * as t from "@babel/types";
3 | import { has } from "../common";
4 | import { HookContextNode, PreHookContextNode } from "./type";
5 |
6 | export function findHookContextNode(node: t.File): NodePath[] {
7 | const hookContextNodes: NodePath[] = [];
8 |
9 | traverse(node, {
10 | FunctionDeclaration(path) {
11 | if (isHookContextNode(path)) {
12 | hookContextNodes.push(path);
13 | }
14 | },
15 | ArrowFunctionExpression(path) {
16 | if (isHookContextNode(path)) {
17 | hookContextNodes.push(path);
18 | }
19 | },
20 | FunctionExpression(path) {
21 | if (isHookContextNode(path)) {
22 | hookContextNodes.push(path);
23 | }
24 | },
25 | });
26 |
27 | return hookContextNodes;
28 | }
29 |
30 | export function isHookContextNode(
31 | path: NodePath
32 | ): path is NodePath {
33 | return isFunctionalComponent(path) || isHook(path);
34 | }
35 |
36 | export function isFunctionalComponent(
37 | path: NodePath
38 | ): boolean {
39 | const functionName = getFunctionName(path);
40 |
41 | // 1. 함수 노드 자체에 id가 있는 경우 (FunctionDeclaration, 이름이 있는 FunctionExpression)
42 |
43 | if (!functionName) {
44 | return false;
45 | }
46 |
47 | // 대문자로 시작하는지 간단 체크
48 | if (!/^[A-Z]/.test(functionName)) {
49 | return false;
50 | }
51 |
52 | return has(path, (node) => t.isJSXElement(node) || t.isJSXFragment(node));
53 | }
54 |
55 | export function isHook(path: NodePath): boolean {
56 | const functionName = getFunctionName(path);
57 |
58 | if (!functionName) {
59 | return false;
60 | }
61 |
62 | return /^use[A-Z0-9]/.test(functionName);
63 | }
64 |
65 | function getFunctionName(path: NodePath) {
66 | let functionName: string | undefined;
67 |
68 | // 1. 함수 노드 자체에 id가 있는 경우 (FunctionDeclaration, 이름이 있는 FunctionExpression)
69 | if (
70 | (t.isFunctionDeclaration(path.node) || t.isFunctionExpression(path.node)) &&
71 | path.node.id
72 | ) {
73 | functionName = path.node.id.name;
74 | }
75 |
76 | // 2. 이름이 없으면 부모가 VariableDeclarator인지 확인 (익명 FunctionExpression, ArrowFunctionExpression)
77 | if (!functionName && path.parentPath.isVariableDeclarator()) {
78 | const id = path.parentPath.node.id;
79 | if (t.isIdentifier(id)) {
80 | functionName = id.name;
81 | }
82 | }
83 |
84 | return functionName;
85 | }
86 |
--------------------------------------------------------------------------------
/src/common/index.ts:
--------------------------------------------------------------------------------
1 | import generate from "@babel/generator";
2 | import traverse, { NodePath } from "@babel/traverse";
3 | import * as t from "@babel/types";
4 |
5 | export function has(path: NodePath, check: (node: t.Node) => boolean): boolean {
6 | let found = false;
7 |
8 | path.traverse({
9 | enter(p) {
10 | if (check(p.node)) {
11 | found = true;
12 | p.stop(); // 조건을 만족하는 노드를 찾으면 탐색 종료
13 | }
14 | },
15 | });
16 |
17 | return found;
18 | }
19 |
20 | export function find(
21 | node: t.Node,
22 | check: (node: t.Node) => node is T
23 | ): NodePath | null {
24 | let findPath: NodePath | null = null;
25 |
26 | traverse(node, {
27 | enter(p) {
28 | if (check(p.node)) {
29 | findPath = p as NodePath;
30 | p.stop();
31 | }
32 | },
33 | });
34 |
35 | return findPath;
36 | }
37 |
38 | export function getTemplateLiteralKey(tplPath: NodePath) {
39 | const quasis = tplPath.node.quasis;
40 | const expressions = tplPath.node.expressions;
41 | let translationKey = "";
42 | const properties: t.ObjectProperty[] = [];
43 |
44 | for (let i = 0; i < expressions.length; i++) {
45 | translationKey += quasis[i].value.cooked;
46 | // expressions[i]가 TSType이 아닌 실행 표현식인 경우에만 처리
47 |
48 | let expr = expressions[i];
49 |
50 | if (t.isTSAsExpression(expr)) {
51 | // 재귀 함수로 'as' 중첩 제거
52 | expr = unwrapTSAsExpression(expr);
53 | }
54 |
55 | if (t.isExpression(expr)) {
56 | const exprCode = generate(expr).code;
57 | translationKey += `{{${exprCode}}}`;
58 | properties.push(
59 | t.objectProperty(t.stringLiteral(exprCode), expr as t.Expression)
60 | );
61 | } else {
62 | // TSType인 경우에는 플레이스홀더만 추가 (빈 플레이스홀더)
63 | translationKey += `{{}}`;
64 | }
65 | }
66 | // 마지막 고정 문자열 부분 추가
67 | translationKey += quasis[expressions.length].value.cooked;
68 |
69 | return { translationKey, properties };
70 | }
71 |
72 | function unwrapTSAsExpression(node: t.Expression): t.Expression {
73 | // 만약 node가 TSAsExpression이면, 그 내부 realExpr를 반환
74 | if (t.isTSAsExpression(node)) {
75 | return unwrapTSAsExpression(node.expression);
76 | }
77 | return node;
78 | }
79 | export function handleParseError(error: unknown, filePath: string) {
80 | console.error(`❌ 파싱 오류 발생: ${filePath}`);
81 |
82 | // Babel 파싱 에러 구조
83 | // error.loc?.line, error.loc?.column, error.message
84 | if (error && typeof error === "object") {
85 | const e = error as any;
86 | if (e.loc) {
87 | console.error(
88 | ` 위치: line ${e.loc.line}, column ${e.loc.column} - ${e.message}`
89 | );
90 | } else {
91 | console.error(` 메시지: ${e.message || e}`);
92 | }
93 | } else {
94 | console.error(` 메시지: ${String(error)}`);
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/source/component.tsx:
--------------------------------------------------------------------------------
1 | // 한글 테스트용 모음
2 |
3 | // 1. 함수 선언문 + JSX 안에 직접 한글
4 | function HelloComponent() {
5 | return 안녕하세요
;
6 | }
7 |
8 | // 2. 함수 선언문 + 변수에 한글 String Literal 할당
9 | function HelloWorldComponent() {
10 | const text = "안녕하세요";
11 | return {text}
;
12 | }
13 |
14 | // 3. 암시적 반환 Arrow Function (화살표 함수) + JSX 안에 한글
15 | // -> 블록문이 없어, 암시적 반환 테스트 가능
16 | const AnotherComponent = () => 안녕하세요 스팬입니다;
17 |
18 | // 4. 명시적 반환 Arrow Function + JSXText/Attribute 한글
19 | const AnotherComponent2 = () => {
20 | return ;
21 | };
22 |
23 | // 5. 함수 선언문 + 조건식(삼항 연산자) 내부 한글
24 | function ConditionalComponent({ isKorean }) {
25 | return {isKorean ? "안녕하세요" : "Hello"}
;
26 | }
27 |
28 | // 6. 템플릿 리터럴 + 한글
29 | function TemplateLiteralComponent({ name }) {
30 | // 예: `${name}님 안녕하세요`
31 | // `${user.name}님 ${time}에 만나요` 같은 패턴 테스트 가능
32 | return {`${name}님 안녕하세요`}
;
33 | }
34 |
35 | // 7. Arrow Function + 여러 String Literal이 한글
36 | const MultipleStringLiterals = () => {
37 | const greeting = "안녕하세요";
38 | const message = "반갑습니다";
39 | return {greeting + ", " + message}
;
40 | };
41 |
42 | // 8. 함수 표현식 + JSXText 한글
43 | const ExpressionComponent = function () {
44 | return 표현식 컴포넌트: 안녕하세요
;
45 | };
46 |
47 | // 9. 함수 선언문 + 여러 한글이 섞인 조건식 + 템플릿 리터럴
48 | function ComplexConditional({ isMorning, user }) {
49 | // 삼항 연산자 + 템플릿 리터럴
50 | // 한국어 / 영어 혼합
51 | const timeStr = isMorning ? "아침" : "오후";
52 | return {`${user.name}님, 지금은 ${timeStr}입니다. Hello!`}
;
53 | }
54 |
55 | // 10. 내부 중첩 함수에도 한글이 있는 경우
56 | // (최상위 함수인지 아닌지 판단용)
57 | const NestedFunction = () => {
58 | // 최상위가 아닌 내부 함수
59 | function inner() {
60 | // 한글 String Literal
61 | return "이건 내부 함수의 한글";
62 | }
63 | return {inner()}
;
64 | };
65 |
66 | // 11. 한글이 전혀 없는 컴포넌트 (변환되지 않아야 함)
67 | function NoKoreanComponent() {
68 | return Hello only, no Korean here
;
69 | }
70 |
71 | // 12. 암시적 반환 Arrow Function + JSXText + 삼항연산자 동시
72 | const ComplexArrow = ({ isHello }) =>
73 | isHello ? 안녕하세요 - 삼항연산자
: Hello - ternary
;
74 |
75 | // 13. 함수 선언문 + JSX Attribute 2개
76 | function DoubleAttributeComponent() {
77 | return (
78 |
81 | );
82 | }
83 |
84 | // 14. 화살표 함수 + 복합 템플릿 리터럴 (표현식, 한글, 영어 혼합)
85 | // ex) `${user.name}님, 오늘은 ${date} 입니다. Have a good day!`
86 | const MixedTemplate = ({ user, date }) => (
87 | {`${user.name}님, 오늘은 ${date} 입니다. Have a good day!`}
88 | );
89 |
90 | // 15. (옵션) 함수 선언문 + JSX + Template Literal 내부에 TSType (타입 정보)
91 | // -> 실무에서 타입이 혼합될 수 있는 케이스 (아주 드묾)
92 | // -> 여기서 작성해두면 TSType 스킵 로직도 테스트 가능
93 | function TypeAnnotatedTemplate(value: T) {
94 | // 예: `${value as string}님 - 한글`
95 | return {`${value as string}님 - 한글`}
;
96 | }
97 |
98 | /**
99 | * 위 16가지 함수/컴포넌트는
100 | * 1) 함수 선언문
101 | * 2) 함수 표현식
102 | * 3) 화살표 함수 (암시적/명시적 반환)
103 | * 4) 삼항 연산자
104 | * 5) 템플릿 리터럴
105 | * 6) JSXText / JSX Attribute
106 | * 7) 여러 String Literal
107 | * 8) 내부 중첩 함수
108 | * 9) TypeScript 타입 (아주 드문 케이스)
109 | * 등에 대한 한글 포함 사례를 커버합니다.
110 | *
111 | * - 이 파일 전체를 Babel로 파싱한 뒤, 한꺼번에 테스트하면
112 | * 대부분의 한글 변환 케이스를 한 번에 확인할 수 있습니다.
113 | */
114 |
--------------------------------------------------------------------------------
/src/core/twrapper.ts:
--------------------------------------------------------------------------------
1 | import generate from "@babel/generator";
2 | import { NodePath } from "@babel/traverse";
3 | import * as t from "@babel/types";
4 | import { HookContextNode } from "./type";
5 | import { getTemplateLiteralKey } from "../common";
6 |
7 | export class TWrapper {
8 | constructor(
9 | private readonly paths: NodePath[],
10 | private readonly checkLanguage: (text: string) => boolean
11 | ) {}
12 |
13 | wrap() {
14 | this.wrapStringLiteral();
15 | this.wrapJSXText();
16 | this.wrapTemplateLiteral();
17 | }
18 |
19 | /**
20 | * 각 HookContextNode 내의 모든 StringLiteral 노드를 순회하여,
21 | * checkLanguage(text)가 true인 경우 t() 호출로 래핑한다.
22 | */
23 | wrapStringLiteral(): void {
24 | this.paths.forEach((path) => {
25 | path.traverse({
26 | StringLiteral: (path: NodePath) => {
27 | if (this.aleadlyWrappedStringLiteral(path)) {
28 | return;
29 | }
30 |
31 | if (this.checkLanguage(path.node.value)) {
32 | const newCallExpr = t.callExpression(t.identifier("t"), [
33 | t.stringLiteral(path.node.value),
34 | ]);
35 |
36 | if (t.isJSXAttribute(path.parent)) {
37 | path.parent.value = t.jsxExpressionContainer(newCallExpr);
38 | } else {
39 | path.replaceWith(newCallExpr);
40 | }
41 | }
42 | },
43 | });
44 | });
45 | }
46 |
47 | /**
48 | * 각 HookContextNode 내의 모든 JSXText 노드를 순회하여,
49 | * checkLanguage(text)가 true인 경우 t() 호출로 래핑한다.
50 | * JSXText는 JSXExpressionContainer 내부에 t() 호출로 감싸진 형태가 된다.
51 | */
52 | wrapJSXText(): void {
53 | this.paths.forEach((path) => {
54 | path.traverse({
55 | JSXText: (jsxTextPath: NodePath) => {
56 | if (this.alreadyWrappedJSX(jsxTextPath)) {
57 | return;
58 | }
59 | const text = jsxTextPath.node.value;
60 | // 필요에 따라 공백을 제거(여기서는 trim 후 빈 문자열이면 패스)
61 | const trimmed = text.trim();
62 | if (trimmed && this.checkLanguage(trimmed)) {
63 | const newCallExpr = t.callExpression(t.identifier("t"), [
64 | t.stringLiteral(trimmed),
65 | ]);
66 | const jsxExprContainer = t.jsxExpressionContainer(newCallExpr);
67 | jsxTextPath.replaceWith(jsxExprContainer);
68 | }
69 | },
70 | });
71 | });
72 | }
73 |
74 | wrapTemplateLiteral(): void {
75 | this.paths.forEach((path) => {
76 | path.traverse({
77 | TemplateLiteral: (tplPath: NodePath) => {
78 | const { translationKey, properties } = getTemplateLiteralKey(tplPath);
79 |
80 | // 템플릿 리터럴 전체 텍스트(translationKey)에 한글이 포함되어 있는지 검사
81 | if (!this.checkLanguage(translationKey)) {
82 | // 한글이 없다면 변환하지 않고 그대로 둠
83 | return;
84 | }
85 |
86 | const objExpr = t.objectExpression(properties);
87 | const callExpr = t.callExpression(t.identifier("t"), [
88 | t.stringLiteral(translationKey),
89 | objExpr,
90 | ]);
91 |
92 | tplPath.replaceWith(callExpr);
93 | },
94 | });
95 | });
96 | }
97 |
98 | private aleadlyWrappedStringLiteral(path: NodePath): boolean {
99 | return (
100 | t.isCallExpression(path.parent) &&
101 | t.isIdentifier(path.parent.callee) &&
102 | path.parent.callee.name === "t"
103 | );
104 | }
105 |
106 | private alreadyWrappedJSX(path: NodePath): boolean {
107 | if (t.isJSXExpressionContainer(path.parent)) {
108 | const expr = path.parent.expression;
109 | if (
110 | t.isCallExpression(expr) &&
111 | t.isIdentifier(expr.callee) &&
112 | expr.callee.name === "t"
113 | ) {
114 | return true;
115 | }
116 | }
117 | return false;
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/generator/index.ts:
--------------------------------------------------------------------------------
1 | import generate from "@babel/generator";
2 | import * as t from "@babel/types";
3 | import * as fs from "fs";
4 | import * as prettier from "prettier";
5 | import * as path from "path";
6 | import { ExtractedText } from "../core/type";
7 |
8 | export class Generator {
9 | private enablePrettier: boolean;
10 | constructor({ enablePrettier }: { enablePrettier: boolean }) {
11 | this.enablePrettier = enablePrettier;
12 | }
13 |
14 | async generate(ast: t.File, filePath: string): Promise {
15 | const code = this.generateCode(ast);
16 |
17 | const formattedCode = await this.formatCode(code);
18 |
19 | this.writeFile(formattedCode, filePath);
20 | }
21 |
22 | async generateJson(
23 | data: ExtractedText[],
24 | locales: string[],
25 | outputDir: string,
26 | outputFileName: string
27 | ): Promise {
28 | const formattedData = this.formatExtractedText(data);
29 | const json = JSON.stringify(formattedData, null, 2).replace(
30 | /(\s+)"(__comment_\d+)"/g,
31 | '\n$1"$2"'
32 | );
33 |
34 | locales.forEach((locale) => {
35 | const filePath = `${outputDir}/${locale}/${outputFileName}`;
36 | this.writeFile(json, filePath);
37 | });
38 | }
39 |
40 | private generateCode(ast: t.File): string {
41 | return generate(ast, {
42 | jsescOption: { minimal: true },
43 | }).code;
44 | }
45 |
46 | private writeFile(content: string, filePath: string): void {
47 | const dir = path.dirname(filePath);
48 |
49 | // recursive: true 옵션으로 경로 전체에 대한 디렉토리 생성
50 | if (!fs.existsSync(dir)) {
51 | fs.mkdirSync(dir, { recursive: true });
52 | }
53 |
54 | fs.writeFileSync(filePath, content);
55 | }
56 |
57 | private async formatCode(code: string): Promise {
58 | if (!this.enablePrettier) {
59 | return code;
60 | }
61 |
62 | const configPath = await prettier.resolveConfigFile();
63 | if (!configPath) {
64 | console.log("Prettier config file not found");
65 | return await prettier.format(code, {
66 | parser: "babel-ts",
67 | });
68 | }
69 |
70 | const config =
71 | (await prettier.resolveConfig(process.cwd(), {
72 | editorconfig: true,
73 | config: configPath,
74 | })) || {};
75 |
76 | // format에 오류가 발생할 수 있음
77 | return await prettier.format(code, {
78 | ...config,
79 | parser: "babel-ts",
80 | });
81 | }
82 |
83 | private formatExtractedText(data: ExtractedText[]): Record {
84 | // trwapper 분리
85 |
86 | const twrappedTexts = data.filter((item) => item.isTWrapped);
87 |
88 | // trwpper 아닌놈들은 containerName을 key로 그룹화
89 | const notTwrappedTexts = data.filter((item) => !item.isTWrapped);
90 | const groupedTexts = notTwrappedTexts.reduce<
91 | Record
92 | >((acc, item) => {
93 | if (!acc[item.containerName]) {
94 | acc[item.containerName] = [];
95 | }
96 | acc[item.containerName].push(item);
97 | return acc;
98 | }, {});
99 |
100 | // record 형태로 만들기
101 |
102 | return {
103 | ...this.plainJson(twrappedTexts),
104 | ...this.groupToPlainJson(groupedTexts),
105 | };
106 | }
107 |
108 | private plainJson(data: ExtractedText[]): Record {
109 | const result: Record = {};
110 |
111 | data.forEach((item) => {
112 | result[item.text] = item.text;
113 | });
114 |
115 | return result;
116 | }
117 |
118 | private groupToPlainJson(
119 | data: Record
120 | ): Record {
121 | const result: Record = {};
122 |
123 | Object.keys(data).forEach((key, index) => {
124 | result[`__comment_${index}`] = key;
125 | data[key].forEach((item) => {
126 | result[item.text] = item.text;
127 | });
128 | });
129 | return result;
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/extractor/index.ts:
--------------------------------------------------------------------------------
1 | import * as t from "@babel/types";
2 | import traverse, { NodePath } from "@babel/traverse";
3 | import { getTemplateLiteralKey } from "../common";
4 | import { ExtractedText } from "../core/type";
5 |
6 | export class Extractor {
7 | private results: ExtractedText[] = [];
8 |
9 | constructor(
10 | private readonly ast: t.File,
11 | private readonly checkLanguage: (text: string) => boolean,
12 | private readonly filepath: string
13 | ) {}
14 |
15 | public extract() {
16 | traverse(this.ast, {
17 | StringLiteral: (path) => this.handleStringLiteral(path),
18 | JSXText: (path) => this.handleJSXText(path),
19 | TemplateLiteral: (path) => this.handleTemplateLiteral(path),
20 | });
21 |
22 | const result = this.results;
23 | this.results = [];
24 | return result;
25 | }
26 |
27 | private handleStringLiteral(path: NodePath) {
28 | const text = path.node.value;
29 | if (!this.checkLanguage(text)) {
30 | return;
31 | }
32 |
33 | const isTWrapped = this.checkTWrapper(path);
34 | const containerName = this.findContainerName(path);
35 |
36 | this.results.push({
37 | text,
38 | isTWrapped,
39 | containerName: this.filepath + "/" + (containerName || ""),
40 | });
41 | }
42 |
43 | private handleJSXText(path: NodePath) {
44 | const text = path.node.value.trim();
45 | if (!text || !this.checkLanguage(text)) {
46 | return;
47 | }
48 |
49 | const isTWrapped = false; // twrapper 후에는 JSXText로 인식되지 않음
50 | const containerName = this.findContainerName(path);
51 |
52 | this.results.push({
53 | text,
54 | isTWrapped,
55 | containerName: this.filepath + "/" + (containerName || ""),
56 | });
57 | }
58 |
59 | private handleTemplateLiteral(path: NodePath) {
60 | const { translationKey } = getTemplateLiteralKey(path);
61 |
62 | if (!this.checkLanguage(translationKey)) {
63 | return;
64 | }
65 |
66 | const isTWrapped = this.checkTWrapper(path);
67 | const containerName = this.findContainerName(path);
68 |
69 | this.results.push({
70 | text: translationKey,
71 | isTWrapped,
72 | containerName: this.filepath + "/" + (containerName || ""),
73 | });
74 | }
75 |
76 | private checkTWrapper(path: NodePath): boolean {
77 | let current: NodePath | null = path;
78 |
79 | while (current) {
80 | if (
81 | current.isCallExpression() &&
82 | t.isIdentifier(current.node.callee) &&
83 | current.node.callee.name === "t"
84 | ) {
85 | return true;
86 | }
87 |
88 | current = current.parentPath;
89 | }
90 |
91 | return false;
92 | }
93 |
94 | private findContainerName(path: NodePath): string | null {
95 | const funcPath = this.findFunctionPath(path);
96 |
97 | if (funcPath) {
98 | // 함수 선언문인 경우
99 | if (t.isFunctionDeclaration(funcPath.node)) {
100 | return funcPath.node.id?.name || null;
101 | }
102 |
103 | // 함수 표현식 혹은 화살표 함수인 경우
104 | if (
105 | t.isFunctionExpression(funcPath.node) ||
106 | t.isArrowFunctionExpression(funcPath.node)
107 | ) {
108 | if (funcPath.parentPath?.isVariableDeclarator()) {
109 | const declId = funcPath.parentPath.node.id;
110 | if (t.isIdentifier(declId)) {
111 | return declId.name;
112 | }
113 | }
114 | }
115 |
116 | return null;
117 | }
118 |
119 | // 함수가 아니라면 변수 선언의 이름을 추출
120 | const varDeclPath = path.findParent((p) =>
121 | t.isVariableDeclarator(p.node)
122 | ) as NodePath | null;
123 | if (varDeclPath) {
124 | const decId = varDeclPath.node.id;
125 | if (t.isIdentifier(decId)) {
126 | return decId.name;
127 | }
128 | }
129 |
130 | return null;
131 | }
132 |
133 | private findFunctionPath(path: NodePath): NodePath | null {
134 | return path.findParent(
135 | (p) =>
136 | t.isFunctionDeclaration(p.node) ||
137 | t.isFunctionExpression(p.node) ||
138 | t.isArrowFunctionExpression(p.node)
139 | ) as NodePath | null;
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/__test__/extractor.test.ts:
--------------------------------------------------------------------------------
1 | import generate from "@babel/generator";
2 | import * as parser from "@babel/parser";
3 | import { Extractor } from "../extractor";
4 | import { createLanguageCheckFunction } from "../common/language";
5 |
6 | const parseCode = (code: string) => {
7 | return parser.parse(code, {
8 | sourceType: "module",
9 | plugins: ["jsx", "typescript"],
10 | });
11 | };
12 |
13 | describe("extractor", () => {
14 | it("component", () => {
15 | const code = `
16 | function MyComponent() {
17 | const msg = "안녕하세요"; // 한글 (비래핑)
18 | console.log(t("이미 t()래핑된 문자열"));
19 | return 반갑습니다
;
20 | }
21 | `;
22 |
23 | const ast = parseCode(code);
24 |
25 | const result = new Extractor(
26 | ast,
27 | createLanguageCheckFunction("ko"),
28 | ""
29 | ).extract();
30 |
31 | expect(result[0]).toEqual({
32 | text: "안녕하세요",
33 | isTWrapped: false,
34 | containerName: "/MyComponent",
35 | });
36 | expect(result[1]).toEqual({
37 | text: "이미 t()래핑된 문자열",
38 | isTWrapped: true,
39 | containerName: "/MyComponent",
40 | });
41 | expect(result[2]).toEqual({
42 | text: "반갑습니다",
43 | isTWrapped: false,
44 | containerName: "/MyComponent",
45 | });
46 | });
47 |
48 | it("not component", () => {
49 | const code = `
50 | function helperFunction() {
51 | return "안녕하세요";
52 | }
53 | `;
54 |
55 | const ast = parseCode(code);
56 |
57 | const result = new Extractor(
58 | ast,
59 | createLanguageCheckFunction("ko"),
60 | ""
61 | ).extract();
62 |
63 | expect(result[0]).toEqual({
64 | text: "안녕하세요",
65 | isTWrapped: false,
66 | containerName: "/helperFunction",
67 | });
68 | });
69 |
70 | it("object", () => {
71 | const code = `
72 | const user = {
73 | name: "양성훈",
74 | sex: "남",
75 | };
76 | `;
77 |
78 | const ast = parseCode(code);
79 |
80 | const result = new Extractor(
81 | ast,
82 | createLanguageCheckFunction("ko"),
83 | ""
84 | ).extract();
85 |
86 | console.log(result);
87 |
88 | expect(result[0]).toEqual({
89 | text: "양성훈",
90 | isTWrapped: false,
91 | containerName: "/user",
92 | });
93 | expect(result[1]).toEqual({
94 | text: "남",
95 | isTWrapped: false,
96 | containerName: "/user",
97 | });
98 | });
99 |
100 | it("handles arrow function returning Korean string", () => {
101 | const code = `
102 | const greet = () => {
103 | return "안녕!";
104 | };
105 | `;
106 |
107 | const ast = parseCode(code);
108 |
109 | const result = new Extractor(
110 | ast,
111 | createLanguageCheckFunction("ko"),
112 | ""
113 | ).extract();
114 |
115 | expect(result[0]).toEqual({
116 | text: "안녕!",
117 | isTWrapped: false,
118 | containerName: "/greet",
119 | });
120 | });
121 |
122 | it("handles class method returning Korean string", () => {
123 | const code = `
124 | class Greeter {
125 | sayHello() {
126 | return "안녕하세요, 클래스!";
127 | }
128 | }
129 | `;
130 |
131 | const ast = parseCode(code);
132 |
133 | const result = new Extractor(
134 | ast,
135 | createLanguageCheckFunction("ko"),
136 | ""
137 | ).extract();
138 |
139 | expect(result[0]).toEqual({
140 | text: "안녕하세요, 클래스!",
141 | isTWrapped: false,
142 | containerName: "/",
143 | });
144 | });
145 |
146 | it("handles template literal with Korean text", () => {
147 | const code = `
148 | function templatedGreeting(name: string) {
149 | return \`반가워요, \${name}!\`;
150 | }
151 | `;
152 |
153 | const ast = parseCode(code);
154 |
155 | const result = new Extractor(
156 | ast,
157 | createLanguageCheckFunction("ko"),
158 | ""
159 | ).extract();
160 |
161 | expect(result[0]).toEqual({
162 | text: "반가워요, {{name}}!",
163 | isTWrapped: false,
164 | containerName: "/templatedGreeting",
165 | });
166 | });
167 |
168 | it("handle object in array", () => {
169 | const code = `
170 | const users = [
171 | {
172 | name: "양성훈",
173 | }
174 | ];
175 | `;
176 | const ast = parseCode(code);
177 |
178 | const result = new Extractor(
179 | ast,
180 | createLanguageCheckFunction("ko"),
181 | ""
182 | ).extract();
183 |
184 | expect(result[0]).toEqual({
185 | text: "양성훈",
186 | isTWrapped: false,
187 | containerName: "/users",
188 | });
189 | });
190 | });
191 |
--------------------------------------------------------------------------------
/src/core/insertion.ts:
--------------------------------------------------------------------------------
1 | import { NodePath } from "@babel/traverse";
2 | import { HookContextNode } from "./type";
3 | import * as t from "@babel/types";
4 | import { find, has } from "../common";
5 | import { RunType } from "../type";
6 |
7 | function getImportModuleName(runType: RunType) {
8 | switch (runType) {
9 | case "next":
10 | return "next-i18next";
11 | case "react":
12 | return "react-i18next";
13 | default:
14 | throw new Error("Run type is not specified");
15 | }
16 | }
17 |
18 | export class Insertion {
19 | private importModuleName: string;
20 | constructor(
21 | private readonly paths: NodePath[],
22 | private readonly parsedFileAST: t.File,
23 | runType: RunType = "next"
24 | ) {
25 | this.importModuleName = getImportModuleName(runType);
26 | }
27 |
28 | insert() {
29 | try {
30 | this.wrapFunctionsWithBlockStatement();
31 | const isChanged = this.insertUseTranslationHook();
32 | this.insertImportDeclartion();
33 |
34 | return isChanged;
35 | } catch (error: unknown) {
36 | if (error instanceof Error) {
37 | throw new Error(`Failed to insert translations: ${error.message}`);
38 | }
39 | // 일반 객체나 다른 타입의 에러인 경우
40 | throw new Error(`Failed to insert translations: ${String(error)}`);
41 | }
42 | }
43 |
44 | insertImportDeclartion() {
45 | const programPath = find(this.parsedFileAST, t.isProgram);
46 |
47 | if (!programPath) {
48 | throw new Error("Invalid file: Program node not found");
49 | }
50 |
51 | if (!this.shouldInsertImportDeclaration(programPath)) {
52 | return;
53 | }
54 |
55 | const importDeclaration = t.importDeclaration(
56 | [
57 | t.importSpecifier(
58 | t.identifier("useTranslation"),
59 | t.identifier("useTranslation")
60 | ),
61 | ],
62 | t.stringLiteral(this.importModuleName)
63 | );
64 |
65 | // Program 노드의 body 최상단에 import 선언 삽입
66 | programPath.unshiftContainer("body", importDeclaration);
67 | }
68 |
69 | private shouldInsertImportDeclaration(
70 | programPath: NodePath
71 | ): boolean {
72 | return (
73 | !has(
74 | programPath,
75 | (node) =>
76 | t.isImportDeclaration(node) &&
77 | node.source.value === this.importModuleName
78 | ) && this.hasTCall(programPath)
79 | );
80 | }
81 |
82 | wrapFunctionsWithBlockStatement() {
83 | this.paths.forEach((path) => {
84 | if (
85 | t.isArrowFunctionExpression(path.node) &&
86 | !t.isBlockStatement(path.node.body)
87 | ) {
88 | path.node.body = t.blockStatement([t.returnStatement(path.node.body)]);
89 | }
90 | });
91 | }
92 |
93 | insertUseTranslationHook() {
94 | let isChanged = false;
95 | this.paths.forEach((path) => {
96 | if (!this.shouldInsertTranslationHook(path)) return;
97 |
98 | const hookInjection = t.variableDeclaration("const", [
99 | t.variableDeclarator(
100 | t.objectPattern([
101 | t.objectProperty(t.identifier("t"), t.identifier("t"), false, true),
102 | ]),
103 | t.callExpression(t.identifier("useTranslation"), [])
104 | ),
105 | ]);
106 |
107 | const blockPath = path.get("body") as NodePath;
108 | blockPath.node.body.unshift(hookInjection);
109 | isChanged = true;
110 | });
111 | return isChanged;
112 | }
113 |
114 | private shouldInsertTranslationHook(
115 | path: NodePath
116 | ): boolean {
117 | return (
118 | this.isTopLevelFunction(path) &&
119 | this.hasTCall(path) &&
120 | t.isBlockStatement(path.node.body) &&
121 | !this.isAlreadyInjectedHook(path)
122 | );
123 | }
124 |
125 | private hasTCall(path: NodePath): boolean {
126 | return has(
127 | path,
128 | (node) =>
129 | t.isCallExpression(node) &&
130 | t.isIdentifier(node.callee) &&
131 | node.callee.name === "t"
132 | );
133 | }
134 |
135 | private isTopLevelFunction(path: NodePath): boolean {
136 | const parentFn = path.findParent(
137 | (p) =>
138 | t.isFunctionDeclaration(p.node) ||
139 | t.isFunctionExpression(p.node) ||
140 | t.isArrowFunctionExpression(p.node)
141 | );
142 |
143 | return !parentFn;
144 | }
145 |
146 | private isAlreadyInjectedHook(path: NodePath): boolean {
147 | const blockPath = path.get("body") as NodePath;
148 | const firstStmt = blockPath.node.body[0];
149 |
150 | return (
151 | t.isVariableDeclaration(firstStmt) &&
152 | t.isVariableDeclarator(firstStmt.declarations[0]) &&
153 | t.isObjectPattern(firstStmt.declarations[0].id) &&
154 | t.isCallExpression(firstStmt.declarations[0].init) &&
155 | t.isIdentifier(firstStmt.declarations[0].init.callee) &&
156 | firstStmt.declarations[0].init.callee.name === "useTranslation"
157 | );
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # i18nmatic
2 |
3 | **"The fastest way to implement internationalization"**
4 |
5 | `i18nmatic` is a CLI tool that automates code transformation and translation key extraction, allowing you to quickly and efficiently implement internationalization (i18n) in React and Next.js projects. Quickly apply internationalization to validate your ideas and business in the global market!
6 |
7 | ## Why?
8 |
9 | To expand your business and seize global opportunities, **internationalization (i18n)** is no longer optional—it's essential.
10 |
11 | React and Next.js projects typically use libraries such as `react-i18next` or `next-i18next` to handle multilingual content. However, wrapping every text manually in `t()` functions and managing extracted translation keys in JSON files is tedious, repetitive, and resource-intensive, especially in large codebases.
12 |
13 | **i18nmatic** automates these repetitive tasks, enabling developers to focus on higher-level problems and empowering your business to quickly validate ideas in the global market.
14 |
15 | ## 📺 Demo
16 |
17 | [](https://github.com/user-attachments/assets/11c3a36b-fb36-4c19-b0f9-c85596269a2a)
18 |
19 | See how quickly `i18nmatic` transforms your code and extracts translation keys.
20 |
21 | ## Key Features
22 |
23 | - **Automatic code transformation**: Detects all text requiring internationalization in JSX, string literals, template literals, etc. Automatically extracts translation keys based on the selected language, wraps them with `t()`, and injects the necessary imports.
24 | - **Translation key extraction**: Extracts all text requiring translation—even if not yet wrapped with t()—and outputs keys with source file paths into JSON, enabling efficient management and traceability.
25 | - **Multilingual support**: Supports major languages including Korean, English, Japanese, and Chinese.
26 | - **React/Next.js compatibility**: Fully compatible with `react-i18next` and `next-i18next`.
27 |
28 | ## Installation
29 |
30 | ```bash
31 | npm install -D i18nmatic
32 | # or
33 | yarn add -D i18nmatic
34 | ```
35 |
36 | ## Usage
37 |
38 | ### 1. Create a configuration file
39 |
40 | **Create an `auto-i18n.config.json` file in your project's root directory** ([Click here to see the default configuration options](https://github.com/seonghunYang/i18nmatic?tab=readme-ov-file#-configuration-auto-i18nconfigjson)):
41 |
42 | ```json
43 | {
44 | "runType": "next", // Choose between "next" or "react"
45 | // - "next": Use for Next.js projects
46 | // - "react": Use for React projects
47 |
48 | "entry": "src", // Root directory of your source code
49 | // - Example: "src" targets all files in the src directory
50 |
51 | "locales": ["en", "ja-JP"], // Array of locale codes to support
52 | // - Example: ["en", "ja-JP"] supports English and Japanese
53 | // - JSON files are generated separately per language
54 |
55 | "outputDir": "public/locales", // Directory to store generated translation JSON files
56 | // - Example: "public/locales" is compatible with Next.js static paths
57 |
58 | "enablePrettier": true, // Whether to format generated code and JSON files using Prettier
59 | // - true: Use Prettier formatting
60 | // - false: Save original formatting
61 |
62 | "outputFileName": "common.json", // Name of the generated translation JSON file
63 | // - Example: "common.json" is consistent across languages
64 |
65 | "keyLanguage": "ko" // Base language to extract translation keys
66 | // - Example: "ko" extracts Korean text as translation keys
67 | // - Supported values: "ko", "en", "ja", "zh", etc.
68 | }
69 |
70 | ```
71 |
72 | ### 2. Run CLI
73 |
74 | Execute the following command to transform code and extract translation keys:
75 |
76 | ```bash
77 | npx auto-i18n
78 | ```
79 |
80 | Or add a script to `package.json`:
81 |
82 | ```json
83 | "scripts": {
84 | "auto-i18n": "auto-i18n"
85 | }
86 | ```
87 |
88 | Then run:
89 |
90 | ```bash
91 | npm run auto-i18n
92 | # or
93 | yarn auto-i18n
94 | ```
95 |
96 | ### 3. Transformation results
97 |
98 | ### Before:
99 |
100 | ```jsx
101 | function Greeting() {
102 | return 안녕하세요
;
103 | }
104 | ```
105 |
106 | ### After:
107 |
108 | ```jsx
109 | import { useTranslation } from "next-i18next";
110 |
111 | function Greeting() {
112 | const { t } = useTranslation();
113 | return {t("안녕하세요")}
;
114 | }
115 | ```
116 |
117 | ### Extracted JSON keys (`public/locales/en/common.json`):
118 |
119 | ```json
120 | {
121 | "안녕하세요": "안녕하세요"
122 | }
123 | ```
124 |
125 | ## Examples
126 |
127 | ### **Input Code (Before Transformation)**
128 |
129 | ```jsx
130 | // 템플릿 리터럴
131 | function TemplateLiteralComponent({ name }) {
132 | return {`${name}님 안녕하세요`}
;
133 | }
134 |
135 | // JSX 속성
136 | function JSXAttributeComponent() {
137 | return ;
138 | }
139 | ```
140 |
141 | ### **Transformed Code (After Transformation)**
142 |
143 | ```jsx
144 | import { useTranslation } from "next-i18next";
145 |
146 | function TemplateLiteralComponent({ name }) {
147 | const { t } = useTranslation();
148 | return {t("{{name}}님 안녕하세요", { name })}
;
149 | }
150 |
151 | function JSXAttributeComponent() {
152 | const { t } = useTranslation();
153 | return ;
154 | }
155 |
156 | ```
157 |
158 | ### **Extracted JSON File (`public/locales/{locale}/common.json`)**
159 |
160 | ```json
161 | {
162 | "{{name}}님 안녕하세요": "{{name}}님 안녕하세요",
163 | "안녕하세요 여기에 입력해 주세요": "안녕하세요 여기에 입력해 주세요"
164 | }
165 | ```
166 | ## When Automatic Wrapping is Difficult
167 |
168 | In certain scenarios, as shown below, it's difficult for the tool to automatically determine whether the attributes should be wrapped with the `t()` function, due to the lack of explicit context within the code itself.
169 |
170 | However, internationalization is still essential in these cases. To handle such scenarios, **i18nmatic** detects these texts, extracts them into JSON files, and includes a comment with the original source file path. This makes it easy for developers to manually locate and wrap the keys with `t()`.
171 |
172 | ### **Example Input Code**
173 |
174 | ```jsx
175 | // src/components/example.tsx
176 |
177 | const ITEMS = [
178 | {
179 | id: 1,
180 | title: '안녕하세요',
181 | description: '반갑습니다.',
182 | },
183 | {
184 | id: 2,
185 | title: '잘부탁드립니다.',
186 | description: '고맙습니다.',
187 | },
188 | {
189 | id: 3,
190 | title: '미안합니다.',
191 | description: '감사합니다.',
192 | },
193 | ];
194 |
195 | function Example() {
196 | return (
197 | <>
198 | {ITEMS.map((item) => (
199 |
200 |
{item.title}
201 |
{item.description}
202 |
203 | ))}
204 | >
205 | );
206 | }
207 | ```
208 |
209 | ### **Extracted JSON File Example (`public/locales/{locale}/common.json`)**
210 |
211 | ```json
212 | {
213 | ...
214 |
215 | "__comment_1": "src/components/example.tsx/ITEMS",
216 | "반갑습니다.": "반갑습니다.",
217 | "고맙습니다.": "고맙습니다.",
218 | "잘부탁드립니다.": "잘부탁드립니다.",
219 | "감사합니다.": "감사합니다.",
220 | "미안합니다.": "미안합니다.",
221 |
222 | ...
223 | }
224 | ```
225 |
226 | ## Supported Patterns
227 |
228 | - **JSX text**: `안녕하세요
` → `{t("안녕하세요")}
`
229 | - **String literals**: `const greeting = "안녕하세요";` → `const greeting = t("안녕하세요");`
230 | - **Template literals**: `const message = `${name}님 안녕하세요`;` → `const message = t("{{name}}님 안녕하세요", { name });`
231 | - **JSX attributes**: `` → ``
232 | - **Conditional expressions**: `isKorean ? "안녕하세요" : "Hello"` → `isKorean ? t("안녕하세요") : t("Hello")`
233 |
234 | ### 📘 Configuration (`auto-i18n.config.json`)
235 |
236 | | Option | Type | Default | Description |
237 | | --- | --- | --- | --- |
238 | | `runType` | `"next"` \| `"react"` | `"next"` | Framework type used in your project. |
239 | | `entry` | `string` | `"src"` | Root directory for your source code. |
240 | | `locales` | `string[]` | `["ja_JP"]` | Supported locale codes (e.g., `["en", "ja-JP"]`). |
241 | | `outputDir` | `string` | `"public/locales"` | Directory for generated translation JSON files. |
242 | | `enablePrettier` | `boolean` | `true` | Format output using Prettier. |
243 | | `outputFileName` | `string` | `"common.json"` | Filename for generated translation files. |
244 | | `keyLanguage` | `"ko"` \| `"en"` \| `"ja"` \| `"zh"` | `"ko"` | Base language for extracting translation keys. |
245 |
246 | ## Testing
247 |
248 | This project uses Jest for testing. To run tests:
249 |
250 | ```bash
251 | npm test
252 | ```
253 |
254 | ## Contributing
255 |
256 | Contributions are always welcome! Please follow these steps:
257 |
258 | 1. Fork this repository.
259 | 2. Create a new branch: `git checkout -b feature/my-feature`
260 | 3. Ensure all existing tests pass, and add relevant tests for your changes.
261 | 4. Commit your changes: `git commit -m "Add my feature"`
262 | 5. Push to your branch: `git push origin feature/my-feature`
263 | 6. Create a Pull Request.
264 |
265 | ## License
266 |
267 | This project is licensed under the MIT License.
268 |
269 | ## Contact
270 |
271 | If you have questions or issues, please open a GitHub issue.
272 |
--------------------------------------------------------------------------------
/src/__test__/core.test.ts:
--------------------------------------------------------------------------------
1 | import * as parser from "@babel/parser";
2 | import traverse from "@babel/traverse";
3 | import { createLanguageCheckFunction } from "../common/language";
4 | import * as demoCode from "./demo";
5 | import * as core from "../core";
6 | import generate from "@babel/generator";
7 |
8 | describe("isFunctionalComponent", () => {
9 | const testCases = [
10 | { code: demoCode.componentDeclaration, expected: true },
11 | { code: demoCode.componentExpression, expected: true },
12 | { code: demoCode.componentArrow, expected: true },
13 | { code: demoCode.hookDeclaration, expected: false },
14 | { code: demoCode.hookExpression, expected: false },
15 | { code: demoCode.hookArrow, expected: false },
16 | { code: demoCode.helperFunction, expected: false },
17 | ];
18 |
19 | testCases.forEach(({ code, expected }, index) => {
20 | it(`should return ${expected} for test case ${index + 1}`, () => {
21 | const ast = parser.parse(code, {
22 | sourceType: "module",
23 | plugins: ["jsx"],
24 | });
25 | traverse(ast, {
26 | FunctionDeclaration(path) {
27 | expect(core.isFunctionalComponent(path)).toBe(expected);
28 | },
29 | FunctionExpression(path) {
30 | expect(core.isFunctionalComponent(path)).toBe(expected);
31 | },
32 | ArrowFunctionExpression(path) {
33 | expect(core.isFunctionalComponent(path)).toBe(expected);
34 | },
35 | });
36 | });
37 | });
38 | });
39 |
40 | describe("isHook", () => {
41 | const testCases = [
42 | { code: demoCode.componentDeclaration, expected: false },
43 | { code: demoCode.componentExpression, expected: false },
44 | { code: demoCode.componentArrow, expected: false },
45 | { code: demoCode.hookDeclaration, expected: true },
46 | { code: demoCode.hookExpression, expected: true },
47 | { code: demoCode.hookArrow, expected: true },
48 | { code: demoCode.helperFunction, expected: false },
49 | ];
50 | testCases.forEach(({ code, expected }, index) => {
51 | it(`should return ${expected} for test case ${index + 1}`, () => {
52 | const ast = parser.parse(code, {
53 | sourceType: "module",
54 | plugins: ["jsx"],
55 | });
56 | traverse(ast, {
57 | FunctionDeclaration(path) {
58 | expect(core.isHook(path)).toBe(expected);
59 | },
60 | FunctionExpression(path) {
61 | expect(core.isHook(path)).toBe(expected);
62 | },
63 | ArrowFunctionExpression(path) {
64 | expect(core.isHook(path)).toBe(expected);
65 | },
66 | });
67 | });
68 | });
69 | });
70 |
71 | describe("isHookContextNode", () => {
72 | const testCases = [
73 | { code: demoCode.componentDeclaration, expected: true },
74 | { code: demoCode.componentExpression, expected: true },
75 | { code: demoCode.componentArrow, expected: true },
76 | { code: demoCode.hookDeclaration, expected: true },
77 | { code: demoCode.hookExpression, expected: true },
78 | { code: demoCode.hookArrow, expected: true },
79 | { code: demoCode.helperFunction, expected: false },
80 | ];
81 | testCases.forEach(({ code, expected }, index) => {
82 | it(`should return ${expected} for test case ${index + 1}`, () => {
83 | const ast = parser.parse(code, {
84 | sourceType: "module",
85 | plugins: ["jsx"],
86 | });
87 | traverse(ast, {
88 | FunctionDeclaration(path) {
89 | expect(core.isHookContextNode(path)).toBe(expected);
90 | },
91 | FunctionExpression(path) {
92 | expect(core.isHookContextNode(path)).toBe(expected);
93 | },
94 | ArrowFunctionExpression(path) {
95 | expect(core.isHookContextNode(path)).toBe(expected);
96 | },
97 | });
98 | });
99 | });
100 | });
101 |
102 | describe("findHookContextNode", () => {
103 | it("should find all hook context nodes", () => {
104 | const ast = parser.parse(
105 | `
106 | ${demoCode.componentDeclaration}
107 | ${demoCode.componentExpression}
108 | ${demoCode.componentArrow}
109 | ${demoCode.hookDeclaration}
110 | ${demoCode.hookExpression}
111 | ${demoCode.hookArrow}
112 | ${demoCode.helperFunction}
113 | `,
114 | {
115 | sourceType: "module",
116 | plugins: ["jsx"],
117 | }
118 | );
119 |
120 | const hookContextNodes = core.findHookContextNode(ast);
121 | expect(hookContextNodes).toHaveLength(6);
122 | });
123 | });
124 |
125 | describe("TWrapper", () => {
126 | it("should wrap string literal containing Korean with t()", () => {
127 | const code = `
128 | const Component = () => {
129 | const greeting = "안녕하세요";
130 | return {greeting}
;
131 | }
132 | `;
133 |
134 | const ast = parser.parse(code, {
135 | sourceType: "module",
136 | plugins: ["jsx", "typescript"],
137 | });
138 | // HookContextNode 후보로 ArrowFunctionExpression 하나를 찾음
139 | const hookContextNodes = core.findHookContextNode(ast);
140 |
141 | // Wrapper 인스턴스를 생성하여 wrapping 실행
142 | const wrapper = new core.TWrapper(
143 | hookContextNodes,
144 | createLanguageCheckFunction("ko")
145 | );
146 | wrapper.wrapStringLiteral();
147 |
148 | // 전체 AST를 코드 문자열로 변환하여 t() 호출이 있는지 확인
149 | const output = generate(ast, {
150 | jsescOption: {
151 | minimal: true,
152 | },
153 | }).code;
154 |
155 | expect(output).toContain('t("안녕하세요")');
156 | });
157 |
158 | it("wraps JSXText containing Korean with t()", () => {
159 | const code = `
160 | const Component = () => (
161 |
162 | 안녕하세요
163 |
164 | );
165 | `;
166 | const ast = parser.parse(code, {
167 | sourceType: "module",
168 | plugins: ["jsx", "typescript"],
169 | });
170 | const hookContextNodes = core.findHookContextNode(ast);
171 |
172 | const wrapper = new core.TWrapper(
173 | hookContextNodes,
174 | createLanguageCheckFunction("ko")
175 | );
176 | wrapper.wrapJSXText();
177 |
178 | // generate 시 jsescOption minimal 옵션을 사용하여 실제 한글이 그대로 보이게 한다.
179 | const output = generate(ast, {
180 | jsescOption: { minimal: true },
181 | }).code;
182 | // 기대: JSXText "안녕하세요"가 {t("안녕하세요")}로 변환됨.
183 | expect(output).toContain('{t("안녕하세요")}');
184 | });
185 |
186 | it("wraps JSXAttribute text aleardy have expression containing Korean with t()", () => {
187 | const code = `
188 | const Component = () => (
189 |
190 | );
191 | `;
192 | const ast = parser.parse(code, {
193 | sourceType: "module",
194 | plugins: ["jsx", "typescript"],
195 | });
196 | const hookContextNodes = core.findHookContextNode(ast);
197 |
198 | const wrapper = new core.TWrapper(
199 | hookContextNodes,
200 | createLanguageCheckFunction("ko")
201 | );
202 | wrapper.wrapStringLiteral();
203 |
204 | const output = generate(ast, {
205 | jsescOption: { minimal: true },
206 | }).code;
207 | // 기대: placeholder 속성 값이 {t("안녕하세요")} 형태로 변환됨.
208 | expect(output).toContain('placeholder={t("안녕하세요")}');
209 | });
210 |
211 | it("wraps JSXAttribute text containing Korean with t()", () => {
212 | const code = `
213 | const Component = () => (
214 |
215 | );
216 | `;
217 | const ast = parser.parse(code, {
218 | sourceType: "module",
219 | plugins: ["jsx", "typescript"],
220 | });
221 | const hookContextNodes = core.findHookContextNode(ast);
222 |
223 | const wrapper = new core.TWrapper(
224 | hookContextNodes,
225 | createLanguageCheckFunction("ko")
226 | );
227 | wrapper.wrapStringLiteral();
228 |
229 | const output = generate(ast, {
230 | jsescOption: { minimal: true },
231 | }).code;
232 | // 기대: placeholder 속성 값이 {t("안녕하세요")} 형태로 변환됨.
233 | expect(output).toContain('placeholder={t("안녕하세요")}');
234 | });
235 |
236 | it("wraps conditional expression consequent containing Korean with t()", () => {
237 | const code = `
238 | const Component = () => {
239 | const message = isKorean ? "안녕하세요" : "hello";
240 | return <> {message} >;
241 | }
242 | `;
243 | const ast = parser.parse(code, {
244 | sourceType: "module",
245 | plugins: ["jsx", "typescript"],
246 | });
247 | const hookContextNodes = core.findHookContextNode(ast);
248 |
249 | const wrapper = new core.TWrapper(
250 | hookContextNodes,
251 | createLanguageCheckFunction("ko")
252 | );
253 | wrapper.wrapStringLiteral();
254 |
255 | const output = generate(ast, {
256 | jsescOption: { minimal: true },
257 | }).code;
258 | // 기대: 삼항 연산자에서 "안녕하세요"가 t("안녕하세요")로 래핑되어야 함.
259 | expect(output).toContain('isKorean ? t("안녕하세요") : "hello"');
260 | });
261 |
262 | it("wraps conditional expression in Component containing Korean with t()", () => {
263 | const code = `
264 | const Component = () => {
265 | return <> {isKorean ? "안녕하세요" : "하hello"} >;
266 | }
267 | `;
268 | const ast = parser.parse(code, {
269 | sourceType: "module",
270 | plugins: ["jsx", "typescript"],
271 | });
272 | const hookContextNodes = core.findHookContextNode(ast);
273 |
274 | const wrapper = new core.TWrapper(
275 | hookContextNodes,
276 | createLanguageCheckFunction("ko")
277 | );
278 | wrapper.wrapStringLiteral();
279 |
280 | const output = generate(ast, {
281 | jsescOption: { minimal: true },
282 | }).code;
283 | // 기대: 삼항 연산자에서 "안녕하세요"가 t("안녕하세요")로 래핑되어야 함.
284 | expect(output).toContain('isKorean ? t("안녕하세요") : t("하hello")');
285 | });
286 |
287 | it("wraps a template literal with interpolations into a t() call", () => {
288 | const code = `
289 | const Component = () => {
290 | const message = \`\${user.name}님 \${time}에 만나요\`;
291 |
292 | return <> {message} >;
293 | }
294 | `;
295 | const ast = parser.parse(code, {
296 | sourceType: "module",
297 | plugins: ["jsx", "typescript"],
298 | });
299 | const hookContextNodes = core.findHookContextNode(ast);
300 |
301 | const wrapper = new core.TWrapper(
302 | hookContextNodes,
303 | createLanguageCheckFunction("ko")
304 | );
305 | wrapper.wrapTemplateLiteral();
306 |
307 | const output = generate(ast, {
308 | jsescOption: { minimal: true },
309 | }).code;
310 | // 예상 변환 결과:
311 | // const message = t("{{user.name}}님 {{time}}에 만나요", { "user.name": user.name, time });
312 | expect(output).toContain('t("{{user.name}}님 {{time}}에 만나요"');
313 | expect(output).toContain('"user.name": user.name');
314 | expect(output).toContain('"time": time');
315 | });
316 |
317 | it("should correctly wrap string literals, JSX text, attributes, template literals, and ternary expressions with t()", () => {
318 | // 1. AST 파싱
319 | const ast = parser.parse(demoCode.allCasesDemo, {
320 | sourceType: "module",
321 | plugins: ["jsx", "typescript"],
322 | });
323 |
324 | // 2. HookContextNode 후보 찾기
325 | const hookContextNodes = core.findHookContextNode(ast);
326 |
327 | // 3. TWrapper 인스턴스 생성 및 wrap 실행
328 | const wrapper = new core.TWrapper(
329 | hookContextNodes,
330 | // 간단히 한글 유무만 확인하도록 구현
331 | createLanguageCheckFunction("ko")
332 | );
333 | wrapper.wrap(); // wrap()은 StringLiteral, JSXText, TemplateLiteral 등을 모두 처리
334 |
335 | // 4. 변환 결과 확인
336 | const output = generate(ast, { jsescOption: { minimal: true } }).code;
337 |
338 | // 5. 모든 사례가 t()로 변환되었는지 검사
339 | // - 일반 문자열 리터럴
340 | expect(output).toContain('t("안녕하세요")');
341 | // - 템플릿 리터럴
342 | expect(output).toContain('t("안녕, {{user.name}}!", ');
343 | expect(output).toContain('"user.name": user.name');
344 | // - 삼항 연산자
345 | expect(output).toContain('isKorean ? t("안녕") : "Hello"');
346 | // - JSX Attribute
347 | expect(output).toContain('placeholder={t("잠시만요")}');
348 | // - JSX Text
349 | expect(output).toContain('{t("반갑습니다.")}');
350 | });
351 |
352 | it("should correctly wrap string literals, JSX text, attributes, template literals, and ternary expressions with t()", () => {
353 | // 1. AST 파싱
354 | const code = `
355 | function TypeAnnotatedTemplate(value: T) {
356 |
357 | return {\`\${value as string}님 - 한글\`}
;
358 | }
359 | `;
360 | const ast = parser.parse(code, {
361 | sourceType: "module",
362 | plugins: ["jsx", "typescript"],
363 | });
364 |
365 | // 2. HookContextNode 후보 찾기
366 | const hookContextNodes = core.findHookContextNode(ast);
367 |
368 | // 3. TWrapper 인스턴스 생성 및 wrap 실행
369 | const wrapper = new core.TWrapper(
370 | hookContextNodes,
371 | // 간단히 한글 유무만 확인하도록 구현
372 | createLanguageCheckFunction("ko")
373 | );
374 | wrapper.wrap(); // wrap()은 StringLiteral, JSXText, TemplateLiteral 등을 모두 처리
375 |
376 | // 4. 변환 결과 확인
377 | const output = generate(ast, {
378 | concise: true,
379 | jsescOption: { minimal: true },
380 | }).code;
381 |
382 | expect(output).toContain('{t("{{value}}님 - 한글", { "value": value })}');
383 | });
384 | });
385 |
386 | describe("Insertion", () => {
387 | it("converts arrow function with implicit return to a block statement with explicit return", () => {
388 | const code = `
389 | const Component = () => 안녕하세요
;
390 | `;
391 | const ast = parser.parse(code, {
392 | sourceType: "module",
393 | plugins: ["jsx", "typescript"],
394 | });
395 |
396 | // 2. HookContextNode 후보 찾기
397 | const hookContextNodes = core.findHookContextNode(ast);
398 |
399 | // 3. TWrapper 인스턴스 생성 및 wrap 실행
400 | const insertion = new core.Insertion(hookContextNodes, ast);
401 |
402 | insertion.wrapFunctionsWithBlockStatement();
403 |
404 | const output = generate(ast, { jsescOption: { minimal: true } }).code;
405 | // 예상 변환 결과:
406 | // const Component = () => {
407 | // return 안녕하세요
;
408 | // };
409 | expect(output).toContain("return 안녕하세요
;");
410 | expect(output).toContain("{");
411 | expect(output).toContain("}");
412 | });
413 |
414 | it("does not convert arrow function with explicit return", () => {
415 | const code = `
416 | const Component = () => {
417 | return 안녕하세요
;
418 | };
419 | `;
420 | const ast = parser.parse(code, {
421 | sourceType: "module",
422 | plugins: ["jsx", "typescript"],
423 | });
424 |
425 | // 2. HookContextNode 후보 찾기
426 | const hookContextNodes = core.findHookContextNode(ast);
427 |
428 | // 3. TWrapper 인스턴스 생성 및 wrap 실행
429 | const insertion = new core.Insertion(hookContextNodes, ast);
430 |
431 | insertion.wrapFunctionsWithBlockStatement();
432 |
433 | const output = generate(ast, { jsescOption: { minimal: true } }).code;
434 | // 예상 변환 결과:
435 | // const Component = () => {
436 | // return 안녕하세요
;
437 | // };
438 | expect(output).toContain("return 안녕하세요
;");
439 | expect(output).toContain("{");
440 | expect(output).toContain("}");
441 | });
442 |
443 | it("inserts useTranslation hook at the top of top-level function if t() exists", () => {
444 | const code = `
445 | const Component = () => {
446 | const handleClick = () => {
447 | alert(t('반갑습니다'));
448 | }
449 | return (
450 |
451 | );
452 | }
453 | `;
454 | const ast = parser.parse(code, {
455 | sourceType: "module",
456 | plugins: ["jsx", "typescript"],
457 | });
458 |
459 | // 2. HookContextNode 후보 찾기
460 | const hookContextNodes = core.findHookContextNode(ast);
461 |
462 | // 3. TWrapper 인스턴스 생성 및 wrap 실행
463 | const insertion = new core.Insertion(hookContextNodes, ast);
464 |
465 | // 먼저 block statement로 감싸는 작업 실행 (암시적 반환이 있을 경우 대비)
466 | insertion.insertUseTranslationHook();
467 |
468 | const output = generate(ast, {
469 | concise: true,
470 | jsescOption: { minimal: true },
471 | }).code;
472 | // 기대: 최상위 함수의 시작 부분에 const { t } = useTranslation(); 이 삽입되어 있어야 한다.
473 | expect(output).toContain("const { t } = useTranslation()");
474 | });
475 |
476 | it("inserts useTranslation hook at the top of top-level functions when t() exists in both components", () => {
477 | const code = `
478 | const Component1 = () => {
479 | const handleClick = () => {
480 | alert(t('반갑습니다'));
481 | }
482 | return (
483 |
484 | );
485 | }
486 |
487 | const Component2 = () =>
488 |
489 | `;
490 | const ast = parser.parse(code, {
491 | sourceType: "module",
492 | plugins: ["jsx", "typescript"],
493 | });
494 |
495 | // 2. HookContextNode 후보 찾기
496 | const hookContextNodes = core.findHookContextNode(ast);
497 |
498 | // 3. TWrapper 인스턴스 생성 및 useTranslation 훅 주입 실행
499 | const insertion = new core.Insertion(hookContextNodes, ast);
500 | insertion.wrapFunctionsWithBlockStatement();
501 | insertion.insertUseTranslationHook();
502 |
503 | const output = generate(ast, {
504 | concise: true,
505 | jsescOption: { minimal: true },
506 | }).code;
507 |
508 | // 정규식을 이용해 "const { t } = useTranslation()" 패턴이 두 번 이상 등장하는지 확인
509 | const regex = /const\s*{\s*t\s*}\s*=\s*useTranslation\(\)/g;
510 | const matches = output.match(regex);
511 |
512 | expect(matches).not.toBeNull();
513 | expect(matches!.length).toBe(2);
514 | });
515 |
516 | it("does not insert duplicate useTranslation hook if already present", () => {
517 | const code = `
518 | const Component = () => {
519 | const { t } = useTranslation();
520 | const handleClick = () => {
521 | alert(t('반갑습니다'));
522 | }
523 | return (
524 |
525 | );
526 | }
527 | `;
528 | const ast = parser.parse(code, {
529 | sourceType: "module",
530 | plugins: ["jsx", "typescript"],
531 | });
532 |
533 | // 2. HookContextNode 후보 찾기
534 | const hookContextNodes = core.findHookContextNode(ast);
535 |
536 | // 3. Insertion 인스턴스 생성 및 useTranslation 훅 주입 시도
537 | const insertion = new core.Insertion(hookContextNodes, ast);
538 | insertion.insertUseTranslationHook();
539 |
540 | const output = generate(ast, {
541 | concise: true,
542 | jsescOption: { minimal: true },
543 | }).code;
544 |
545 | // 정규식을 이용해 "const { t } = useTranslation()" 패턴이 정확히 한 번만 존재하는지 확인
546 | const regex = /const\s*{\s*t\s*}\s*=\s*useTranslation\(\)/g;
547 | const matches = output.match(regex);
548 |
549 | expect(matches).not.toBeNull();
550 | expect(matches!.length).toBe(1);
551 | });
552 |
553 | it("does not insert useTranslation hook if t() call is absent", () => {
554 | const code = `
555 | const Component = () => {
556 | return (
557 | Hello
558 | );
559 | }
560 | `;
561 |
562 | const ast = parser.parse(code, {
563 | sourceType: "module",
564 | plugins: ["jsx", "typescript"],
565 | });
566 |
567 | // 2. HookContextNode 후보 찾기
568 | const hookContextNodes = core.findHookContextNode(ast);
569 |
570 | // 3. TWrapper 인스턴스 생성 및 wrap 실행
571 | const insertion = new core.Insertion(hookContextNodes, ast);
572 |
573 | // 먼저 block statement로 감싸는 작업 실행 (암시적 반환이 있을 경우 대비)
574 | insertion.insertUseTranslationHook();
575 |
576 | const output = generate(ast, {
577 | concise: true,
578 | jsescOption: { minimal: true },
579 | }).code;
580 |
581 | expect(output).not.toContain("const { t } = useTranslation()");
582 | });
583 |
584 | it("should insert an import declaration when a t call exists and the import is missing", () => {
585 | const code = `
586 | const Component = () => {
587 | return (
588 | {t("안녕하세요")}
589 | );
590 | }
591 | `;
592 |
593 | const ast = parser.parse(code, {
594 | sourceType: "module",
595 | plugins: ["jsx", "typescript"],
596 | });
597 |
598 | const hookContextNodes = core.findHookContextNode(ast);
599 |
600 | const insertion = new core.Insertion(hookContextNodes, ast);
601 |
602 | insertion.insertImportDeclartion();
603 |
604 | const output = generate(ast, {
605 | concise: true,
606 | jsescOption: { minimal: true },
607 | }).code;
608 |
609 | expect(output).toContain('import { useTranslation } from "next-i18next"');
610 | });
611 |
612 | it("should insert an import declaration when a t call exists and the import is missin on react", () => {
613 | const code = `
614 | const Component = () => {
615 | return (
616 | {t("안녕하세요")}
617 | );
618 | }
619 | `;
620 |
621 | const ast = parser.parse(code, {
622 | sourceType: "module",
623 | plugins: ["jsx", "typescript"],
624 | });
625 |
626 | const hookContextNodes = core.findHookContextNode(ast);
627 |
628 | const insertion = new core.Insertion(hookContextNodes, ast, "react");
629 |
630 | insertion.insertImportDeclartion();
631 |
632 | const output = generate(ast, {
633 | concise: true,
634 | jsescOption: { minimal: true },
635 | }).code;
636 |
637 | expect(output).toContain('import { useTranslation } from "react-i18next"');
638 | });
639 |
640 | it("should inject both the translation hook and the import declaration when a t call exists", () => {
641 | const code = `
642 | const Component = () => {t("안녕하세요")}
643 | `;
644 |
645 | // 1. AST 생성 (jsx, typescript 플러그인 활성화)
646 | const ast = parser.parse(code, {
647 | sourceType: "module",
648 | plugins: ["jsx", "typescript"],
649 | });
650 |
651 | // 2. HookContextNode 후보 찾기
652 | const hookContextNodes = core.findHookContextNode(ast);
653 |
654 | // 3. Insertion 인스턴스 생성 후 insert() 실행
655 | const insertion = new core.Insertion(hookContextNodes, ast);
656 | insertion.insert();
657 |
658 | // 4. AST를 코드로 변환하여 결과 검증
659 | const output = generate(ast, {
660 | concise: true,
661 | jsescOption: { minimal: true },
662 | }).code;
663 |
664 | expect(output).toContain('import { useTranslation } from "next-i18next"');
665 | expect(output).toContain("const { t } = useTranslation()");
666 | });
667 |
668 | it("does not insert import declaration if already present", () => {
669 | const code = `
670 | import { useTranslation } from "next-i18next"
671 |
672 | const Component = () => {
673 | const { t } = useTranslation();
674 | return (
675 | {t("안녕하세요")}
676 | );
677 | }
678 | `;
679 |
680 | const ast = parser.parse(code, {
681 | sourceType: "module",
682 | plugins: ["jsx", "typescript"],
683 | });
684 |
685 | const hookContextNodes = core.findHookContextNode(ast);
686 |
687 | const insertion = new core.Insertion(hookContextNodes, ast);
688 |
689 | insertion.insertImportDeclartion();
690 |
691 | const output = generate(ast, {
692 | concise: true,
693 | jsescOption: { minimal: true },
694 | }).code;
695 |
696 | const regex = /import\s*{\s*useTranslation\s*}\s*from\s*"next-i18next"/g;
697 | const matches = output.match(regex);
698 |
699 | expect(matches).not.toBeNull();
700 | expect(matches!.length).toBe(1);
701 | });
702 |
703 | it("does not insert import declaration if already present", () => {
704 | const code = `
705 | import { useState } from "react";
706 | import { useTranslation } from "next-i18next";
707 |
708 | const Component = () => {
709 | const { t } = useTranslation();
710 | return (
711 | {t("안녕하세요")}
712 | );
713 | }
714 | `;
715 |
716 | const ast = parser.parse(code, {
717 | sourceType: "module",
718 | plugins: ["jsx", "typescript"],
719 | });
720 |
721 | const hookContextNodes = core.findHookContextNode(ast);
722 |
723 | const insertion = new core.Insertion(hookContextNodes, ast);
724 |
725 | insertion.insertImportDeclartion();
726 |
727 | const output = generate(ast, {
728 | concise: true,
729 | jsescOption: { minimal: true },
730 | }).code;
731 |
732 | const regex = /import\s*{\s*useTranslation\s*}\s*from\s*"next-i18next"/g;
733 | const matches = output.match(regex);
734 |
735 | expect(matches).not.toBeNull();
736 | expect(matches!.length).toBe(1);
737 | });
738 | });
739 |
740 | const parseCode = (code: string) => {
741 | return parser.parse(code, {
742 | sourceType: "module",
743 | plugins: ["jsx", "typescript"],
744 | });
745 | };
746 |
747 | describe("Template Literal and JSX Attribute Transformation (global)", () => {
748 | it("should transform template literals containing Chinese", () => {
749 | const code = `
750 | function TemplateLiteralComponent({ name }) {
751 | return {\`\${name},你好\`}
;
752 | }
753 | `;
754 |
755 | const ast = parseCode(code);
756 | const hookContextNodes = core.findHookContextNode(ast);
757 |
758 | const wrapper = new core.TWrapper(
759 | hookContextNodes,
760 | createLanguageCheckFunction("zh")
761 | );
762 | wrapper.wrapTemplateLiteral();
763 |
764 | const output = generate(ast, {
765 | concise: true,
766 | jsescOption: { minimal: true },
767 | }).code;
768 |
769 | expect(output).toContain('t("{{name}},你好", { "name": name })');
770 | });
771 |
772 | it("should transform JSX attributes containing Chinese", () => {
773 | const code = `
774 | function JSXAttributeComponent() {
775 | return ;
776 | }
777 | `;
778 |
779 | const ast = parseCode(code);
780 | const hookContextNodes = core.findHookContextNode(ast);
781 |
782 | const wrapper = new core.TWrapper(
783 | hookContextNodes,
784 | createLanguageCheckFunction("zh")
785 | );
786 | wrapper.wrapStringLiteral();
787 |
788 | const output = generate(ast, {
789 | concise: true,
790 | jsescOption: { minimal: true },
791 | }).code;
792 |
793 | expect(output).toContain('placeholder={t("请输入您的名字")}');
794 | });
795 |
796 | it("should transform template literals containing English", () => {
797 | const code = `
798 | function TemplateLiteralComponent({ name }) {
799 | return {\`\${name}, hello\`}
;
800 | }
801 | `;
802 |
803 | const ast = parseCode(code);
804 | const hookContextNodes = core.findHookContextNode(ast);
805 |
806 | const wrapper = new core.TWrapper(
807 | hookContextNodes,
808 | createLanguageCheckFunction("en")
809 | );
810 | wrapper.wrapTemplateLiteral();
811 |
812 | const output = generate(ast, {
813 | concise: true,
814 | jsescOption: { minimal: true },
815 | }).code;
816 |
817 | expect(output).toContain('t("{{name}}, hello", { "name": name })');
818 | });
819 |
820 | it("should transform JSX attributes containing English", () => {
821 | const code = `
822 | function JSXAttributeComponent() {
823 | return ;
824 | }
825 | `;
826 |
827 | const ast = parseCode(code);
828 | const hookContextNodes = core.findHookContextNode(ast);
829 |
830 | const wrapper = new core.TWrapper(
831 | hookContextNodes,
832 | createLanguageCheckFunction("en")
833 | );
834 | wrapper.wrapStringLiteral();
835 |
836 | const output = generate(ast, {
837 | concise: true,
838 | jsescOption: { minimal: true },
839 | }).code;
840 |
841 | expect(output).toContain('placeholder={t("Please enter your name")}');
842 | });
843 | });
844 |
--------------------------------------------------------------------------------