224 lines
12 KiB
TypeScript
224 lines
12 KiB
TypeScript
// User-facing release notes from commit subjects, and writing them into the two listing files. Pure.
|
|
// The draft is a starting point for a human: commit messages are written for developers, notes are for users.
|
|
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. */
|
|
export const HOOK_CHARS = 170;
|
|
|
|
const SKIP = /^(chore|ci|build|docs?|test|tests|refactor|style|revert|wip|merge|bump|release|lint|deps?)(\(.+?\))?!?:|^Merge |^v?\d+\.\d+\.\d+$|^Release\b/i;
|
|
const KIND: [RegExp, 'new' | 'fix' | 'better'][] = [
|
|
[/^feat(\(.+?\))?!?:\s*/i, 'new'],
|
|
[/^(fix|bugfix|hotfix)(\(.+?\))?!?:\s*/i, 'fix'],
|
|
[/^(perf|improve|ux|ui|balance|content)(\(.+?\))?!?:\s*/i, 'better'],
|
|
];
|
|
|
|
export interface NoteItem { kind: 'new' | 'fix' | 'better'; text: string }
|
|
|
|
/** Commit subjects -> note items: drops housekeeping, strips conventional prefixes and issue refs, dedupes. */
|
|
export function noteItems(subjects: string[]): NoteItem[] {
|
|
const out: NoteItem[] = [];
|
|
const seen = new Set<string>();
|
|
for (const raw of subjects) {
|
|
const s = raw.trim();
|
|
if (!s || SKIP.test(s) || /\[(skip notes|internal)\]/i.test(s)) continue;
|
|
let kind: NoteItem['kind'] = 'better';
|
|
let text = s;
|
|
for (const [re, k] of KIND) if (re.test(text)) { kind = k; text = text.replace(re, ''); break; }
|
|
text = text.replace(/\s*\((#|!)\d+\)\s*$/, '').replace(/\s*(closes|fixes|refs?)\s+#\d+.*$/i, '').trim();
|
|
if (!text) continue;
|
|
text = kind === 'fix' && !/^fix(ed)?\b/i.test(text) ? `Fixed: ${text[0]!.toLocaleLowerCase() + text.slice(1)}` : text[0]!.toLocaleUpperCase() + text.slice(1);
|
|
if (!/[.!?]$/.test(text)) text += '.';
|
|
const key = text.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push({ kind, text });
|
|
}
|
|
const rank = { new: 0, better: 1, fix: 2 } as const;
|
|
return out.sort((a, b) => rank[a.kind] - rank[b.kind]);
|
|
}
|
|
|
|
/** Bullet list under a one-line hook, cut to `limit` characters at a whole line. */
|
|
export function formatNotes(items: NoteItem[], o: { hook?: string; limit: number }): string {
|
|
const hook = o.hook ?? (items.some(i => i.kind === 'new') ? 'New in this update:' : 'Fixes and polish in this update:');
|
|
if (!items.length) return 'Bug fixes and performance improvements.'.slice(0, o.limit);
|
|
const lines = [hook, ...items.map(i => `• ${i.text}`)];
|
|
let text = '';
|
|
for (const l of lines) {
|
|
const next = text ? `${text}\n${l}` : l;
|
|
if (len(next) > o.limit) break;
|
|
text = next;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/** Problems with a finished note text for one store. */
|
|
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}`);
|
|
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 = '*'). */
|
|
export function setPlayWhatsNew(md: string, lang: string, text: string): { md: string; changed: string[] } {
|
|
const parts = md.split(/^(?=## )/m);
|
|
const changed: string[] = [];
|
|
const out = parts.map(sec => {
|
|
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(sec);
|
|
if (!m) return sec;
|
|
const codes = m[1]!.split(',').map(s => s.trim());
|
|
if (lang !== '*' && !codes.includes(lang)) return sec;
|
|
changed.push(...codes);
|
|
const re = /(^### [^\n]*(what'?s new|release notes)[^\n]*\n)```[^\n]*\n[\s\S]*?\n```/im;
|
|
if (re.test(sec)) return sec.replace(re, (_a, h: string) => h + fence(text));
|
|
return sec.replace(/\s*$/, '') + `\n\n### What's new (max 500)\n${fence(text)}\n\n`;
|
|
});
|
|
return { md: out.join(''), changed };
|
|
}
|
|
|
|
/** Sets `### whatsNew` of one App Store locale section (or of all, locale = '*'). */
|
|
export function setAppleWhatsNew(md: string, locale: string, text: string): { md: string; changed: string[] } {
|
|
const parts = md.split(/^(?=## )/m);
|
|
const changed: string[] = [];
|
|
const out = parts.map(sec => {
|
|
if (!sec.startsWith('## ')) return sec;
|
|
const loc = sec.slice(3).split(/[\s·]/)[0]!.trim();
|
|
if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(loc) || (locale !== '*' && loc !== locale)) return sec;
|
|
changed.push(loc);
|
|
const re = /(^### whatsNew[^\n]*\n)```[^\n]*\n[\s\S]*?\n```/m;
|
|
if (re.test(sec)) return sec.replace(re, (_a, h: string) => h + fence(text));
|
|
return sec.replace(/\s*$/, '') + `\n\n### whatsNew\n${fence(text)}\n\n`;
|
|
});
|
|
return { md: out.join(''), changed };
|
|
}
|
|
|
|
// ---- many languages: one file per language, translated by your agent (whats-new skill) or a provider hook ----
|
|
// Limits per store (what each store enforces for the release-notes field):
|
|
// Google Play "What's new" (edits.tracks releaseNotes / Play Console): 500 characters per language.
|
|
// App Store "What's New in This Version" (appStoreVersionLocalizations.whatsNew): 4000 characters.
|
|
|
|
export type NotesStatus = 'source' | 'translate' | 'translated';
|
|
export interface NotesFile { lang: string; source: string; status: NotesStatus; text: string }
|
|
|
|
const HEAD_RE = /^<!--\s*whats-new\s+lang=(\S+)\s+source=(\S+)\s+status=(source|translate|translated)\s*-->\n?/;
|
|
|
|
/** One language file: a header line the tools read, then the text. Pure. */
|
|
export function notesFileText(f: NotesFile): string {
|
|
return `<!-- whats-new lang=${f.lang} source=${f.source} status=${f.status} -->\n${f.text.trim()}\n`;
|
|
}
|
|
|
|
/** Parses a language file; a file without the header is taken as translated text. Pure. */
|
|
export function parseNotesFile(text: string, lang: string): NotesFile {
|
|
const m = HEAD_RE.exec(text);
|
|
if (!m) return { lang, source: '', status: 'translated', text: text.trim() };
|
|
return { lang: m[1]!, source: m[2]!, status: m[3] as NotesStatus, text: text.slice(m[0].length).trim() };
|
|
}
|
|
|
|
/** --languages values (config language ids, App Store locales or Play codes) -> language entries, in that order. */
|
|
export function resolveLanguages(langs: Language[], wanted: string[]): Language[] {
|
|
const out: Language[] = [];
|
|
for (const w of wanted) {
|
|
const l = langs.find(x => x.id === w) ?? langs.find(x => x.apple === w) ?? langs.find(x => playCodes(x).includes(w));
|
|
if (!l) throw new Error(`--languages: "${w}" is not a language of the config (ids: ${langs.map(x => x.id).join(', ')})`);
|
|
if (!out.includes(l)) out.push(l);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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, 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`); }
|
|
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');
|
|
const hook = f.text.split('\n')[0] ?? '';
|
|
if (len(hook) > HOOK_CHARS) c.problems.push(`first line is ${len(hook)} characters; about ${HOOK_CHARS} show before "more"`);
|
|
return c;
|
|
}
|
|
|
|
/** Writes one language's notes into the listing files (every Play code of the language, its App Store locale). 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);
|
|
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);
|
|
out.apple = r.md;
|
|
out.changed.push(...r.changed.map(x => `apple:${x}`));
|
|
}
|
|
out.changed = [...new Set(out.changed)];
|
|
return out;
|
|
}
|