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:
parent
e9507d8884
commit
bab3f63227
9 changed files with 287 additions and 21 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
79
cli/lib/jargon.ts
Normal 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'}`;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue