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
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue