171 lines
10 KiB
TypeScript
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));
|