102 lines
5.7 KiB
TypeScript
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;
|
|
}
|