storebound/cli/lib/listing-edit.ts

119 lines
7.1 KiB
TypeScript

// One field of the listing files (store/listing.md for Google Play, store/appstore.md for the App Store) set as a text
// edit: only the fenced block of that field changes; headings, prose, comments and the other sections stay as they
// are. Used by `storebound edit` (the desktop's listing editor). Pure.
import { APPLE_LIMITS, type AppleField, PLAY_LIMITS } from './copy';
export type PlayField = 'title' | 'short' | 'full' | 'whatsNew';
export const PLAY_FIELDS: readonly PlayField[] = ['title', 'short', 'full', 'whatsNew'];
export const APPLE_FIELDS: readonly AppleField[] = ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew'];
/** TestFlight "What to Test" per locale lives in the App Store listing file too (`apple testflight what-to-test`). */
export const WHAT_TO_TEST = 'whatToTest';
export const WHAT_TO_TEST_MAX = 4000;
type AppleEditField = AppleField | typeof WHAT_TO_TEST;
/** The heading a field's block sits under (the parsers in copy.ts match these) and the one a new block gets. */
const PLAY_HEAD: Record<PlayField, { re: RegExp; heading: string }> = {
title: { re: /[^\n]*\btitle\b/i, heading: `Title (max ${PLAY_LIMITS.title})` },
short: { re: /[^\n]*\bshort\b/i, heading: `Short description (max ${PLAY_LIMITS.short})` },
full: { re: /[^\n]*\bfull\b/i, heading: `Full description (max ${PLAY_LIMITS.full})` },
whatsNew: { re: /[^\n]*(?:what'?s new|release notes)/i, heading: `What's new (max ${PLAY_LIMITS.whatsNew})` },
};
const fence = (t: string): string => '```\n' + t + '\n```';
const blockRe = (heading: RegExp): RegExp => new RegExp(`(^### (?:${heading.source})[^\\n]*\\n)\`\`\`[^\\n]*\\n([\\s\\S]*?)\\n?\`\`\`\\n?`, 'm' + (heading.flags.includes('i') ? 'i' : ''));
/** One section with `text` as the block under `heading` (replaced, appended, or removed when text is empty). */
function setBlock(sec: string, heading: RegExp, newHeading: string, text: string): { sec: string; from: string | null } {
const re = blockRe(heading);
const m = re.exec(sec);
const from = m ? m[2]!.trim() : null;
if (!text) {
if (!m) return { sec, from };
// the heading line, its block and one blank line after it
const start = m.index, end = m.index + m[0].length;
const rest = sec.slice(end).replace(/^\n/, '');
// the last block of the section: the section ends as it did before the block (one newline)
if (!rest.trim()) return { sec: sec.slice(0, start).replace(/\s*$/, '\n'), from };
return { sec: sec.slice(0, start) + rest, from };
}
if (m) return { sec: sec.slice(0, m.index) + m[1] + fence(text) + '\n' + sec.slice(m.index + m[0].length), from };
const body = sec.replace(/\s*$/, '');
return { sec: `${body}\n\n### ${newHeading}\n${fence(text)}\n\n`, from: null };
}
export interface ListingEditResult { md: string; from: string | null; codes: string[]; created: boolean }
/** Sets one Play field of the section that serves `lang` (all its codes change: one section can serve several Play
* languages). A language without a section gets a new one at the end. */
export function setPlayField(md: string, lang: string, field: PlayField, text: string): ListingEditResult {
const parts = md.split(/^(?=## )/m);
const v = text.replace(/\r\n/g, '\n').trim();
for (let i = 0; i < parts.length; i++) {
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(parts[i]!);
if (!m || !parts[i]!.startsWith('## ')) continue;
const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean);
if (!codes.includes(lang)) continue;
const r = setBlock(parts[i]!, PLAY_HEAD[field].re, PLAY_HEAD[field].heading, v);
parts[i] = r.sec;
return { md: parts.join(''), from: r.from, codes, created: false };
}
if (!v) return { md, from: null, codes: [lang], created: false };
const sec = `## ${lang}\n<!-- play: ${lang} -->\n\n### ${PLAY_HEAD[field].heading}\n${fence(v)}\n`;
return { md: `${md.replace(/\s*$/, '')}\n\n${sec}`, from: null, codes: [lang], created: true };
}
/** Sets one App Store field of the `locale` section (a missing section is added at the end). */
export function setAppleField(md: string, locale: string, field: AppleEditField, text: string): ListingEditResult {
const parts = md.split(/^(?=## )/m);
const v = text.replace(/\r\n/g, '\n').trim();
for (let i = 0; i < parts.length; i++) {
const sec = parts[i]!;
if (!sec.startsWith('## ')) continue;
if (sec.slice(3).split(/[\s·]/)[0]!.trim() !== locale) continue;
const r = setBlock(sec, new RegExp(`${field}\\b`), field, v);
parts[i] = r.sec;
return { md: parts.join(''), from: r.from, codes: [locale], created: false };
}
if (!v) return { md, from: null, codes: [locale], created: false };
return { md: `${md.replace(/\s*$/, '')}\n\n## ${locale}\n\n### ${field}\n${fence(v)}\n`, from: null, codes: [locale], created: true };
}
export const isPlayField = (f: string): f is PlayField => (PLAY_FIELDS as readonly string[]).includes(f);
export const isAppleField = (f: string): f is AppleEditField => f in APPLE_LIMITS || f === WHAT_TO_TEST;
/** The fields of the Play section that serves `lang` (null: no section). */
export function playSection(md: string, lang: string): { codes: string[]; heading: string; values: Partial<Record<PlayField, string>>; status: string | null } | null {
for (const sec of md.split(/^(?=## )/m)) {
if (!sec.startsWith('## ')) continue;
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(sec);
if (!m) continue;
const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean);
if (!codes.includes(lang)) continue;
const values: Partial<Record<PlayField, string>> = {};
for (const f of PLAY_FIELDS) { const b = blockRe(PLAY_HEAD[f].re).exec(sec); if (b) values[f] = b[2]!.trim(); }
return { codes, heading: sec.slice(3).split('\n')[0]!.trim(), values, status: translationStatus(sec) };
}
return null;
}
/** The fields of one App Store locale section (null: no section). */
export function appleSection(md: string, locale: string): { heading: string; values: Partial<Record<AppleEditField, string>>; status: string | null } | null {
for (const sec of md.split(/^(?=## )/m)) {
if (!sec.startsWith('## ') || sec.slice(3).split(/[\s·]/)[0]!.trim() !== locale) continue;
const values: Partial<Record<AppleEditField, string>> = {};
for (const f of [...APPLE_FIELDS, WHAT_TO_TEST] as const) { const b = blockRe(new RegExp(`${f}\\b`)).exec(sec); if (b) values[f] = b[2]!.trim(); }
return { heading: sec.slice(3).split('\n')[0]!.trim(), values, status: translationStatus(sec) };
}
return null;
}
/** `listing translate` marks: status=translate (not translated yet) or status=draft (machine draft, not reviewed). */
function translationStatus(sec: string): string | null {
return /<!--\s*storebound-translation\s+from=\S+\s+status=(\w+)/.exec(sec)?.[1] ?? null;
}
/** The store limit of a listing field (characters). */
export function listingLimit(store: 'play' | 'apple', field: string): number | null {
if (store === 'apple') return field === WHAT_TO_TEST ? WHAT_TO_TEST_MAX : field in APPLE_LIMITS ? APPLE_LIMITS[field as AppleField] : null;
return isPlayField(field) ? PLAY_LIMITS[field] : null;
}