161 lines
9.4 KiB
TypeScript
161 lines
9.4 KiB
TypeScript
// 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 "<command>"` runs a command of
|
|
// yours once per field. Every generated section carries a marker line until a human reviewed it:
|
|
// <!-- storebound-translation from=en-US status=translate --> source text copied in, still to translate
|
|
// <!-- storebound-translation from=en-US status=draft --> 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*storebound-translation\s+from=(\S+)\s+status=(translate|draft)\s*-->\s*$/m;
|
|
export const mark = (from: string, status: TStatus): string => `<!-- storebound-translation from=${from} status=${status} -->`;
|
|
|
|
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<AppleTField, number>,
|
|
play: { title: PLAY_LIMITS.title, short: PLAY_LIMITS.short, full: PLAY_LIMITS.full } as Record<PlayTField, number>,
|
|
};
|
|
|
|
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 = /^<!--\s*play:\s*([^>]+?)\s*-->$/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<Record<AppleTField | 'whatsNew', string>> {
|
|
const out: Partial<Record<AppleTField | 'whatsNew', string>> = {};
|
|
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<Record<PlayTField | 'whatsNew', string>> {
|
|
const out: Partial<Record<PlayTField | 'whatsNew', string>> = {};
|
|
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<Record<AppleTField | 'whatsNew', string>>, 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<PlayTField | 'whatsNew', string> = { 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<Record<PlayTField | 'whatsNew', string>>, marker: string | null): string {
|
|
const parts = [`## ${label || codes[0]} (${codes.join(', ')})`, `<!-- play: ${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(/^<!--\s*storebound-translation[^>]*-->[ \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<string>();
|
|
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<string, string | undefined>, source: Record<string, string | undefined> = {}, 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<string, number>)[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;
|
|
}
|