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
|
|
@ -79,16 +79,8 @@ const coveredBy = (w: string, set: Iterable<string>, locale: string): boolean =>
|
|||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Competitors (iTunes Search API)
|
||||
|
||||
/** App Store Connect category id -> iTunes genre id (the "genreIds" of Search API results). Apple's genre ids. */
|
||||
export const GENRE_IDS: Record<string, string> = {
|
||||
BOOKS: '6018', BUSINESS: '6000', DEVELOPER_TOOLS: '6026', EDUCATION: '6017', ENTERTAINMENT: '6016', FINANCE: '6015', FOOD_AND_DRINK: '6023', GAMES: '6014',
|
||||
GRAPHICS_AND_DESIGN: '6027', HEALTH_AND_FITNESS: '6013', LIFESTYLE: '6012', MAGAZINES_AND_NEWSPAPERS: '6021', MEDICAL: '6020', MUSIC: '6011', NAVIGATION: '6010',
|
||||
NEWS: '6009', PHOTO_AND_VIDEO: '6008', PRODUCTIVITY: '6007', REFERENCE: '6006', SHOPPING: '6024', SOCIAL_NETWORKING: '6005', SPORTS: '6004', STICKERS: '6025',
|
||||
TRAVEL: '6003', UTILITIES: '6002', WEATHER: '6001',
|
||||
GAMES_ACTION: '7001', GAMES_ADVENTURE: '7002', GAMES_CASUAL: '7003', GAMES_BOARD: '7004', GAMES_CARD: '7005', GAMES_CASINO: '7006', GAMES_FAMILY: '7009',
|
||||
GAMES_MUSIC: '7011', GAMES_PUZZLE: '7012', GAMES_RACING: '7013', GAMES_ROLE_PLAYING: '7014', GAMES_SIMULATION: '7015', GAMES_SPORTS: '7016',
|
||||
GAMES_STRATEGY: '7017', GAMES_TRIVIA: '7018', GAMES_WORD: '7019',
|
||||
};
|
||||
export { GENRE_IDS } from './store-catalogs';
|
||||
import { GENRE_IDS } from './store-catalogs';
|
||||
|
||||
export interface CompetitorApp {
|
||||
rank: number; id: string; name: string; developer: string; ratings: number; rating: number | null;
|
||||
|
|
|
|||
297
cli/lib/config-path.ts
Normal file
297
cli/lib/config-path.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// One value of storebound.config.ts / .js / .json set by its path, as a text edit: the rest of the file (comments,
|
||||
// order, quotes, formatting) stays byte for byte. Used by `storebound edit` (the desktop's editors). Pure.
|
||||
//
|
||||
// A path is dot-separated keys with array selectors: `app.supportUrl`, `apple.info.categories.primary`,
|
||||
// `products[id=com.example.no_ads].listings.en.title`, `subscriptions[0].listings.tr.name`,
|
||||
// `apple.testflight.groups[name=Friends].internal`. A selector `[key=value]` picks the array element whose `key` is that
|
||||
// string; `[n]` the n-th element. Keys may be quoted in the file ('pt-BR': ...) or not.
|
||||
// Setting `undefined` removes the property. Missing objects on the way are created; a missing array element is an
|
||||
// error (adding a product is a config change for the owner's editor, not a path edit). A value on the way that is
|
||||
// not a plain literal (a variable, a function call, a spread) is an error: the owner edits that in the file.
|
||||
import { matchBrace, toTs } from './scan/config-edit';
|
||||
|
||||
export type PathPart = { key: string } | { index: number } | { match: string; eq: string };
|
||||
|
||||
/** `a.b[id=x].c[0]` -> parts. Throws on a malformed path. */
|
||||
export function parsePath(path: string): PathPart[] {
|
||||
const out: PathPart[] = [];
|
||||
let i = 0;
|
||||
while (i < path.length) {
|
||||
if (path[i] === '.') { i++; continue; }
|
||||
if (path[i] === '[') {
|
||||
const end = path.indexOf(']', i);
|
||||
if (end < 0) throw new Error(`path ${path}: unclosed [`);
|
||||
const inner = path.slice(i + 1, end);
|
||||
if (/^\d+$/.test(inner)) out.push({ index: Number(inner) });
|
||||
else {
|
||||
const m = /^([A-Za-z_$][\w$]*)=(.+)$/.exec(inner);
|
||||
if (!m) throw new Error(`path ${path}: [${inner}] is neither [n] nor [key=value]`);
|
||||
out.push({ match: m[1]!, eq: m[2]! });
|
||||
}
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
const m = /^[A-Za-z0-9_$-]+/.exec(path.slice(i));
|
||||
if (!m) throw new Error(`path ${path}: unexpected "${path[i]}"`);
|
||||
out.push({ key: m[0] });
|
||||
i += m[0].length;
|
||||
}
|
||||
if (!out.length) throw new Error('empty path');
|
||||
return out;
|
||||
}
|
||||
|
||||
export const pathText = (parts: PathPart[]): string =>
|
||||
parts.map((p, i) => ('key' in p ? `${i ? '.' : ''}${p.key}` : 'index' in p ? `[${p.index}]` : `[${p.match}=${p.eq}]`)).join('');
|
||||
|
||||
// ---- a small scanner for object and array literals ----
|
||||
|
||||
const isWs = (c: string | undefined): boolean => c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
||||
|
||||
/** Index after whitespace and comments from `i`. */
|
||||
function skip(t: string, i: number): number {
|
||||
for (;;) {
|
||||
while (i < t.length && isWs(t[i])) i++;
|
||||
if (t[i] === '/' && t[i + 1] === '/') { const n = t.indexOf('\n', i); i = n < 0 ? t.length : n + 1; continue; }
|
||||
if (t[i] === '/' && t[i + 1] === '*') { const n = t.indexOf('*/', i + 2); i = n < 0 ? t.length : n + 2; continue; }
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
/** End (exclusive) of the string literal starting at `i`. */
|
||||
function stringEnd(t: string, i: number): number {
|
||||
const q = t[i]!;
|
||||
for (let j = i + 1; j < t.length; j++) {
|
||||
if (t[j] === '\\') { j++; continue; }
|
||||
if (t[j] === q) return j + 1;
|
||||
}
|
||||
throw new Error('unterminated string in the config');
|
||||
}
|
||||
|
||||
/** End (exclusive, trailing whitespace and comments not included) of the expression starting at `i`: up to the next
|
||||
* `,` or closing bracket at depth 0. */
|
||||
function exprEnd(t: string, i: number): number {
|
||||
let last = i;
|
||||
let j = i;
|
||||
while (j < t.length) {
|
||||
const c = t[j]!;
|
||||
if (c === '/' && (t[j + 1] === '/' || t[j + 1] === '*')) { j = skip(t, j); continue; }
|
||||
if (isWs(c)) { j++; continue; }
|
||||
if (c === ',' || c === '}' || c === ']' || c === ')') return last;
|
||||
if (c === '"' || c === '\'' || c === '`') { j = stringEnd(t, j); last = j; continue; }
|
||||
if (c === '{' || c === '[' || c === '(') {
|
||||
const close = matchBrace(t, j);
|
||||
if (close < 0) throw new Error('unbalanced brackets in the config');
|
||||
j = close + 1; last = j; continue;
|
||||
}
|
||||
j++; last = j;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
interface Member { key: string | null; start: number; valueStart: number; valueEnd: number; end: number }
|
||||
|
||||
/** Members of the object (or elements of the array) whose opening bracket is at `open`. `end`: after the member's
|
||||
* trailing comma, if any. key null: a spread, a computed key or an array element. */
|
||||
function members(t: string, open: number): { list: Member[]; close: number } {
|
||||
const close = matchBrace(t, open);
|
||||
if (close < 0) throw new Error('unbalanced brackets in the config');
|
||||
const isArray = t[open] === '[';
|
||||
const list: Member[] = [];
|
||||
let i = skip(t, open + 1);
|
||||
while (i < close) {
|
||||
const start = i;
|
||||
let key: string | null = null;
|
||||
let valueStart = i;
|
||||
if (!isArray) {
|
||||
if (t.startsWith('...', i)) { key = null; valueStart = i; }
|
||||
else {
|
||||
let k: string;
|
||||
let after: number;
|
||||
if (t[i] === '"' || t[i] === '\'') { after = stringEnd(t, i); k = JSON.parse(t[i] === '"' ? t.slice(i, after) : `"${t.slice(i + 1, after - 1).replace(/\\'/g, '\'').replace(/"/g, '\\"')}"`); }
|
||||
else if (t[i] === '[') { after = matchBrace(t, i) + 1; k = ''; }
|
||||
else { const m = /^[A-Za-z0-9_$]+/.exec(t.slice(i)); if (!m) throw new Error(`unexpected "${t[i]}" in the config`); k = m[0]; after = i + k.length; }
|
||||
key = t[i] === '[' ? null : k;
|
||||
const colon = skip(t, after);
|
||||
if (t[colon] === ':') valueStart = skip(t, colon + 1);
|
||||
else if (t[colon] === '(') { key = null; valueStart = i; } // a method
|
||||
else valueStart = i; // shorthand `{ name }`: the value is the identifier
|
||||
}
|
||||
}
|
||||
const valueEnd = exprEnd(t, valueStart);
|
||||
let end = skip(t, valueEnd);
|
||||
if (t[end] === ',') end++;
|
||||
list.push({ key, start, valueStart, valueEnd, end });
|
||||
i = skip(t, end);
|
||||
if (end === valueEnd && i < close) throw new Error('could not read the config (a member without a comma)');
|
||||
}
|
||||
return { list, close };
|
||||
}
|
||||
|
||||
/** The opening brace of the object the config exports: `export default {`, `export default defineConfig({`,
|
||||
* `module.exports = {`, or the `const x = {` that `export default x` names. */
|
||||
export function rootObject(t: string): number {
|
||||
const direct = /(?:export\s+default|module\.exports\s*=)\s*(?:[A-Za-z_$][\w$]*\s*\(\s*)?\{/.exec(t);
|
||||
if (direct) return direct.index + direct[0].length - 1;
|
||||
const named = /export\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(t) ?? /module\.exports\s*=\s*([A-Za-z_$][\w$]*)/.exec(t);
|
||||
if (named) {
|
||||
const decl = new RegExp(`(?:const|let|var)\\s+${named[1]}\\b[^=]*=\\s*(?:[A-Za-z_$][\\w$]*\\s*\\(\\s*)?\\{`).exec(t);
|
||||
if (decl) return decl.index + decl[0].length - 1;
|
||||
}
|
||||
throw new Error('could not find the exported config object (export default { ... })');
|
||||
}
|
||||
|
||||
/** Indentation of the line that `at` is on. */
|
||||
function lineIndent(t: string, at: number): string {
|
||||
const ls = t.lastIndexOf('\n', at - 1) + 1;
|
||||
return /^[ \t]*/.exec(t.slice(ls))![0];
|
||||
}
|
||||
|
||||
/** One indentation step of the file (2 spaces by default). */
|
||||
function step(t: string): string {
|
||||
const m = /\n([ \t]+)\S/.exec(t);
|
||||
return m ? (m[1]!.startsWith('\t') ? '\t' : m[1]!.length >= 4 && m[1]!.length % 4 === 0 && !/\n {2}\S/.test(t) ? ' ' : ' ') : ' ';
|
||||
}
|
||||
|
||||
/** A literal for `v` in the style of the value it replaces (the quote character of an old string). */
|
||||
function literal(v: unknown, ind: string, old?: string): string {
|
||||
if (typeof v === 'string' && old && (old[0] === '"' || old[0] === '`') && !v.includes('\n')) {
|
||||
if (old[0] === '"') return JSON.stringify(v);
|
||||
if (!/[`$\\]/.test(v)) return `\`${v}\``;
|
||||
}
|
||||
return toTs(v, ind);
|
||||
}
|
||||
|
||||
/** Is the text at [s, e) a literal we may read through or replace (no identifiers, calls or spreads)? */
|
||||
const plain = (src: string): boolean => /^(['"`]|-?\d|true\b|false\b|null\b|undefined\b|\{|\[)/.test(src.trim());
|
||||
|
||||
/** The config text with the value at `path` set (undefined: removed). */
|
||||
export function setByPath(text: string, file: string, path: string | PathPart[], value: unknown): string {
|
||||
const parts = typeof path === 'string' ? parsePath(path) : path;
|
||||
if (file.endsWith('.json')) return setJson(text, parts, value);
|
||||
let open = rootObject(text);
|
||||
for (let n = 0; n < parts.length; n++) {
|
||||
const part = parts[n]!;
|
||||
const last = n === parts.length - 1;
|
||||
const { list, close } = members(text, open);
|
||||
const isArray = text[open] === '[';
|
||||
let hit: Member | undefined;
|
||||
if ('key' in part) {
|
||||
if (isArray) throw new Error(`${pathText(parts.slice(0, n))} is a list; use [n] or [key=value]`);
|
||||
hit = [...list].reverse().find(m => m.key === part.key);
|
||||
} else {
|
||||
if (!isArray) throw new Error(`${pathText(parts.slice(0, n))} is not a list`);
|
||||
if ('index' in part) hit = list[part.index];
|
||||
else hit = list.find(m => {
|
||||
const v = text.slice(m.valueStart, m.valueEnd);
|
||||
if (!v.startsWith('{')) return false;
|
||||
const inner = members(text, m.valueStart).list.find(x => x.key === part.match);
|
||||
if (!inner) return false;
|
||||
const s = text.slice(inner.valueStart, inner.valueEnd);
|
||||
return plain(s) && /^['"`]/.test(s) && evalString(s) === part.eq;
|
||||
});
|
||||
if (!hit) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`);
|
||||
}
|
||||
if (last) {
|
||||
if (value === undefined) {
|
||||
if (!hit) return text;
|
||||
return removeMember(text, hit, list, open, close);
|
||||
}
|
||||
if (hit) {
|
||||
const old = text.slice(hit.valueStart, hit.valueEnd);
|
||||
if (hit.valueStart === hit.start && !isArray) throw new Error(`${pathText(parts)} is written as a shorthand property; change it in the file`);
|
||||
if (!plain(old)) throw new Error(`${pathText(parts)} is not a plain value in the config (${old.slice(0, 40)}); change it in the file`);
|
||||
return text.slice(0, hit.valueStart) + literal(value, lineIndent(text, hit.start), old) + text.slice(hit.valueEnd);
|
||||
}
|
||||
return insertMember(text, open, close, list, (part as { key: string }).key, value);
|
||||
}
|
||||
if (!hit) {
|
||||
if (value === undefined) return text;
|
||||
// build the rest of the path as nested objects (only keys can be created)
|
||||
let v: unknown = value;
|
||||
for (let k = parts.length - 1; k > n; k--) {
|
||||
const p = parts[k]!;
|
||||
if (!('key' in p)) throw new Error(`${pathText(parts.slice(0, k + 1))}: no such element in the config`);
|
||||
v = { [p.key]: v };
|
||||
}
|
||||
return insertMember(text, open, close, list, (part as { key: string }).key, v);
|
||||
}
|
||||
const src = text.slice(hit.valueStart, hit.valueEnd);
|
||||
if (!src.startsWith('{') && !src.startsWith('[')) throw new Error(`${pathText(parts.slice(0, n + 1))} is not a plain object in the config (${src.slice(0, 40)}); change it in the file`);
|
||||
open = hit.valueStart;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** The value of a string literal (single, double or backtick quotes without ${}). */
|
||||
function evalString(s: string): string {
|
||||
const q = s[0];
|
||||
if (q === '"') return JSON.parse(s) as string;
|
||||
const body = s.slice(1, -1);
|
||||
return body.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (_m, e: string) => {
|
||||
if (e[0] === 'u') return String.fromCodePoint(parseInt(e[1] === '{' ? e.slice(2, -1) : e.slice(1), 16));
|
||||
if (e[0] === 'x') return String.fromCharCode(parseInt(e.slice(1), 16));
|
||||
return ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', '0': '\0' } as Record<string, string>)[e] ?? e;
|
||||
});
|
||||
}
|
||||
|
||||
function insertMember(t: string, open: number, close: number, list: Member[], key: string, value: unknown): string {
|
||||
const k = /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`;
|
||||
const body = t.slice(open + 1, close);
|
||||
if (!body.includes('\n')) {
|
||||
const lit = toTs(value, lineIndent(t, open));
|
||||
const inner = body.trim().replace(/,$/, '');
|
||||
return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${lit} ${t.slice(close)}`;
|
||||
}
|
||||
const lastM = list.at(-1);
|
||||
const ind = lastM ? lineIndent(t, lastM.start) : lineIndent(t, open) + step(t);
|
||||
const lit = toTs(value, ind);
|
||||
if (lastM) {
|
||||
const needComma = t.slice(lastM.valueEnd, lastM.end).indexOf(',') < 0;
|
||||
// after the last member's comma and any comment on the same line
|
||||
let at = lastM.end;
|
||||
const nl = t.indexOf('\n', at);
|
||||
if (nl >= 0 && nl < close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(at, nl))) at = nl;
|
||||
const pre = needComma ? t.slice(0, lastM.valueEnd) + ',' + t.slice(lastM.valueEnd, at) : t.slice(0, at);
|
||||
return `${pre}\n${ind}${k}: ${lit},${t.slice(at)}`;
|
||||
}
|
||||
return `${t.slice(0, open + 1)}\n${ind}${k}: ${lit},${t.slice(open + 1)}`;
|
||||
}
|
||||
|
||||
function removeMember(t: string, m: Member, list: Member[], open: number, close: number): string {
|
||||
const ls = t.lastIndexOf('\n', m.start - 1) + 1;
|
||||
const ownLine = /^[ \t]*$/.test(t.slice(ls, m.start));
|
||||
let end = m.end;
|
||||
const nl = t.indexOf('\n', end);
|
||||
if (ownLine && nl >= 0 && nl <= close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(end, nl))) end = nl + 1;
|
||||
const start = ownLine ? ls : m.start;
|
||||
let out = t.slice(0, start) + t.slice(end);
|
||||
if (list.length === 1 && !t.slice(open, close).includes('\n')) out = t.slice(0, open + 1) + t.slice(close);
|
||||
else if (m === list.at(-1) && t.slice(m.valueEnd, m.end).indexOf(',') < 0 && list.length > 1) {
|
||||
// the previous member keeps a trailing comma: valid in TS and JS
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setJson(text: string, parts: PathPart[], value: unknown): string {
|
||||
const root = JSON.parse(text) as Record<string, unknown>;
|
||||
let cur: unknown = root;
|
||||
for (let n = 0; n < parts.length; n++) {
|
||||
const p = parts[n]!;
|
||||
const last = n === parts.length - 1;
|
||||
if ('key' in p) {
|
||||
if (typeof cur !== 'object' || cur === null || Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not an object`);
|
||||
const o = cur as Record<string, unknown>;
|
||||
if (last) { if (value === undefined) delete o[p.key]; else o[p.key] = value; break; }
|
||||
if (o[p.key] === undefined) { if (value === undefined) return text; o[p.key] = {}; }
|
||||
cur = o[p.key];
|
||||
} else {
|
||||
if (!Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not a list`);
|
||||
const i = 'index' in p ? p.index : cur.findIndex(x => x && typeof x === 'object' && (x as Record<string, unknown>)[p.match] === p.eq);
|
||||
if (i < 0 || i >= cur.length) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`);
|
||||
if (last) { if (value === undefined) cur.splice(i, 1); else cur[i] = value; break; }
|
||||
cur = cur[i];
|
||||
}
|
||||
}
|
||||
const ind = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ';
|
||||
return JSON.stringify(root, null, ind) + (text.endsWith('\n') ? '\n' : '');
|
||||
}
|
||||
|
|
@ -850,14 +850,9 @@ export interface ConsoleConfig {
|
|||
appContent?: AppContentConfig;
|
||||
}
|
||||
|
||||
/** Play Console's game categories (Store settings > App category, app type Game). */
|
||||
export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing',
|
||||
'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const;
|
||||
/** Play Console's app categories (Store settings > App category, app type App). */
|
||||
export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating',
|
||||
'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle',
|
||||
'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping',
|
||||
'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const;
|
||||
export { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from './store-catalogs';
|
||||
import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from './store-catalogs';
|
||||
|
||||
/** App Store category ids with an exact Play counterpart. */
|
||||
const APPLE_TO_PLAY: Record<string, string> = {
|
||||
GAMES_ACTION: 'Action', GAMES_ADVENTURE: 'Adventure', GAMES_BOARD: 'Board', GAMES_CARD: 'Card', GAMES_CASINO: 'Casino', GAMES_CASUAL: 'Casual',
|
||||
|
|
|
|||
|
|
@ -413,3 +413,44 @@ export interface ConsoleFillResult {
|
|||
/** Real run only: fields filled, fields highlighted, whether it fell back to highlighting only, where it stopped. */
|
||||
filled?: number; highlighted?: number; fallback?: unknown; stoppedAt?: string | null;
|
||||
}
|
||||
|
||||
// ---- Editors (console parity, docs/console-parity.md) ----
|
||||
/** One listing language of one store as the listing file holds it (project.editable). `exists`: the file has a section. */
|
||||
export interface EditableListing { lang: string; language: string; status: string | null; exists: boolean; values: Record<string, string>; codes?: string[] }
|
||||
/** project.editable (`edit --show --json`): what the desktop's editors show and change. */
|
||||
export interface EditableResult {
|
||||
root: string;
|
||||
files: { config: string; play: string | null; apple: string | null; contact: string };
|
||||
format: 'ts' | 'js' | 'json';
|
||||
/** The app folder is a git work tree; which of the editable files have uncommitted changes. storebound never commits. */
|
||||
git: { repo: boolean; uncommitted: string[] };
|
||||
appName: string;
|
||||
appleName: string;
|
||||
languages: { id: string; play: string[]; apple: string | null; rtl: boolean }[];
|
||||
play: EditableListing[] | null;
|
||||
apple: EditableListing[] | null;
|
||||
/** The config sections the editors change, as the config holds them (plain data). */
|
||||
config: {
|
||||
app: Record<string, unknown>; apple: Record<string, unknown> | null; play: Record<string, unknown> | null;
|
||||
products: Record<string, unknown>[]; subscriptions: Record<string, unknown>[];
|
||||
dataSafety: Record<string, unknown> | null; appPrivacy: Record<string, unknown> | null; reviewAccess: Record<string, unknown> | null;
|
||||
accounts: Record<string, unknown> | null; social: Record<string, unknown> | null;
|
||||
/** Screenshot scenes in store order and the feature graphic (shots). */
|
||||
shots: { scenes: { id: string; frames?: { src: string }[] }[]; feature: Record<string, unknown> | null } | null;
|
||||
};
|
||||
/** App Review contact from .storebound/credentials.json, masked (personal data); null when not set. */
|
||||
contact: { firstName: string; lastName: string; phone: string; email: string } | null;
|
||||
/** The App Review demo account's user name, masked; the password is never read out. */
|
||||
demoAccount: string | null;
|
||||
limits: Record<string, Record<string, number> | number>;
|
||||
}
|
||||
/** project.edit (`edit --ops --json`): the plan (and with a confirm token, the write). */
|
||||
export interface EditResult {
|
||||
changes: { what: string; file: string; from: string | null; to: string | null; also?: string[] }[];
|
||||
files: { file: string; rel: string; created: boolean; diff: string[]; masked?: boolean }[];
|
||||
issues: { level: 'error' | 'warn' | 'info'; field: string; where: string; msg: string }[];
|
||||
errors: number;
|
||||
written: boolean;
|
||||
dry: boolean;
|
||||
git: { repo: boolean; uncommitted: string[] };
|
||||
}
|
||||
|
|
|
|||
83
cli/lib/edit-values.ts
Normal file
83
cli/lib/edit-values.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// What the desktop's editors show (`storebound edit --show --json`, serve method project.editable): the listing
|
||||
// fields per store and language as the files hold them, the config sections the editors change, the App Review
|
||||
// contact (masked: personal data), the store limits, and whether the files are uncommitted in git. Offline.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { APPLE_IAP_LIMITS, APPLE_LIMITS, PLAY_LIMITS } from './copy';
|
||||
import { appleName, type Loaded, playCodes } from './config';
|
||||
import { maskUser, reviewDemoAccount } from './credentials';
|
||||
import type { EditableResult } from './desktop-protocol';
|
||||
import { appleSection, playSection } from './listing-edit';
|
||||
import { NOTES_MAX_BYTES } from './review-details';
|
||||
import * as rt from './rt';
|
||||
import { APPLE_SUB_LIMITS, PLAY_SUB_LIMITS } from './subscriptions';
|
||||
|
||||
/** Is `root` in a git work tree, and which of `files` have uncommitted changes (or are untracked)? */
|
||||
export function gitState(root: string, files: string[]): { repo: boolean; uncommitted: string[] } {
|
||||
try {
|
||||
const inside = rt.spawnSync(['git', '-C', root, 'rev-parse', '--is-inside-work-tree'], { timeout: 5000 });
|
||||
if (!inside.success || inside.stdout.toString().trim() !== 'true') return { repo: false, uncommitted: [] };
|
||||
if (!files.length) return { repo: true, uncommitted: [] };
|
||||
const st = rt.spawnSync(['git', '-C', root, 'status', '--porcelain', '--untracked-files=all', '--', ...files.map(f => relative(root, f) || f)], { timeout: 5000 });
|
||||
if (!st.success) return { repo: true, uncommitted: [] };
|
||||
const out = st.stdout.toString().split('\n').filter(Boolean).map(l => l.slice(3).replace(/^"|"$/g, ''));
|
||||
// porcelain paths are relative to the repository top: keep the file names the window knows
|
||||
const rels = files.map(f => relative(root, f) || f);
|
||||
return { repo: true, uncommitted: rels.filter(r => out.some(o => o === r || o.endsWith(`/${r}`))) };
|
||||
} catch {
|
||||
return { repo: false, uncommitted: [] };
|
||||
}
|
||||
}
|
||||
|
||||
const mask = (field: string, v: string): string => (!v ? '' : field === 'email' ? maskUser(v) : field === 'phone' ? `${v.slice(0, 3)}***` : `${v[0]}***`);
|
||||
|
||||
export function editableValues(l: Loaded): EditableResult {
|
||||
const { config, root, path, resolve } = l;
|
||||
const rel = (f: string): string => relative(root, f) || f;
|
||||
const playFile = config.play ? resolve(config.play.listing ?? 'store/listing.md') : null;
|
||||
const appleFile = config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : null;
|
||||
const playMd = playFile && existsSync(playFile) ? readFileSync(playFile, 'utf8') : null;
|
||||
const appleMd = appleFile && existsSync(appleFile) ? readFileSync(appleFile, 'utf8') : null;
|
||||
const contactFile = join(root, '.storebound', 'credentials.json');
|
||||
let contact: EditableResult['contact'] = null;
|
||||
try {
|
||||
const c = existsSync(contactFile) ? (JSON.parse(readFileSync(contactFile, 'utf8')) as { reviewContact?: Record<string, string> }).reviewContact : undefined;
|
||||
if (c) contact = { firstName: mask('firstName', c.firstName ?? ''), lastName: mask('lastName', c.lastName ?? ''), phone: mask('phone', c.phone ?? ''), email: mask('email', c.email ?? '') };
|
||||
} catch { /* unreadable: the declarations card names it */ }
|
||||
let demo: string | null = null;
|
||||
try { const d = reviewDemoAccount(root); demo = d ? maskUser(d.username) : null; } catch { /* unreadable */ }
|
||||
const play: EditableResult['play'] = [];
|
||||
const apple: EditableResult['apple'] = [];
|
||||
for (const lang of config.languages) {
|
||||
if (config.play) for (const code of playCodes(lang)) {
|
||||
const s = playMd ? playSection(playMd, code) : null;
|
||||
play.push({ lang: code, language: lang.id, codes: s?.codes ?? [code], status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
|
||||
}
|
||||
if (config.apple && lang.apple) {
|
||||
const s = appleMd ? appleSection(appleMd, lang.apple) : null;
|
||||
apple.push({ lang: lang.apple, language: lang.id, status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
|
||||
}
|
||||
}
|
||||
const files = [path, ...(playFile ? [playFile] : []), ...(appleFile ? [appleFile] : [])];
|
||||
const { signing: _s, build: _b, adhoc: _a, ...appleRest } = config.apple ?? ({} as NonNullable<typeof config.apple>);
|
||||
const { build: _pb, ...playRest } = config.play ?? ({} as NonNullable<typeof config.play>);
|
||||
return {
|
||||
root,
|
||||
files: { config: rel(path), play: playFile ? rel(playFile) : null, apple: appleFile ? rel(appleFile) : null, contact: rel(contactFile) },
|
||||
format: path.endsWith('.json') ? 'json' : path.endsWith('.js') ? 'js' : 'ts',
|
||||
git: gitState(root, files),
|
||||
appName: config.app.name,
|
||||
appleName: appleName(config),
|
||||
languages: config.languages.map(x => ({ id: x.id, play: playCodes(x), apple: x.apple ?? null, rtl: !!x.rtl })),
|
||||
play: config.play ? play : null,
|
||||
apple: config.apple ? apple : null,
|
||||
config: JSON.parse(JSON.stringify({
|
||||
app: config.app, apple: config.apple ? appleRest : null, play: config.play ? playRest : null, products: config.products ?? [], subscriptions: config.subscriptions ?? [],
|
||||
dataSafety: config.dataSafety ?? null, appPrivacy: config.appPrivacy ?? null, reviewAccess: config.reviewAccess ?? null, accounts: config.accounts ?? null, social: config.social ?? null,
|
||||
shots: config.shots ? { scenes: config.shots.scenes, feature: config.shots.feature ?? null } : null,
|
||||
})) as EditableResult['config'],
|
||||
contact,
|
||||
demoAccount: demo,
|
||||
limits: { play: PLAY_LIMITS, apple: APPLE_LIMITS, iap: APPLE_IAP_LIMITS, appleSub: APPLE_SUB_LIMITS, playSub: PLAY_SUB_LIMITS, reviewNotesBytes: NOTES_MAX_BYTES },
|
||||
};
|
||||
}
|
||||
188
cli/lib/edit.ts
Normal file
188
cli/lib/edit.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// The desktop's editors write through here (`storebound edit`, serve method project.edit): a list of edits of the
|
||||
// app folder's sources of truth, planned as text edits, checked, shown as a diff, and written only when the checks
|
||||
// pass. Sources:
|
||||
// - storebound.config.ts / .js / .json: one value by path (config-path.ts), the rest of the file unchanged
|
||||
// - store/listing.md (Google Play) and store/appstore.md (App Store): one field block (listing-edit.ts)
|
||||
// - .storebound/credentials.json reviewContact (App Review contact: personal data, never in the config or a log line)
|
||||
// The plan itself is pure (texts in, texts out); cli/edit.ts reads and writes the files.
|
||||
import { compactDiff } from './diff';
|
||||
import { setByPath } from './config-path';
|
||||
import type { AppleField } from './copy';
|
||||
import { appleSection, isAppleField, isPlayField, playSection, setAppleField, setPlayField } from './listing-edit';
|
||||
import { appleLocaleIssues, emailIssue, type FieldIssue, playLangIssues, urlIssue } from './listing-rules';
|
||||
|
||||
export type EditOp =
|
||||
| { kind: 'config'; path: string; value?: unknown; remove?: boolean }
|
||||
| { kind: 'listing'; store: 'play' | 'apple'; lang: string; field: string; value: string }
|
||||
| { kind: 'contact'; field: ContactField; value: string };
|
||||
export type ContactField = 'firstName' | 'lastName' | 'phone' | 'email';
|
||||
export const CONTACT_FIELDS: readonly ContactField[] = ['firstName', 'lastName', 'phone', 'email'];
|
||||
|
||||
export interface EditChange { what: string; file: string; from: string | null; to: string | null; also?: string[] }
|
||||
export interface EditIssue extends FieldIssue { where: string }
|
||||
export interface EditFile { file: string; rel: string; created: boolean; diff: string[]; masked?: boolean }
|
||||
export interface EditPlan { changes: EditChange[]; files: EditFile[]; issues: EditIssue[]; errors: number }
|
||||
|
||||
/** What the plan needs to know about the folder: the files by role and their current text. */
|
||||
export interface EditSources {
|
||||
config: { file: string; rel: string; text: string };
|
||||
play: { file: string; rel: string; text: string | null } | null;
|
||||
apple: { file: string; rel: string; text: string | null } | null;
|
||||
contact: { file: string; rel: string; text: string | null };
|
||||
appName: string;
|
||||
appleName: string;
|
||||
}
|
||||
|
||||
/** Checks one edit's shape (it comes from the window or a terminal). Throws with the reason. */
|
||||
export function checkOp(o: unknown, i: number): EditOp {
|
||||
if (!o || typeof o !== 'object' || Array.isArray(o)) throw new Error(`edit ${i + 1}: an object`);
|
||||
const e = o as Record<string, unknown>;
|
||||
const str = (k: string, max: number): string => {
|
||||
if (typeof e[k] !== 'string' || (e[k] as string).length > max) throw new Error(`edit ${i + 1}: ${k} must be text (at most ${max} characters)`);
|
||||
return e[k] as string;
|
||||
};
|
||||
if (e.kind === 'config') {
|
||||
const path = str('path', 300);
|
||||
if (!/^[A-Za-z_$][\w$-]*(\.[A-Za-z0-9_$-]+|\[\d+\]|\[[A-Za-z_$][\w$]*=[^\]]{1,200}\])*$/.test(path)) throw new Error(`edit ${i + 1}: path ${path} is not a config path`);
|
||||
if (/^(account|scan)\b/.test(path)) throw new Error(`edit ${i + 1}: ${path} is not edited from here`);
|
||||
if (e.remove !== undefined && typeof e.remove !== 'boolean') throw new Error(`edit ${i + 1}: remove is true or false`);
|
||||
if (e.value === undefined && e.remove !== true) throw new Error(`edit ${i + 1}: a value (or remove: true)`);
|
||||
checkValue(e.value, `edit ${i + 1}`, 0);
|
||||
return { kind: 'config', path, ...(e.remove === true ? { remove: true } : { value: e.value }) };
|
||||
}
|
||||
if (e.kind === 'listing') {
|
||||
if (e.store !== 'play' && e.store !== 'apple') throw new Error(`edit ${i + 1}: store play or apple`);
|
||||
const lang = str('lang', 20);
|
||||
if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error(`edit ${i + 1}: ${lang} is not a language code`);
|
||||
const field = str('field', 30);
|
||||
if (e.store === 'play' ? !isPlayField(field) : !isAppleField(field)) throw new Error(`edit ${i + 1}: ${String(e.store)} has no field ${field}`);
|
||||
return { kind: 'listing', store: e.store, lang, field, value: str('value', 20_000) };
|
||||
}
|
||||
if (e.kind === 'contact') {
|
||||
const field = str('field', 20) as ContactField;
|
||||
if (!CONTACT_FIELDS.includes(field)) throw new Error(`edit ${i + 1}: contact field ${field}`);
|
||||
return { kind: 'contact', field, value: str('value', 200) };
|
||||
}
|
||||
throw new Error(`edit ${i + 1}: kind config, listing or contact`);
|
||||
}
|
||||
|
||||
/** Plain JSON data only (no functions), not too deep or big. */
|
||||
function checkValue(v: unknown, at: string, depth: number): void {
|
||||
if (depth > 8) throw new Error(`${at}: value nested too deep`);
|
||||
if (v === null || v === undefined || typeof v === 'boolean' || typeof v === 'string') return;
|
||||
if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error(`${at}: a finite number`); return; }
|
||||
if (Array.isArray(v)) { if (v.length > 500) throw new Error(`${at}: list too long`); for (const x of v) checkValue(x, at, depth + 1); return; }
|
||||
if (typeof v === 'object') { const e = Object.entries(v as Record<string, unknown>); if (e.length > 300) throw new Error(`${at}: too many keys`); for (const [, x] of e) checkValue(x, at, depth + 1); return; }
|
||||
throw new Error(`${at}: plain data only`);
|
||||
}
|
||||
|
||||
export const parseOps = (json: string): EditOp[] => {
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(json); } catch (e) { throw new Error(`--ops: not JSON (${(e as Error).message})`); }
|
||||
const list = Array.isArray(raw) ? raw : [raw];
|
||||
if (!list.length) throw new Error('--ops: no edits');
|
||||
if (list.length > 400) throw new Error('--ops: at most 400 edits at once');
|
||||
return list.map(checkOp);
|
||||
};
|
||||
|
||||
const show = (v: unknown): string | null => (v === undefined || v === null ? null : typeof v === 'string' ? v : JSON.stringify(v));
|
||||
const maskValue = (field: ContactField, v: string): string => (!v ? '' : field === 'email' ? `${v[0]}***${v.slice(v.indexOf('@'))}` : field === 'phone' ? `${v.slice(0, 3)}***` : `${v[0]}***`);
|
||||
|
||||
/** Values the config paths of the editors hold: URL and email fields get their format checked. */
|
||||
const URL_PATHS = /^(app\.(privacyPolicyUrl|termsUrl|supportUrl|marketingUrl|website)|accounts\.deletionUrl|dataSafety\.partialDeletionUrl|play\.video)$/;
|
||||
const EMAIL_PATHS = /^app\.contactEmail$/;
|
||||
|
||||
/**
|
||||
* The plan of a list of edits: new file texts, what changes (old -> new), the diff per file and the checks. Pure.
|
||||
* `getPath` reads a value of the loaded config by path (for the "from" side of config edits).
|
||||
*/
|
||||
export function planEdits(ops: EditOp[], src: EditSources, getPath: (path: string) => unknown): { plan: EditPlan; texts: Map<string, string> } {
|
||||
const texts = new Map<string, string>();
|
||||
const before = new Map<string, string | null>();
|
||||
const rel = new Map<string, string>();
|
||||
const read = (f: { file: string; rel: string; text: string | null }): string => {
|
||||
if (!texts.has(f.file)) { texts.set(f.file, f.text ?? ''); before.set(f.file, f.text); rel.set(f.file, f.rel); }
|
||||
return texts.get(f.file)!;
|
||||
};
|
||||
const changes: EditChange[] = [];
|
||||
const issues: EditIssue[] = [];
|
||||
const touched = { play: new Set<string>(), apple: new Set<string>() };
|
||||
const onlyWhatToTest = new Map<string, boolean>();
|
||||
let contact: Record<string, unknown> | null = null;
|
||||
for (const op of ops) {
|
||||
if (op.kind === 'config') {
|
||||
const t = read(src.config);
|
||||
const value = op.remove ? undefined : op.value;
|
||||
const next = setByPath(t, src.config.file, op.path, value);
|
||||
texts.set(src.config.file, next);
|
||||
changes.push({ what: op.path, file: src.config.rel, from: show(getPath(op.path)), to: show(value) });
|
||||
if (typeof value === 'string' && URL_PATHS.test(op.path)) { const u = urlIssue(value); if (u) issues.push({ level: 'error', field: op.path, where: op.path, msg: u }); }
|
||||
if (typeof value === 'string' && EMAIL_PATHS.test(op.path)) { const u = emailIssue(value); if (u) issues.push({ level: 'error', field: op.path, where: op.path, msg: u }); }
|
||||
} else if (op.kind === 'listing') {
|
||||
const f = op.store === 'play' ? src.play : src.apple;
|
||||
if (!f) throw new Error(`this app has no ${op.store === 'play' ? 'Google Play' : 'App Store'} section in the config`);
|
||||
const t = read(f);
|
||||
const r = op.store === 'play' ? setPlayField(t, op.lang, op.field as never, op.value) : setAppleField(t, op.lang, op.field as AppleField | 'whatToTest', op.value);
|
||||
texts.set(f.file, r.md);
|
||||
changes.push({ what: `${op.store === 'play' ? 'Google Play' : 'App Store'} ${op.lang} ${op.field}`, file: f.rel, from: r.from, to: op.value.trim() || null, ...(r.codes.length > 1 ? { also: r.codes.filter(c => c !== op.lang) } : {}) });
|
||||
touched[op.store].add(op.lang);
|
||||
if (op.store === 'apple') onlyWhatToTest.set(op.lang, (onlyWhatToTest.get(op.lang) ?? true) && op.field === 'whatToTest');
|
||||
} else {
|
||||
read(src.contact);
|
||||
contact ??= src.contact.text ? JSON.parse(src.contact.text) as Record<string, unknown> : {};
|
||||
const rc = { ...((contact.reviewContact ?? {}) as Record<string, string>) };
|
||||
const from = rc[op.field] ?? '';
|
||||
rc[op.field] = op.value.trim();
|
||||
contact.reviewContact = rc;
|
||||
if (op.field === 'email') { const u = emailIssue(op.value.trim()); if (u) issues.push({ level: 'error', field: 'email', where: 'App Review contact', msg: u }); }
|
||||
texts.set(src.contact.file, JSON.stringify(contact, null, 2) + '\n');
|
||||
changes.push({ what: `App Review contact ${op.field}`, file: src.contact.rel, from: from ? maskValue(op.field, from) : null, to: op.value.trim() ? maskValue(op.field, op.value.trim()) : null });
|
||||
}
|
||||
}
|
||||
// the touched languages as they would be saved, with the checks of `check` / `check --aso` / `scan`
|
||||
if (src.play) for (const lang of touched.play) {
|
||||
const s = playSection(texts.get(src.play.file) ?? '', lang);
|
||||
if (s) for (const x of playLangIssues(lang, s.values, src.appName)) issues.push({ ...x, where: `Google Play ${lang}` });
|
||||
}
|
||||
if (src.apple) for (const loc of touched.apple) {
|
||||
const s = appleSection(texts.get(src.apple.file) ?? '', loc);
|
||||
if (s && onlyWhatToTest.get(loc)) { const n = [...(s.values.whatToTest ?? '')].length; if (n > 4000) issues.push({ level: 'error', field: 'whatToTest', where: `TestFlight ${loc}`, msg: `What to Test is ${n} characters; TestFlight takes 4000` }); continue; }
|
||||
if (s) for (const x of appleLocaleIssues(loc, s.values, src.appleName)) issues.push({ ...x, where: `App Store ${loc}` });
|
||||
}
|
||||
const files: EditFile[] = [];
|
||||
for (const [file, after] of texts) {
|
||||
const b = before.get(file) ?? null;
|
||||
if (b === after) continue;
|
||||
const masked = file === src.contact.file;
|
||||
files.push({ file, rel: rel.get(file)!, created: b === null, diff: masked ? [] : compactDiff(b ?? '', after) , ...(masked ? { masked: true } : {}) });
|
||||
}
|
||||
for (const [file] of [...texts]) if (!files.some(f => f.file === file)) texts.delete(file);
|
||||
return { plan: { changes, files, issues, errors: issues.filter(i => i.level === 'error').length }, texts };
|
||||
}
|
||||
|
||||
/** A value of a plain object by config path (`products[id=x].listings.en.title`); undefined when missing. */
|
||||
export function getByPath(root: unknown, path: string): unknown {
|
||||
let cur: unknown = root;
|
||||
const re = /([A-Za-z0-9_$-]+)|\[(\d+)\]|\[([A-Za-z_$][\w$]*)=([^\]]+)\]/g;
|
||||
for (let m = re.exec(path); m; m = re.exec(path)) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
if (m[1] !== undefined) cur = (cur as Record<string, unknown>)[m[1]];
|
||||
else if (m[2] !== undefined) cur = Array.isArray(cur) ? cur[Number(m[2])] : undefined;
|
||||
else cur = Array.isArray(cur) ? cur.find(x => x && typeof x === 'object' && (x as Record<string, unknown>)[m![3]!] === m![4]) : undefined;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** Text lines of a plan for a terminal and the confirm dialog (personal data masked). */
|
||||
export function planLines(p: EditPlan): string[] {
|
||||
const cut = (s: string | null): string => (s === null ? '(none)' : s.length > 70 ? `"${s.slice(0, 67).replace(/\n/g, ' ')}..."` : `"${s.replace(/\n/g, ' ')}"`);
|
||||
const out: string[] = [];
|
||||
for (const c of p.changes) out.push(`${c.what}: ${cut(c.from)} -> ${cut(c.to)}${c.also?.length ? ` (the same section also serves ${c.also.join(', ')})` : ''}`);
|
||||
for (const i of p.issues) out.push(`${i.level === 'error' ? 'ERROR' : i.level === 'warn' ? 'warn ' : 'hint '} [${i.where}] ${i.msg}`);
|
||||
for (const f of p.files) {
|
||||
out.push(`--- ${f.rel}${f.created ? ' (new file)' : ''}`);
|
||||
if (f.masked) out.push(' (personal data: not shown)');
|
||||
else out.push(...f.diff.map(l => ` ${l}`));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
119
cli/lib/listing-edit.ts
Normal file
119
cli/lib/listing-edit.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// One field of the listing files (store/listing.md for Google Play, store/appstore.md for the App Store) set as a text
|
||||
// edit: only the fenced block of that field changes; headings, prose, comments and the other sections stay as they
|
||||
// are. Used by `storebound edit` (the desktop's listing editor). Pure.
|
||||
import { APPLE_LIMITS, type AppleField, PLAY_LIMITS } from './copy';
|
||||
|
||||
export type PlayField = 'title' | 'short' | 'full' | 'whatsNew';
|
||||
export const PLAY_FIELDS: readonly PlayField[] = ['title', 'short', 'full', 'whatsNew'];
|
||||
export const APPLE_FIELDS: readonly AppleField[] = ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew'];
|
||||
/** TestFlight "What to Test" per locale lives in the App Store listing file too (`apple testflight what-to-test`). */
|
||||
export const WHAT_TO_TEST = 'whatToTest';
|
||||
export const WHAT_TO_TEST_MAX = 4000;
|
||||
type AppleEditField = AppleField | typeof WHAT_TO_TEST;
|
||||
|
||||
/** The heading a field's block sits under (the parsers in copy.ts match these) and the one a new block gets. */
|
||||
const PLAY_HEAD: Record<PlayField, { re: RegExp; heading: string }> = {
|
||||
title: { re: /[^\n]*\btitle\b/i, heading: `Title (max ${PLAY_LIMITS.title})` },
|
||||
short: { re: /[^\n]*\bshort\b/i, heading: `Short description (max ${PLAY_LIMITS.short})` },
|
||||
full: { re: /[^\n]*\bfull\b/i, heading: `Full description (max ${PLAY_LIMITS.full})` },
|
||||
whatsNew: { re: /[^\n]*(?:what'?s new|release notes)/i, heading: `What's new (max ${PLAY_LIMITS.whatsNew})` },
|
||||
};
|
||||
|
||||
const fence = (t: string): string => '```\n' + t + '\n```';
|
||||
const blockRe = (heading: RegExp): RegExp => new RegExp(`(^### (?:${heading.source})[^\\n]*\\n)\`\`\`[^\\n]*\\n([\\s\\S]*?)\\n?\`\`\`\\n?`, 'm' + (heading.flags.includes('i') ? 'i' : ''));
|
||||
|
||||
/** One section with `text` as the block under `heading` (replaced, appended, or removed when text is empty). */
|
||||
function setBlock(sec: string, heading: RegExp, newHeading: string, text: string): { sec: string; from: string | null } {
|
||||
const re = blockRe(heading);
|
||||
const m = re.exec(sec);
|
||||
const from = m ? m[2]!.trim() : null;
|
||||
if (!text) {
|
||||
if (!m) return { sec, from };
|
||||
// the heading line, its block and one blank line after it
|
||||
const start = m.index, end = m.index + m[0].length;
|
||||
const rest = sec.slice(end).replace(/^\n/, '');
|
||||
// the last block of the section: the section ends as it did before the block (one newline)
|
||||
if (!rest.trim()) return { sec: sec.slice(0, start).replace(/\s*$/, '\n'), from };
|
||||
return { sec: sec.slice(0, start) + rest, from };
|
||||
}
|
||||
if (m) return { sec: sec.slice(0, m.index) + m[1] + fence(text) + '\n' + sec.slice(m.index + m[0].length), from };
|
||||
const body = sec.replace(/\s*$/, '');
|
||||
return { sec: `${body}\n\n### ${newHeading}\n${fence(text)}\n\n`, from: null };
|
||||
}
|
||||
|
||||
export interface ListingEditResult { md: string; from: string | null; codes: string[]; created: boolean }
|
||||
|
||||
/** Sets one Play field of the section that serves `lang` (all its codes change: one section can serve several Play
|
||||
* languages). A language without a section gets a new one at the end. */
|
||||
export function setPlayField(md: string, lang: string, field: PlayField, text: string): ListingEditResult {
|
||||
const parts = md.split(/^(?=## )/m);
|
||||
const v = text.replace(/\r\n/g, '\n').trim();
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(parts[i]!);
|
||||
if (!m || !parts[i]!.startsWith('## ')) continue;
|
||||
const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!codes.includes(lang)) continue;
|
||||
const r = setBlock(parts[i]!, PLAY_HEAD[field].re, PLAY_HEAD[field].heading, v);
|
||||
parts[i] = r.sec;
|
||||
return { md: parts.join(''), from: r.from, codes, created: false };
|
||||
}
|
||||
if (!v) return { md, from: null, codes: [lang], created: false };
|
||||
const sec = `## ${lang}\n<!-- play: ${lang} -->\n\n### ${PLAY_HEAD[field].heading}\n${fence(v)}\n`;
|
||||
return { md: `${md.replace(/\s*$/, '')}\n\n${sec}`, from: null, codes: [lang], created: true };
|
||||
}
|
||||
|
||||
/** Sets one App Store field of the `locale` section (a missing section is added at the end). */
|
||||
export function setAppleField(md: string, locale: string, field: AppleEditField, text: string): ListingEditResult {
|
||||
const parts = md.split(/^(?=## )/m);
|
||||
const v = text.replace(/\r\n/g, '\n').trim();
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const sec = parts[i]!;
|
||||
if (!sec.startsWith('## ')) continue;
|
||||
if (sec.slice(3).split(/[\s·]/)[0]!.trim() !== locale) continue;
|
||||
const r = setBlock(sec, new RegExp(`${field}\\b`), field, v);
|
||||
parts[i] = r.sec;
|
||||
return { md: parts.join(''), from: r.from, codes: [locale], created: false };
|
||||
}
|
||||
if (!v) return { md, from: null, codes: [locale], created: false };
|
||||
return { md: `${md.replace(/\s*$/, '')}\n\n## ${locale}\n\n### ${field}\n${fence(v)}\n`, from: null, codes: [locale], created: true };
|
||||
}
|
||||
|
||||
export const isPlayField = (f: string): f is PlayField => (PLAY_FIELDS as readonly string[]).includes(f);
|
||||
export const isAppleField = (f: string): f is AppleEditField => f in APPLE_LIMITS || f === WHAT_TO_TEST;
|
||||
|
||||
/** The fields of the Play section that serves `lang` (null: no section). */
|
||||
export function playSection(md: string, lang: string): { codes: string[]; heading: string; values: Partial<Record<PlayField, string>>; status: string | null } | null {
|
||||
for (const sec of md.split(/^(?=## )/m)) {
|
||||
if (!sec.startsWith('## ')) continue;
|
||||
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(sec);
|
||||
if (!m) continue;
|
||||
const codes = m[1]!.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!codes.includes(lang)) continue;
|
||||
const values: Partial<Record<PlayField, string>> = {};
|
||||
for (const f of PLAY_FIELDS) { const b = blockRe(PLAY_HEAD[f].re).exec(sec); if (b) values[f] = b[2]!.trim(); }
|
||||
return { codes, heading: sec.slice(3).split('\n')[0]!.trim(), values, status: translationStatus(sec) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The fields of one App Store locale section (null: no section). */
|
||||
export function appleSection(md: string, locale: string): { heading: string; values: Partial<Record<AppleEditField, string>>; status: string | null } | null {
|
||||
for (const sec of md.split(/^(?=## )/m)) {
|
||||
if (!sec.startsWith('## ') || sec.slice(3).split(/[\s·]/)[0]!.trim() !== locale) continue;
|
||||
const values: Partial<Record<AppleEditField, string>> = {};
|
||||
for (const f of [...APPLE_FIELDS, WHAT_TO_TEST] as const) { const b = blockRe(new RegExp(`${f}\\b`)).exec(sec); if (b) values[f] = b[2]!.trim(); }
|
||||
return { heading: sec.slice(3).split('\n')[0]!.trim(), values, status: translationStatus(sec) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `listing translate` marks: status=translate (not translated yet) or status=draft (machine draft, not reviewed). */
|
||||
function translationStatus(sec: string): string | null {
|
||||
return /<!--\s*storebound-translation\s+from=\S+\s+status=(\w+)/.exec(sec)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** The store limit of a listing field (characters). */
|
||||
export function listingLimit(store: 'play' | 'apple', field: string): number | null {
|
||||
if (store === 'apple') return field === WHAT_TO_TEST ? WHAT_TO_TEST_MAX : field in APPLE_LIMITS ? APPLE_LIMITS[field as AppleField] : null;
|
||||
return isPlayField(field) ? PLAY_LIMITS[field] : null;
|
||||
}
|
||||
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';
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ const ID = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|||
export function toTs(v: unknown, ind = ''): string {
|
||||
const next = ind + ' ';
|
||||
if (v === null || v === undefined) return 'undefined';
|
||||
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
|
||||
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
if (Array.isArray(v)) {
|
||||
const parts = v.map(x => toTs(x, next));
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
|
||||
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
|
||||
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
|
||||
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
|
||||
import type { ShipConfig } from '../config';
|
||||
import { allSubscriptions } from '../subscriptions';
|
||||
import { sdkOfProjectPath } from '../privacy-files';
|
||||
|
|
@ -127,13 +128,6 @@ export const SIGNALS = {
|
|||
};
|
||||
|
||||
const ATT_SDKS_SHOW_PROMPT = new Set(['applovin', 'levelplay']);
|
||||
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'];
|
||||
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).
|
||||
const PLACEHOLDER_MARKERS = /\bTODO\b|\bTBD\b|\bFIXME\b|\bXXX\b/;
|
||||
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;
|
||||
const PLACEHOLDER = { test: (l: string) => PLACEHOLDER_MARKERS.test(l) || PLACEHOLDER_TEXT.test(l) } as RegExp;
|
||||
const FGS_PERMISSION: Record<string, string> = {
|
||||
camera: 'CAMERA', connectedDevice: 'CONNECTED_DEVICE', dataSync: 'DATA_SYNC', health: 'HEALTH', location: 'LOCATION', mediaPlayback: 'MEDIA_PLAYBACK',
|
||||
|
|
@ -368,12 +362,12 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
if (other.length)
|
||||
add({ rule: 'other-platforms', guideline: '2.3.10', store: 'apple', severity: 'high', title: 'Other mobile platforms or stores named in the App Store metadata or screenshot captions', evidence: other.slice(0, 4),
|
||||
fix: 'Remove "Android", "Google Play" and other platforms or stores from the App Store text and screenshots (Play listing text is separate: store/listing.md).', skill: 'aso-basics' });
|
||||
const words = new RegExp(`(^|[\\s,|/–-])(${COMPETITORS.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?=$|[\\s,|/–-])`, 'i');
|
||||
const words = competitorRe();
|
||||
const kw = [...listingHits(inp.listings.apple, words, { field: /^(keywords|name|subtitle)$/ })];
|
||||
if (kw.length)
|
||||
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'high', title: 'Other apps\' names or trademarks in the name, subtitle or keywords', evidence: kw.slice(0, 4),
|
||||
fix: 'Use only terms that describe your app; popular app names, trademarks and Apple product names in keywords get the metadata rejected.', skill: 'aso-basics' });
|
||||
const price = listingHits(inp.listings.apple, /\b(free|best|#1|no\.? ?1|top|cheap|sale|\d+% off|\$\d)\b/i, { field: /^(name|subtitle|keywords)$/ });
|
||||
const price = listingHits(inp.listings.apple, PRICE_CLAIMS, { field: /^(name|subtitle|keywords)$/ });
|
||||
if (price.length)
|
||||
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Prices or claims ("free", "best", "#1") in the name, subtitle or keywords', evidence: price.slice(0, 3),
|
||||
fix: 'Keep prices and superlatives out of the name, subtitle and keywords (keywords already match "free" searches only by category).', skill: 'aso-basics' });
|
||||
|
|
@ -381,7 +375,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
if (stuffed.length)
|
||||
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Keyword list in the name or subtitle', evidence: stuffed.slice(0, 2), fix: 'Name and subtitle are a short phrase; keywords belong in the keywords field.', skill: 'aso-basics' });
|
||||
// 2.2: demos, betas and trial versions belong on TestFlight, not on the App Store (the name and the listing say so)
|
||||
const BETA = /\b(beta|demo|trial version|test (version|build|app)|testing build|early access|prototype|preview (build|version)|alpha (build|version))\b/i;
|
||||
const BETA = BETA_WORDS;
|
||||
const betaName: Hit[] = [];
|
||||
if (c?.app?.name && BETA.test(c.app.name)) betaName.push(...cfg(c.app.name, `app.name: ${c.app.name}`));
|
||||
if (c?.apple?.name && c.apple.name !== c.app?.name && BETA.test(c.apple.name)) betaName.push(...cfg(c.apple.name, `apple.name: ${c.apple.name}`));
|
||||
|
|
|
|||
44
cli/lib/store-catalogs.ts
Normal file
44
cli/lib/store-catalogs.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Fixed lists of the consoles' choice fields, shared by the CLI and the desktop's editors (pure, no Node imports):
|
||||
// App Store categories (with the iTunes genre ids), Google Play categories, the App Store age rating questions with
|
||||
// their "nothing" answers, Play target audience age groups.
|
||||
|
||||
/** App Store Connect category id -> iTunes genre id (the "genreIds" of Search API results). Apple's genre ids. */
|
||||
export const GENRE_IDS: Record<string, string> = {
|
||||
BOOKS: '6018', BUSINESS: '6000', DEVELOPER_TOOLS: '6026', EDUCATION: '6017', ENTERTAINMENT: '6016', FINANCE: '6015', FOOD_AND_DRINK: '6023', GAMES: '6014',
|
||||
GRAPHICS_AND_DESIGN: '6027', HEALTH_AND_FITNESS: '6013', LIFESTYLE: '6012', MAGAZINES_AND_NEWSPAPERS: '6021', MEDICAL: '6020', MUSIC: '6011', NAVIGATION: '6010',
|
||||
NEWS: '6009', PHOTO_AND_VIDEO: '6008', PRODUCTIVITY: '6007', REFERENCE: '6006', SHOPPING: '6024', SOCIAL_NETWORKING: '6005', SPORTS: '6004', STICKERS: '6025',
|
||||
TRAVEL: '6003', UTILITIES: '6002', WEATHER: '6001',
|
||||
GAMES_ACTION: '7001', GAMES_ADVENTURE: '7002', GAMES_CASUAL: '7003', GAMES_BOARD: '7004', GAMES_CARD: '7005', GAMES_CASINO: '7006', GAMES_FAMILY: '7009',
|
||||
GAMES_MUSIC: '7011', GAMES_PUZZLE: '7012', GAMES_RACING: '7013', GAMES_ROLE_PLAYING: '7014', GAMES_SIMULATION: '7015', GAMES_SPORTS: '7016',
|
||||
GAMES_STRATEGY: '7017', GAMES_TRIVIA: '7018', GAMES_WORD: '7019',
|
||||
};
|
||||
/** App Store primary / secondary categories (no GAMES_* subcategories). */
|
||||
export const APPLE_CATEGORIES = Object.keys(GENRE_IDS).filter(k => !k.startsWith('GAMES_'));
|
||||
/** App Store game subcategories (primarySubOne / primarySubTwo when the primary is GAMES). */
|
||||
export const APPLE_GAME_SUBCATEGORIES = Object.keys(GENRE_IDS).filter(k => k.startsWith('GAMES_'));
|
||||
|
||||
/** Play Console's game categories (Store settings > App category, app type Game). */
|
||||
export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing',
|
||||
'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const;
|
||||
/** Play Console's app categories (Store settings > App category, app type App). */
|
||||
export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating',
|
||||
'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle',
|
||||
'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping',
|
||||
'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const;
|
||||
|
||||
/** Every question of the current App Store age rating declaration at its "nothing" answer. */
|
||||
export const AGE_RATING_NONE: Record<string, string | boolean | null> = {
|
||||
advertising: false, ageAssurance: false, alcoholTobaccoOrDrugUseOrReferences: 'NONE', contests: 'NONE', gambling: false,
|
||||
gamblingSimulated: 'NONE', gunsOrOtherWeapons: 'NONE', healthOrWellnessTopics: false, horrorOrFearThemes: 'NONE', lootBox: false,
|
||||
matureOrSuggestiveThemes: 'NONE', medicalOrTreatmentInformation: 'NONE', messagingAndChat: false, parentalControls: false,
|
||||
profanityOrCrudeHumor: 'NONE', socialMedia: false, socialMediaAgeRestricted: false, sexualContentGraphicAndNudity: 'NONE', sexualContentOrNudity: 'NONE', unrestrictedWebAccess: false,
|
||||
userGeneratedContent: false, violenceCartoonOrFantasy: 'NONE', violenceRealistic: 'NONE',
|
||||
violenceRealisticProlongedGraphicOrSadistic: 'NONE', kidsAgeBand: null,
|
||||
};
|
||||
/** The answers of a frequency question (App Store Connect's None / Infrequent or mild / Frequent or intense). */
|
||||
export const AGE_FREQUENCY = ['NONE', 'INFREQUENT_OR_MILD', 'FREQUENT_OR_INTENSE'] as const;
|
||||
/** The Kids Category age band (null: not in the Kids Category). */
|
||||
export const KIDS_AGE_BANDS = ['FIVE_AND_UNDER', 'SIX_TO_EIGHT', 'NINE_TO_ELEVEN'] as const;
|
||||
|
||||
/** Play target audience age groups (App content > Target audience and content). */
|
||||
export const PLAY_AGE_GROUPS = ['0-5', '6-8', '9-12', '13-15', '16-17', '18+'] as const;
|
||||
Loading…
Add table
Add a link
Reference in a new issue