@dropshot/i18n-toolkit
v0.2.0
Published
PO 카탈로그 파이프라인 CLI — 완성도 검사·초기 번역·초안 승격·원문 수정
Readme
@dropshot/i18n-toolkit
PO 카탈로그 파이프라인 CLI 2종(check · draft)이다.
pnpm add -D @dropshot/i18n-toolkit카탈로그를 소유한 앱에 설치한다 — 워크플로우가 그 앱의 node_modules/.bin에서 CLI를 찾는다. @dropshot/i18n-po는 이 패키지가 이미 의존하므로 직접 쓰는 경우가 아니면 따로 넣지 않아도 된다.
CLI만으로는 반쪽이다. PR 게이트·초기 번역 백스톱을 쓰려면 워크플로우 2종을 함께 가져와야 한다.
경고 두 가지.
명령 표면
i18n-toolkit --help 출력 그대로다.
| 명령 | 동작 |
|---|---|
| check | 번역 완성도 검사 — 빈 msgstr가 있으면 실패(exit 1). fuzzy 항목은 경고만 하고 통과시킨다 |
| draft --list [--csv] | 미번역 항목을 JSON(기본) 또는 CSV로 출력. CSV는 번역 시트 핸드오프용(BOM 포함) |
| draft --apply <file> | [{key, translation}]을 fuzzy로 반영. 파일 확장자가 .csv면 CSV로, 그 외엔 JSON으로 읽는다 |
| draft --claude | claude CLI로 초기 번역 생성 후 즉시 반영(#, fuzzy로 표시) |
인자 없이 실행하거나 알 수 없는 명령을 주면 위 사용법을 출력하고 exit 1. --help/-h는 exit 0.
앱 package.json에 스크립트로 걸어두면 로컬에서도 같은 게이트를 돌릴 수 있다.
{
"scripts": {
"i18n:check": "i18n-toolkit check",
"i18n:draft": "i18n-toolkit draft"
}
}경로 주입
| 대상 | 우선순위 | 기본값 |
|---|---|---|
| 카탈로그 디렉터리 | I18N_CATALOG_DIR > 기본값 | <cwd>/messages |
| 초기 번역 명령(draft 전용) | I18N_DRAFT_CMD > 기본값 | claude -p (프롬프트는 stdin) |
| 원본 로케일 | I18N_SOURCE_LOCALE > 기본값 | ko — <카탈로그>/ko.po |
| 번역 로케일 | I18N_TARGET_LOCALE > 기본값 | en — <카탈로그>/en.po. 워크플로우 사용 시 주의 |
카탈로그 디렉터리를 지정하는 CLI 플래그는 없다 — 환경변수 아니면 기본값뿐이다. 상대경로는 모두 cwd 기준으로 해석된다(path.resolve(cwd, value)).
프로젝트 번역 가이드 주입
카탈로그 디렉터리에 translation-guide.md가 있으면 draft --claude가 그 전문을 프롬프트에 덧붙인다. 없으면 현행과 동일하게 동작한다.
- 용어집·톤 가이드처럼 사람도 읽는 번역 문서를 그대로 쓴다 — 앱과 함께 버전관리되고, CI 설정이 필요 없으며, 변경이 PR 리뷰로 추적된다
- 기본 프롬프트 교체는 지원하지 않는다. 구조 규칙(ICU 보존, JSON 배열만 출력)은 응답 파싱·검증과 한 몸인 계약이라, 가이드는 그 뒤에 덧붙기만 하고 출력 형식 지시가 항상 마지막에 온다 — 가이드가 출력 형식을 깨는 지시를 담아도 구조 규칙이 이긴다
워크플로우 설치
두 워크플로우는 패키지에 동봉하지 않는다 — GitHub Actions는 워크플로우가 저장소 루트의 .github/workflows/에 있어야 하고, node_modules 안의 파일을 읽지 않는다. 이 저장소에서 복사해 온다.
| 파일 | 역할 |
|---|---|
| i18n-check.yml | PR 게이트 |
| draft-translate.yml | 초기 번역 백스톱 |
둘 다 workflow-level env: APP_DIR: apps/example 한 줄로 앱 경로를 모아 뒀다. 그 줄만 실제 앱 경로로 바꾼다.
I18N_TARGET_LOCALE을 기본값(en)이 아닌 값으로 쓸 거라면 한 곳 더: 두 워크플로우 모두 messages/en.po를 하드코딩한다(diff 검사와 git add). CLI는 <번역>.po에 쓰는데 워크플로우가 en.po만 보므로, 안 바꾸면 초기 번역이 조용히 버려지고 CI는 성공으로 끝난다. 워크플로우 안의 en.po를 실제 번역 로케일 파일명으로 함께 바꾼다.
draft-translate.yml은 추가로 on.push.paths: ['apps/example/messages/**']가 하드코딩돼 있다 — GitHub Actions는 트리거 조건에 표현식(${{ }})을 쓸 수 없으므로 APP_DIR와 별개로 함께 고쳐야 한다. 이 워크플로우에는 concurrency 블록도 있다(i18n-check.yml은 PR별로 독립 실행되므로 없다) — 지우면 같은 base를 대상으로 한 실행이 동시에 돌 때 서로 "열린 PR 없음"으로 판단해 각자 새로 시작한 뒤 서로의 커밋을 force-push로 덮어쓴다.
지우면 안 되는 것 둘
경고 1: npx를 쓰지 않는다
npm 공개 레지스트리에는 i18n-toolkit이라는 이름만 같은 무관한 패키지([email protected])가 실재한다. 이 패키지의 bin 이름이 i18n-toolkit이라 겹친다.
npx는 로컬에 bin이 없으면 레지스트리에서 내려받아 실행하므로, 의존 선언을 빠뜨린 repo의 CI가 에러 없이 조용히 남의 도구를 돌리게 된다. pnpm exec는 로컬 node_modules/.bin만 보고, 없으면 받아오지 않고 즉시 실패한다 — 그래서 워크플로우가 이쪽을 쓴다.
같은 이유로 설치는 반드시 스코프 이름(@dropshot/i18n-toolkit)으로 한다.
경고 2: I18N_CATALOG_DIR는 절대경로로만
워크플로우 스텝은 working-directory: ${{ env.APP_DIR }}에서 실행된다. resolveCatalogDir는 상대경로를 그 스텝의 cwd(즉 APP_DIR 안) 기준으로 다시 해석한다. repo 루트 기준 상대경로(apps/example/messages 같은 값)를 그대로 넣으면 APP_DIR 안에서 한 번 더 풀려 존재하지 않는 경로를 가리키게 된다.
check는 그 경로를 fs.readdirSync로, draft는 <원본>.po/<번역>.po를 fs.readFileSync로 읽으므로 잘못된 경로는 ENOENT로 즉시 죽는다 — CI가 빨간불로 실패하므로 알아채지 못할 일은 없지만, 에러 메시지만 보면 어느 경로가 잘못됐는지 바로 와닿지 않는다. 앱 트리 밖의 카탈로그를 가리키려는 경우가 아니면 애초에 이 값을 설정하지 않는 편이 안전하고, 설정한다면 반드시 절대경로를 쓴다.
전제
- pnpm. 두 워크플로우 모두
pnpm/action-setup@v4를 쓰고, 버전은 워크플로우가 아니라 루트package.json의packageManager필드가 고정한다. npm/yarn을 쓰는 repo라면 워크플로우의pnpm/action-setup스텝과packageManager필드 두 곳을 함께 바꿔야 한다. - Node 22. 두 워크플로우 모두
actions/setup-node에node-version: 22를 박아 뒀다. - 루트
pnpm test.i18n-check.yml의 마지막 두 스텝은working-directory지정 없이 repo 루트에서 돈다. 그중pnpm test가 동작하려면 루트package.json에test스크립트와vitestdevDependency, 그 스크립트가 읽는vitest.config.ts가 있어야 한다 — 이건 CLI가 아니라 repo 루트 셋업이므로 패키지 설치로 따라오지 않는다. 루트 단위 vitest 스위트가 없다면 이 스텝을 지운다. - 루트
i18n:check스크립트의--filter대상. 이 저장소의 루트 스크립트는pnpm --filter @i18n/example i18n:check로 카탈로그를 소유한 패키지 이름을 그대로 박아 뒀다. 워크플로우를 복사해 온 repo에는 그 이름의 패키지가 없으므로 그대로 두면--filter가 대상을 못 찾아 "No projects matched"로 실패한다 — 게이트가 깨진 것처럼 보이지만 실은 이 한 줄을 안 바꾼 탓이다. 앱 패키지 이름으로 바꾼다.
초기 번역 LLM 설정
I18N_DRAFT_CMD(저장소 변수) 또는 ANTHROPIC_API_KEY(시크릿) 중 하나라도 있으면 초기 번역이 돈다. 명령은 프롬프트를 stdin으로 받아 결과를 stdout으로 뱉기만 하면 되고, 셸을 거치지 않으므로 공백으로만 갈린다(따옴표 파싱 없음).
둘 다 없어도 게이트 자체는 동작하지만, 워크플로우마다 실패 방식이 다르다.
| 워크플로우 | 둘 다 없을 때 |
|---|---|
| i18n-check.yml(PR 게이트) | 초기 번역 스텝만 건너뛰고(exit 0) 이어서 진행 — 완성도 검사는 그대로 돈다 |
| draft-translate.yml(백스톱) | 미번역이 있으면 그 스텝이 실패한다(exit 1) — 사람이 직접 부른 실행이라 조용히 넘기지 않는다 |
설정 없이 운영하려면 draft-translate.yml이 실패 상태로 쌓이는 것을 감수하거나, 그 워크플로우를 비활성화한다. 번역 자체는 draft --list → --apply 경로로 계속 채울 수 있다.
부록: 복사(vendoring)로 도입하기
외부 패키지 반입이 막힌 저장소를 위한 대안이다. 기본 경로는 위의 npm 설치이고, 이쪽은 그게 불가능할 때만 쓴다.
| 경로 | 내용 |
|---|---|
| packages/po | PO 엔진 — 툴킷이 의존하므로 함께 간다 |
| packages/toolkit | CLI 2종 + lib + bin |
| .github/workflows/*.yml | 위와 동일하게 2종 |
- 두 패키지 디렉터리를 복사한다.
- 대상 repo가 pnpm workspace여야 하고,
pnpm-workspace.yaml의packages글롭이 복사해 넣은 위치를 포함해야 한다. - 카탈로그를 소유한 앱의
package.json에"@dropshot/i18n-toolkit": "workspace:*"를 넣는다. pnpm install로 링크한다.
3번을 건너뛰면 워크플로우가 Command not found로 실패한다. 워크플로우는 working-directory: $APP_DIR에서 pnpm exec i18n-toolkit ...으로 부르므로, 그 앱이 의존성을 가져야 로컬 bin을 찾는다.
복사본의 유일한 실질 비용은 upstream 개선을 손으로 반영해야 한다는 것이다. 복사 시점의 커밋 SHA를 대상 repo에 기록해두면 다음 반영 때 diff 범위가 분명해진다. 대상 repo에서 스크립트를 직접 고치는 대신, 이 저장소에 고치고 다시 복사하는 흐름을 유지한다.
라이선스
MIT
