แปลข้อความ ไฟล์ คอมเมนต์ และไฟล์ภาษา (i18n) ด้วย Claude และสร้าง README กับ API Docs จากโค้ด

ใช้ Claude แปลข้อความ ไฟล์ทั้งไฟล์ เฉพาะคอมเมนต์ในโค้ด (โค้ดไม่เปลี่ยน) และไฟล์ภาษา i18n แบบ JSON หลายภาษาในครั้งเดียว (ตรวจว่า key ครบเท่าต้นฉบับ) สร้าง API.md จาก export และ JSDoc ในโค้ด และให้ Claude ร่าง README จากโครงสร้างโปรเจกต์ ทุกคำสั่งจะสร้างเป็นไฟล์ใหม่ ไม่เขียนทับไฟล์เดิม
| คำสั่ง | ทำอะไร | |
|---|---|---|
/translate <ข้อความ> | ไทย → อังกฤษ, ภาษาอื่น → ไทย | |
/translate to ja <ข้อความ> | แปลเป็นภาษาที่เลือก | |
/translate file README.md en | แปลทั้งไฟล์ → README.en.md | |
/translate comments src/app.ts en | แปลเฉพาะคอมเมนต์ → src/app.en.ts | |
/translate i18n locales/th.json en ja | แปลไฟล์ภาษา → locales/en.json, locales/ja.json | |
/docgen api [โฟลเดอร์] | สร้าง API.md จาก export + JSDoc (ไม่ใช้ AI) | |
/docgen readme | ให้ Claude ร่าง README.generated.md | |
/docgen usage | token ที่ใช้ไปใน session นี้ | |
| `/translate live on\ | off` | ล่ามสด: คำตอบภาษาอังกฤษของ Claude มีคำแปลไทยต่อท้ายให้อ่านอัตโนมัติ (ใช้ token เพิ่ม) |
ภาษาที่รองรับ: th en ja zh ko vi lo my km ms id fr de es
/plugin install translate-docs-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
$.model.complete และถ้า model ที่ขอใช้ไม่ได้ จะลองใหม่ด้วย model ของ session/translate ที่มี subcommand/docgen/docgen readme เรียก model จริง (ค่าเริ่มต้นใช้ haiku) คิดค่าใช้จ่ายตามบัญชีของคุณ ทุกคำสั่งจะบอกจำนวน token ที่ใช้/docgen ไม่ใช่ /docs เพราะชนกับคำสั่ง /docs ที่มีอยู่แล้วในบางเครื่อง/docgen readme ร่างจาก package.json และรายชื่อไฟล์เท่านั้น ส่วนที่ไม่รู้จะใส่ TODO ไว้ ควรตรวจและเติมเองก่อนใช้hooks/register.tsx 351 lines1/**
2 * ============================================================================
3 * MOD 19: Translate & Docs TH - แปลภาษาและทำเอกสารจากโค้ด (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: งานที่ Dev ไม่อยากทำเอง - แปลคอมเมนต์ ทำไฟล์ภาษา (i18n) เขียน README และ API Docs
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. /translate แปลข้อความ ด้วย Claude ($.model.complete) ไม่ใช่ดิกชันนารีคำต่อคำ
9 * 2. /translate file, /translate comments แปลทั้งไฟล์ หรือแปลเฉพาะคอมเมนต์ (โค้ดไม่เปลี่ยน)
10 * 3. /translate i18n แปลไฟล์ภาษา JSON หลายภาษา ตรวจว่า key ครบเท่าต้นฉบับ
11 * 4. /docgen api สร้าง API.md จาก export และ JSDoc ในโค้ด (ไม่ใช้ AI ได้ผลตรงตามโค้ด)
12 * 5. /docgen readme ให้ Claude ร่าง README จากโครงสร้างโปรเจกต์
13 * 6. /translate live on - ล่ามสด: คำตอบของ Claude ที่เป็นภาษาอังกฤษ แปลเป็นไทยให้อ่านใต้คำตอบอัตโนมัติ
14 *
15 * หลักการ: ไม่เขียนทับไฟล์เดิมเลย ผลลัพธ์เป็นไฟล์ใหม่เสมอ (เช่น app.en.ts, README.generated.md)
16 * ค่าใช้จ่าย: การแปลเรียก model จริง (คิดเงินตามบัญชีของคุณ) Mod จะบอกจำนวน token ทุกครั้ง
17 */
18
19import type { EngineInterface, FsEntry, Register } from 'claude-code'
20
21const LANGS: Record<string, string> = {
22 th: 'Thai', en: 'English', ja: 'Japanese', zh: 'Simplified Chinese', ko: 'Korean', vi: 'Vietnamese',
23 lo: 'Lao', my: 'Burmese', km: 'Khmer', ms: 'Malay', id: 'Indonesian', fr: 'French', de: 'German', es: 'Spanish',
24}
25const DEFAULT_MODEL = 'haiku' // ถูกและเร็ว เหมาะกับงานแปล
26
27let projectRoot = ''
28let tokensUsed = 0
29let live = false
30
31// ล่ามสด: แปลเฉพาะคำตอบที่เป็นภาษาอังกฤษเป็นหลัก และยาวพอจะคุ้มค่า token
32function mostlyEnglish(text: string): boolean {
33 const letters = text.replace(/```[\s\S]*?```/g, '').match(/[A-Za-z-]/g) ?? []
34 const latin = letters.filter(c => /[A-Za-z]/.test(c)).length
35 return letters.length >= 40 && latin / letters.length > 0.7
36}
37
38const sep = () => (projectRoot.includes('\\') ? '\\' : '/')
39const resolve = (p: string) => (/^([A-Za-z]:|[\\/])/.test(p) ? p : `${projectRoot}${sep()}${p.replace(/[\\/]/g, sep())}`)
40const rel = (p: string) => (p.toLowerCase().startsWith(projectRoot.toLowerCase()) ? p.slice(projectRoot.length + 1) : p)
41
42// ข้อความมีตัวอักษรไทย -> แปลเป็นอังกฤษ, ไม่มี -> แปลเป็นไทย
43function guessTarget(text: string): string {
44 return /[-]/.test(text) ? 'en' : 'th'
45}
46
47// app.ts + en -> app.en.ts
48function withLang(path: string, lang: string): string {
49 const dot = path.lastIndexOf('.')
50 return dot > path.lastIndexOf(sep()) ? `${path.slice(0, dot)}.${lang}${path.slice(dot)}` : `${path}.${lang}`
51}
52
53// ==========================================================================
54// บทที่ 1: เรียก Claude ด้วย $.model.complete (ไม่มีประวัติแชต เป็นคำขอเดี่ยวๆ)
55// ==========================================================================
56// ถ้า model ที่ขอใช้ไม่ได้ (เช่น องค์กรไม่อนุญาต) ลองใหม่ด้วย model ของ session
57async function ask($: EngineInterface, system: string, prompt: string, maxTokens = 4000): Promise<string> {
58 const models = [DEFAULT_MODEL, await $.session.model()]
59 let lastError = ''
60 for (const model of models) {
61 try {
62 const r = await $.model.complete({ model, system, prompt, maxTokens, timeoutMs: 120_000 })
63 tokensUsed += (r.usage?.input_tokens ?? 0) + (r.usage?.output_tokens ?? 0)
64 if (r.isAnswered) return r.text.trim()
65 lastError = r.reason
66 } catch (err) {
67 lastError = err instanceof Error ? err.message : String(err)
68 }
69 }
70 throw new Error(`เรียก model ไม่สำเร็จ (${lastError})`)
71}
72
73// ตัด ``` ที่ model อาจห่อมาให้ออก
74function unfence(text: string): string {
75 const m = /^```[\w-]*\n([\s\S]*?)\n```$/.exec(text.trim())
76 return m?.[1] ?? text
77}
78
79const TRANSLATOR = 'You are a professional translator for software teams. Translate faithfully and naturally. Keep code, identifiers, URLs, placeholders like {name} or %s, and Markdown formatting unchanged. Output only the translation, with no notes.'
80
81async function translateText($: EngineInterface, text: string, to: string, from?: string): Promise<string> {
82 const source = from ? `from ${LANGS[from] ?? from} ` : ''
83 return ask($, TRANSLATOR, `Translate ${source}into ${LANGS[to] ?? to}:\n\n${text}`)
84}
85
86// ==========================================================================
87// บทที่ 2: แปลไฟล์ i18n แบบ JSON - แปลเฉพาะค่า ไม่แตะ key แล้วตรวจว่า key ครบ
88// ==========================================================================
89function flatKeys(obj: unknown, prefix = ''): string[] {
90 if (obj === null || typeof obj !== 'object') return [prefix]
91 return Object.entries(obj as Record<string, unknown>).flatMap(([k, v]) => flatKeys(v, prefix ? `${prefix}.${k}` : k))
92}
93
94async function translateI18n($: EngineInterface, json: string, to: string): Promise<{ text: string; missing: string[] }> {
95 const source = JSON.parse(json) as unknown
96 const out = unfence(
97 await ask(
98 $,
99 `${TRANSLATOR} You receive a JSON object of UI strings. Translate only the string values. Keep every key, the nesting and placeholders exactly. Output valid JSON only.`,
100 `Target language: ${LANGS[to] ?? to}\n\n${json}`,
101 8000,
102 ),
103 )
104 const translated = JSON.parse(out) as unknown // ถ้า model ตอบ JSON ผิดรูปแบบ จะ throw ตรงนี้
105 const have = new Set(flatKeys(translated))
106 return { text: JSON.stringify(translated, null, 2) + '\n', missing: flatKeys(source).filter(k => !have.has(k)) }
107}
108
109// ==========================================================================
110// บทที่ 3: API Docs จากโค้ด - ใช้ regex หา export + JSDoc (ไม่ใช้ AI ได้ผลตรงตามโค้ด)
111// ==========================================================================
112interface ApiItem {
113 kind: string
114 name: string
115 signature: string
116 doc: string
117 file: string
118 line: number
119}
120
121function extractApi(file: string, text: string): ApiItem[] {
122 const items: ApiItem[] = []
123 const lines = text.split('\n')
124 lines.forEach((line, i) => {
125 const m = /^export\s+(?:default\s+)?(?:declare\s+)?(async\s+function|function|class|interface|type|const|enum)\s+([A-Za-z_$][\w$]*)/.exec(line)
126 if (!m) return
127 // JSDoc = คอมเมนต์ /** ... */ ที่อยู่เหนือบรรทัด export
128 let doc = ''
129 if (lines[i - 1]?.trim().endsWith('*/')) {
130 let j = i - 1
131 while (j > 0 && !lines[j]?.trim().startsWith('/**')) j--
132 doc = lines
133 .slice(j, i)
134 .map(l => l.trim().replace(/^\/\*\*|\*\/$|^\*\s?/g, '').trim())
135 .filter(Boolean)
136 .join(' ')
137 }
138 items.push({ kind: (m[1] ?? '').replace('async ', ''), name: m[2] ?? '', signature: line.trim().replace(/\s*[{=]\s*$/, '').slice(0, 160), doc, file, line: i + 1 })
139 })
140 return items
141}
142
143async function walk($: EngineInterface, root: string, match: RegExp, limit = 500): Promise<string[]> {
144 const files: string[] = []
145 const queue = [root]
146 while (queue.length && files.length < limit) {
147 const dir = queue.shift() ?? root
148 let entries: FsEntry[] = []
149 try {
150 entries = await $.fs.list(dir)
151 } catch {
152 continue
153 }
154 for (const e of entries) {
155 const path = `${dir}${sep()}${e.name}`
156 if (e.kind === 'dir' && !['node_modules', '.git', 'dist', 'build', '.next', 'coverage'].includes(e.name) && !e.name.startsWith('.')) queue.push(path)
157 else if (e.kind === 'file' && match.test(e.name) && !/\.(test|spec|d)\.[jt]sx?$/.test(e.name)) files.push(path)
158 }
159 }
160 return files
161}
162
163function apiMarkdown(items: ApiItem[]): string {
164 const byFile = new Map<string, ApiItem[]>()
165 for (const it of items) byFile.set(it.file, [...(byFile.get(it.file) ?? []), it])
166 const out = ['# API Reference', '', `สร้างอัตโนมัติจากโค้ด ${items.length} รายการ`, '']
167 for (const [file, list] of byFile) {
168 out.push(`## \`${file}\``, '')
169 for (const it of list) out.push(`### ${it.name} _(${it.kind})_`, '', '```ts', it.signature, '```', '', it.doc || '_ยังไม่มีคำอธิบาย (เพิ่ม JSDoc เหนือบรรทัด export)_', '')
170 }
171 return out.join('\n')
172}
173
174const HELP = `🌐 Translate & Docs TH (Mod 19)
175========================================
176แปล (ใช้ Claude):
177 /translate <ข้อความ> ไทย -> อังกฤษ, ภาษาอื่น -> ไทย
178 /translate to <ภาษา> <ข้อความ> เช่น /translate to ja สวัสดี
179 /translate file <ไฟล์> [ภาษา] แปลทั้งไฟล์ -> ไฟล์ใหม่ เช่น README.en.md
180 /translate comments <ไฟล์> [ภาษา] แปลเฉพาะคอมเมนต์ โค้ดไม่เปลี่ยน -> app.en.ts
181 /translate i18n <ไฟล์ json> <ภาษา...> เช่น /translate i18n locales/th.json en ja
182 ภาษา: ${Object.keys(LANGS).join(' ')}
183
184เอกสาร:
185 /docgen api [โฟลเดอร์] สร้าง API.md จาก export + JSDoc (ไม่ใช้ AI)
186 /docgen readme ให้ Claude ร่าง README.generated.md
187 /docgen usage token ที่ใช้ไปใน session นี้
188
189ล่ามสด:
190 /translate live on|off แปลคำตอบภาษาอังกฤษของ Claude เป็นไทยให้อ่านอัตโนมัติ (ใช้ token เพิ่ม)`
191
192export const register: Register = on => {
193 on('session.start', async ($, e, next) => {
194 projectRoot = e.cwd.replace(/[\\/]$/, '')
195 tokensUsed = 0
196 live = ((await $.store.get('live')) as boolean | undefined) ?? false
197 await $.command.register({ name: 'translate', description: 'แปลข้อความ ไฟล์ คอมเมนต์ และไฟล์ภาษาด้วย Claude (/translate help)', argumentHint: '<ข้อความ>|to|file|comments|i18n' })
198 await $.command.register({ name: 'docgen', description: 'สร้าง API Docs และ README จากโค้ด (/docgen help)', argumentHint: 'api|readme|usage' })
199 return next(e)
200 })
201
202 // ==========================================================================
203 // บทที่ 3.5: ล่ามสด - หลัง Claude ตอบเสร็จ แปลเป็นไทยแล้วเขียนลงแชต (โมเดลหลักไม่เห็น)
204 // ==========================================================================
205 on('turn.complete', async ($, e, next) => {
206 const r = await next(e)
207 if (!live || e.agentId || e.reason !== 'answer' || !mostlyEnglish(e.answer)) return r
208 // ไม่ await: ไม่ให้การแปลถ่วงรอบถัดไป
209 void translateText($, e.answer.slice(0, 12_000), 'th', 'en')
210 .then(th => {
211 try {
212 $.ui.log(`🌐 แปลไทย:\n${th}`)
213 } catch {}
214 })
215 .catch(() => undefined)
216 return r
217 })
218
219 // ==========================================================================
220 // บทที่ 4: คำสั่ง /translate
221 // ==========================================================================
222 on('command.run', { command: 'translate' }, async ($, e) => {
223 const args = e.args.trim()
224 const [sub = '', ...rest] = args.split(/\s+/)
225 const before = tokensUsed
226 const cost = () => `\n(ใช้ ${(tokensUsed - before).toLocaleString('en-US')} tokens)`
227
228 try {
229 if (!args || sub === 'help') return { text: HELP }
230
231 if (sub === 'live') {
232 if (rest[0] !== 'on' && rest[0] !== 'off') return { text: `ล่ามสด: ${live ? 'เปิด' : 'ปิด'} (/translate live on|off)` }
233 live = rest[0] === 'on'
234 await $.store.set('live', live)
235 return { text: live ? '🌐 เปิดล่ามสด: คำตอบภาษาอังกฤษของ Claude จะมีคำแปลไทยต่อท้าย (ใช้ token เพิ่มทุกคำตอบ)' : 'ปิดล่ามสดแล้ว' }
236 }
237
238 if (sub === 'to') {
239 const [lang = '', ...words] = rest
240 if (!LANGS[lang] || words.length === 0) return { text: `วิธีใช้: /translate to <${Object.keys(LANGS).join('|')}> <ข้อความ>` }
241 return { text: `🌐 → ${LANGS[lang]}\n${await translateText($, words.join(' '), lang)}${cost()}` }
242 }
243
244 if (sub === 'file' || sub === 'comments') {
245 const [file = '', langArg] = rest
246 const path = resolve(file)
247 const text = await $.fs.read(path).catch(() => undefined)
248 if (text === undefined) return { text: `❌ ไม่พบไฟล์ ${file}` }
249 if (text.length > 60_000) return { text: '❌ ไฟล์ยาวเกิน 60,000 ตัวอักษร แบ่งไฟล์ก่อนแปล' }
250 const lang = langArg && LANGS[langArg] ? langArg : guessTarget(text)
251 const result =
252 sub === 'file'
253 ? await ask($, TRANSLATOR, `Translate this whole file into ${LANGS[lang]}:\n\n${text}`, 16_000)
254 : await ask(
255 $,
256 `${TRANSLATOR} You receive a source code file. Translate ONLY the comments (and docstrings) into the target language. Every other character, including code, strings, whitespace and line breaks, must stay exactly the same. Output the full file only.`,
257 `Target language: ${LANGS[lang]}\n\n${text}`,
258 16_000,
259 )
260 const out = withLang(path, lang)
261 await $.fs.write(out, unfence(result).replace(/\n?$/, '\n'))
262 // นับบรรทัดโดยไม่สนบรรทัดว่างท้ายไฟล์ (model มักตัดออก)
263 const lineNote = sub === 'comments' && unfence(result).trimEnd().split('\n').length !== text.trimEnd().split('\n').length ?'\n⚠️ จำนวนบรรทัดไม่เท่าต้นฉบับ ตรวจด้วย diff ก่อนใช้' : ''
264 return { text: `✅ แปลแล้ว → ${rel(out)} (ไฟล์เดิมไม่ถูกแก้)${lineNote}${cost()}` }
265 }
266
267 if (sub === 'i18n') {
268 const [file = '', ...langs] = rest
269 const targets = langs.filter(l => LANGS[l])
270 if (!file || targets.length === 0) return { text: 'วิธีใช้: /translate i18n locales/th.json en ja' }
271 const path = resolve(file)
272 const json = await $.fs.read(path).catch(() => undefined)
273 if (json === undefined) return { text: `❌ ไม่พบไฟล์ ${file}` }
274 const dir = path.slice(0, path.lastIndexOf(sep()))
275 const lines: string[] = []
276 for (const lang of targets) {
277 const out = `${dir}${sep()}${lang}.json`
278 if (await $.fs.exists(out)) {
279 lines.push(`⏭️ ${rel(out)} มีอยู่แล้ว ข้าม (ลบหรือเปลี่ยนชื่อก่อนถ้าต้องการแปลใหม่)`)
280 continue
281 }
282 const { text, missing } = await translateI18n($, json, lang)
283 await $.fs.write(out, text)
284 lines.push(missing.length ? `⚠️ ${rel(out)} ขาด key: ${missing.slice(0, 10).join(', ')}` : `✅ ${rel(out)} key ครบ`)
285 }
286 return { text: [`🌐 แปลไฟล์ภาษา ${rel(path)}`, ...lines].join('\n') + cost() }
287 }
288
289 // ไม่มี subcommand = แปลข้อความทั้งหมดที่พิมพ์
290 const lang = guessTarget(args)
291 return { text: `🌐 → ${LANGS[lang]}\n${await translateText($, args, lang)}${cost()}` }
292 } catch (err) {
293 return { text: `❌ แปลไม่สำเร็จ: ${err instanceof Error ? err.message : String(err)}` }
294 }
295 })
296
297 // ==========================================================================
298 // บทที่ 5: คำสั่ง /docgen
299 // ==========================================================================
300 on('command.run', { command: 'docgen' }, async ($, e) => {
301 const [sub = 'help', arg] = e.args.trim().split(/\s+/).filter(Boolean)
302 const before = tokensUsed
303
304 if (sub === 'usage') return { text: `🧮 session นี้ Mod 19 ใช้ไป ${tokensUsed.toLocaleString('en-US')} tokens` }
305
306 if (sub === 'api') {
307 const root = arg ? resolve(arg) : projectRoot
308 // ข้ามไฟล์ที่ /translate สร้าง (เช่น cart.en.ts) จะได้ไม่ซ้ำกับต้นฉบับ
309 const translated = new RegExp(`\\.(${Object.keys(LANGS).join('|')})\\.[jt]sx?$|\\.(${Object.keys(LANGS).join('|')})\\.mjs$`)
310 const files = (await walk($, root, /\.(ts|tsx|js|jsx|mjs)$/)).filter(f => !translated.test(f))
311 const items: ApiItem[] = []
312 for (const f of files) items.push(...extractApi(rel(f).replace(/\\/g, '/'), await $.fs.read(f).catch(() => '')))
313 if (items.length === 0) return { text: `ไม่พบ export ใน ${rel(root) || '.'}` }
314 const out = `${projectRoot}${sep()}API.md`
315 await $.fs.write(out, apiMarkdown(items))
316 const undocumented = items.filter(i => !i.doc).length
317 return { text: `📚 สร้าง ${rel(out)} แล้ว: ${items.length} รายการจาก ${files.length} ไฟล์${undocumented ? ` (ยังไม่มีคำอธิบาย ${undocumented} รายการ)` : ''}` }
318 }
319
320 if (sub === 'readme') {
321 try {
322 const pkg = await $.fs.read(`${projectRoot}${sep()}package.json`).catch(() => '')
323 const files = (await walk($, projectRoot, /./, 200)).map(rel)
324 const existing = await $.fs.read(`${projectRoot}${sep()}README.md`).catch(() => '')
325 const draft = await ask(
326 $,
327 'You write clear, accurate README files. Use only facts present in the material given; where something is unknown, leave a TODO marker instead of inventing it. Write in Thai, keep commands and code in English.',
328 `Write a README.md for this project.\n\npackage.json:\n${pkg.slice(0, 4000) || '(none)'}\n\nFiles:\n${files.slice(0, 200).join('\n')}\n\nCurrent README (may be empty):\n${existing.slice(0, 4000)}`,
329 4000,
330 )
331 const out = `${projectRoot}${sep()}README.generated.md`
332 await $.fs.write(out, unfence(draft).replace(/\n?$/, '\n'))
333 return { text: `📝 ร่าง README ไว้ที่ ${rel(out)} (README.md เดิมไม่ถูกแก้) ตรวจก่อนใช้\n(ใช้ ${(tokensUsed - before).toLocaleString('en-US')} tokens)` }
334 } catch (err) {
335 return { text: `❌ สร้าง README ไม่สำเร็จ: ${err instanceof Error ? err.message : String(err)}` }
336 }
337 }
338
339 return { text: HELP }
340 })
341}
342
343/**
344 * ========================================================================
345 * การบ้าน / ต่อยอด
346 * ========================================================================
347 * 1. ใส่ cache: true ให้ system prompt ของ TRANSLATOR เมื่อแปลหลายไฟล์ติดกัน จะประหยัด token (ดู Mod 05)
348 * 2. แปลไฟล์ภาษาเฉพาะ key ที่ยังไม่มี แทนการแปลใหม่ทั้งไฟล์
349 * 3. ให้ /docgen api ใช้ AI ช่วยเขียนคำอธิบายเฉพาะรายการที่ยังไม่มี JSDoc
350 */
351