@@ -2,7 +2,11 @@ import consola from 'consola';
22import assert from 'node:assert' ;
33import { readFile , writeFile } from 'node:fs/promises' ;
44import { parseArgs } from 'node:util' ;
5+ import { tmpdir } from 'node:os' ;
6+ import { join } from 'node:path' ;
57import { cli } from 'textlint' ;
8+ import { globby } from 'globby' ;
9+ import pLimit from 'p-limit' ;
610import {
711 cpRf ,
812 exists ,
@@ -15,8 +19,10 @@ import { MarkdownTranslator } from './translate';
1519 * CLI引数の型定義
1620 */
1721interface CliArgs {
18- file : string ;
22+ pattern : string ;
1923 write ?: boolean ;
24+ concurrency ?: number ;
25+ force ?: boolean ;
2026 help ?: boolean ;
2127}
2228
@@ -38,24 +44,28 @@ function validateEnvironment(): { googleApiKey: string; geminiModel?: string } {
3844 */
3945function showHelp ( ) : void {
4046 console . log ( `
41- 使用方法: npx tsx tools/translator/main.ts [オプション] <ファイルパス >
47+ 使用方法: npx tsx tools/translator/main.ts [オプション] <パターン >
4248
4349Markdownファイルを日本語に翻訳します。
4450
4551オプション:
46- -w, --write 確認なしで翻訳結果を保存
47- -h, --help このヘルプメッセージを表示
52+ -w, --write 確認なしで翻訳結果を保存
53+ -c, --concurrency <n> 並列処理数(デフォルト: 2)
54+ --force 翻訳済みファイルを再翻訳
55+ -h, --help このヘルプメッセージを表示
4856
4957引数:
50- <ファイルパス > 翻訳するMarkdownファイルのパス
58+ <パターン > 翻訳するMarkdownファイルのパスまたはglobパターン
5159
5260環境変数:
5361 GOOGLE_API_KEY Google AI API キー(必須)
5462 GEMINI_MODEL 使用するGeminiモデル(オプション)
5563
5664例:
5765 npx tsx tools/translator/main.ts example.md
58- npx tsx tools/translator/main.ts -w example.md
66+ npx tsx tools/translator/main.ts -w "adev-ja/src/content/guide/*.md"
67+ npx tsx tools/translator/main.ts -w -c 5 "adev-ja/src/content/**/*.md"
68+ npx tsx tools/translator/main.ts -w --force "adev-ja/src/content/guide/*.md"
5969` ) ;
6070}
6171
@@ -66,35 +76,59 @@ function parseCliArgs(): CliArgs {
6676 const args = parseArgs ( {
6777 options : {
6878 write : { type : 'boolean' , default : false , short : 'w' } ,
79+ concurrency : { type : 'string' , default : '2' , short : 'c' } ,
80+ force : { type : 'boolean' , default : false } ,
6981 help : { type : 'boolean' , default : false , short : 'h' } ,
7082 } ,
7183 allowPositionals : true ,
7284 } ) ;
7385
74- const { write, help } = args . values ;
75- const [ file ] = args . positionals ;
86+ const { write, help, force } = args . values ;
87+ const concurrency = parseInt ( args . values . concurrency || '2' , 10 ) ;
88+ const [ pattern ] = args . positionals ;
7689
7790 if ( help ) {
7891 showHelp ( ) ;
7992 process . exit ( 0 ) ;
8093 }
8194
82- if ( ! file ) {
95+ if ( ! pattern ) {
8396 showHelp ( ) ;
84- throw new Error ( 'ファイルパスを指定してください 。' ) ;
97+ throw new Error ( 'ファイルパスまたはglobパターンを指定してください 。' ) ;
8598 }
8699
87- return { write, file, help } ;
100+ if ( isNaN ( concurrency ) || concurrency < 1 ) {
101+ throw new Error ( '並列数は1以上の整数で指定してください。' ) ;
102+ }
103+
104+ return { write, pattern, concurrency, force, help } ;
88105}
89106
90107/**
91- * ファイルの存在確認
108+ * glob パターンからファイルリストを収集
92109 */
93- async function validateFileExistence ( file : string ) : Promise < void > {
94- const fileExists = await exists ( file ) ;
95- if ( ! fileExists ) {
96- throw new Error ( `ファイルが見つかりません: ${ file } ` ) ;
110+ async function collectFiles ( pattern : string , force : boolean ) : Promise < string [ ] > {
111+ const files = await globby ( pattern , {
112+ ignore : [ '**/*.en.md' , '**/*.en.ts' , '**/*.en.json' ] ,
113+ } ) ;
114+
115+ if ( files . length === 0 ) {
116+ throw new Error ( `パターンに一致するファイルが見つかりません: ${ pattern } ` ) ;
117+ }
118+
119+ // force が false の場合、翻訳済みファイルをフィルタリング
120+ if ( ! force ) {
121+ const untranslatedFiles : string [ ] = [ ] ;
122+ for ( const file of files ) {
123+ const enFile = getEnFilePath ( file ) ;
124+ if ( ! ( await exists ( enFile ) ) ) {
125+ untranslatedFiles . push ( file ) ;
126+ }
127+ }
128+ return untranslatedFiles ;
97129 }
130+
131+ return files ;
98132}
99133
100134/**
@@ -197,31 +231,92 @@ async function runTextlint(file: string): Promise<void> {
197231 }
198232}
199233
234+ /**
235+ * 単一ファイルの翻訳処理(エラーハンドリング含む)
236+ */
237+ async function processSingleFile (
238+ file : string ,
239+ googleApiKey : string ,
240+ geminiModel : string | undefined ,
241+ forceWrite : boolean
242+ ) : Promise < { file : string ; success : boolean ; error ?: Error } > {
243+ try {
244+ consola . start ( `翻訳開始: ${ file } ` ) ;
245+
246+ const translated = await translateFile ( file , googleApiKey , geminiModel ) ;
247+ const savedFile = await saveTranslation ( file , translated , forceWrite ) ;
248+
249+ if ( ! savedFile ) {
250+ return { file, success : false } ;
251+ }
252+
253+ // 翻訳結果の分析
254+ await validateLineCount ( getEnFilePath ( savedFile ) , savedFile ) ;
255+ await runTextlint ( savedFile ) ;
256+
257+ consola . success ( `翻訳完了: ${ file } ` ) ;
258+ return { file, success : true } ;
259+ } catch ( error ) {
260+ consola . warn ( `翻訳失敗: ${ file } ` ) ;
261+ return { file, success : false , error : error as Error } ;
262+ }
263+ }
264+
200265/**
201266 * アプリケーションのメインエントリーポイント
202267 */
203268async function main ( ) {
204- const { write, file } = parseCliArgs ( ) ;
269+ const { write, pattern , concurrency , force } = parseCliArgs ( ) ;
205270 const { googleApiKey, geminiModel } = validateEnvironment ( ) ;
206271
207- await validateFileExistence ( file ) ;
208-
209- consola . start ( `Starting translation for ${ file } ` ) ;
210-
211- const translated = await translateFile ( file , googleApiKey , geminiModel ) ;
272+ // ファイルリスト収集
273+ const files = await collectFiles ( pattern , ! ! force ) ;
212274
213- console . log ( translated ) ;
214- const savedFile = await saveTranslation ( file , translated , ! ! write ) ;
215- if ( ! savedFile ) {
275+ if ( files . length === 0 ) {
276+ consola . warn ( '翻訳対象のファイルがありません。' ) ;
216277 return ;
217278 }
218279
219- // 翻訳結果の分析
220- consola . start ( `翻訳結果を分析...` ) ;
221- // 原文ファイルとの行数比較
222- await validateLineCount ( getEnFilePath ( savedFile ) , savedFile ) ;
223- // textlintの実行
224- await runTextlint ( savedFile ) ;
280+ consola . info ( `翻訳対象: ${ files . length } 件 (並列数: ${ concurrency } )` ) ;
281+
282+ // 並列処理制御
283+ const limit = pLimit ( concurrency ! ) ;
284+ const results = await Promise . all (
285+ files . map ( ( file ) =>
286+ limit ( ( ) => processSingleFile ( file , googleApiKey , geminiModel , ! ! write ) )
287+ )
288+ ) ;
289+
290+ // 最終サマリー表示
291+ const succeeded = results . filter ( ( r ) => r . success ) ;
292+ const failed = results . filter ( ( r ) => ! r . success ) ;
293+
294+ // エラー詳細を一時ファイルに保存
295+ let errorLogPath : string | null = null ;
296+ if ( failed . length > 0 ) {
297+ errorLogPath = join ( tmpdir ( ) , `translate-errors-${ Date . now ( ) } .log` ) ;
298+ const errorDetails = failed
299+ . map ( ( r ) => {
300+ const errorStack = r . error ?. stack || r . error ?. message || 'Unknown error' ;
301+ return `\n${ '=' . repeat ( 80 ) } \nファイル: ${ r . file } \n${ '=' . repeat ( 80 ) } \n${ errorStack } \n` ;
302+ } )
303+ . join ( '\n' ) ;
304+
305+ await writeFile ( errorLogPath , errorDetails , 'utf-8' ) ;
306+ }
307+
308+ consola . box ( `
309+ 翻訳完了
310+
311+ 成功: ${ succeeded . length } 件
312+ 失敗: ${ failed . length } 件
313+ ${ failed . length > 0 ? '\n失敗したファイル:\n' + failed . map ( ( r ) => ` - ${ r . file } ` ) . join ( '\n' ) : '' }
314+ ${ errorLogPath ? `\nエラー詳細: ${ errorLogPath } ` : '' }
315+ ` ) ;
316+
317+ if ( failed . length > 0 ) {
318+ process . exit ( 1 ) ;
319+ }
225320}
226321
227322main ( ) . catch ( ( error ) => {
0 commit comments