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:
CRTkafa 2026-10-20 02:57:25 +07:00
parent 3e4015b686
commit edabbec874
32 changed files with 613 additions and 53 deletions

View file

@ -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}`);

View file

@ -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' };
}

View file

@ -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('; ')); }

View file

@ -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;

View file

@ -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 ?? [],
};
}

View file

@ -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
View 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));

View file

@ -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;

View file

@ -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,

View file

@ -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) {

View 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;
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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)

View file

@ -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}`));
}

View file

@ -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 });

View file

@ -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}` : ''}`);

View file

@ -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) => {

View file

@ -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),

View file

@ -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')) {

View file

@ -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;