// 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 = { 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(`(? = { 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 = { apple: 'App Store', play: 'Google Play' }; export const GUIDELINE: Record = { 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, 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 ``. */ 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 = /^$/.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));