mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
fix(changelog): make every surface split a release the same way
The app, the website and the release scripts each parse CHANGELOG.md with their own code, and only the app skipped fenced code blocks and read CRLF. A `###` or a release heading inside a fenced example in a highlight would have ended the highlights early on the website and in the GitHub release body, or started a phantom release, while the app showed it correctly. The website and script parsers now follow the app's rules, and one case file, scripts/test-support/changelog-structure.json, is run by all three suites so a rule cannot change in one place only. The release-notes script tests were not part of any CI job; they now run with the other script contract tests.
This commit is contained in:
@@ -36,6 +36,25 @@ function isNotBlank(line: string): boolean {
|
||||
return line.trim().length > 0;
|
||||
}
|
||||
|
||||
function readRepositoryFile(path: string): string {
|
||||
return readFileSync(fileURLToPath(new URL(`../../../../../${path}`, import.meta.url)), "utf8");
|
||||
}
|
||||
|
||||
/** The cases the app, the website and the release scripts must all split the same way. */
|
||||
interface ChangelogStructureCases {
|
||||
document: string[];
|
||||
releases: {
|
||||
version: string;
|
||||
date: string;
|
||||
highlights: string[];
|
||||
sectionTitles: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
const structureCases: ChangelogStructureCases = JSON.parse(
|
||||
readRepositoryFile("scripts/test-support/changelog-structure.json"),
|
||||
);
|
||||
|
||||
describe("parseChangelog", () => {
|
||||
it("reads the shape the changelog is authored in today", () => {
|
||||
const releases = parseChangelog(
|
||||
@@ -300,13 +319,39 @@ describe("formatChangelogDate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the structure every changelog surface shares", () => {
|
||||
for (const lineEnding of ["\n", "\r\n"]) {
|
||||
it(`splits the shared cases with ${JSON.stringify(lineEnding)} line endings`, () => {
|
||||
const releases = parseChangelog(structureCases.document.join(lineEnding));
|
||||
|
||||
expect(
|
||||
releases.map((release) => ({
|
||||
version: release.version,
|
||||
date: release.date,
|
||||
highlights: release.highlights.split("\n").filter(isNotBlank),
|
||||
sectionTitles: release.sections.map(sectionTitle),
|
||||
})),
|
||||
).toEqual(
|
||||
structureCases.releases.map((release) => ({
|
||||
version: release.version,
|
||||
date: release.date,
|
||||
highlights: release.highlights.filter(isNotBlank),
|
||||
sectionTitles: release.sectionTitles,
|
||||
})),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the repository's own CHANGELOG.md", () => {
|
||||
const markdown = readFileSync(
|
||||
fileURLToPath(new URL("../../../../../CHANGELOG.md", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
const markdown = readRepositoryFile("CHANGELOG.md");
|
||||
const releases = parseChangelog(markdown);
|
||||
|
||||
it("finds a release for every release heading line", () => {
|
||||
const headingLines = markdown.split("\n").filter((line) => line.startsWith("## "));
|
||||
expect(releases).toHaveLength(headingLines.length);
|
||||
});
|
||||
|
||||
it("parses every release heading", () => {
|
||||
expect(releases.length).toBeGreaterThan(50);
|
||||
for (const release of releases) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getChangelogReleaseGroups, localizeReleases, parseChangelog } from "~/changelog";
|
||||
|
||||
@@ -52,6 +54,45 @@ describe("parseChangelog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The cases the app, the website and the release scripts must all split the same way. */
|
||||
interface ChangelogStructureCases {
|
||||
document: string[];
|
||||
releases: { version: string; date: string; highlights: string[]; sections: string[] }[];
|
||||
}
|
||||
|
||||
const structureCases: ChangelogStructureCases = JSON.parse(
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL("../../../scripts/test-support/changelog-structure.json", import.meta.url),
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
|
||||
describe("the structure every changelog surface shares", () => {
|
||||
for (const lineEnding of ["\n", "\r\n"]) {
|
||||
it(`splits the shared cases with ${JSON.stringify(lineEnding)} line endings`, () => {
|
||||
const releases = parseChangelog(structureCases.document.join(lineEnding), "en");
|
||||
|
||||
expect(
|
||||
releases.map((release) => ({
|
||||
version: release.version,
|
||||
date: release.date,
|
||||
highlights: release.highlights,
|
||||
sections: release.sections,
|
||||
})),
|
||||
).toEqual(
|
||||
structureCases.releases.map((release) => ({
|
||||
version: release.version,
|
||||
date: release.date,
|
||||
highlights: release.highlights.join("\n"),
|
||||
sections: release.sections.join("\n"),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("localizeReleases", () => {
|
||||
it("uses a translation whose version and date match the English entry", () => {
|
||||
const chinese = parseChangelog("## 0.1.0 - 2026-05-01\n\n- 旧\n", "zh-CN");
|
||||
|
||||
@@ -18,8 +18,12 @@ export interface ChangelogReleaseGroup {
|
||||
releases: ChangelogRelease[];
|
||||
}
|
||||
|
||||
// The app's What's new sheet and the release scripts parse the same file with their own code.
|
||||
// `scripts/test-support/changelog-structure.json` holds the cases all three must split the same
|
||||
// way; a change to the structure rules starts there.
|
||||
const releaseHeadingPattern = /^## (.+?) - (\d{4}-\d{2}-\d{2})$/;
|
||||
const sectionHeadingPattern = /^### /;
|
||||
const sectionHeadingPattern = /^ {0,3}###(?:[ \t]|$)/;
|
||||
const fencePattern = /^\s*(`{3,}|~{3,})/;
|
||||
|
||||
export function releaseAnchor(version: string): string {
|
||||
return `release-${version}`;
|
||||
@@ -40,9 +44,20 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
|
||||
const releases: ReleaseDraft[] = [];
|
||||
const versions = new Set<string>();
|
||||
let currentRelease: ReleaseDraft | null = null;
|
||||
// A `##` or `###` inside a fenced code block is an example, so it starts nothing.
|
||||
let fence: string | null = null;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const heading = line.match(releaseHeadingPattern);
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
const fenceEdge = line.match(fencePattern)?.[1];
|
||||
if (fenceEdge) {
|
||||
if (!fence) {
|
||||
fence = fenceEdge;
|
||||
} else if (fenceEdge[0] === fence[0] && fenceEdge.length >= fence.length) {
|
||||
fence = null;
|
||||
}
|
||||
}
|
||||
|
||||
const heading = fence ? null : line.match(releaseHeadingPattern);
|
||||
if (heading) {
|
||||
const version = heading[1];
|
||||
if (versions.has(version)) {
|
||||
@@ -56,7 +71,8 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
|
||||
}
|
||||
|
||||
if (!currentRelease) continue;
|
||||
if (currentRelease.sections.length > 0 || sectionHeadingPattern.test(line)) {
|
||||
const startsSection = !fence && sectionHeadingPattern.test(line);
|
||||
if (currentRelease.sections.length > 0 || startsSection) {
|
||||
currentRelease.sections.push(line);
|
||||
} else {
|
||||
currentRelease.highlights.push(line);
|
||||
@@ -67,12 +83,20 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
|
||||
return releases.map((release) => ({
|
||||
version: release.version,
|
||||
date: release.date,
|
||||
highlights: release.highlights.join("\n").trim(),
|
||||
sections: release.sections.join("\n").trim(),
|
||||
highlights: joinWithoutBlankEdges(release.highlights),
|
||||
sections: joinWithoutBlankEdges(release.sections),
|
||||
locale,
|
||||
}));
|
||||
}
|
||||
|
||||
function joinWithoutBlankEdges(lines: string[]): string {
|
||||
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).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The English releases, each replaced by its translation when one exists. A translation counts
|
||||
* only when it carries the same version and date as the English entry, so a stale translation never
|
||||
|
||||
Reference in New Issue
Block a user