Files
paseo/scripts/changelog-utils.mjs
T
Mohamed Boudra c91948048b Lead each release with highlights in What's new, the website changelog and release notes (#9281)
Every release entry in CHANGELOG.md now leads with highlights: an unheaded
list above the first `###`. The app's What's new sheet, the website
changelog and the GitHub release body show the highlights and fold the
sections, the full record, behind one control. An entry without highlights
keeps its sections in view. docs/release.md carries the writing rules.

Installed apps fetch CHANGELOG.md from main and parse it with the parser
they shipped with, so the two heading rules are a compatibility surface:
`##` starts a release, `###` starts a section, and headings inside fenced
code are text. The app, website and release-script parsers now split an
entry the same way and each runs the shared cases in
scripts/test-support/changelog-structure.json. The release-script tests,
which no CI job ran before, are part of "Validate CI contracts".

Adds the 0.16.0-beta.1 entry (en and zh-CN) ahead of the release.
2026-11-10 17:44:57 +07:00

211 lines
6.5 KiB
JavaScript

// One definition of how CHANGELOG.md is structured. Consumed by the GitHub release
// notes sync and by the F-Droid changelog sync, which need the same section bodies
// rendered two different ways.
//
// The app's What's new sheet and the website parse the same file with their own
// code. `test-support/changelog-structure.json` holds the cases all three must
// split the same way; a change to the structure rules starts there.
const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/;
const sectionHeadingPattern = /^ {0,3}###(?:[ \t]+(.*?))?[ \t]*$/;
const fencePattern = /^\s*(`{3,}|~{3,})/;
const bulletPattern = /^\s*[-*]\s+(.+?)\s*$/;
const blockquotePattern = /^\s*>\s?(.*)$/;
// Returns entries newest-first, matching the order they appear in the file.
export function parseChangelogEntries(changelogText) {
const lines = changelogText.split(/\r?\n/);
const fenced = markFencedLines(lines);
const headings = [];
for (let index = 0; index < lines.length; index += 1) {
const match = fenced[index] ? null : lines[index].match(headingPattern);
if (!match) {
continue;
}
headings.push({
version: match[1],
date: match[2],
headingLineIndex: index,
});
}
if (headings.length === 0) {
throw new Error(
"No release headings found in CHANGELOG.md. Expected headings like `## 0.1.14 - 2026-02-19`.",
);
}
return headings.map((heading, index) => {
const nextHeading = headings[index + 1];
const bodyStart = heading.headingLineIndex + 1;
const bodyEnd = nextHeading ? nextHeading.headingLineIndex : lines.length;
const bodyLines = lines.slice(bodyStart, bodyEnd);
while (bodyLines.length > 0 && bodyLines[0].trim() === "") {
bodyLines.shift();
}
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") {
bodyLines.pop();
}
return Object.assign({}, heading, { bodyLines });
});
}
// Splits an entry body at its first `### ` section. What comes before it is the
// release's highlights; the sections after it are the full record. Either part is
// empty when the entry has none.
export function splitChangelogHighlights(bodyLines) {
const fenced = markFencedLines(bodyLines);
const firstSection = bodyLines.findIndex(
(line, index) => !fenced[index] && sectionHeadingPattern.test(line),
);
const split = firstSection === -1 ? bodyLines.length : firstSection;
return {
highlightLines: trimBlankLines(bodyLines.slice(0, split)),
sectionLines: trimBlankLines(bodyLines.slice(split)),
};
}
// Flags the lines that belong to a fenced code block, edges included. A `##` or
// `###` in there is an example, so it starts neither a release nor a section.
function markFencedLines(lines) {
let fence = null;
return lines.map((line) => {
const edge = line.match(fencePattern)?.[1];
if (!edge) {
return fence !== null;
}
if (fence === null) {
fence = edge;
} else if (edge[0] === fence[0] && edge.length >= fence.length) {
fence = null;
}
return true;
});
}
function trimBlankLines(lines) {
let start = 0;
let end = lines.length;
while (start < end && lines[start].trim() === "") start += 1;
while (end > start && lines[end - 1].trim() === "") end -= 1;
return lines.slice(start, end);
}
// Turns `[label](url)` into `label` and drops the trailing PR/attribution
// parenthetical, which is pure noise in a store changelog with a 500 char budget.
export function stripChangelogMarkup(text) {
return text
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\s*\((?:[^()]*(?:#\d+|by @)[^()]*)\)\s*$/, "")
.replace(/`([^`]*)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/_([^_]+)_/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
// Collapses one blockquote into plain text, keeping paragraph breaks as newlines.
// `> **Important update notice**` + `>` + `> body` becomes a heading line and a
// body line, which is how it should read in a plain-text store changelog.
function formatQuotedNote(quotedLines) {
const paragraphs = [];
let current = [];
for (const line of quotedLines) {
if (line.trim() === "") {
if (current.length > 0) {
paragraphs.push(current.join(" "));
current = [];
}
continue;
}
current.push(line);
}
if (current.length > 0) {
paragraphs.push(current.join(" "));
}
return paragraphs
.map((paragraph) => stripChangelogMarkup(paragraph))
.filter((paragraph) => paragraph.length > 0)
.join("\n");
}
// Splits an entry body into blockquoted notes and `### Heading` sections of
// plain-text bullets. Bullets before any section heading land in a leading
// untitled section. Standalone prose is kept as a note: releases occasionally
// use it for a short, important notice without a blockquote.
//
// Notes are parsed separately rather than dropped because releases use
// blockquotes for action-required notices — 0.1.109's "you must reinstall
// manually" warning is the canonical example, and it matters more than any
// bullet in that entry. Anything that is neither a note, a heading, nor a bullet
// is still ignored.
export function parseChangelogBody(bodyLines) {
const notes = [];
const sections = [];
let current = { bullets: [], title: null };
let quotedLines = null;
const flushQuote = () => {
if (quotedLines === null) {
return;
}
const note = formatQuotedNote(quotedLines);
if (note.length > 0) {
notes.push(note);
}
quotedLines = null;
};
for (const line of bodyLines) {
const quoteMatch = line.match(blockquotePattern);
if (quoteMatch) {
if (quotedLines === null) {
quotedLines = [];
}
quotedLines.push(quoteMatch[1]);
continue;
}
// A blank line inside a `>` run would already have matched above, so any
// non-quote line ends the current note.
flushQuote();
const sectionMatch = line.match(sectionHeadingPattern);
if (sectionMatch) {
if (current.bullets.length > 0) {
sections.push(current);
}
current = { bullets: [], title: stripChangelogMarkup(sectionMatch[1] ?? "") };
continue;
}
const bulletMatch = line.match(bulletPattern);
if (bulletMatch) {
const bullet = stripChangelogMarkup(bulletMatch[1]);
if (bullet.length > 0) {
current.bullets.push(bullet);
}
continue;
}
const prose = stripChangelogMarkup(line);
if (prose.length > 0) {
notes.push(prose);
}
}
flushQuote();
if (current.bullets.length > 0) {
sections.push(current);
}
return { notes, sections };
}