storebound/cli/lib/listing-rules.ts

114 lines
7.9 KiB
TypeScript

// Checks of one listing language as the owner types it: the store limits (characters), the fields the parsers need,
// and the metadata rules `scan` and `check --aso` apply to the whole file (other platforms named in App Store text,
// other apps' names or prices in the name / subtitle / keywords, placeholder text, beta wording, keyword stuffing).
// Pure, no Node imports: the desktop window runs it on every keystroke, `storebound edit` before it writes.
import { auditAppleKeywords, auditPlayListing } from './aso';
import { APPLE_LIMITS, type AppleCopy, len, PLAY_LIMITS } from './copy';
export type IssueLevel = 'error' | 'warn' | 'info';
export interface FieldIssue { level: IssueLevel; field: string; msg: string }
/** Other apps and trademarks that get App Store metadata rejected (2.3.7) in the name, subtitle and keywords. */
export const COMPETITORS = ['instagram', 'tiktok', 'whatsapp', 'facebook', 'snapchat', 'youtube', 'netflix', 'spotify', 'telegram', 'messenger', 'twitter', 'pinterest', 'uber', 'airbnb',
'amazon', 'chatgpt', 'openai', 'minecraft', 'roblox', 'fortnite', 'pubg', 'candy crush', 'clash of clans', 'pokemon', 'pokémon', 'among us', 'subway surfers', 'temple run',
'angry birds', 'tetris', 'wordle', 'duolingo', 'zoom', 'gmail', 'google maps', 'waze', 'shazam', 'tinder', 'notion', 'evernote', 'photoshop', 'canva', 'capcut', 'iphone', 'ipad', 'apple'];
/** Other mobile platforms or stores in App Store text (2.3.10). */
export const OTHER_PLATFORMS = /\b(Android|Google Play|Play Store|Galaxy Store|AppGallery|Huawei|Windows Phone|BlackBerry|Amazon Appstore)\b/;
// Developer markers count only in capitals ("TODO:", "FIXME"): "todo" is a real word and keyword (a to-do app).
export const PLACEHOLDER_MARKERS = /\bTODO\b|\bTBD\b|\bFIXME\b|\bXXX\b/;
export const PLACEHOLDER_TEXT = /lorem ipsum|\bplaceholder\b|your app (name|description) here|coming soon|First paragraph:|Short hook under the name|One or two sentences you can change|İsmin altındaki kısa kanca|Yeni sürüm göndermeden istediğin zaman|\bExample (Game|App|Pro|Studio)\b/i;
/** Demo, beta and trial wording (Apple 2.2: those belong on TestFlight). */
export const BETA_WORDS = /\b(beta|demo|trial version|test (version|build|app)|testing build|early access|prototype|preview (build|version)|alpha (build|version))\b/i;
/** Prices and superlatives in the App Store name, subtitle or keywords (2.3.7). */
export const PRICE_CLAIMS = /\b(free|best|#1|no\.? ?1|top|cheap|sale|\d+% off|\$\d)\b/i;
export const competitorRe = (): RegExp => new RegExp(`(^|[\\s,|/–-])(${COMPETITORS.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?=$|[\\s,|/–-])`, 'i');
export const PLAY_REQUIRED = ['short', 'full'] as const;
export const APPLE_REQUIRED = ['subtitle', 'promotionalText', 'keywords', 'description'] as const;
/**
* Issues of one App Store locale (the values as they would be saved). `appName`: what the name falls back to
* (apple.name, else app.name). Errors stop a save (the store would refuse it, or the listing file would no longer
* parse); warnings and hints do not.
*/
export function appleLocaleIssues(locale: string, v: AppleCopy, appName: string): FieldIssue[] {
const out: FieldIssue[] = [];
for (const f of APPLE_REQUIRED) if (!(v[f] ?? '').trim()) out.push({ level: 'error', field: f, msg: `${locale}: ${f} is required` });
for (const [f, lim] of Object.entries(APPLE_LIMITS) as [keyof typeof APPLE_LIMITS, number][]) {
const n = len(v[f] ?? '');
if (n > lim) out.push({ level: 'error', field: f, msg: `${f} is ${n} characters; the App Store takes ${lim}` });
}
const name = (v.name ?? appName).trim();
if (!v.name && len(name) > APPLE_LIMITS.name) out.push({ level: 'error', field: 'name', msg: `the name falls back to "${name}" (${len(name)} > ${APPLE_LIMITS.name}); give this locale its own name` });
if (v.keywords) {
// auditAppleKeywords: the 100-character limit, spaces around commas, duplicates, words the name / subtitle index
for (const a of auditAppleKeywords(locale, v, appName)) {
if (/characters > 100/.test(a.msg)) continue; // the limit above already says it
out.push({ level: a.level, field: 'keywords', msg: a.msg.replace(/^keywords:\s*/, '') });
}
}
const comp = competitorRe();
for (const f of ['name', 'subtitle', 'keywords'] as const) {
const t = f === 'name' ? name : v[f] ?? '';
if (!t) continue;
const c = comp.exec(t);
if (c) out.push({ level: 'warn', field: f, msg: `"${c[2]}" is another app's name or a trademark; Apple rejects it in the ${f} (2.3.7)` });
const p = PRICE_CLAIMS.exec(t);
if (p) out.push({ level: 'warn', field: f, msg: `"${p[0]}": prices and claims do not belong in the ${f} (2.3.7)` });
}
for (const f of ['name', 'subtitle'] as const) {
const t = f === 'name' ? name : v[f] ?? '';
if (/^([^,|]{1,24}[,|]){5,}/.test(t)) out.push({ level: 'warn', field: f, msg: `reads like a keyword list; the ${f} is a short phrase (2.3.7)` });
}
for (const f of ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew'] as const) {
const t = f === 'name' ? (v.name ?? '') : v[f] ?? '';
if (!t) continue;
const o = OTHER_PLATFORMS.exec(t);
if (o) out.push({ level: 'warn', field: f, msg: `"${o[0]}": other platforms or stores in App Store text get it rejected (2.3.10)` });
if (PLACEHOLDER_MARKERS.test(t) || PLACEHOLDER_TEXT.test(t)) out.push({ level: 'warn', field: f, msg: 'placeholder text (template or TODO) is still in it' });
const b = f === 'whatsNew' ? null : BETA_WORDS.exec(t);
if (b && !/\bdemo account\b|\bdemo (video|mode)\b/i.test(t)) out.push({ level: 'warn', field: f, msg: `"${b[0]}": demos, betas and trials belong on TestFlight (2.2)` });
}
return out;
}
export interface PlayValues { title?: string; short?: string; full?: string; whatsNew?: string }
/** Issues of one Google Play language (title falls back to app.name). */
export function playLangIssues(lang: string, v: PlayValues, appName: string): FieldIssue[] {
const out: FieldIssue[] = [];
for (const f of PLAY_REQUIRED) if (!(v[f] ?? '').trim()) out.push({ level: 'error', field: f, msg: `${lang}: the ${f} description is required` });
const title = (v.title ?? '').trim() || appName;
for (const [f, lim] of Object.entries(PLAY_LIMITS) as [keyof typeof PLAY_LIMITS, number][]) {
const n = len(f === 'title' ? title : v[f] ?? '');
if (n > lim) out.push({ level: 'error', field: f, msg: f === 'title' && !v.title ? `the title falls back to "${title}" (${n} > ${lim}); give this language its own title` : `${f} is ${n} characters; Google Play takes ${lim}` });
}
if (v.short && v.full) {
for (const a of auditPlayListing({ lang, title, short: v.short, full: v.full, whatsNew: v.whatsNew ?? '' })) {
const field = /^full description/.test(a.msg) ? 'full' : /^title \/ short/.test(a.msg) ? 'short' : /^title/.test(a.msg) ? 'title' : /^short/.test(a.msg) ? 'short' : 'full';
out.push({ level: a.level, field, msg: a.msg });
}
}
for (const f of ['title', 'short', 'full', 'whatsNew'] as const) {
const t = v[f] ?? '';
if (t && (PLACEHOLDER_MARKERS.test(t) || PLACEHOLDER_TEXT.test(t))) out.push({ level: 'warn', field: f, msg: 'placeholder text (template or TODO) is still in it' });
}
return out;
}
/** An http(s) URL the stores take (empty: no issue; required fields say so themselves). */
export function urlIssue(v: string): string | null {
if (!v) return null;
try {
const u = new URL(v);
if (u.protocol !== 'https:' && u.protocol !== 'http:') return 'an http(s) address';
if (u.protocol === 'http:') return 'use https (the stores want a secure page)';
if (!u.hostname.includes('.')) return 'a public address (with a domain)';
return null;
} catch { return 'not a web address (https://...)'; }
}
export function emailIssue(v: string): string | null {
if (!v) return null;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : 'not an email address';
}