storebound/cli/lib/copy.ts

102 lines
5.7 KiB
TypeScript

// 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<Record<AppleField, string>>;
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 `<!-- play: en-US[, en-GB] -->` 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 = /^<!--\s*play:\s*([^>]+?)\s*-->$/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 "## <ASC locale> ..." section per locale (the first word is the locale), with "### <field>"
* 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<string, AppleCopy> {
const out: Record<string, AppleCopy> = {};
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 && /,\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<string, AppleCopy>, 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}: <tagline>" (30 characters at most); app.name stays the Play and on-device name` });
return out;
}