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,5 +1,15 @@
# Changelog
## Unreleased
- **`whats-new` drafts from player-facing notes before the git log.** It takes `whatsNew.source`, then
`docs/RELEASE-<version>.md`, then the CHANGELOG.md section of the version (else Unreleased); version from
`--release`, else package.json. Only without one (or with `--from-git`) it drafts from commit messages, and says so.
- **Internal jargon warnings in release notes.** The draft and `whats-new check` name SDK / vendor names, developer
and version-control words, file paths, issue ids and code identifiers, each with a plain alternative; warnings never
block. One list in cli/lib/jargon.ts; `whatsNew.allowWords` keeps a word. The whats-new skill says to write from what
players feel, never from commit messages.
## 0.8.3 · 2026-10-14
Docs: no private account names in the published package. `npx storebound` prompts work on Node again (the [Y/n]

View file

@ -801,9 +801,11 @@
"all",
"dir",
"force",
"from-git",
"hook",
"lang",
"languages",
"release",
"since",
"translate"
],
@ -883,6 +885,7 @@
"fresh",
"fresh-profile",
"from",
"from-git",
"from-text",
"godot",
"group",
@ -968,6 +971,7 @@
"react-native",
"reconvert",
"region",
"release",
"release-date",
"remove",
"render",
@ -1381,7 +1385,7 @@
"storebound web check [<domain>] [--json] read-only, no credentials: the live files on https://<domain>"
],
"whats-new": [
"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]",
"storebound whats-new --languages en-US,de-DE,tr [--dir store/whats-new] [--translate \"<command>\"] [--force] [--dry] [--json]",
"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"

View file

@ -689,6 +689,14 @@ 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`). */
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. */
source?: string;
/** Words `whats-new check` must not flag as internal jargon (a product or game term that collides), any case. */
allowWords?: string[];
};
/** Native builds (`apple build`, `play build`). */
build?: {
/** Shell command run in the project root before the archive / bundle, e.g. "npm run build && npx cap sync" for a

79
cli/lib/jargon.ts Normal file
View file

@ -0,0 +1,79 @@
// Internal jargon in release notes: words players do not use. The one list of them; `whatsNew.allowWords` in the
// config keeps a word the owner wants (a product name that collides, a game term). Pure.
export type JargonKind = 'vendor' | 'dev' | 'vcs' | 'path' | 'issue' | 'identifier';
export interface JargonFinding { word: string; kind: JargonKind; plain?: string }
const DROP_NAME = 'drop the name and say what changed for the player';
/** SDK, service and tool names players never see. */
export const JARGON_VENDORS: readonly string[] = [
'Umami', 'Sentry', 'Firebase', 'Crashlytics', 'Capacitor', 'Tauri', 'Electron', 'Jenkins', 'Forgejo', 'GitHub', 'GitLab',
'Gitea', 'Gradle', 'Xcode', 'CocoaPods', 'Webpack', 'Vite', 'Docker', 'Supabase', 'RevenueCat', 'Amplitude', 'Mixpanel',
'Keeper OS', 'TestFlight', 'Fastlane',
];
/** Developer words, with a plain alternative where one is obvious (undefined: just drop it). */
export const JARGON_DEV: Readonly<Record<string, string | undefined>> = {
telemetry: 'drop it, or "anonymous usage stats"', analytics: 'drop it, or "anonymous usage stats"',
refactor: '"faster" / "more stable", or drop it', refactored: '"faster" / "more stable", or drop it', refactoring: '"faster" / "more stable", or drop it',
endpoint: '"server"', endpoints: '"servers"', payload: '"data"', api: '"connection" / "online features"', apis: '"online features"',
ci: undefined, pr: undefined, prs: undefined, fingerprint: 'say what the player notices', ota: '"in-app update"',
bump: '"update"', bumped: '"updated"', deps: '"under-the-hood updates"', dependency: '"under-the-hood update"', dependencies: '"under-the-hood updates"',
sdk: 'say what changed for the player', sdks: 'say what changed for the player', backend: '"server"', frontend: undefined,
config: '"settings"', regression: '"a bug"', lint: undefined, linter: undefined, typecheck: undefined, nullable: undefined,
async: undefined, websocket: '"live connection"', json: undefined, schema: undefined, webhook: undefined, stacktrace: '"crash"',
'stack trace': '"crash"', env: undefined, changelog: undefined, codebase: undefined, repo: undefined,
};
/** Version-control words ("reads like a commit log"). */
export const JARGON_VCS: Readonly<Record<string, string | undefined>> = {
commit: undefined, commits: undefined, merge: undefined, merged: undefined, rebase: undefined, 'pull request': undefined,
'merge request': undefined, revert: '"undo"', reverted: '"undid"', squash: undefined, git: undefined, 'cherry-pick': undefined,
};
/** Product names that look like code identifiers. */
const NOT_IDENTIFIERS = /^(iPhone|iPad|iPod|iOS|iCloud|iMessage|macOS|watchOS|tvOS|visionOS|eBook|eSIM|eSports|YouTube|iTunes|AirPods|AirPlay)s?$/;
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordRe = (w: string): RegExp => new RegExp(`(?<![\\p{L}\\p{N}_-])${esc(w).replace(/ /g, '\\s+')}(?![\\p{L}\\p{N}_])`, 'iu');
/** Internal words in a notes text, each once, in text order. `allow`: words the owner keeps (case-insensitive). */
export function jargonFindings(text: string, allow: readonly string[] = []): JargonFinding[] {
const allowed = new Set(allow.map(w => w.toLowerCase()));
const hits: { at: number; f: JargonFinding }[] = [];
const seen = new Set<string>();
const add = (at: number, f: JargonFinding): void => {
const k = f.word.toLowerCase();
if (seen.has(k) || allowed.has(k)) return;
seen.add(k);
hits.push({ at, f });
};
// links are fine (a support page); look at the text without them
const t = text.replace(/https?:\/\/\S+/g, m => ' '.repeat(m.length));
const scan = (words: Iterable<[string, string | undefined]>, kind: JargonKind): void => {
for (const [w, plain] of words) { const m = wordRe(w).exec(t); if (m) add(m.index, { word: kind === 'vendor' ? w : m[0], kind, plain }); }
};
scan(JARGON_VENDORS.map(v => [v, DROP_NAME] as [string, string]), 'vendor');
scan(Object.entries(JARGON_VCS), 'vcs');
scan(Object.entries(JARGON_DEV), 'dev');
for (const m of t.matchAll(/(?<![\w/.])(?:\.{0,2}\/)?(?:[\w-]+\/)+[\w.-]*[A-Za-z][\w.-]*|(?<![\w/.@])[\w-]+\.(?:ts|tsx|js|jsx|mjs|kt|java|swift|mm|gd|cs|cpp|py|rb|go|rs|json|ya?ml|plist|gradle|xml|md|lock|toml)\b/g)) {
if (/^(and|or|and\/or)$/i.test(m[0])) continue;
add(m.index, { word: m[0], kind: 'path', plain: 'drop the file name' });
}
for (const m of t.matchAll(/(?<![\w&])(?:#\d+|![1-9]\d*|[A-Z][A-Z0-9]{1,9}-\d+)\b/g)) add(m.index, { word: m[0], kind: 'issue', plain: 'drop the ticket number' });
for (const m of t.matchAll(/(?<![\p{L}\p{N}_])(?:[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*|[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+)(?![\p{L}\p{N}_])/gu)) {
if (NOT_IDENTIFIERS.test(m[0])) continue;
add(m.index, { word: m[0], kind: 'identifier', plain: 'describe the feature in player words' });
}
return hits.sort((a, b) => a.at - b.at).map(h => h.f);
}
const LABEL: Record<JargonKind, string> = {
vendor: 'SDK / vendor name', dev: 'developer word', vcs: 'reads like a commit log', path: 'file path', issue: 'issue id', identifier: 'code identifier',
};
/** One warning line for a finding. */
export function jargonMessage(f: JargonFinding): string {
return `internal jargon (${LABEL[f.kind]}): "${f.word}"${f.plain ? `; instead: ${f.plain}` : '; drop it'}`;
}

View file

@ -3,6 +3,7 @@
import type { Language } from './config';
import { playCodes } from './config';
import { len, PLAY_LIMITS } from './copy';
import { jargonFindings, jargonMessage } from './jargon';
export const NOTES_LIMITS = { play: PLAY_LIMITS.whatsNew, apple: 4000 } as const;
/** Roughly what both stores show before "more" on a phone. */
@ -55,17 +56,72 @@ export function formatNotes(items: NoteItem[], o: { hook?: string; limit: number
}
/** Problems with a finished note text for one store. */
export function checkNotes(text: string, store: 'play' | 'apple'): string[] {
export function checkNotes(text: string, store: 'play' | 'apple', allow: readonly string[] = []): string[] {
const e: string[] = [];
const n = len(text);
if (n > NOTES_LIMITS[store]) e.push(`${n} > ${NOTES_LIMITS[store]} characters`);
const firstBreak = text.indexOf('\n');
const hook = firstBreak < 0 ? text : text.slice(0, firstBreak);
if (len(hook) > HOOK_CHARS) e.push(`first line is ${len(hook)} characters; the part before "more" is about ${HOOK_CHARS}`);
if (/\b(bump|refactor|merge|ci|lint)\b/i.test(text)) e.push('reads like a commit log (bump / refactor / merge ...)');
for (const f of jargonFindings(text, allow)) e.push(jargonMessage(f));
return e;
}
// ---- player-facing sources: a release doc or the changelog section beats the git log ----
/** Markdown of a release doc / changelog section -> plain notes text (bullets as •, no emphasis, link or code syntax). */
export function notesFromMarkdown(md: string): string {
return md.replace(/<!--[\s\S]*?-->/g, '').split('\n')
.filter(l => !/^#\s/.test(l))
.map(l => l.replace(/^#{2,6}\s+/, '').replace(/^\s*[-*+]\s+/, '• ').replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/`([^`]+)`/g, '$1').trimEnd())
.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
/** The body of a CHANGELOG section for `version` ("## 1.2.0", "## [1.2.0] - date", "## v1.2.0 · date"), else of
* "## Unreleased" when no version is given. */
export function changelogSection(md: string, version?: string): string | undefined {
const want = (version ? version.replace(/^v/i, '') : 'unreleased').toLowerCase();
for (const sec of md.split(/^(?=## )/m)) {
if (!sec.startsWith('## ')) continue;
const nl = sec.indexOf('\n');
const id = sec.slice(3, nl < 0 ? undefined : nl).trim().replace(/^\[?v?/i, '').split(/[\]\s·]/)[0]!.toLowerCase();
if (id === want) return nl < 0 ? undefined : sec.slice(nl + 1).trim() || undefined;
}
return undefined;
}
/** Cuts a text to `limit` characters at a whole line. */
export function cutNotes(text: string, limit: number): string {
let out = '';
for (const l of text.split('\n')) { const next = out ? `${out}\n${l}` : l; if (len(next) > limit) break; out = next; }
return out.trim() || [...text].slice(0, limit).join('');
}
export interface NotesSource { kind: 'file' | 'release-doc' | 'changelog'; path: string; text: string }
/** The player-facing source of the notes, first match wins: `whatsNew.source` (a file; a CHANGELOG-like file gives the
* version's section), docs/RELEASE-<version>.md, CHANGELOG.md's section for the version (or Unreleased). undefined:
* fall back to the git log. `read` returns undefined for a missing file. */
export function findNotesSource(o: { version?: string; source?: string; read: (rel: string) => string | undefined }): NotesSource | undefined {
const v = o.version?.replace(/^v/i, '');
if (o.source) {
const md = o.read(o.source);
if (md === undefined) throw new Error(`whatsNew.source: ${o.source} does not exist`);
const sec = /^## /m.test(md) ? changelogSection(md, v) : undefined;
const text = notesFromMarkdown(sec ?? md);
if (text) return { kind: 'file', path: o.source, text };
}
if (v) for (const rel of [`docs/RELEASE-${v}.md`, `docs/RELEASE-v${v}.md`, `RELEASE-${v}.md`]) {
const md = o.read(rel);
if (md !== undefined && notesFromMarkdown(md)) return { kind: 'release-doc', path: rel, text: notesFromMarkdown(md) };
}
const cl = o.read('CHANGELOG.md');
const sec = cl === undefined ? undefined : changelogSection(cl, v) ?? (v ? changelogSection(cl) : undefined);
if (sec && notesFromMarkdown(sec)) return { kind: 'changelog', path: 'CHANGELOG.md', text: notesFromMarkdown(sec) };
return undefined;
}
const fence = (t: string) => '```\n' + t + '\n```';
/** Sets the "What's new" block of every `<!-- play: ... -->` section that contains `lang` (or of all, lang = '*'). */
@ -134,12 +190,12 @@ export function resolveLanguages(langs: Language[], wanted: string[]): Language[
return out;
}
export interface LangCheck { lang: string; chars: number; play?: { limit: number; ok: boolean }; apple?: { limit: number; ok: boolean }; problems: string[] }
export interface LangCheck { lang: string; chars: number; play?: { limit: number; ok: boolean }; apple?: { limit: number; ok: boolean }; problems: string[]; warnings: string[] }
/** Limits and wording of one language's notes, for the stores that language is listed on. Pure. */
export function checkLanguageNotes(l: Language, f: NotesFile, sourceText?: string): LangCheck {
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: [] };
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`); }
if (!f.text) c.problems.push('empty');

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;

View file

@ -5,12 +5,30 @@ description: Write user-facing release notes ("What's new") for a mobile app or
# What's new (release notes)
CLI: `storebound whats-new [--since v1.2.0] [--dry]`. It reads `git log <tag>..HEAD`, drops housekeeping commits
CLI: `storebound whats-new [--since v1.2.0] [--release 1.2.0] [--dry]`. It takes a player-facing source when one exists (below); otherwise it reads `git log <tag>..HEAD`, drops housekeeping commits
(chore, ci, docs, refactor, test, merges, version bumps), turns `feat:` / `fix:` / `perf:` subjects into bullets and
writes a draft into the default language of `store/listing.md` (Play "What's new") and `store/appstore.md`
(`whatsNew`). Nothing is sent to a store. `--all` writes the draft into every language as a starting point for
translation; `storebound check` warns while a translation is still identical to the source.
## Where the text comes from
Write from what players feel, never from commit messages. Commit subjects are written for developers ("Umami
telemetry", "refactor save endpoint", SDK and tool names); players care about what they see and feel ("Levels load
faster", "Your progress is saved more reliably"). So the draft comes from a player-facing source first, the first
that exists:
1. `whatsNew.source` in the config (a notes file; a CHANGELOG-like file gives the version's `## <version>` section),
2. `docs/RELEASE-<version>.md`,
3. the `CHANGELOG.md` section of the version, else its `## Unreleased` section.
The version is `--release 1.2.0`, else `package.json`. Only without any of these (or with `--from-git`) the draft is
built from `git log`; treat that draft as raw material and rewrite every line. If a project keeps a developer
changelog, add a short player-facing `docs/RELEASE-<version>.md` instead of reusing commit-speak.
The draft and `whats-new check` warn about internal jargon (never blocking): SDK / vendor names (Umami, Sentry,
Firebase, Capacitor, Tauri, Jenkins, Forgejo, ...), developer words (telemetry, refactor, endpoint, payload, API,
CI, PR, OTA, fingerprint, bump, deps, ...), version-control words (commit, merge, revert), file paths, issue ids and
code identifiers (`saveSlot`, `max_lives`). Each warning names the word and a plain alternative. Rewrite the line;
when a flagged word is a real player-facing term (a character called "Sentry"), add it to `whatsNew.allowWords`.
## Write it for your users
1. Run `storebound whats-new --dry`, read the draft with the owner.
2. Rewrite:

View file

@ -206,7 +206,7 @@ describe('whats-new', () => {
expect([...formatNotes(many, { limit: 500 })].length).toBeLessThanOrEqual(500);
expect(formatNotes([], { limit: 500 })).toBe('Bug fixes and performance improvements.');
expect(checkNotes('x'.repeat(501), 'play')[0]).toContain('501 > 500');
expect(checkNotes('bump version', 'apple')[0]).toContain('commit log');
expect(checkNotes('bump version', 'apple')[0]).toBe('internal jargon (developer word): "bump"; instead: "update"');
});
test('writes into both listing files, keeping them parseable', () => {
const play = readFileSync(join(T, 'listing.md'), 'utf8');

View file

@ -0,0 +1,75 @@
// Release notes in player language: a player-facing source beats the git log, and internal jargon is flagged.
import { afterEach, describe, expect, test } from 'bun:test';
import { jargonFindings, jargonMessage } from '../cli/lib/jargon';
import { changelogSection, checkLanguageNotes, cutNotes, findNotesSource, notesFromMarkdown } from '../cli/lib/whats-new';
import { run as whatsNew } from '../cli/whats-new';
import { BASE, capture, jsonOut, project, restore } from './helpers/store-mock';
afterEach(restore);
const words = (t: string, allow?: string[]) => jargonFindings(t, allow).map(f => `${f.kind}:${f.word}`);
describe('internal jargon', () => {
test('vendors, dev and version-control words, paths, issue ids and code identifiers', () => {
expect(words('Added Umami telemetry and a Keeper OS fix')).toEqual(['vendor:Umami', 'dev:telemetry', 'vendor:Keeper OS']);
expect(words('Sentry, Firebase, Capacitor, Tauri, Jenkins and Forgejo')).toEqual(['vendor:Sentry', 'vendor:Firebase', 'vendor:Capacitor', 'vendor:Tauri', 'vendor:Jenkins', 'vendor:Forgejo']);
expect(words('refactor the endpoint payload via the API in CI, PR merged, commit bump of deps, OTA fingerprint'))
.toEqual(['dev:refactor', 'dev:endpoint', 'dev:payload', 'dev:API', 'dev:CI', 'dev:PR', 'vcs:merged', 'vcs:commit', 'dev:bump', 'dev:deps', 'dev:OTA', 'dev:fingerprint']);
expect(words('Fixed src/game/save.ts and Player.gd')).toEqual(['path:src/game/save.ts', 'path:Player.gd']);
expect(words('Fixes #123 and GAME-42')).toEqual(['issue:#123', 'issue:GAME-42']);
expect(words('saveSlot now works, as does max_lives')).toEqual(['identifier:saveSlot', 'identifier:max_lives']);
});
test('player language passes: product names, links, dates, plain words', () => {
expect(words('New: 20 desert levels on iPhone and iPad! Faster loading. Help: https://example.com/help/api_v2 on 12/10.')).toEqual([]);
expect(words('Capital letters, a pricey item, a picnic and the kick-off')).toEqual([]); // no "api" / "ci" inside words
});
test('allowWords keeps a word, any case; messages name the word and a plain alternative', () => {
expect(words('New Firebase level and saveSlot', ['firebase', 'SAVESLOT'])).toEqual([]);
expect(jargonMessage(jargonFindings('endpoint')[0]!)).toBe('internal jargon (developer word): "endpoint"; instead: "server"');
expect(jargonMessage(jargonFindings('Umami')[0]!)).toBe('internal jargon (SDK / vendor name): "Umami"; instead: drop the name and say what changed for the player');
expect(jargonMessage(jargonFindings('merge')[0]!)).toBe('internal jargon (reads like a commit log): "merge"; drop it');
});
test('language check: jargon is a warning, never a blocking problem', () => {
const c = checkLanguageNotes({ id: 'en', apple: 'en-US' }, { lang: 'en', source: 'en', status: 'source', text: 'Telemetry via Umami.' });
expect(c.problems).toEqual([]);
expect(c.warnings).toHaveLength(2);
expect(checkLanguageNotes({ id: 'en', apple: 'en-US' }, { lang: 'en', source: 'en', status: 'source', text: 'Telemetry via Umami.' }, undefined, ['telemetry', 'umami']).warnings).toEqual([]);
});
});
describe('player-facing sources', () => {
const CL = '# Changelog\n\n## Unreleased\n\n- **Night mode** for the map.\n\n## [1.2.0] - 2026-10-01\n\nBig update.\n\n- New `desert` world, see [the trailer](https://example.com).\n\n## 1.1.0\n\n- Old.\n';
test('changelog sections, markdown to text, cuts', () => {
expect(changelogSection(CL, '1.2.0')).toContain('New `desert` world');
expect(changelogSection(CL, 'v1.1.0')).toBe('- Old.');
expect(changelogSection(CL)).toBe('- **Night mode** for the map.');
expect(changelogSection(CL, '9.9.9')).toBeUndefined();
expect(notesFromMarkdown(changelogSection(CL, '1.2.0')!)).toBe('Big update.\n\n• New desert world, see the trailer.');
expect(cutNotes('aaa\nbbb\nccc', 7)).toBe('aaa\nbbb');
});
test('order: whatsNew.source, docs/RELEASE-<version>.md, CHANGELOG (version, then Unreleased), else none', () => {
const files: Record<string, string> = { 'CHANGELOG.md': CL, 'docs/RELEASE-1.2.0.md': '# 1.2.0\n\n- Desert world!\n', 'notes.md': 'Hand written.' };
const read = (rel: string) => files[rel];
expect(findNotesSource({ version: '1.2.0', source: 'notes.md', read })).toEqual({ kind: 'file', path: 'notes.md', text: 'Hand written.' });
expect(findNotesSource({ version: '1.2.0', read })).toEqual({ kind: 'release-doc', path: 'docs/RELEASE-1.2.0.md', text: '• Desert world!' });
expect(findNotesSource({ version: '1.1.0', read })?.text).toBe('• Old.');
expect(findNotesSource({ version: '2.0.0', read })?.text).toBe('• Night mode for the map.');
expect(findNotesSource({ read: () => undefined })).toBeUndefined();
expect(() => findNotesSource({ source: 'missing.md', read })).toThrow('whatsNew.source: missing.md does not exist');
});
test('command: the release doc wins over commit messages; --from-git and jargon warnings', async () => {
const p = project({ ...BASE, whatsNew: { allowWords: ['sentry'] } }, { 'package.json': '{"version":"1.2.0"}', 'docs/RELEASE-1.2.0.md': '# 1.2.0\n\n- 20 desert levels.\n- Sentry the guard dog joins you.\n' });
const git = (...a: string[]) => Bun.spawnSync(['git', ...a], { cwd: p.root, env: { ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@example.com', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@example.com' } });
git('init', '-q'); git('add', '.'); git('commit', '-qm', 'chore: init'); git('tag', 'v1.1.0'); git('commit', '-q', '--allow-empty', '-m', 'feat: Umami telemetry endpoint');
let r = await capture(() => whatsNew(p.ctx(['--dry', '--json'])));
let j = jsonOut(r.out);
expect(j.source).toEqual({ kind: 'release-doc', path: 'docs/RELEASE-1.2.0.md' });
expect(j.play).toBe('• 20 desert levels.\n• Sentry the guard dog joins you.');
expect(r.err).not.toContain('jargon'); // "Sentry" is allowed by whatsNew.allowWords
r = await capture(() => whatsNew(p.ctx(['--dry', '--json', '--from-git'])));
j = jsonOut(r.out);
expect(j.source).toBeNull();
expect(j.play).toContain('Umami telemetry endpoint');
expect(r.err).toContain('internal jargon (SDK / vendor name): "Umami"');
expect(r.err).toContain('"endpoint"; instead: "server"');
});
});