whats-new drafts from player-facing notes first (whatsNew.source, docs/RELEASE-<version>.md, the CHANGELOG section of the version or Unreleased; --release, --from-git) and only then from the git log; internal jargon warnings (SDK / vendor names, developer and version-control words, file paths, issue ids, code identifiers) with plain alternatives in the draft and whats-new check, one list in cli/lib/jargon.ts, whatsNew.allowWords keeps a word; skill: write from what players feel, never from commit messages; tests

This commit is contained in:
CRTkafa 2026-10-14 14:45:19 +08:00
parent e9507d8884
commit bab3f63227
9 changed files with 287 additions and 21 deletions

View file

@ -1,7 +1,9 @@
// Draft user-facing release notes from the git log and write them into the listing files (text only; nothing is
// sent to a store). A human (or an agent with the whats-new skill) rewrites and translates before `play release` /
// `apple listing` push them.
// storebound whats-new [--since v1.1.0] [--lang en-US] [--all] [--hook "text"] [--dry]
// storebound whats-new [--since v1.1.0] [--release 1.2.0] [--from-git] [--lang en-US] [--all] [--hook "text"] [--dry]
// The text comes from a player-facing source when there is one (whatsNew.source, docs/RELEASE-<version>.md, the
// CHANGELOG.md section of the version or Unreleased; version: --release, else package.json); only then the git log.
// --since: default the latest tag (git describe --tags --abbrev=0); --lang: the Play code / App Store locale to write
// (default: the Play default language and the first App Store locale); --all: write the same draft into every
// language (to translate afterwards; `storebound check` warns while they are identical).
@ -23,7 +25,7 @@ import type { Ctx } from './lib/ctx';
import { done, log, printJson, warn } from './lib/log';
import { textTable } from './lib/table';
import {
applyLanguageNotes, checkLanguageNotes, checkNotes, formatNotes, type LangCheck, NOTES_LIMITS, type NotesFile, noteItems, notesFileText, parseNotesFile,
applyLanguageNotes, checkLanguageNotes, checkNotes, cutNotes, findNotesSource, formatNotes, type LangCheck, NOTES_LIMITS, type NoteItem, type NotesFile, noteItems, notesFileText, parseNotesFile,
resolveLanguages, setAppleWhatsNew, setPlayWhatsNew,
} from './lib/whats-new';
import * as rt from './lib/rt';
@ -38,14 +40,16 @@ function readDir(dir: string, langs: Language[]): { l: Language; f: NotesFile }[
return langs.filter(l => names.has(l.id)).map(l => ({ l, f: parseNotesFile(readFileSync(join(dir, `${l.id}.md`), 'utf8'), l.id) }));
}
function checkAll(files: { l: Language; f: NotesFile }[]): LangCheck[] {
function checkAll(files: { l: Language; f: NotesFile }[], allow: readonly string[]): LangCheck[] {
const src = files.find(x => x.f.status === 'source');
return files.map(x => checkLanguageNotes(x.l, x.f, src && src !== x ? src.f.text : undefined));
return files.map(x => checkLanguageNotes(x.l, x.f, src && src !== x ? src.f.text : undefined, allow));
}
function printChecks(checks: LangCheck[]): void {
console.log(textTable(['language', 'chars', 'play (500)', 'app store (4000)', 'problems'], checks.map(c => [c.lang, c.chars,
c.play ? (c.play.ok ? 'ok' : 'OVER') : '-', c.apple ? (c.apple.ok ? 'ok' : 'OVER') : '-', c.problems.join('; ')])));
// jargon never blocks: players read these notes, so name each word and a plain alternative (whatsNew.allowWords keeps one)
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> {
@ -70,7 +74,7 @@ async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string }):
files.push({ l, f: { lang: l.id, source: src!.id, status: 'translated', text: out }, write: true, note: 'translated by the hook' });
} else files.push({ l, f: { lang: l.id, source: src!.id, status: 'translate', text }, write: true, note: hook ? 'would run the --translate hook' : 'to translate (your agent: whats-new skill)' });
}
const checks = checkAll(files.map(x => ({ l: x.l, f: x.f })));
const checks = checkAll(files.map(x => ({ l: x.l, f: x.f })), config.whatsNew?.allowWords ?? []);
if (a.has('json')) printJson({ dir, dryRun: a.has('dry'), files: files.map((x, i) => ({ lang: x.l.id, file: join(dir, `${x.l.id}.md`), status: x.f.status, write: x.write, note: x.note, check: checks[i] })) });
else { for (const x of files) log(TAG, `${join(dir, `${x.l.id}.md`)}: ${x.note}`); printChecks(checks); }
if (a.has('dry')) { done(TAG, 'dry run: nothing written'); return; }
@ -92,7 +96,7 @@ export async function run(ctx: Ctx): Promise<void> {
const dir = resolve(a.get('dir') ?? 'store/whats-new');
const files = readDir(dir, config.languages);
if (!files.length) throw new Error(`${dir}: no <language id>.md files`);
const checks = checkAll(files);
const checks = checkAll(files, config.whatsNew?.allowWords ?? []);
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 });
@ -115,15 +119,27 @@ export async function run(ctx: Ctx): Promise<void> {
}
const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })();
const range = since ? `${since}..HEAD` : 'HEAD';
const subjects = git(['log', range, '--no-merges', '--format=%s'], root).split('\n').filter(Boolean);
const items = noteItems(subjects);
log('whats-new', `${subjects.length} commits since ${since || 'the first commit'} -> ${items.length} user-facing items`);
const allow = config.whatsNew?.allowWords ?? [];
// a player-facing source (whatsNew.source, docs/RELEASE-<version>.md, the CHANGELOG section) beats the git log
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');
const play = formatNotes(items, { hook, limit: NOTES_LIMITS.play });
const apple = formatNotes(items, { hook, limit: NOTES_LIMITS.apple });
for (const [store, text] of [['play', play], ['apple', apple]] as const) for (const p of checkNotes(text, store)) warn('whats-new', `${store}: ${p}`);
let play: string, apple: 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);
} 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 });
}
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('json')) printJson({ since: since || null, commits: subjects.length, items, play, apple, limits: NOTES_LIMITS });
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;