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
|
|
@ -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}`));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue