// Store copy lives in two human-edited Markdown files (templates/listing.md, templates/appstore.md). Pure parsers + // limit checks; the consoles count characters (code points), so do we. export const PLAY_LIMITS = { title: 30, short: 80, full: 4000, whatsNew: 500 } as const; /** App Store Connect Help, "In-app purchase information": display name 2-30, description at most 45 characters. * (The older App Store product page article says 55; App Store Connect enforces the Help reference.) */ export const APPLE_IAP_LIMITS = { name: 30, description: 45 } as const; export const APPLE_LIMITS = { name: 30, subtitle: 30, promotionalText: 170, keywords: 100, description: 4000, whatsNew: 4000 } as const; export interface PlayCopy { lang: string; title: string; short: string; full: string; whatsNew: string } export type AppleField = keyof typeof APPLE_LIMITS; export type AppleCopy = Partial>; export const len = (s: string): number => [...s].length; /** "### heading" sub-sections of a "## " section, each with its first fenced block. */ function blocks(section: string): { heading: string; text: string }[] { const out: { heading: string; text: string }[] = []; for (const part of section.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; } /** * Play copy. Every "## ..." section with a `` line contributes, under "### " headings * that contain (case-insensitive): "title" (optional, defaults to `defaultTitle`), "short", "full", "what's new" * (or "release notes"). One section can serve several Play languages. */ export function parsePlayListing(md: string, defaultTitle: string): PlayCopy[] { const out: PlayCopy[] = []; const errors: string[] = []; for (const sec of md.split(/^## /m).slice(1)) { const m = /^$/m.exec(sec); if (!m) continue; const name = sec.split('\n')[0]!.trim(); const bs = blocks(sec); const find = (re: RegExp): string | undefined => bs.find(b => re.test(b.heading))?.text; const title = find(/\btitle\b/i) ?? defaultTitle; const short = find(/\bshort\b/i); const full = find(/\bfull\b/i); const whatsNew = find(/what'?s new|release notes/i) ?? ''; if (!short) errors.push(`"${name}": no "### ... short ..." block`); if (!full) errors.push(`"${name}": no "### ... full ..." block`); if (!short || !full) continue; for (const lang of m[1]!.split(',').map(s => s.trim()).filter(Boolean)) { const c: PlayCopy = { lang, title, short, full, whatsNew }; for (const k of Object.keys(PLAY_LIMITS) as (keyof typeof PLAY_LIMITS)[]) if (len(c[k]) > PLAY_LIMITS[k]) errors.push(`${lang}: ${k} is ${len(c[k])} > ${PLAY_LIMITS[k]}`); if (out.some(o => o.lang === lang)) errors.push(`${lang}: listed in two sections`); out.push(c); } } if (errors.length) throw new Error('listing: ' + errors.join('; ')); return out; } const APPLE_REQUIRED: AppleField[] = ['subtitle', 'promotionalText', 'keywords', 'description']; /** * App Store copy. One "## ..." section per locale (the first word is the locale), with "### " * headings named exactly like the API attributes: name (optional), subtitle, promotionalText, keywords, description, * whatsNew (optional; not allowed on a first version). */ export function parseAppStoreListing(md: string): Record { const out: Record = {}; const errors: string[] = []; for (const sec of md.split(/^## /m).slice(1)) { const locale = sec.split(/[\s·]/)[0]!.trim(); if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(locale)) continue; // prose sections const f: AppleCopy = {}; for (const b of blocks(sec)) if (b.heading in APPLE_LIMITS) f[b.heading as AppleField] = b.text; for (const k of APPLE_REQUIRED) if (!f[k]) errors.push(`${locale}: missing ${k}`); for (const [k, v] of Object.entries(f) as [AppleField, string][]) if (len(v) > APPLE_LIMITS[k]) errors.push(`${locale}: ${k} is ${len(v)} > ${APPLE_LIMITS[k]}`); if (f.keywords && Buffer.byteLength(f.keywords) > 100) errors.push(`${locale}: keywords are ${Buffer.byteLength(f.keywords)} bytes > 100`); if (f.keywords && /,\s/.test(f.keywords)) errors.push(`${locale}: keywords: no spaces after commas (they count)`); out[locale] = f; } if (errors.length) throw new Error('appstore listing: ' + errors.join('; ')); return out; } export interface NameIssue { level: 'error' | 'info'; msg: string } /** * The App Store name per locale (a `### name` block, else `fallback` = apple.name, else app.name) against the 30-char * limit, plus a hint when every locale uses the plain app.name: App Store names are unique, so a taken name stops the * app record ("already being used"). Pure. */ export function appleNameIssues(copy: Record, locales: string[], fallback: string, appName: string): NameIssue[] { const out: NameIssue[] = []; const all = [...new Set([...locales, ...Object.keys(copy)])]; const names = all.map(l => ({ l, name: (copy[l]?.name ?? fallback).trim(), own: !!copy[l]?.name })); for (const n of names) if (len(n.name) > APPLE_LIMITS.name) out.push({ level: 'error', msg: `App Store name of ${n.l} is ${len(n.name)} > ${APPLE_LIMITS.name} characters: "${n.name}" (${n.own ? 'its ### name block' : fallback === appName ? 'app.name; set apple.name' : 'apple.name'})` }); if (names.length && names.every(n => n.name === appName)) out.push({ level: 'info', msg: `the App Store name is "${appName}" in every locale: App Store names are unique, so if it is taken App Store Connect refuses the record ("already being used"). Then set apple.name with a short suffix, e.g. "${appName}: " (30 characters at most); app.name stays the Play and on-device name` }); return out; }