- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot, config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it - tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci - package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20 - bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help, init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/ - install writes npx MCP commands when run with Node; bin/storebound falls back to Node - docs: npx storebound init (Node 20+) or bunx storebound init
215 lines
14 KiB
TypeScript
215 lines
14 KiB
TypeScript
// 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 "<command>"] [--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';
|
|
import * as rt from './lib/rt';
|
|
|
|
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<void> {
|
|
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 <languages>: 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 = rt.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<string, string> = {};
|
|
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<string, string> = {};
|
|
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 <ids>');
|
|
}
|
|
|
|
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 <ids> 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 <ids> | --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<void> {
|
|
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 <languages> [--from <language>] [--translate "<command>"] [--dry] | check | reviewed --lang <ids>');
|
|
}
|