cross-store text: other platforms named in App Store text (2.3.10: Android, Google Play, ...) or Play text (Metadata: iOS, App Store, Game Center, ...) are refused by every text write (apple / play listing, play release / upload notes, listing set, whats-new, storebound edit, desktop editor), blockers in check / validate, scan other-platforms / play-other-platforms; listing.allowPlatformMentions for the owner's functional mentions; whats-new splits [apple] / [play] lines per store; storebound api --yes without a TTY is refused (exit 1, stderr) instead of a silent dry run; pitfalls 77, 78
This commit is contained in:
parent
3e4015b686
commit
edabbec874
32 changed files with 613 additions and 53 deletions
|
|
@ -145,7 +145,11 @@ export async function run(args: Args): Promise<void> {
|
|||
tty: stdinIsTTY() && !!process.stdout.isTTY,
|
||||
agent: process.env.STOREBOUND_SERVE === '1' || process.env.STOREBOUND_MCP === '1',
|
||||
});
|
||||
if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`);
|
||||
if (decision.action === 'refuse') {
|
||||
const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null;
|
||||
if (instead) console.error(`api: dedicated command: ${instead}`);
|
||||
throw new Error(`${op.id}: ${decision.why}`);
|
||||
}
|
||||
if (decision.action === 'dry') {
|
||||
const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null;
|
||||
if (instead) console.error(`api: ${op.id} has a dedicated storebound command that runs without a terminal: ${instead}`);
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@ export type Decision =
|
|||
| { action: 'dry'; why: string }
|
||||
| { action: 'refuse'; why: string };
|
||||
|
||||
/** --yes without a terminal: refused loudly (exit 1, stderr), never a silent dry run (pitfall 77). */
|
||||
export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, or use the dedicated storebound command.';
|
||||
|
||||
/** What to do with one call. Pure. */
|
||||
export function decide(g: GateInput): Decision {
|
||||
if (g.getOnly && g.method.toUpperCase() !== 'GET') return { action: 'refuse', why: 'api get runs GET operations only' };
|
||||
|
|
@ -94,7 +97,7 @@ export function decide(g: GateInput): Decision {
|
|||
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
|
||||
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
|
||||
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
|
||||
if (!g.tty) return { action: 'dry', why: 'not an interactive terminal: by design, writes through `storebound api` are sent only from an interactive terminal (--yes alone is not enough)' };
|
||||
if (!g.tty) return { action: 'refuse', why: NOT_A_TTY };
|
||||
return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
// listing file: shows the diff, writes only with --write.
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { type AppleCopy, parseAppStoreListing } from '../lib/copy';
|
||||
import { fieldsMentions, hitLine, type PlatformHit, refuseMentions } from '../lib/cross-store';
|
||||
import { compactDiff } from '../lib/diff';
|
||||
import { mergeAppleListing, overLimits } from '../lib/listing-pull';
|
||||
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, planLocales, planVersion, short, type Skipped, type VersionPlan } from '../lib/listing-plan';
|
||||
|
|
@ -86,6 +87,8 @@ export interface AppleListingPlan {
|
|||
skipped: Skipped[];
|
||||
notes: string[];
|
||||
changes: number;
|
||||
/** Android, Google Play, ... in the texts this run sends (Guideline 2.3.10): the run is refused (pitfall 77) */
|
||||
mentions: PlatformHit[];
|
||||
}
|
||||
|
||||
type Asc = Awaited<ReturnType<typeof ascClient>>;
|
||||
|
|
@ -133,7 +136,10 @@ async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Pr
|
|||
|
||||
const changes = (versionPlan.action === 'create' || versionPlan.action === 'update' ? 1 : 0) + (build?.action === 'attach' ? 1 : 0)
|
||||
+ infoLocs.filter(x => x.action !== 'same').length + (versionLocales ? versionLocales.filter(x => x.action !== 'same').length : Object.keys(wanted).length);
|
||||
return { version, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
|
||||
const allow = config.listing?.allowPlatformMentions;
|
||||
const mentions = Object.entries(listing).flatMap(([l, f]) => fieldsMentions('apple', l, { name: f.name, subtitle: f.subtitle, promotionalText: f.promotionalText,
|
||||
keywords: f.keywords, description: f.description, whatsNew: versionPlan.first ? undefined : f.whatsNew }, allow));
|
||||
return { mentions, version, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
|
||||
versionLocales, wanted, skipped, notes, changes };
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +162,7 @@ export function listingPlanLines(p: AppleListingPlan): string[] {
|
|||
}
|
||||
for (const s of p.skipped) out.push(`Skipped ${s.key}: ${s.reason}`);
|
||||
for (const n of p.notes) out.push(`Note: ${n}`);
|
||||
for (const h of p.mentions ?? []) out.push(`REFUSE: ${hitLine(h)}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +194,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const p = await planListing(ctx, c, appId, version);
|
||||
const refused = [p.versionPlan.action === 'refuse' ? p.versionPlan.reason : '', p.build?.action === 'refuse' ? p.build.reason : ''].filter(Boolean);
|
||||
const refused = [p.versionPlan.action === 'refuse' ? p.versionPlan.reason : '', p.build?.action === 'refuse' ? p.build.reason : '',
|
||||
(() => { try { refuseMentions(p.mentions, 'apple listing'); return ''; } catch (e) { return (e as Error).message; } })()].filter(Boolean);
|
||||
if (a.has('json')) printJson({ dry, ...p, refused });
|
||||
else for (const line of listingPlanLines(p)) console.log(line);
|
||||
if (refused.length) { if (dry) { process.exitCode = 1; warn('apple', `dry run: would refuse: ${refused.join('; ')}`); return; } throw new Error(refused.join('; ')); }
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import { posix, readPulled } from './lib/art-pull';
|
|||
import { creativeNote } from './shots/creative';
|
||||
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
|
||||
import { iphoneDuoNote } from './shots/spec';
|
||||
import { hitLine } from './lib/cross-store';
|
||||
import { listingFileMentions } from './lib/listing-mentions';
|
||||
|
||||
/** The newest .ipa in a folder (apple build's export), or null. */
|
||||
function newestIpa(dir: string): string | null {
|
||||
|
|
@ -92,6 +94,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
return `${Object.keys(copy).length} locales`;
|
||||
});
|
||||
// other platforms named in a store's text: App Store 2.3.10 / Play Metadata, a blocker (pitfall 77)
|
||||
try { for (const h of listingFileMentions(ctx.loaded)) problems.push(`other platform in ${hitLine(h)}`); } catch (e) { warn('check', `platform mentions: ${(e as Error).message}`); }
|
||||
// listing translation drafts (storebound listing translate): fine to keep working on, not to push unreviewed
|
||||
for (const f of [config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : '', config.play ? resolve(config.play.listing ?? 'store/listing.md') : ''].filter(Boolean)) {
|
||||
if (!existsSync(f)) continue;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export function sources(ctx: Ctx): EditSources {
|
|||
shotsCopy: config.shots?.copy ? file(resolve(config.shots.copy)) : null,
|
||||
appName: config.app.name,
|
||||
appleName: appleName(config),
|
||||
allowPlatformMentions: config.listing?.allowPlatformMentions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -700,7 +700,17 @@ export interface ShipConfig {
|
|||
/** Owner answers for console forms that have no API (`storebound console fill`); only what the rest of the config
|
||||
* cannot prove. Anything left out stays empty and highlighted in the page. */
|
||||
console?: ConsoleConfig;
|
||||
/** Release notes (`storebound whats-new`). */
|
||||
/** Listing text rules for both stores. */
|
||||
listing?: {
|
||||
/** Functional mentions of another platform or store that may stay in a store's text, e.g. a "Continue with
|
||||
* Google" button in App Store text or "Sign in with Apple" in Play text: `{ store: 'apple' | 'play' | 'both',
|
||||
* text: 'Sign in with Apple', reason: 'the sign-in button label' }`. A line that contains `text` (any case) keeps
|
||||
* the terms inside it. Every other mention of Android / Google Play in App Store text (Guideline 2.3.10) or of
|
||||
* iOS / App Store in Play text (Metadata policy) stops the write. The owner adds entries; storebound never does. */
|
||||
allowPlatformMentions?: { store: 'apple' | 'play' | 'both'; text: string; reason: string }[];
|
||||
};
|
||||
/** Release notes (`storebound whats-new`). Lines tagged `[apple]` or `[play]` (or a `[play]` line heading a block
|
||||
* until the next blank line) go only into that store's notes. */
|
||||
whatsNew?: {
|
||||
/** Player-facing notes file the draft comes from before the git log (a CHANGELOG-like file gives the version's
|
||||
* section), e.g. "docs/player-notes.md". Without it: docs/RELEASE-<version>.md, then CHANGELOG.md. */
|
||||
|
|
|
|||
171
cli/lib/cross-store.ts
Normal file
171
cli/lib/cross-store.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// Other platforms and stores named in one store's text (pitfall 77). App Store Guideline 2.3.10 (Accurate Metadata):
|
||||
// no other mobile platforms in the metadata; a Mac version was rejected for a What's New taken from release notes
|
||||
// shared with Google Play ("Google Play Games", "Android"). Google Play's Metadata policy is the mirror case
|
||||
// (misleading references to other platforms or apps). One checker for every surface: the listing pushes, whats-new,
|
||||
// the store-only listing edit and the desktop editor refuse on a hit; dry runs show it; check / validate / scan
|
||||
// report it with file, line, field and locale. A functional mention ("Sign in with Apple", "Continue with Google")
|
||||
// is kept only through `listing.allowPlatformMentions` in the config: the owner's call, the tool never adds one.
|
||||
// Pure, no Node imports (the desktop window runs it too).
|
||||
|
||||
export type CrossStore = 'apple' | 'play';
|
||||
|
||||
/** One owner-approved mention: `text` (any case) on a line keeps the terms inside it. */
|
||||
export interface PlatformAllow { store: CrossStore | 'both'; text: string; reason: string }
|
||||
|
||||
export interface PlatformHit {
|
||||
store: CrossStore;
|
||||
locale: string;
|
||||
field: string;
|
||||
/** line inside the field's text (1-based) */
|
||||
line: number;
|
||||
term: string;
|
||||
/** the whole line, trimmed */
|
||||
text: string;
|
||||
/** listing file and its line, when the hit comes from a file */
|
||||
file?: string;
|
||||
fileLine?: number;
|
||||
fix: string;
|
||||
}
|
||||
|
||||
interface Term { t: string; cs?: boolean; cjk?: boolean }
|
||||
|
||||
/** Named in App Store text: other mobile platforms and stores (Latin forms match whole words, any case). */
|
||||
export const APPLE_FORBIDDEN: readonly Term[] = [
|
||||
{ t: 'Google Play Games' }, { t: 'Google Play Spiele' }, { t: 'Google Play Jeux' }, { t: 'Google Play Juegos' }, { t: 'Google Play Giochi' }, { t: 'Google Play Oyun' },
|
||||
{ t: 'Google Play' }, { t: 'Play Store' }, { t: 'Play Market' }, { t: 'Play Маркет' }, { t: 'Плей Маркет' }, { t: 'Google Play 游戏', cjk: true }, { t: 'Google Play ゲーム', cjk: true },
|
||||
{ t: 'Android' }, { t: 'Андроид' }, { t: 'Android TV' }, { t: 'Wear OS' }, { t: 'Chromebook' },
|
||||
{ t: '安卓', cjk: true }, { t: 'アンドロイド', cjk: true }, { t: '안드로이드', cjk: true }, { t: '구글 플레이', cjk: true }, { t: 'グーグルプレイ', cjk: true }, { t: '谷歌商店', cjk: true },
|
||||
{ t: 'Galaxy Store' }, { t: 'Amazon Appstore' }, { t: 'Amazon App Store' }, { t: 'AppGallery' }, { t: 'Huawei' }, { t: 'Xiaomi GetApps' },
|
||||
{ t: 'Steam', cs: true }, { t: 'Microsoft Store' }, { t: 'Windows Store' }, { t: 'Windows Phone' }, { t: 'BlackBerry' },
|
||||
];
|
||||
|
||||
/** Named in Google Play text: Apple platforms and services. */
|
||||
export const PLAY_FORBIDDEN: readonly Term[] = [
|
||||
{ t: 'Apple App Store' }, { t: 'App Store', cs: true }, { t: 'AppStore', cs: true }, { t: 'App-Store', cs: true },
|
||||
{ t: 'iOS', cs: true }, { t: 'iPadOS' }, { t: 'iPhone' }, { t: 'iPad' }, { t: 'macOS' }, { t: 'Apple Watch' }, { t: 'Apple TV' }, { t: 'Vision Pro' },
|
||||
{ t: 'Apple Arcade' }, { t: 'Game Center', cs: true }, { t: 'TestFlight' },
|
||||
{ t: 'Sign in with Apple' }, { t: 'iCloud' },
|
||||
{ t: 'アップストア', cjk: true }, { t: '苹果商店', cjk: true }, { t: '앱스토어', cjk: true }, { t: 'アイフォン', cjk: true }, { t: '아이폰', cjk: true },
|
||||
];
|
||||
|
||||
/** A line tagged for the other store's notes (`[play]` in App Store text, `[apple]` in Play text) is always a hit. */
|
||||
const OTHER_TAG: Record<CrossStore, RegExp> = { apple: /^\s*(?:[-*•]\s*)?\[(play|android|google)\]/i, play: /^\s*(?:[-*•]\s*)?\[(apple|ios|app ?store)\]/i };
|
||||
|
||||
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const termRe = (x: Term): RegExp => x.cjk ? new RegExp(esc(x.t), 'g')
|
||||
: new RegExp(`(?<![\\p{L}\\p{N}])${esc(x.t).replace(/ /g, '[\\s\\u00a0-]?')}(?![\\p{L}\\p{N}])`, x.cs ? 'gu' : 'giu');
|
||||
const COMPILED: Record<CrossStore, { term: string; re: RegExp }[]> = {
|
||||
apple: [...APPLE_FORBIDDEN].sort((a, b) => b.t.length - a.t.length).map(x => ({ term: x.t, re: termRe(x) })),
|
||||
play: [...PLAY_FORBIDDEN].sort((a, b) => b.t.length - a.t.length).map(x => ({ term: x.t, re: termRe(x) })),
|
||||
};
|
||||
|
||||
export const STORE_NAME: Record<CrossStore, string> = { apple: 'App Store', play: 'Google Play' };
|
||||
export const GUIDELINE: Record<CrossStore, string> = { apple: '2.3.10', play: 'Play: Metadata' };
|
||||
|
||||
export function fixFor(store: CrossStore, field: string): string {
|
||||
const other = store === 'apple' ? 'play' : 'apple';
|
||||
const notes = /whatsnew|release/i.test(field) ? ` or tag the line [${other}] in the shared notes (whats-new keeps it out of ${STORE_NAME[store]})` : '';
|
||||
return `remove it from the ${STORE_NAME[store]} text${notes}; a functional mention (a sign-in button) goes into listing.allowPlatformMentions in storebound.config.ts, the owner's call`;
|
||||
}
|
||||
|
||||
function allowed(store: CrossStore, line: string, term: string, allow: readonly PlatformAllow[]): boolean {
|
||||
const l = line.toLowerCase(), t = term.toLowerCase();
|
||||
return allow.some(a => (a.store === store || a.store === 'both') && !!a.text.trim() && l.includes(a.text.toLowerCase()) && a.text.toLowerCase().includes(t));
|
||||
}
|
||||
|
||||
/** Hits in one field of one locale, line by line. Overlapping terms report the longest ("Google Play Games"). */
|
||||
export function textMentions(text: string, o: { store: CrossStore; locale: string; field: string; allow?: readonly PlatformAllow[] }): PlatformHit[] {
|
||||
const out: PlatformHit[] = [];
|
||||
const allow = o.allow ?? [];
|
||||
text.split('\n').forEach((raw, i) => {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
const spans: [number, number][] = [];
|
||||
const add = (term: string): void => { out.push({ store: o.store, locale: o.locale, field: o.field, line: i + 1, term, text: line.slice(0, 200), fix: fixFor(o.store, o.field) }); };
|
||||
const tag = OTHER_TAG[o.store].exec(line);
|
||||
if (tag) add(`[${tag[1]}]`);
|
||||
for (const { term, re } of COMPILED[o.store]) {
|
||||
re.lastIndex = 0;
|
||||
for (let m = re.exec(line); m; m = re.exec(line)) {
|
||||
const s = m.index, e = s + m[0].length;
|
||||
if (spans.some(([a, b]) => s < b && e > a)) continue;
|
||||
spans.push([s, e]);
|
||||
if (!allowed(o.store, line, m[0], allow)) add(m[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Hits in many fields of one locale: values as `{ field: text }`. */
|
||||
export function fieldsMentions(store: CrossStore, locale: string, fields: Record<string, string | undefined>, allow?: readonly PlatformAllow[]): PlatformHit[] {
|
||||
return Object.entries(fields).flatMap(([field, t]) => t ? textMentions(t, { store, locale, field, allow }) : []);
|
||||
}
|
||||
|
||||
/** Hits in a listing file (store/appstore.md or store/listing.md): fenced blocks under `### field` of each `## locale`
|
||||
* section, with the file line. Play sections take their codes from `<!-- play: ... -->`. */
|
||||
export function fileMentions(store: CrossStore, path: string, md: string, allow?: readonly PlatformAllow[]): PlatformHit[] {
|
||||
const out: PlatformHit[] = [];
|
||||
const lines = md.split('\n');
|
||||
let locale = '', field = '', fence = -1, buf: string[] = [];
|
||||
lines.forEach((l, i) => {
|
||||
if (fence < 0 && /^## /.test(l)) { locale = l.slice(3).split(/[\s·]/)[0]!.trim(); field = ''; return; }
|
||||
const pc = /^<!--\s*play:\s*([^>]+?)\s*-->$/.exec(l);
|
||||
if (fence < 0 && pc) { locale = pc[1]!.split(',').map(s => s.trim()).join(','); return; }
|
||||
if (fence < 0 && /^###\s/.test(l)) { field = l.slice(4).trim(); return; }
|
||||
if (/^```/.test(l)) {
|
||||
if (fence < 0) { fence = i; buf = []; return; }
|
||||
if (field && locale) for (const h of textMentions(buf.join('\n'), { store, locale, field: fieldName(field), allow })) out.push({ ...h, file: path, fileLine: fence + 1 + h.line });
|
||||
fence = -1; return;
|
||||
}
|
||||
if (fence >= 0) buf.push(l);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "### Short description (max 80)" -> "short", "### whatsNew" -> "whatsNew". */
|
||||
function fieldName(h: string): string {
|
||||
if (/^whatsNew\b|what'?s new|release notes/i.test(h)) return 'whatsNew';
|
||||
if (/\bshort\b/i.test(h)) return 'short';
|
||||
if (/\bfull\b/i.test(h)) return 'full';
|
||||
if (/\btitle\b/i.test(h)) return 'title';
|
||||
return h.split(/\s/)[0]!;
|
||||
}
|
||||
|
||||
/** One line per hit: where, what, and the fix. */
|
||||
export function hitLine(h: PlatformHit): string {
|
||||
const at = h.file ? `${h.file}:${h.fileLine} ` : '';
|
||||
return `${at}${STORE_NAME[h.store]} ${h.locale} ${h.field} line ${h.line}: "${h.term}" in "${h.text}" (${GUIDELINE[h.store]}): ${h.fix}`;
|
||||
}
|
||||
|
||||
/** Throws (nothing is written) when there are hits; `what` names the write. */
|
||||
export function refuseMentions(hits: readonly PlatformHit[], what: string): void {
|
||||
if (!hits.length) return;
|
||||
const stores = [...new Set(hits.map(h => h.store))];
|
||||
throw new Error(`refused: ${what}: other platforms or stores named in ${stores.map(s => `${STORE_NAME[s]} text (${GUIDELINE[s]})`).join(' and ')}; nothing was sent\n${hits.map(h => ` ${hitLine(h)}`).join('\n')}`);
|
||||
}
|
||||
|
||||
// ---- per-store notes: lines tagged [apple] / [play] in shared release notes ----
|
||||
|
||||
const TAG_RE = /^(\s*(?:[-*•]\s*)?)\[(apple|ios|app ?store|play|android|google ?play|google)\]\s*/i;
|
||||
const tagStore = (t: string): CrossStore => /^(apple|ios|app ?store)$/i.test(t) ? 'apple' : 'play';
|
||||
|
||||
/** The notes for one store: untagged lines stay, lines tagged for this store lose the tag, lines tagged for the other
|
||||
* store go. A heading line that is only a tag (`[play]`) starts a block for that store until a blank line. Pure. */
|
||||
export function notesForStore(text: string, store: CrossStore): string {
|
||||
const out: string[] = [];
|
||||
let block: CrossStore | null = null;
|
||||
for (const l of text.split('\n')) {
|
||||
if (!l.trim()) { block = null; out.push(l); continue; }
|
||||
const only = /^\s*\[(apple|ios|app ?store|play|android|google ?play|google)\]\s*:?\s*$/i.exec(l);
|
||||
if (only) { block = tagStore(only[1]!); continue; }
|
||||
if (block) { if (block === store) out.push(l); continue; }
|
||||
const m = TAG_RE.exec(l);
|
||||
if (!m) { out.push(l); continue; }
|
||||
if (tagStore(m[2]!) === store) out.push(m[1]! + l.slice(m[0].length));
|
||||
}
|
||||
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/** Whether a text has store tags at all (then it is split before it goes anywhere). */
|
||||
export const hasStoreTags = (text: string): boolean => text.split('\n').some(l => TAG_RE.test(l) || /^\s*\[(apple|ios|play|android)\]\s*:?\s*$/i.test(l));
|
||||
|
|
@ -446,6 +446,8 @@ export interface EditableResult {
|
|||
git: { repo: boolean; uncommitted: string[] };
|
||||
appName: string;
|
||||
appleName: string;
|
||||
/** listing.allowPlatformMentions: functional mentions of other platforms the owner allowed (pitfall 77) */
|
||||
allowPlatformMentions?: { store: 'apple' | 'play' | 'both'; text: string; reason: string }[];
|
||||
languages: { id: string; play: string[]; apple: string | null; rtl: boolean }[];
|
||||
play: EditableListing[] | null;
|
||||
apple: EditableListing[] | null;
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export function editableValues(l: Loaded): EditableResult {
|
|||
git: gitState(root, files),
|
||||
appName: config.app.name,
|
||||
appleName: appleName(config),
|
||||
allowPlatformMentions: config.listing?.allowPlatformMentions ?? [],
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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 type { PlatformAllow } from './cross-store';
|
||||
import { appleLocaleIssues, emailIssue, type FieldIssue, playLangIssues, urlIssue } from './listing-rules';
|
||||
|
||||
export type EditOp =
|
||||
|
|
@ -35,6 +36,8 @@ export interface EditSources {
|
|||
shotsCopy?: { file: string; rel: string; text: string | null } | null;
|
||||
appName: string;
|
||||
appleName: string;
|
||||
/** listing.allowPlatformMentions of the config (pitfall 77) */
|
||||
allowPlatformMentions?: readonly PlatformAllow[];
|
||||
}
|
||||
|
||||
/** Checks one edit's shape (it comes from the window or a terminal). Throws with the reason. */
|
||||
|
|
@ -171,12 +174,12 @@ export function planEdits(ops: EditOp[], src: EditSources, getPath: (path: strin
|
|||
// 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 (s) for (const x of playLangIssues(lang, s.values, src.appName, src.allowPlatformMentions)) 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}` });
|
||||
if (s) for (const x of appleLocaleIssues(loc, s.values, src.appleName, src.allowPlatformMentions)) issues.push({ ...x, where: `App Store ${loc}` });
|
||||
}
|
||||
const files: EditFile[] = [];
|
||||
for (const [file, after] of texts) {
|
||||
|
|
|
|||
18
cli/lib/listing-mentions.ts
Normal file
18
cli/lib/listing-mentions.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Other platforms named in the local listing files of both stores (pitfall 77): the blockers `check` and `validate`
|
||||
// report. Reads store/appstore.md and store/listing.md (or the config's listing paths).
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { relative } from 'node:path';
|
||||
import type { Loaded } from './config';
|
||||
import { fileMentions, type PlatformHit } from './cross-store';
|
||||
|
||||
export function listingFileMentions(loaded: Pick<Loaded, 'config' | 'resolve' | 'root'>, stores: { apple: boolean; play: boolean } = { apple: true, play: true }): PlatformHit[] {
|
||||
const { config, resolve, root } = loaded;
|
||||
const allow = config.listing?.allowPlatformMentions;
|
||||
const out: PlatformHit[] = [];
|
||||
const files: ['apple' | 'play', string | null][] = [
|
||||
['apple', stores.apple && config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : null],
|
||||
['play', stores.play && config.play ? resolve(config.play.listing ?? 'store/listing.md') : null],
|
||||
];
|
||||
for (const [store, f] of files) if (f && existsSync(f)) out.push(...fileMentions(store, relative(root, f) || f, readFileSync(f, 'utf8'), allow));
|
||||
return out;
|
||||
}
|
||||
|
|
@ -4,6 +4,10 @@
|
|||
// 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';
|
||||
import { fieldsMentions, GUIDELINE, type PlatformAllow, type PlatformHit } from './cross-store';
|
||||
|
||||
const mentionIssue = (h: PlatformHit): FieldIssue => ({ level: 'error', field: h.field,
|
||||
msg: `line ${h.line}: "${h.term}": other platforms or stores in ${h.store === 'apple' ? 'App Store' : 'Google Play'} text get it rejected (${GUIDELINE[h.store]}); ${h.fix}` });
|
||||
|
||||
export type IssueLevel = 'error' | 'warn' | 'info';
|
||||
export interface FieldIssue { level: IssueLevel; field: string; msg: string }
|
||||
|
|
@ -12,8 +16,6 @@ export interface FieldIssue { level: IssueLevel; field: string; msg: string }
|
|||
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;
|
||||
|
|
@ -31,7 +33,7 @@ export const APPLE_REQUIRED = ['subtitle', 'promotionalText', 'keywords', 'descr
|
|||
* (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[] {
|
||||
export function appleLocaleIssues(locale: string, v: AppleCopy, appName: string, allow: readonly PlatformAllow[] = []): 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][]) {
|
||||
|
|
@ -63,19 +65,19 @@ export function appleLocaleIssues(locale: string, v: AppleCopy, appName: string)
|
|||
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)` });
|
||||
}
|
||||
// 2.3.10: other platforms or stores (Android, Google Play, ...) stop the save (pitfall 77)
|
||||
out.push(...fieldsMentions('apple', locale, { ...v, name: v.name ?? '' }, allow).map(mentionIssue));
|
||||
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[] {
|
||||
export function playLangIssues(lang: string, v: PlayValues, appName: string, allow: readonly PlatformAllow[] = []): 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;
|
||||
|
|
@ -93,6 +95,8 @@ export function playLangIssues(lang: string, v: PlayValues, appName: string): Fi
|
|||
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' });
|
||||
}
|
||||
// Play Metadata policy: iOS, App Store, ... in Play text stop the save (pitfall 77)
|
||||
out.push(...fieldsMentions('play', lang, { ...v }, allow).map(mentionIssue));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import { consoleLink } from '../console-links';
|
|||
import { PLAY_AGE_GROUPS } from '../config';
|
||||
import { allSubscriptions } from '../subscriptions';
|
||||
import { grep, type Hit, type ProjectFacts } from './project';
|
||||
import type { ScanFinding, ScanInput } from './rules';
|
||||
import { platformHits, type ScanFinding, type ScanInput } from './rules';
|
||||
import { versionAtLeast } from './sdks';
|
||||
|
||||
const PLAY = 'https://support.google.com/googleplay/android-developer/answer/';
|
||||
|
|
@ -347,6 +347,11 @@ export function playRules(inp: ScanInput, x: PlayCtx): void {
|
|||
// ---- Play listing metadata ----
|
||||
if (c?.play || inp.listings.play) {
|
||||
const listing = inp.listings.play;
|
||||
// Apple platforms or services named in Play text (Metadata policy; the mirror of App Store 2.3.10, pitfall 77)
|
||||
const apple = platformHits(inp, 'play');
|
||||
if (apple.length)
|
||||
add({ rule: 'play-other-platforms', guideline: 'Play: Metadata', severity: 'high', title: 'Apple platforms or stores (iOS, iPhone, App Store, Game Center, TestFlight) named in the Play listing text', evidence: apple.slice(0, 6),
|
||||
ref: PLAY_REFS.metadata, skill: 'aso-basics', fix: 'Remove them from store/listing.md (release notes shared with the App Store: tag the line [apple]); a functional mention ("Sign in with Apple") goes into listing.allowPlatformMentions (owner\'s call). Every Play text write refuses these.' });
|
||||
const titles: Hit[] = fieldBlocks(listing, /\btitle\b/i).map(b => ({ ...b.at, text: b.text.trim() }));
|
||||
if (!titles.length && c?.app.name) titles.push(...cfg(/\bname\s*:/, c.app.name).map(h => ({ ...h, text: c.app.name })));
|
||||
const long = titles.filter(t => [...t.text].length > 30);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
import { consoleLink, consoleUrl } from '../console-links';
|
||||
import type { FixAction } from '../fix-actions';
|
||||
import { templateBlockers } from '../template-answers';
|
||||
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
|
||||
import { fileMentions, GUIDELINE, type PlatformHit, textMentions } from '../cross-store';
|
||||
import { BETA_WORDS, competitorRe, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
|
||||
import type { ShipConfig } from '../config';
|
||||
import { allSubscriptions } from '../subscriptions';
|
||||
import { sdkOfProjectPath } from '../privacy-files';
|
||||
|
|
@ -186,6 +187,17 @@ export function rewardNearReviewApi(sources: { path: string; text: string }[], m
|
|||
}
|
||||
|
||||
/** Text lines (inside fenced blocks of a listing file, or JSON string values) that match `re`. */
|
||||
/** Other platforms named in one store's listing file (and, for the App Store, the screenshot captions), with
|
||||
* locale, field and line (pitfall 77). */
|
||||
export function platformHits(inp: Pick<ScanInput, 'listings' | 'config'>, store: 'apple' | 'play'): Hit[] {
|
||||
const allow = inp.config?.listing?.allowPlatformMentions;
|
||||
const file = store === 'apple' ? inp.listings.apple : inp.listings.play;
|
||||
const hits: PlatformHit[] = file ? fileMentions(store, file.path, file.text, allow) : [];
|
||||
const cap = store === 'apple' ? inp.listings.captions : undefined;
|
||||
if (cap) hits.push(...textMentions(cap.text, { store, locale: 'captions', field: 'screenshot captions', allow }).map(h => ({ ...h, file: cap.path, fileLine: h.line })));
|
||||
return hits.map(h => ({ file: h.file!, line: h.fileLine!, text: `${h.locale} ${h.field}: "${h.term}" in ${h.text.slice(0, 120)}` }));
|
||||
}
|
||||
|
||||
function listingHits(file: { path: string; text: string } | undefined, re: RegExp, o: { fenced?: boolean; field?: RegExp } = {}): Hit[] {
|
||||
if (!file) return [];
|
||||
const out: Hit[] = [];
|
||||
|
|
@ -439,10 +451,10 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
|
||||
// ---- metadata (iOS listing + screenshot captions) ----
|
||||
if (apple) {
|
||||
const other = [...listingHits(inp.listings.apple, OTHER_PLATFORMS), ...listingHits(inp.listings.captions, OTHER_PLATFORMS, { fenced: false })];
|
||||
const other = platformHits(inp, 'apple');
|
||||
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' });
|
||||
add({ rule: 'other-platforms', guideline: GUIDELINE.apple, store: 'apple', severity: 'high', title: 'Other mobile platforms or stores named in the App Store metadata or screenshot captions', evidence: other.slice(0, 6),
|
||||
fix: 'Remove "Android", "Google Play" and other platforms or stores from the App Store text and screenshots (a What\'s New shared with Play: tag the line [play]); a functional mention goes into listing.allowPlatformMentions (owner\'s call). Every App Store text write refuses these.', skill: 'aso-basics' });
|
||||
const words = competitorRe();
|
||||
const kw = [...listingHits(inp.listings.apple, words, { field: /^(keywords|name|subtitle)$/ })];
|
||||
if (kw.length)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,33 @@ import type { Language } from './config';
|
|||
import { playCodes } from './config';
|
||||
import { len, PLAY_LIMITS } from './copy';
|
||||
import { jargonFindings, jargonMessage } from './jargon';
|
||||
import { type CrossStore, fieldsMentions, notesForStore, type PlatformAllow, type PlatformHit, textMentions } from './cross-store';
|
||||
|
||||
/** One store's draft from shared notes: tagged lines split ([apple] / [play]), and an untagged line that names the
|
||||
* other side's platform (a "Google Play Games" line for the App Store) left out of this store's draft and reported,
|
||||
* so a draft never carries it into the wrong store (pitfall 77). Tag the line to make it explicit. Pure. */
|
||||
export function draftForStore(text: string, store: CrossStore, allow: readonly PlatformAllow[] = []): { text: string; dropped: string[] } {
|
||||
const dropped: string[] = [];
|
||||
const kept = notesForStore(text, store).split('\n').filter(l => {
|
||||
if (!l.trim() || !textMentions(l, { store, locale: '', field: 'whatsNew', allow }).length) return true;
|
||||
dropped.push(l.trim());
|
||||
return false;
|
||||
});
|
||||
return { text: kept.join('\n').replace(/\n{3,}/g, '\n\n').trim(), dropped };
|
||||
}
|
||||
|
||||
/** Note items for one store (items tagged [apple] / [play] go only there, the tag removed). */
|
||||
export function itemsForStore(items: NoteItem[], store: CrossStore): NoteItem[] {
|
||||
return items.flatMap(i => { const t = notesForStore(i.text, store); return t ? [{ ...i, text: t }] : []; });
|
||||
}
|
||||
|
||||
/** Other-platform hits in the per-store notes of one language (the texts `whats-new apply` would write). Pure. */
|
||||
export function languageNoteMentions(l: Language, text: string, allow: readonly PlatformAllow[] = []): PlatformHit[] {
|
||||
const out: PlatformHit[] = [];
|
||||
for (const code of playCodes(l)) out.push(...fieldsMentions('play', code, { whatsNew: notesForStore(text, 'play') }, allow));
|
||||
if (l.apple) out.push(...fieldsMentions('apple', l.apple, { whatsNew: notesForStore(text, 'apple') }, allow));
|
||||
return out;
|
||||
}
|
||||
|
||||
export const NOTES_LIMITS = { play: PLAY_LIMITS.whatsNew, apple: 4000 } as const;
|
||||
/** Roughly what both stores show before "more" on a phone. */
|
||||
|
|
@ -196,8 +223,10 @@ export interface LangCheck { lang: string; chars: number; play?: { limit: number
|
|||
export function checkLanguageNotes(l: Language, f: NotesFile, sourceText?: string, allow: readonly string[] = []): LangCheck {
|
||||
const chars = len(f.text);
|
||||
const c: LangCheck = { lang: l.id, chars, problems: [], warnings: jargonFindings(f.text, allow).map(jargonMessage) };
|
||||
if (playCodes(l).length) { c.play = { limit: NOTES_LIMITS.play, ok: chars <= NOTES_LIMITS.play }; if (!c.play.ok) c.problems.push(`Google Play: ${chars} > ${NOTES_LIMITS.play} characters`); }
|
||||
if (l.apple) { c.apple = { limit: NOTES_LIMITS.apple, ok: chars <= NOTES_LIMITS.apple }; if (!c.apple.ok) c.problems.push(`App Store: ${chars} > ${NOTES_LIMITS.apple} characters`); }
|
||||
// each store counts its own notes: lines tagged for the other store are not sent there
|
||||
const pn = len(notesForStore(f.text, 'play')), an = len(notesForStore(f.text, 'apple'));
|
||||
if (playCodes(l).length) { c.play = { limit: NOTES_LIMITS.play, ok: pn <= NOTES_LIMITS.play }; if (!c.play.ok) c.problems.push(`Google Play: ${pn} > ${NOTES_LIMITS.play} characters`); }
|
||||
if (l.apple) { c.apple = { limit: NOTES_LIMITS.apple, ok: an <= NOTES_LIMITS.apple }; if (!c.apple.ok) c.problems.push(`App Store: ${an} > ${NOTES_LIMITS.apple} characters`); }
|
||||
if (!f.text) c.problems.push('empty');
|
||||
if (f.status === 'translate') c.problems.push('not translated yet (status=translate)');
|
||||
else if (sourceText !== undefined && f.status !== 'source' && f.text === sourceText.trim()) c.problems.push('identical to the source language: translate it');
|
||||
|
|
@ -206,16 +235,17 @@ export function checkLanguageNotes(l: Language, f: NotesFile, sourceText?: strin
|
|||
return c;
|
||||
}
|
||||
|
||||
/** Writes one language's notes into the listing files (every Play code of the language, its App Store locale). Pure. */
|
||||
/** Writes one language's notes into the listing files (every Play code of the language, its App Store locale); lines
|
||||
* tagged [apple] / [play] go only into that store's notes. Pure. */
|
||||
export function applyLanguageNotes(md: { play?: string; apple?: string }, l: Language, text: string): { play?: string; apple?: string; changed: string[] } {
|
||||
const out: { play?: string; apple?: string; changed: string[] } = { ...md, changed: [] };
|
||||
if (md.play !== undefined) for (const code of playCodes(l)) {
|
||||
const r = setPlayWhatsNew(out.play!, code, text);
|
||||
const r = setPlayWhatsNew(out.play!, code, notesForStore(text, 'play'));
|
||||
out.play = r.md;
|
||||
out.changed.push(...r.changed.map(x => `play:${x}`));
|
||||
}
|
||||
if (md.apple !== undefined && l.apple) {
|
||||
const r = setAppleWhatsNew(out.apple!, l.apple, text);
|
||||
const r = setAppleWhatsNew(out.apple!, l.apple, notesForStore(text, 'apple'));
|
||||
out.apple = r.md;
|
||||
out.changed.push(...r.changed.map(x => `apple:${x}`));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
// from the config of an app folder).
|
||||
import { appleLocaleIssues, type FieldIssue, playLangIssues } from './lib/listing-rules';
|
||||
import type { AppleCopy } from './lib/copy';
|
||||
import type { PlatformAllow } from './lib/cross-store';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { confirm, need } from './lib/ctx';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
|
|
@ -39,7 +40,7 @@ export interface FieldPlan { field: string; from: string | null; to: string }
|
|||
export interface SetPlan { store: 'play' | 'apple'; lang: string; create: boolean; changes: FieldPlan[]; same: string[]; issues: FieldIssue[]; refused: string | null; target?: string }
|
||||
|
||||
/** Old -> new for the given fields of one language (an empty and a missing value are the same). Pure. */
|
||||
export function setPlan(store: 'play' | 'apple', lang: string, current: Record<string, string> | null, want: Record<string, string>, o: { appName: string; editable?: boolean }): SetPlan {
|
||||
export function setPlan(store: 'play' | 'apple', lang: string, current: Record<string, string> | null, want: Record<string, string>, o: { appName: string; editable?: boolean; allow?: readonly PlatformAllow[] }): SetPlan {
|
||||
const changes: FieldPlan[] = [];
|
||||
const same: string[] = [];
|
||||
for (const [f, v] of Object.entries(want)) {
|
||||
|
|
@ -50,8 +51,8 @@ export function setPlan(store: 'play' | 'apple', lang: string, current: Record<s
|
|||
// only the fields this run sends are judged (a store value it leaves alone is not this run's problem)
|
||||
const sent = new Set(Object.keys(want));
|
||||
const all = store === 'play'
|
||||
? playLangIssues(lang, { title: merged.title || undefined, short: merged.short ?? '', full: merged.full ?? '' }, o.appName)
|
||||
: appleLocaleIssues(lang, merged as AppleCopy, o.appName);
|
||||
? playLangIssues(lang, { title: merged.title || undefined, short: merged.short ?? '', full: merged.full ?? '' }, o.appName, o.allow)
|
||||
: appleLocaleIssues(lang, merged as AppleCopy, o.appName, o.allow);
|
||||
const issues = all.filter(i => sent.has(i.field) && !(i.level === 'error' && /is required$/.test(i.msg) && !(want[i.field] !== undefined && !want[i.field]!.trim())));
|
||||
let refused: string | null = null;
|
||||
if (store === 'apple' && o.editable === false && changes.some(c => c.field !== 'promotionalText' && (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)))
|
||||
|
|
@ -138,7 +139,7 @@ async function set(ctx: Ctx): Promise<void> {
|
|||
const playName = config.play?.name ?? config.app.name;
|
||||
const now = await playLive(ctx);
|
||||
const cur = now?.find(l => l.language === lang) ?? null;
|
||||
const p = setPlan('play', lang, cur ? { title: cur.title, short: cur.short, full: cur.full } : null, want, { appName: playName });
|
||||
const p = setPlan('play', lang, cur ? { title: cur.title, short: cur.short, full: cur.full } : null, want, { appName: playName, allow: config.listing?.allowPlatformMentions });
|
||||
show(ctx, p);
|
||||
const errors = p.issues.filter(i => i.level === 'error').length;
|
||||
if (a.has('json')) printJson({ ...p, dry, written: false });
|
||||
|
|
@ -167,7 +168,7 @@ async function set(ctx: Ctx): Promise<void> {
|
|||
promotionalText: vl?.attributes.promotionalText ?? '', whatsNew: vl?.attributes.whatsNew ?? '',
|
||||
} : null;
|
||||
const appName = config.apple?.name ?? config.app.name;
|
||||
const p = { ...setPlan('apple', lang, cur, want, { appName, editable: r.editable }), target: r.version ? `version ${r.version.attributes.versionString}, ${r.version.attributes.appVersionState ?? r.version.attributes.appStoreState}` : 'no version' };
|
||||
const p = { ...setPlan('apple', lang, cur, want, { appName, editable: r.editable, allow: config.listing?.allowPlatformMentions }), target: r.version ? `version ${r.version.attributes.versionString}, ${r.version.attributes.appVersionState ?? r.version.attributes.appStoreState}` : 'no version' };
|
||||
show(ctx, p);
|
||||
const errors = p.issues.filter(i => i.level === 'error').length;
|
||||
if (a.has('json')) printJson({ ...p, dry, written: false });
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
// storebound play listing pull [--write] [--json] live listings -> diff -> (with --write) the Play listing file
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { parsePlayListing } from '../lib/copy';
|
||||
import { fieldsMentions, refuseMentions } from '../lib/cross-store';
|
||||
import { compactDiff } from '../lib/diff';
|
||||
import { mergePlayListing, overLimits } from '../lib/listing-pull';
|
||||
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, type Skipped } from '../lib/listing-plan';
|
||||
|
|
@ -21,6 +22,12 @@ import { done, log, printJson, step, warn } from '../lib/log';
|
|||
import { playClient } from './client';
|
||||
import { stdinIsTTY } from '../lib/tty';
|
||||
|
||||
/** Play release notes that name iOS, iPhone, App Store, ... are refused before any edit, dry runs included (pitfall 77). */
|
||||
export function refusePlayNotes(ctx: Ctx, notes: { language: string; text: string }[], what: string): void {
|
||||
const allow = ctx.loaded.config.listing?.allowPlatformMentions;
|
||||
refuseMentions(notes.flatMap(n => fieldsMentions('play', n.language, { whatsNew: n.text }, allow)), what);
|
||||
}
|
||||
|
||||
export function readPlayCopy(ctx: Ctx) {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
const file = resolve(config.play?.listing ?? 'store/listing.md');
|
||||
|
|
@ -125,6 +132,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
skipped.push({ key: c.lang, reason: draftReason(st) });
|
||||
return false;
|
||||
});
|
||||
// Play Metadata policy: iOS / App Store / Game Center in Play text stop the run, dry runs included (pitfall 77)
|
||||
refuseMentions(copy.flatMap(c => fieldsMentions('play', c.lang, { title: c.title, short: c.short, full: c.full }, config.listing?.allowPlatformMentions)), `play listing${a.has('dry') ? ' (dry run)' : ''}`);
|
||||
const details = { defaultLanguage: config.play?.defaultLanguage ?? 'en-US', contactEmail: config.app.contactEmail, contactWebsite: config.app.website };
|
||||
if (!all.some(c => c.lang === details.defaultLanguage) && !a.has('only')) throw new Error(`listing has no section for the default language ${details.defaultLanguage}`);
|
||||
if (!a.has('json')) log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}${config.play?.video ? ` · promo video ${config.play.video}` : ''}`);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { done, log, warn } from '../lib/log';
|
|||
import { aabInfo } from '../lib/gradle';
|
||||
import { playClient } from './client';
|
||||
import { usedVersionCodes } from './next-version-code';
|
||||
import { readPlayCopy } from './listing';
|
||||
import { readPlayCopy, refusePlayNotes } from './listing';
|
||||
|
||||
const TRACKS = ['internal', 'alpha', 'beta', 'production'];
|
||||
|
||||
|
|
@ -45,9 +45,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
for (const t of tracks) if (!STATUSES.includes(t.status)) throw new Error(`--${t.track}: status must be one of ${STATUSES.join(', ')}`);
|
||||
const fraction = a.get('fraction');
|
||||
const live = tracks.find(t => t.track === 'production' && t.status !== 'draft');
|
||||
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
|
||||
refusePlayNotes(ctx, notes, `play release notes${a.has('dry') ? ' (dry run)' : ''}`);
|
||||
if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return;
|
||||
|
||||
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
|
||||
const play = await playClient(root, pkg);
|
||||
try {
|
||||
await play.edit(async (id, ctl) => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { aabInfo } from '../lib/gradle';
|
|||
import { done, log, printJson } from '../lib/log';
|
||||
import { nextVersionCode } from '../lib/release';
|
||||
import { playClient } from './client';
|
||||
import { readPlayCopy } from './listing';
|
||||
import { readPlayCopy, refusePlayNotes } from './listing';
|
||||
import { usedVersionCodes } from './next-version-code';
|
||||
|
||||
const TAG = 'upload';
|
||||
|
|
@ -43,6 +43,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const dry = a.has('dry');
|
||||
const json = a.has('json');
|
||||
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
|
||||
refusePlayNotes(ctx, notes, `play upload release notes${dry ? ' (dry run)' : ''}`);
|
||||
const play = await playClient(root, pkg);
|
||||
const { used, current } = await play.read(async id => ({
|
||||
used: await usedVersionCodes(play, id),
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import type { ScanFinding } from './lib/scan/rules';
|
|||
import { playClient } from './play/client';
|
||||
import { listOneTimeProducts } from './play/products';
|
||||
import { creativeNote } from './shots/creative';
|
||||
import { fixFor, GUIDELINE, hitLine } from './lib/cross-store';
|
||||
import { listingFileMentions } from './lib/listing-mentions';
|
||||
|
||||
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
|
||||
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
|
||||
|
|
@ -204,6 +206,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }
|
||||
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); }
|
||||
} else if (a.has('play')) warn('validate', 'no play section in the config');
|
||||
// other platforms named in a store's listing text (App Store 2.3.10, Play Metadata): blockers (pitfall 77)
|
||||
try {
|
||||
for (const h of listingFileMentions(ctx.loaded, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play }))
|
||||
findings.push({ store: h.store, level: 'blocker', code: 'other-platforms', msg: `${GUIDELINE[h.store]}: ${hitLine(h)}`, fix: fixFor(h.store, h.field) });
|
||||
} catch (e) { warn('validate', `platform mentions: ${(e as Error).message}`); }
|
||||
// template compliance answers: the scan reports them; without the scan, here
|
||||
if (a.has('no-scan')) for (const msg of templateBlockers(config)) findings.push({ store: config.apple ? 'apple' : 'play', level: 'blocker', code: 'template-answers', msg });
|
||||
if (!a.has('no-scan')) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
// the translation. Existing translated files are kept unless --force.
|
||||
// storebound whats-new check [--dir store/whats-new] [--json] limits per store (Play 500, App Store 4000), untranslated files
|
||||
// storebound whats-new apply [--dir store/whats-new] [--dry] [--json] checked files -> both listing files
|
||||
// Per store: a line tagged `[apple]` or `[play]` (also `• [play] ...`, or a `[play]` line heading a block up to the next
|
||||
// blank line) goes only into that store's notes. check / apply refuse notes that name the other side's platform
|
||||
// (Android / Google Play in App Store notes: Guideline 2.3.10; iOS / App Store in Play notes) unless
|
||||
// listing.allowPlatformMentions allows the line; the draft leaves such untagged lines out of the other store (pitfall 77).
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Language } from './lib/config';
|
||||
|
|
@ -25,10 +29,11 @@ import type { Ctx } from './lib/ctx';
|
|||
import { done, log, printJson, warn } from './lib/log';
|
||||
import { textTable } from './lib/table';
|
||||
import {
|
||||
applyLanguageNotes, checkLanguageNotes, checkNotes, cutNotes, findNotesSource, formatNotes, type LangCheck, NOTES_LIMITS, type NoteItem, type NotesFile, noteItems, notesFileText, parseNotesFile,
|
||||
applyLanguageNotes, checkLanguageNotes, checkNotes, cutNotes, draftForStore, findNotesSource, formatNotes, itemsForStore, languageNoteMentions, type LangCheck, NOTES_LIMITS, type NoteItem, type NotesFile, noteItems, notesFileText, parseNotesFile,
|
||||
resolveLanguages, setAppleWhatsNew, setPlayWhatsNew,
|
||||
} from './lib/whats-new';
|
||||
import * as rt from './lib/rt';
|
||||
import { hitLine } from './lib/cross-store';
|
||||
|
||||
const TAG = 'whats-new';
|
||||
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
|
||||
|
|
@ -52,14 +57,16 @@ function printChecks(checks: LangCheck[]): void {
|
|||
for (const c of checks) for (const w of c.warnings) warn(TAG, `${c.lang}: ${w}`);
|
||||
}
|
||||
|
||||
async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string }): Promise<void> {
|
||||
async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string; shared: string }): Promise<void> {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
const a = ctx.args;
|
||||
const dir = resolve(a.get('dir') ?? 'store/whats-new');
|
||||
const langs = resolveLanguages(config.languages, a.list('languages'));
|
||||
const [src, ...rest] = langs;
|
||||
// the source text: the shorter Play draft when the source language is on Play (it has to fit 500)
|
||||
const text = playCodes(src!).length ? draft.play : draft.apple;
|
||||
// the source text: the shorter Play draft when the source language is on Play (it has to fit 500); with [apple] /
|
||||
// [play] tags the shared text keeps them (apply splits it per store)
|
||||
const tagged = draft.shared !== draft.play || draft.shared !== draft.apple;
|
||||
const text = tagged && /\[(apple|play|ios|android)\]/i.test(draft.shared) ? draft.shared : playCodes(src!).length ? draft.play : draft.apple;
|
||||
const files: { l: Language; f: NotesFile; write: boolean; note: string }[] = [{ l: src!, f: { lang: src!.id, source: src!.id, status: 'source', text }, write: true, note: 'draft (source)' }];
|
||||
const hook = a.get('translate');
|
||||
for (const l of rest) {
|
||||
|
|
@ -97,6 +104,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const files = readDir(dir, config.languages);
|
||||
if (!files.length) throw new Error(`${dir}: no <language id>.md files`);
|
||||
const checks = checkAll(files, config.whatsNew?.allowWords ?? []);
|
||||
// other platforms named in a store's notes: a blocker for check and apply (pitfall 77)
|
||||
const hits = files.flatMap(({ l, f }) => languageNoteMentions(l, f.text, config.listing?.allowPlatformMentions));
|
||||
for (const h of hits) { const c = checks.find(x => x.lang === files.find(y => playCodes(y.l).includes(h.locale) || y.l.apple === h.locale)?.l.id); c?.problems.push(`${h.store === 'apple' ? 'App Store' : 'Google Play'} line ${h.line}: "${h.term}" (${h.store === 'apple' ? '2.3.10' : 'Play: Metadata'})`); }
|
||||
for (const h of hits) warn(TAG, `refused: ${hitLine(h)}`);
|
||||
const bad = checks.filter(c => c.problems.some(p => !p.startsWith('first line')));
|
||||
if (a._[0] === 'check' || bad.length) {
|
||||
if (a.has('json')) printJson({ dir, checks, ok: !bad.length });
|
||||
|
|
@ -124,21 +135,30 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const version = a.get('release') ?? (() => { try { return (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version?: string }).version; } catch { return undefined; } })();
|
||||
const source = a.has('from-git') ? undefined : findNotesSource({ version, source: config.whatsNew?.source, read: rel => { const p = resolve(rel); return existsSync(p) ? readFileSync(p, 'utf8') : undefined; } });
|
||||
const hook = a.get('hook');
|
||||
let play: string, apple: string, subjects: string[] = [], items: NoteItem[] = [];
|
||||
let play: string, apple: string, shared: string, subjects: string[] = [], items: NoteItem[] = [];
|
||||
if (source) {
|
||||
log(TAG, `notes from ${source.path} (${source.kind === 'changelog' ? 'CHANGELOG section' : source.kind === 'release-doc' ? 'release doc' : 'whatsNew.source'}), not the git log`);
|
||||
const body = hook ? `${hook}\n${source.text}` : source.text;
|
||||
play = cutNotes(body, NOTES_LIMITS.play);
|
||||
apple = cutNotes(body, NOTES_LIMITS.apple);
|
||||
const dp = draftForStore(body, 'play', config.listing?.allowPlatformMentions), da = draftForStore(body, 'apple', config.listing?.allowPlatformMentions);
|
||||
for (const [s, d] of [['Google Play', dp], ['App Store', da]] as const) for (const l of d.dropped) warn(TAG, `left out of the ${s} draft (names another platform; tag it [${s === 'App Store' ? 'play' : 'apple'}] in ${source.path}): ${l}`);
|
||||
shared = cutNotes(body, NOTES_LIMITS.apple);
|
||||
play = cutNotes(dp.text, NOTES_LIMITS.play);
|
||||
apple = cutNotes(da.text, NOTES_LIMITS.apple);
|
||||
} else {
|
||||
subjects = git(['log', range, '--no-merges', '--format=%s'], root).split('\n').filter(Boolean);
|
||||
items = noteItems(subjects);
|
||||
log(TAG, `${subjects.length} commits since ${since || 'the first commit'} -> ${items.length} items, drafted from commit messages (no docs/RELEASE-<version>.md, CHANGELOG section or whatsNew.source): rewrite them in player words`);
|
||||
play = formatNotes(items, { hook, limit: NOTES_LIMITS.play });
|
||||
apple = formatNotes(items, { hook, limit: NOTES_LIMITS.apple });
|
||||
const pick = (store: 'play' | 'apple') => {
|
||||
const d = draftForStore(formatNotes(itemsForStore(items, store), { hook, limit: NOTES_LIMITS[store] }), store, config.listing?.allowPlatformMentions);
|
||||
for (const l of d.dropped) warn(TAG, `left out of the ${store === 'apple' ? 'App Store' : 'Google Play'} draft (names another platform): ${l}`);
|
||||
return d.text;
|
||||
};
|
||||
shared = formatNotes(items, { hook, limit: NOTES_LIMITS.apple });
|
||||
play = pick('play');
|
||||
apple = pick('apple');
|
||||
}
|
||||
for (const [store, text] of [['play', play], ['apple', apple]] as const) for (const p of checkNotes(text, store, allow)) warn(TAG, `${store}: ${p}`);
|
||||
if (a.has('languages')) return languagesFlow(ctx, { play, apple });
|
||||
if (a.has('languages')) return languagesFlow(ctx, { play, apple, shared });
|
||||
if (a.has('json')) printJson({ since: since || null, source: source ? { kind: source.kind, path: source.path } : null, commits: subjects.length, items, play, apple, limits: NOTES_LIMITS });
|
||||
else console.log(`--- draft (Play, ${[...play].length}/500) ---\n${play}\n---`);
|
||||
if (a.has('dry')) return;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue