desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case
This commit is contained in:
parent
714352ec18
commit
3cea2af4e1
37 changed files with 3020 additions and 54 deletions
114
cli/lib/listing-rules.ts
Normal file
114
cli/lib/listing-rules.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// 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';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue