// Listing localization: per-language sections of the two listing files (store/appstore.md, store/listing.md) as // translation drafts. Pure. The CLI never calls a translation service: your agent translates the status=translate // sections (listing-localization skill), or `storebound listing translate --translate ""` runs a command of // yours once per field. Every generated section carries a marker line until a human reviewed it: // source text copied in, still to translate // machine-translated: review, then `listing reviewed` // Limits per store field, in characters (App Store keywords too: live fields of 265 UTF-8 bytes are accepted): // App Store (appStoreVersionLocalizations / appInfoLocalizations): name 30, subtitle 30, promotionalText 170, // keywords 100, description 4000. https://developer.apple.com/help/app-store-connect/reference/app-information // Google Play (edits.listings): title 30, shortDescription 80, fullDescription 4000. // https://support.google.com/googleplay/android-developer/answer/9859152 // Keyword rules (App Store): comma separated, no spaces around commas, no duplicates, no words already in the name or // subtitle (they are indexed anyway), whole keywords cut at the limit. import { APPLE_LIMITS, len, PLAY_LIMITS } from './copy'; export type TStatus = 'translate' | 'draft'; export const MARK_RE = /^\s*$/m; export const mark = (from: string, status: TStatus): string => ``; export const APPLE_FIELDS = ['name', 'subtitle', 'promotionalText', 'keywords', 'description'] as const; export type AppleTField = typeof APPLE_FIELDS[number]; export const PLAY_FIELDS = ['title', 'short', 'full'] as const; export type PlayTField = typeof PLAY_FIELDS[number]; export const FIELD_LIMITS = { apple: Object.fromEntries(APPLE_FIELDS.map(f => [f, APPLE_LIMITS[f]])) as Record, play: { title: PLAY_LIMITS.title, short: PLAY_LIMITS.short, full: PLAY_LIMITS.full } as Record, }; const fence = (t: string) => '```\n' + t + '\n```'; /** "## " sections of a listing file (index 0 = the preamble). */ export const sections = (md: string): string[] => md.split(/^(?=## )/m); /** "### heading" -> first fenced block text, in order. */ export function blocksOf(sec: string): { heading: string; text: string }[] { const out: { heading: string; text: string }[] = []; for (const part of sec.split(/^### /m).slice(1)) { const heading = part.split('\n')[0]!.trim(); const m = /^```[^\n]*\n([\s\S]*?)\n```/m.exec(part); if (m) out.push({ heading, text: m[1]!.trim() }); } return out; } export const appleLocaleOf = (sec: string): string | null => { if (!sec.startsWith('## ')) return null; const l = sec.slice(3).split(/[\s·]/)[0]!.trim(); return /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(l) ? l : null; }; export const playCodesOf = (sec: string): string[] => { const m = /^$/m.exec(sec); return m ? m[1]!.split(',').map(s => s.trim()).filter(Boolean) : []; }; export function markerOf(sec: string): { from: string; status: TStatus } | null { const m = MARK_RE.exec(sec); return m ? { from: m[1]!, status: m[2] as TStatus } : null; } export function appleFields(sec: string): Partial> { const out: Partial> = {}; for (const b of blocksOf(sec)) if ((APPLE_FIELDS as readonly string[]).includes(b.heading) || b.heading === 'whatsNew') out[b.heading as AppleTField] = b.text; return out; } export function playFields(sec: string): Partial> { const out: Partial> = {}; for (const b of blocksOf(sec)) { const k = /what'?s new|release notes/i.test(b.heading) ? 'whatsNew' : /\btitle\b/i.test(b.heading) ? 'title' : /\bshort\b/i.test(b.heading) ? 'short' : /\bfull\b/i.test(b.heading) ? 'full' : null; if (k && out[k] === undefined) out[k] = b.text; } return out; } export const findAppleSection = (md: string, locale: string): string | undefined => sections(md).find(s => appleLocaleOf(s) === locale); export const findPlaySection = (md: string, codes: string[]): string | undefined => sections(md).find(s => playCodesOf(s).some(c => codes.includes(c))); export function appleSectionText(locale: string, label: string, f: Partial>, marker: string | null): string { const parts = [`## ${locale}${label ? ` · ${label}` : ''}`, ...(marker ? [marker] : []), '']; for (const k of [...APPLE_FIELDS, 'whatsNew'] as const) if (f[k] !== undefined && f[k] !== '') parts.push(`### ${k}`, fence(f[k]!), ''); return parts.join('\n') + '\n'; } const PLAY_HEAD: Record = { title: 'Play title (max 30)', short: 'Play short description (max 80)', full: 'Play full description (max 4000)', whatsNew: 'What\'s new (max 500)' }; export function playSectionText(codes: string[], label: string, f: Partial>, marker: string | null): string { const parts = [`## ${label || codes[0]} (${codes.join(', ')})`, ``, ...(marker ? [marker] : []), '']; for (const k of [...PLAY_FIELDS, 'whatsNew'] as const) if (f[k] !== undefined && f[k] !== '') parts.push(`### ${PLAY_HEAD[k]}`, fence(f[k]!), ''); return parts.join('\n') + '\n'; } /** Replaces the section `old` (exact text) with `text`, or appends `text` at the end. */ export function putSection(md: string, old: string | undefined, text: string): string { if (old !== undefined) { const parts = sections(md); const i = parts.indexOf(old); // keep what ended the old section (blank lines, a --- rule) let body = old.replace(/\s+$/, ''); while (/\n---$/.test(body)) body = body.slice(0, -4).replace(/\s+$/, ''); const tail = old.slice(body.length); parts[i] = text.replace(/\s+$/, '') + (tail || '\n'); return parts.join(''); } return md.replace(/\s*$/, '') + '\n\n' + text; } /** Removes the translation marker of one section (after review). */ export const unmark = (sec: string): string => sec.replace(/^[ \t]*\n/m, ''); // ---- keyword rules ---- /** App Store keywords: split on commas (also 、,،), trimmed, deduplicated (case-insensitive), words of the name / * subtitle dropped, joined with "," and cut at whole keywords to fit 100 characters. Pure. */ export function normalizeKeywords(text: string, o: { name?: string; subtitle?: string; limit?: number } = {}): { text: string; dropped: { kw: string; why: string }[] } { const limit = o.limit ?? APPLE_LIMITS.keywords; const taken = new Set(`${o.name ?? ''} ${o.subtitle ?? ''}`.toLocaleLowerCase().split(/[\s,.:;!?·|/–-]+/).filter(w => w.length > 1)); const seen = new Set(); const dropped: { kw: string; why: string }[] = []; const kept: string[] = []; for (const raw of text.split(/[,、,،;\n]/)) { const kw = raw.trim().replace(/\s+/g, ' '); if (!kw) continue; const key = kw.toLocaleLowerCase(); if (seen.has(key)) { dropped.push({ kw, why: 'duplicate' }); continue; } seen.add(key); if (taken.has(key)) { dropped.push({ kw, why: 'already in the name or subtitle' }); continue; } const next = [...kept, kw].join(','); if (len(next) > limit) { dropped.push({ kw, why: `over ${limit}` }); continue; } kept.push(kw); } return { text: kept.join(','), dropped }; } /** `chars` is what the store counts: characters, App Store keywords included (App Store Connect counts characters, * not UTF-8 bytes: store-pitfalls 43). `unit` stays for readers of the JSON and is always 'chars'. */ export interface FieldCheck { store: 'apple' | 'play'; field: string; chars: number; unit: 'chars' | 'bytes'; limit: number; problems: string[] } /** Limits and rules of one translated section. `source` = the source language's fields (identical text is flagged). */ export function checkFields(store: 'apple' | 'play', f: Record, source: Record = {}, status?: TStatus): FieldCheck[] { const fields = store === 'apple' ? APPLE_FIELDS : PLAY_FIELDS; const out: FieldCheck[] = []; for (const k of fields) { const v = f[k]; if (v === undefined) continue; const limit = (FIELD_LIMITS[store] as Record)[k]!; const isKw = store === 'apple' && k === 'keywords'; const n = len(v); const c: FieldCheck = { store, field: k, chars: n, unit: 'chars', limit, problems: [] }; if (n > limit) c.problems.push(`${n} > ${limit} characters`); if (isKw) { if (/,\s|\s,/.test(v)) c.problems.push('spaces around commas'); const words = v.split(',').map(w => w.trim().toLocaleLowerCase()).filter(Boolean); const dup = words.filter((w, i) => words.indexOf(w) !== i); if (dup.length) c.problems.push(`duplicates: ${[...new Set(dup)].join(', ')}`); if (/,,|^,|,$/.test(v)) c.problems.push('empty keyword'); } if (!v.trim() && k !== 'title' && k !== 'name') c.problems.push('empty'); if (status === 'translate') c.problems.push('not translated yet (status=translate)'); else if (source[k] !== undefined && v.trim() === source[k]!.trim() && v.trim().length > 12) c.problems.push('identical to the source language'); out.push(c); } return out; }