// Listing localization: translation drafts of the listing fields in both listing files (rules: lib/listing-translate.ts). // storebound listing translate --to de-DE,ja [--from en-US] [--store apple|play] [--translate ""] [--force] [--dry] [--json] // per target language: the App Store section (name, subtitle, promotionalText, keywords, description) in // store/appstore.md and the Play section (title, short, full) in store/listing.md. Without --translate the source // text is copied in with status=translate for your agent to translate in place (listing-localization skill); with // --translate your command runs once per field with the source text on stdin and STOREBOUND_SOURCE_LANG, // STOREBOUND_TARGET_LANG, STOREBOUND_STORE, STOREBOUND_FIELD, STOREBOUND_LIMIT in the environment; its stdout is // the translation (status=draft). Keywords are cleaned up (commas, duplicates, name / subtitle words, 100 limit). // Reviewed sections (no marker) are kept unless --force; a section's What's new is kept. // storebound listing check [--json] limits, keyword rules, untranslated and draft sections; exit 1 on a problem // storebound listing reviewed --lang de-DE[,ja] | --all [--dry] a human read the draft: remove its marker // --to / --from / --lang take config language ids, App Store locales or Play codes. Local files only: nothing is sent // to a store (apple listing / play listing push them after `storebound check`). import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import type { Language } from './lib/config'; import { playCodes } from './lib/config'; import type { Ctx } from './lib/ctx'; import { done, log, printJson, warn } from './lib/log'; import { APPLE_FIELDS, appleFields, appleLocaleOf, appleSectionText, checkFields, FIELD_LIMITS, type FieldCheck, findAppleSection, findPlaySection, mark, markerOf, normalizeKeywords, PLAY_FIELDS, playCodesOf, playFields, playSectionText, putSection, sections, unmark, } from './lib/listing-translate'; import { textTable } from './lib/table'; import { resolveLanguages } from './lib/whats-new'; const TAG = 'listing'; const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id; interface Files { apple?: { path: string; md: string }; play?: { path: string; md: string } } function readFiles(ctx: Ctx, store?: string): Files { const { config, resolve } = ctx.loaded; const out: Files = {}; if (config.apple && store !== 'play') { const p = resolve(config.apple.listing ?? 'store/appstore.md'); if (existsSync(p)) out.apple = { path: p, md: readFileSync(p, 'utf8') }; else warn(TAG, `${p} missing: App Store skipped`); } if (config.play && store !== 'apple') { const p = resolve(config.play.listing ?? 'store/listing.md'); if (existsSync(p)) out.play = { path: p, md: readFileSync(p, 'utf8') }; else warn(TAG, `${p} missing: Google Play skipped`); } if (!out.apple && !out.play) throw new Error('no listing file to work on (store/appstore.md, store/listing.md; storebound init creates them)'); return out; } function sourceLanguage(ctx: Ctx): Language { const { config } = ctx.loaded; const want = ctx.args.get('from'); if (want) return resolveLanguages(config.languages, [want])[0]!; const primary = config.apple?.primaryLocale ?? config.play?.defaultLanguage; return config.languages.find(l => l.apple === primary || playCodes(l).includes(primary ?? '')) ?? config.languages[0]!; } interface Row { lang: string; store: 'apple' | 'play'; action: string; checks: FieldCheck[] } async function translate(ctx: Ctx): Promise { const { config } = ctx.loaded; const a = ctx.args; const store = a.get('store'); if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play'); const targets = resolveLanguages(config.languages, a.list('to')); if (!targets.length) throw new Error('--to : config language ids, App Store locales or Play codes (add a language to languages[] in the config first)'); const src = sourceLanguage(ctx); const files = readFiles(ctx, store); const hook = a.get('translate'); const dry = a.has('dry'); const rows: Row[] = []; const runHook = (text: string, l: Language, st: 'apple' | 'play', field: string, limit: number): string | null => { const r = Bun.spawnSync(['sh', '-c', hook!], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe', env: { ...process.env, STOREBOUND_SOURCE_LANG: storeCode(src), STOREBOUND_TARGET_LANG: st === 'apple' ? l.apple ?? l.id : playCodes(l)[0] ?? l.id, STOREBOUND_TARGET_LANG_ID: l.id, STOREBOUND_STORE: st, STOREBOUND_FIELD: field, STOREBOUND_LIMIT: String(limit) } }); const out = r.stdout.toString().trim(); if (r.exitCode !== 0 || !out) { warn(TAG, `${l.id} ${st}.${field}: --translate failed (exit ${r.exitCode}): ${r.stderr.toString().trim().slice(0, 200)}`); return null; } return out; }; for (const l of targets) { if (l === src) { warn(TAG, `${l.id} is the source language: skipped`); continue; } // ---- App Store ---- if (files.apple && l.apple && src.apple) { const srcSec = findAppleSection(files.apple.md, src.apple); const cur = findAppleSection(files.apple.md, l.apple); if (!srcSec) warn(TAG, `${files.apple.path}: no ${src.apple} section to translate from`); else if (cur && !markerOf(cur) && !a.has('force')) rows.push({ lang: l.id, store: 'apple', action: 'kept (reviewed; --force replaces it)', checks: [] }); else { const s = appleFields(srcSec); const f: Record = {}; let failed = false; for (const k of APPLE_FIELDS) { if (s[k] === undefined) continue; if (hook && !dry) { const t = runHook(s[k]!, l, 'apple', k, FIELD_LIMITS.apple[k]); if (t === null) failed = true; f[k] = t ?? s[k]!; } else f[k] = s[k]!; } const status = hook && !dry && !failed ? 'draft' : 'translate'; if (f.keywords && status === 'draft') { const n = normalizeKeywords(f.keywords, { name: f.name, subtitle: f.subtitle }); for (const d of n.dropped) log(TAG, `${l.apple} keywords: dropped "${d.kw}" (${d.why})`); f.keywords = n.text; } const keep = cur ? appleFields(cur).whatsNew : undefined; const text = appleSectionText(l.apple, l.id, { ...f, ...(keep ? { whatsNew: keep } : {}) }, mark(src.apple, status)); files.apple.md = putSection(files.apple.md, cur, text); rows.push({ lang: l.id, store: 'apple', action: `${cur ? 'replaced' : 'added'} (status=${status})`, checks: checkFields('apple', f, s, status) }); } } // ---- Google Play ---- const codes = playCodes(l), srcCodes = playCodes(src); if (files.play && codes.length && srcCodes.length) { const srcSec = findPlaySection(files.play.md, srcCodes); const cur = findPlaySection(files.play.md, codes); if (!srcSec) warn(TAG, `${files.play.path}: no ${srcCodes.join(', ')} section to translate from`); else if (cur && !markerOf(cur) && !a.has('force')) rows.push({ lang: l.id, store: 'play', action: 'kept (reviewed; --force replaces it)', checks: [] }); else { const s = playFields(srcSec); const f: Record = {}; let failed = false; for (const k of PLAY_FIELDS) { if (s[k] === undefined) continue; if (hook && !dry) { const t = runHook(s[k]!, l, 'play', k, FIELD_LIMITS.play[k]); if (t === null) failed = true; f[k] = t ?? s[k]!; } else f[k] = s[k]!; } const status = hook && !dry && !failed ? 'draft' : 'translate'; const keep = cur ? playFields(cur).whatsNew : undefined; // the section keeps serving every Play code it served (es-ES, es-419), plus the language's own const allCodes = [...new Set([...(cur ? playCodesOf(cur) : []), ...codes])]; const text = playSectionText(allCodes, l.id, { ...f, ...(keep ? { whatsNew: keep } : {}) }, mark(srcCodes[0]!, status)); files.play.md = putSection(files.play.md, cur, text); rows.push({ lang: l.id, store: 'play', action: `${cur ? 'replaced' : 'added'} (status=${status})`, checks: checkFields('play', f, s, status) }); } } } const problemsOf = (r: Row) => r.checks.flatMap(c => c.problems.filter(p => !p.startsWith('not translated')).map(p => `${c.field}: ${p}`)); if (a.has('json')) printJson({ source: src.id, dryRun: dry, hook: !!hook, files: { apple: files.apple?.path ?? null, play: files.play?.path ?? null }, results: rows.map(r => ({ ...r, problems: problemsOf(r) })) }); else { console.log(textTable(['language', 'store', 'section', 'fields', 'problems'], rows.map(r => [r.lang, r.store === 'apple' ? 'App Store' : 'Google Play', r.action, r.checks.map(c => `${c.field} ${c.chars}/${c.limit}${c.unit === 'bytes' ? ' B' : ''}`).join(', '), problemsOf(r).join('; ')]))); } const json = a.has('json'); if (dry) { if (!json) done(TAG, 'dry run: listing files not written'); return; } if (files.apple) writeFileSync(files.apple.path, files.apple.md); if (files.play) writeFileSync(files.play.path, files.play.md); if (json) return; const todo = rows.filter(r => r.action.includes('status=translate')).length; done(TAG, todo ? `${todo} section(s) to translate in place (keep the marker line, set status=draft), then storebound listing check` : 'drafts written: review them, storebound listing check, then storebound listing reviewed --lang '); } interface SecReport { file: string; store: 'apple' | 'play'; lang: string; status: 'reviewed' | 'translate' | 'draft'; from: string | null; checks: FieldCheck[] } function report(files: Files): SecReport[] { const out: SecReport[] = []; if (files.apple) for (const sec of sections(files.apple.md)) { const loc = appleLocaleOf(sec); if (!loc) continue; const m = markerOf(sec); const srcSec = m ? findAppleSection(files.apple.md, m.from) : undefined; out.push({ file: files.apple.path, store: 'apple', lang: loc, status: m?.status ?? 'reviewed', from: m?.from ?? null, checks: checkFields('apple', appleFields(sec), srcSec ? appleFields(srcSec) : {}, m?.status) }); } if (files.play) for (const sec of sections(files.play.md)) { const codes = playCodesOf(sec); if (!codes.length) continue; const m = markerOf(sec); const srcSec = m ? findPlaySection(files.play.md, [m.from]) : undefined; out.push({ file: files.play.path, store: 'play', lang: codes.join(', '), status: m?.status ?? 'reviewed', from: m?.from ?? null, checks: checkFields('play', playFields(sec), srcSec ? playFields(srcSec) : {}, m?.status) }); } return out; } function check(ctx: Ctx): void { const files = readFiles(ctx); const r = report(files); const bad = r.filter(x => x.checks.some(c => c.problems.length)); const drafts = r.filter(x => x.status === 'draft'); if (ctx.args.has('json')) printJson({ sections: r, ok: !bad.length, drafts: drafts.map(d => `${d.store}:${d.lang}`) }); else { console.log(textTable(['store', 'language', 'status', 'fields', 'problems'], r.map(x => [x.store === 'apple' ? 'App Store' : 'Google Play', x.lang, x.status === 'draft' ? 'DRAFT (review)' : x.status === 'translate' ? 'TO TRANSLATE' : 'reviewed', x.checks.map(c => `${c.field} ${c.chars}/${c.limit}${c.unit === 'bytes' ? ' B' : ''}`).join(', '), x.checks.flatMap(c => c.problems.map(p => `${c.field}: ${p}`)).join('; ')]))); if (drafts.length) warn(TAG, `${drafts.length} machine-translated draft(s) not reviewed yet: ${drafts.map(d => `${d.store}:${d.lang}`).join(', ')} (storebound listing reviewed --lang after reading them)`); done(TAG, bad.length ? `${bad.length} section(s) with problems` : `${r.length} section(s) within the store limits`); } if (bad.length) process.exitCode = 1; } function reviewed(ctx: Ctx): void { const { config } = ctx.loaded; const a = ctx.args; const files = readFiles(ctx); const all = a.has('all'); const langs = all ? [] : resolveLanguages(config.languages, a.list('lang')); if (!all && !langs.length) throw new Error('--lang | --all'); const changed: string[] = [], refused: string[] = []; const pick = (sec: string, store: 'apple' | 'play'): boolean => { if (all) return true; return store === 'apple' ? langs.some(l => l.apple && appleLocaleOf(sec) === l.apple) : langs.some(l => playCodes(l).some(c => playCodesOf(sec).includes(c))); }; for (const store of ['apple', 'play'] as const) { const f = files[store]; if (!f) continue; f.md = sections(f.md).map(sec => { const m = markerOf(sec); if (!m || !pick(sec, store)) return sec; const label = `${store}:${store === 'apple' ? appleLocaleOf(sec) : playCodesOf(sec).join(',')}`; if (m.status === 'translate') { refused.push(label); return sec; } changed.push(label); return unmark(sec); }).join(''); } for (const r of refused) warn(TAG, `${r}: still status=translate (translate it first)`); if (a.has('dry')) { done(TAG, `dry run: would mark ${changed.length} section(s) reviewed: ${changed.join(', ') || 'none'}`); return; } for (const store of ['apple', 'play'] as const) if (files[store]) writeFileSync(files[store]!.path, files[store]!.md); done(TAG, `${changed.length} section(s) marked reviewed${changed.length ? `: ${changed.join(', ')}` : ''}`); } export async function run(ctx: Ctx): Promise { const sub = ctx.args._[0]; if (sub === 'translate') return translate(ctx); if (sub === 'check') return check(ctx); if (sub === 'reviewed') return reviewed(ctx); throw new Error('usage: storebound listing translate --to [--from ] [--translate ""] [--dry] | check | reviewed --lang '); }