// 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'; 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(); 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'): 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 ...)'); return e; } const fence = (t: string) => '```\n' + t + '\n```'; /** Sets the "What's new" block of every `` 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 = /^$/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 = /^\n?/; /** One language file: a header line the tools read, then the text. Pure. */ export function notesFileText(f: NotesFile): string { return `\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[] } /** 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 { const chars = len(f.text); const c: LangCheck = { lang: l.id, chars, problems: [] }; 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; }