storebound/cli/lib/cross-store.ts

171 lines
10 KiB
TypeScript

// Other platforms and stores named in one store's text (pitfall 77). App Store Guideline 2.3.10 (Accurate Metadata):
// no other mobile platforms in the metadata; a Mac version was rejected for a What's New taken from release notes
// shared with Google Play ("Google Play Games", "Android"). Google Play's Metadata policy is the mirror case
// (misleading references to other platforms or apps). One checker for every surface: the listing pushes, whats-new,
// the store-only listing edit and the desktop editor refuse on a hit; dry runs show it; check / validate / scan
// report it with file, line, field and locale. A functional mention ("Sign in with Apple", "Continue with Google")
// is kept only through `listing.allowPlatformMentions` in the config: the owner's call, the tool never adds one.
// Pure, no Node imports (the desktop window runs it too).
export type CrossStore = 'apple' | 'play';
/** One owner-approved mention: `text` (any case) on a line keeps the terms inside it. */
export interface PlatformAllow { store: CrossStore | 'both'; text: string; reason: string }
export interface PlatformHit {
store: CrossStore;
locale: string;
field: string;
/** line inside the field's text (1-based) */
line: number;
term: string;
/** the whole line, trimmed */
text: string;
/** listing file and its line, when the hit comes from a file */
file?: string;
fileLine?: number;
fix: string;
}
interface Term { t: string; cs?: boolean; cjk?: boolean }
/** Named in App Store text: other mobile platforms and stores (Latin forms match whole words, any case). */
export const APPLE_FORBIDDEN: readonly Term[] = [
{ t: 'Google Play Games' }, { t: 'Google Play Spiele' }, { t: 'Google Play Jeux' }, { t: 'Google Play Juegos' }, { t: 'Google Play Giochi' }, { t: 'Google Play Oyun' },
{ t: 'Google Play' }, { t: 'Play Store' }, { t: 'Play Market' }, { t: 'Play Маркет' }, { t: 'Плей Маркет' }, { t: 'Google Play 游戏', cjk: true }, { t: 'Google Play ゲーム', cjk: true },
{ t: 'Android' }, { t: 'Андроид' }, { t: 'Android TV' }, { t: 'Wear OS' }, { t: 'Chromebook' },
{ t: '安卓', cjk: true }, { t: 'アンドロイド', cjk: true }, { t: '안드로이드', cjk: true }, { t: '구글 플레이', cjk: true }, { t: 'グーグルプレイ', cjk: true }, { t: '谷歌商店', cjk: true },
{ t: 'Galaxy Store' }, { t: 'Amazon Appstore' }, { t: 'Amazon App Store' }, { t: 'AppGallery' }, { t: 'Huawei' }, { t: 'Xiaomi GetApps' },
{ t: 'Steam', cs: true }, { t: 'Microsoft Store' }, { t: 'Windows Store' }, { t: 'Windows Phone' }, { t: 'BlackBerry' },
];
/** Named in Google Play text: Apple platforms and services. */
export const PLAY_FORBIDDEN: readonly Term[] = [
{ t: 'Apple App Store' }, { t: 'App Store', cs: true }, { t: 'AppStore', cs: true }, { t: 'App-Store', cs: true },
{ t: 'iOS', cs: true }, { t: 'iPadOS' }, { t: 'iPhone' }, { t: 'iPad' }, { t: 'macOS' }, { t: 'Apple Watch' }, { t: 'Apple TV' }, { t: 'Vision Pro' },
{ t: 'Apple Arcade' }, { t: 'Game Center', cs: true }, { t: 'TestFlight' },
{ t: 'Sign in with Apple' }, { t: 'iCloud' },
{ t: 'アップストア', cjk: true }, { t: '苹果商店', cjk: true }, { t: '앱스토어', cjk: true }, { t: 'アイフォン', cjk: true }, { t: '아이폰', cjk: true },
];
/** A line tagged for the other store's notes (`[play]` in App Store text, `[apple]` in Play text) is always a hit. */
const OTHER_TAG: Record<CrossStore, RegExp> = { apple: /^\s*(?:[-*•]\s*)?\[(play|android|google)\]/i, play: /^\s*(?:[-*•]\s*)?\[(apple|ios|app ?store)\]/i };
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const termRe = (x: Term): RegExp => x.cjk ? new RegExp(esc(x.t), 'g')
: new RegExp(`(?<![\\p{L}\\p{N}])${esc(x.t).replace(/ /g, '[\\s\\u00a0-]?')}(?![\\p{L}\\p{N}])`, x.cs ? 'gu' : 'giu');
const COMPILED: Record<CrossStore, { term: string; re: RegExp }[]> = {
apple: [...APPLE_FORBIDDEN].sort((a, b) => b.t.length - a.t.length).map(x => ({ term: x.t, re: termRe(x) })),
play: [...PLAY_FORBIDDEN].sort((a, b) => b.t.length - a.t.length).map(x => ({ term: x.t, re: termRe(x) })),
};
export const STORE_NAME: Record<CrossStore, string> = { apple: 'App Store', play: 'Google Play' };
export const GUIDELINE: Record<CrossStore, string> = { apple: '2.3.10', play: 'Play: Metadata' };
export function fixFor(store: CrossStore, field: string): string {
const other = store === 'apple' ? 'play' : 'apple';
const notes = /whatsnew|release/i.test(field) ? ` or tag the line [${other}] in the shared notes (whats-new keeps it out of ${STORE_NAME[store]})` : '';
return `remove it from the ${STORE_NAME[store]} text${notes}; a functional mention (a sign-in button) goes into listing.allowPlatformMentions in storebound.config.ts, the owner's call`;
}
function allowed(store: CrossStore, line: string, term: string, allow: readonly PlatformAllow[]): boolean {
const l = line.toLowerCase(), t = term.toLowerCase();
return allow.some(a => (a.store === store || a.store === 'both') && !!a.text.trim() && l.includes(a.text.toLowerCase()) && a.text.toLowerCase().includes(t));
}
/** Hits in one field of one locale, line by line. Overlapping terms report the longest ("Google Play Games"). */
export function textMentions(text: string, o: { store: CrossStore; locale: string; field: string; allow?: readonly PlatformAllow[] }): PlatformHit[] {
const out: PlatformHit[] = [];
const allow = o.allow ?? [];
text.split('\n').forEach((raw, i) => {
const line = raw.trim();
if (!line) return;
const spans: [number, number][] = [];
const add = (term: string): void => { out.push({ store: o.store, locale: o.locale, field: o.field, line: i + 1, term, text: line.slice(0, 200), fix: fixFor(o.store, o.field) }); };
const tag = OTHER_TAG[o.store].exec(line);
if (tag) add(`[${tag[1]}]`);
for (const { term, re } of COMPILED[o.store]) {
re.lastIndex = 0;
for (let m = re.exec(line); m; m = re.exec(line)) {
const s = m.index, e = s + m[0].length;
if (spans.some(([a, b]) => s < b && e > a)) continue;
spans.push([s, e]);
if (!allowed(o.store, line, m[0], allow)) add(m[0]);
}
}
});
return out;
}
/** Hits in many fields of one locale: values as `{ field: text }`. */
export function fieldsMentions(store: CrossStore, locale: string, fields: Record<string, string | undefined>, allow?: readonly PlatformAllow[]): PlatformHit[] {
return Object.entries(fields).flatMap(([field, t]) => t ? textMentions(t, { store, locale, field, allow }) : []);
}
/** Hits in a listing file (store/appstore.md or store/listing.md): fenced blocks under `### field` of each `## locale`
* section, with the file line. Play sections take their codes from `<!-- play: ... -->`. */
export function fileMentions(store: CrossStore, path: string, md: string, allow?: readonly PlatformAllow[]): PlatformHit[] {
const out: PlatformHit[] = [];
const lines = md.split('\n');
let locale = '', field = '', fence = -1, buf: string[] = [];
lines.forEach((l, i) => {
if (fence < 0 && /^## /.test(l)) { locale = l.slice(3).split(/[\s·]/)[0]!.trim(); field = ''; return; }
const pc = /^<!--\s*play:\s*([^>]+?)\s*-->$/.exec(l);
if (fence < 0 && pc) { locale = pc[1]!.split(',').map(s => s.trim()).join(','); return; }
if (fence < 0 && /^###\s/.test(l)) { field = l.slice(4).trim(); return; }
if (/^```/.test(l)) {
if (fence < 0) { fence = i; buf = []; return; }
if (field && locale) for (const h of textMentions(buf.join('\n'), { store, locale, field: fieldName(field), allow })) out.push({ ...h, file: path, fileLine: fence + 1 + h.line });
fence = -1; return;
}
if (fence >= 0) buf.push(l);
});
return out;
}
/** "### Short description (max 80)" -> "short", "### whatsNew" -> "whatsNew". */
function fieldName(h: string): string {
if (/^whatsNew\b|what'?s new|release notes/i.test(h)) return 'whatsNew';
if (/\bshort\b/i.test(h)) return 'short';
if (/\bfull\b/i.test(h)) return 'full';
if (/\btitle\b/i.test(h)) return 'title';
return h.split(/\s/)[0]!;
}
/** One line per hit: where, what, and the fix. */
export function hitLine(h: PlatformHit): string {
const at = h.file ? `${h.file}:${h.fileLine} ` : '';
return `${at}${STORE_NAME[h.store]} ${h.locale} ${h.field} line ${h.line}: "${h.term}" in "${h.text}" (${GUIDELINE[h.store]}): ${h.fix}`;
}
/** Throws (nothing is written) when there are hits; `what` names the write. */
export function refuseMentions(hits: readonly PlatformHit[], what: string): void {
if (!hits.length) return;
const stores = [...new Set(hits.map(h => h.store))];
throw new Error(`refused: ${what}: other platforms or stores named in ${stores.map(s => `${STORE_NAME[s]} text (${GUIDELINE[s]})`).join(' and ')}; nothing was sent\n${hits.map(h => ` ${hitLine(h)}`).join('\n')}`);
}
// ---- per-store notes: lines tagged [apple] / [play] in shared release notes ----
const TAG_RE = /^(\s*(?:[-*•]\s*)?)\[(apple|ios|app ?store|play|android|google ?play|google)\]\s*/i;
const tagStore = (t: string): CrossStore => /^(apple|ios|app ?store)$/i.test(t) ? 'apple' : 'play';
/** The notes for one store: untagged lines stay, lines tagged for this store lose the tag, lines tagged for the other
* store go. A heading line that is only a tag (`[play]`) starts a block for that store until a blank line. Pure. */
export function notesForStore(text: string, store: CrossStore): string {
const out: string[] = [];
let block: CrossStore | null = null;
for (const l of text.split('\n')) {
if (!l.trim()) { block = null; out.push(l); continue; }
const only = /^\s*\[(apple|ios|app ?store|play|android|google ?play|google)\]\s*:?\s*$/i.exec(l);
if (only) { block = tagStore(only[1]!); continue; }
if (block) { if (block === store) out.push(l); continue; }
const m = TAG_RE.exec(l);
if (!m) { out.push(l); continue; }
if (tagStore(m[2]!) === store) out.push(m[1]! + l.slice(m[0].length));
}
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
/** Whether a text has store tags at all (then it is split before it goes anywhere). */
export const hasStoreTags = (text: string): boolean => text.split('\n').some(l => TAG_RE.test(l) || /^\s*\[(apple|ios|play|android)\]\s*:?\s*$/i.test(l));