114 lines
7.9 KiB
TypeScript
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';
|
|
}
|