merge origin/main (cross-store text checker); macOS platform pitfall renumbered to 79

This commit is contained in:
CRTkafa 2026-10-20 03:06:16 +07:00
commit 9db7180eb9
35 changed files with 618 additions and 60 deletions

View file

@ -18,6 +18,10 @@
// 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] [--platform ios|macos|tvos|visionos] [--dry] [--json] checked files -> both listing files
// (--platform only goes into the printed apple listing command: the App Store's what's new is per platform version)
// 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';
@ -26,11 +30,12 @@ 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 { parsePlatform } from './apple/platform';
import { hitLine } from './lib/cross-store';
const TAG = 'whats-new';
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
@ -54,14 +59,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) {
@ -100,6 +107,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 });
@ -128,21 +139,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;