mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
feat(changelog): lead each release with highlights and fold the rest away
A release entry now opens with a short list of highlights above its first section. The What's new sheet, the website changelog and the GitHub release body show those and keep the sections, the full record, behind one control, so a 100-bullet release reads as ten lines. CHANGELOG.md stays plain Markdown because the app and the website render it without HTML, including apps already installed. Each surface folds the sections itself; only the release body gets a <details> block, written by Release Notes Sync. A release with nothing before its first section shows its sections as before. Adds the 0.16.0-beta.1 entry in the new format, with its Chinese translation, and the highlight writing rules in docs/release.md.
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { openWhatsNew, release, serveChangelog } from "../support/helpers/changelog";
|
||||
import {
|
||||
allChangesToggle,
|
||||
hideAllChanges,
|
||||
openWhatsNew,
|
||||
release,
|
||||
serveChangelog,
|
||||
showAllChanges,
|
||||
} from "../support/helpers/changelog";
|
||||
|
||||
const DISCORD_DESTINATION =
|
||||
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
|
||||
@@ -73,26 +80,30 @@ test("opens troubleshooting and support destinations", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("renders the changelog in the app and links the website", async ({ page }) => {
|
||||
// A callout, a section name the app has never seen, and a fenced sample whose
|
||||
// contents look like a release heading.
|
||||
// Highlights, a section name the app has never seen, a callout, and a fenced
|
||||
// sample whose contents look like a release heading.
|
||||
await serveChangelog(page, [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 9.1.0 - 2026-03-04",
|
||||
"",
|
||||
"Headline release note.",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> Read this before upgrading.",
|
||||
"- **Background tasks:** Paseo now shows the tasks an agent is running.",
|
||||
"",
|
||||
"### Sparkles",
|
||||
"",
|
||||
"- Added a brand new thing",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> Read this before upgrading.",
|
||||
"",
|
||||
"```md",
|
||||
"## 0.0.0 - 1999-01-01",
|
||||
"```",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a crash",
|
||||
"",
|
||||
"## 9.0.0 - 2026-02-01",
|
||||
"",
|
||||
"### Fixed",
|
||||
@@ -104,14 +115,32 @@ test("renders the changelog in the app and links the website", async ({ page })
|
||||
|
||||
const sheet = await openWhatsNew(page);
|
||||
const latest = release(sheet, "9.1.0");
|
||||
const older = release(sheet, "9.0.0");
|
||||
|
||||
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Headline release note.")).toBeVisible();
|
||||
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
|
||||
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
|
||||
await expect(release(sheet, "9.0.0")).toBeVisible();
|
||||
await expect(release(sheet, "0.0.0")).toHaveCount(0);
|
||||
await test.step("leads a release with its highlights and tucks the rest away", async () => {
|
||||
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Paseo now shows the tasks an agent is running.")).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toHaveCount(0);
|
||||
await expect(latest.getByText("Fixed a crash")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("shows every section on request and tucks them away again", async () => {
|
||||
await showAllChanges(latest);
|
||||
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
|
||||
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
|
||||
await expect(latest.getByText("Fixed a crash")).toBeVisible();
|
||||
await expect(release(sheet, "0.0.0")).toHaveCount(0);
|
||||
|
||||
await hideAllChanges(latest);
|
||||
await expect(latest.getByText("Added a brand new thing")).toHaveCount(0);
|
||||
await expect(latest.getByText("Paseo now shows the tasks an agent is running.")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("shows a release without highlights whole", async () => {
|
||||
await expect(older.getByText("Fixed an older thing")).toBeVisible();
|
||||
await expect(allChangesToggle(older)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await expectExternalPage(page, "changelog-open-website", CHANGELOG_DESTINATION);
|
||||
await closeSheet(page, "changelog-sheet");
|
||||
|
||||
@@ -27,3 +27,18 @@ export async function openWhatsNew(page: Page): Promise<Locator> {
|
||||
export function release(sheet: Locator, version: string): Locator {
|
||||
return sheet.getByTestId(`changelog-release-${version}`);
|
||||
}
|
||||
|
||||
/** The one control a release with highlights has for its sections, in either state. */
|
||||
export function allChangesToggle(entry: Locator): Locator {
|
||||
return entry.getByRole("button", { name: /^(Show|Hide) all changes$/ });
|
||||
}
|
||||
|
||||
export async function showAllChanges(entry: Locator): Promise<void> {
|
||||
await entry.getByRole("button", { name: "Show all changes" }).click();
|
||||
await expect(entry.getByRole("button", { name: "Hide all changes" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function hideAllChanges(entry: Locator): Promise<void> {
|
||||
await entry.getByRole("button", { name: "Hide all changes" }).click();
|
||||
await expect(entry.getByRole("button", { name: "Show all changes" })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ExternalLink, Gift } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -156,9 +156,19 @@ interface ReleaseViewProps {
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A release that has highlights shows only them until the reader asks for the
|
||||
* sections, which are the full record. A release without highlights has nothing
|
||||
* to stand in for its sections, so it shows them as it always did.
|
||||
*/
|
||||
const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const date = formatChangelogDate(release.date);
|
||||
const [showingAllChanges, setShowingAllChanges] = useState(false);
|
||||
const toggleAllChanges = useCallback(() => setShowingAllChanges((value) => !value), []);
|
||||
const allChangesState = useMemo(() => ({ expanded: showingAllChanges }), [showingAllChanges]);
|
||||
const hasHighlights = release.highlights.length > 0;
|
||||
const showsSections = !hasHighlights || showingAllChanges;
|
||||
|
||||
return (
|
||||
<View style={styles.release} testID={`changelog-release-${release.version}`}>
|
||||
@@ -168,12 +178,25 @@ const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseVie
|
||||
<View style={styles.headingSpacer} />
|
||||
{date ? <Text style={styles.date}>{date}</Text> : null}
|
||||
</View>
|
||||
{keyChangelogSections(release.sections).map(({ key, section }) => (
|
||||
<View key={key} style={styles.section}>
|
||||
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
|
||||
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
|
||||
</View>
|
||||
))}
|
||||
{hasHighlights ? <MarkdownRenderer text={release.highlights} compact /> : null}
|
||||
{hasHighlights && release.sections.length > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={styles.allChangesToggle}
|
||||
accessibilityState={allChangesState}
|
||||
onPress={toggleAllChanges}
|
||||
>
|
||||
{t(showingAllChanges ? "changelog.hideAllChanges" : "changelog.showAllChanges")}
|
||||
</Button>
|
||||
) : null}
|
||||
{showsSections
|
||||
? keyChangelogSections(release.sections).map(({ key, section }) => (
|
||||
<View key={key} style={styles.section}>
|
||||
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
|
||||
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
|
||||
</View>
|
||||
))
|
||||
: null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -184,10 +207,9 @@ function keyChangelogSections(
|
||||
): { key: string; section: ChangelogSection }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return sections.map((section) => {
|
||||
const title = section.title ?? "";
|
||||
const occurrence = seen.get(title) ?? 0;
|
||||
seen.set(title, occurrence + 1);
|
||||
return { key: `${title}:${occurrence}`, section };
|
||||
const occurrence = seen.get(section.title) ?? 0;
|
||||
seen.set(section.title, occurrence + 1);
|
||||
return { key: `${section.title}:${occurrence}`, section };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,6 +258,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
// Flush with the highlights above it, so the label's ink sits on their rail.
|
||||
allChangesToggle: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 0,
|
||||
borderWidth: 0,
|
||||
},
|
||||
showMore: {
|
||||
alignSelf: "center",
|
||||
},
|
||||
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
type ChangelogSection,
|
||||
} from "./parse-changelog";
|
||||
|
||||
function bodies(markdown: string, releaseIndex = 0): string[] {
|
||||
return parseChangelog(markdown)[releaseIndex].sections.map(sectionBody);
|
||||
function bodies(markdown: string): string[] {
|
||||
return parseChangelog(markdown)[0].sections.map(sectionBody);
|
||||
}
|
||||
|
||||
function sectionBody(section: ChangelogSection): string {
|
||||
return section.body;
|
||||
}
|
||||
|
||||
function sectionTitle(section: ChangelogSection): string | null {
|
||||
function sectionTitle(section: ChangelogSection): string {
|
||||
return section.title;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ function releaseDate(release: ChangelogRelease): string {
|
||||
}
|
||||
|
||||
function authoredLines(release: ChangelogRelease): string[] {
|
||||
return release.sections.flatMap(sectionLines);
|
||||
return [...release.highlights.split("\n"), ...release.sections.flatMap(sectionLines)];
|
||||
}
|
||||
|
||||
function sectionLines(section: ChangelogSection): string[] {
|
||||
return [...(section.title ? [`### ${section.title}`] : []), ...section.body.split("\n")];
|
||||
return [`### ${section.title}`, ...section.body.split("\n")];
|
||||
}
|
||||
|
||||
function isNotBlank(line: string): boolean {
|
||||
@@ -44,7 +44,8 @@ describe("parseChangelog", () => {
|
||||
"",
|
||||
"## 0.8.0-beta.1 - 2026-09-08",
|
||||
"",
|
||||
"This release extends what plugins can do.",
|
||||
"- **Plugin providers:** Plugins can add providers.",
|
||||
"- **Faster diffs:** Diffs load sooner.",
|
||||
"",
|
||||
"### Plugins",
|
||||
"",
|
||||
@@ -67,8 +68,9 @@ describe("parseChangelog", () => {
|
||||
{
|
||||
version: "0.8.0-beta.1",
|
||||
date: "2026-09-08",
|
||||
highlights:
|
||||
"- **Plugin providers:** Plugins can add providers.\n- **Faster diffs:** Diffs load sooner.",
|
||||
sections: [
|
||||
{ title: null, body: "This release extends what plugins can do." },
|
||||
{
|
||||
title: "Plugins",
|
||||
body: "- Added provider contributions ([#4314](https://example.com/4314))",
|
||||
@@ -79,6 +81,7 @@ describe("parseChangelog", () => {
|
||||
{
|
||||
version: "0.7.2",
|
||||
date: "2026-09-02",
|
||||
highlights: "",
|
||||
sections: [{ title: "Fixed", body: "- Fixed a thing" }],
|
||||
},
|
||||
]);
|
||||
@@ -89,7 +92,7 @@ describe("parseChangelog", () => {
|
||||
"# Changelog\n\nAll notable changes.\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
|
||||
);
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "- One" }]);
|
||||
expect(releases[0]).toMatchObject({ highlights: "- One", sections: [] });
|
||||
});
|
||||
|
||||
it("returns nothing for a document with no releases", () => {
|
||||
@@ -136,13 +139,13 @@ describe("parseChangelog", () => {
|
||||
it("does not treat a deeper heading as a release", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n#### Deep\n\n- One\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "#### Deep\n\n- One" }]);
|
||||
expect(releases[0].highlights).toBe("#### Deep\n\n- One");
|
||||
});
|
||||
|
||||
it("does not treat an unspaced hash run as a heading", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n##notaheading\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections[0].body).toBe("##notaheading");
|
||||
expect(releases[0].highlights).toBe("##notaheading");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,14 +165,34 @@ describe("parseChangelog", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops an empty lead section", () => {
|
||||
it("keeps a bare section heading as an untitled section", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n###\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([{ title: "", body: "- One" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlights", () => {
|
||||
it("has none when the release opens with a section", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([{ title: "Fixed", body: "- One" }]);
|
||||
expect(releases[0]).toMatchObject({
|
||||
highlights: "",
|
||||
sections: [{ title: "Fixed", body: "- One" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the summary paragraphs older entries opened with as highlights", () => {
|
||||
const releases = parseChangelog(
|
||||
"## 1.0.0 - 2026-01-01\n\nDaemon restarts keep terminals.\n\nMore here.\n\n### Added\n\n- One\n",
|
||||
);
|
||||
expect(releases[0]).toMatchObject({
|
||||
highlights: "Daemon restarts keep terminals.\n\nMore here.",
|
||||
sections: [{ title: "Added", body: "- One" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a release that is only prose", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\nJust a note.\n");
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "Just a note." }]);
|
||||
expect(releases[0]).toMatchObject({ highlights: "Just a note.", sections: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,7 +279,12 @@ describe("parseChangelog", () => {
|
||||
it("reads CRLF documents", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\r\n\r\n### Fixed\r\n\r\n- One\r\n");
|
||||
expect(releases).toEqual([
|
||||
{ version: "1.0.0", date: "2026-01-01", sections: [{ title: "Fixed", body: "- One" }] },
|
||||
{
|
||||
version: "1.0.0",
|
||||
date: "2026-01-01",
|
||||
highlights: "",
|
||||
sections: [{ title: "Fixed", body: "- One" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -284,7 +312,7 @@ describe("the repository's own CHANGELOG.md", () => {
|
||||
for (const release of releases) {
|
||||
expect(release.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(release.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(release.sections.length).toBeGreaterThan(0);
|
||||
expect(release.highlights.length + release.sections.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
*
|
||||
* The document is authored in the repository, fetched at runtime, and rewritten
|
||||
* on every release, so this parser commits to exactly two facts: a `##` heading
|
||||
* starts a release and a `###` heading starts a section inside it. Everything
|
||||
* starts a release and a `###` heading starts a section inside it. Whatever sits
|
||||
* between the release heading and its first section is the release's
|
||||
* highlights: the list a release now leads with, or the summary paragraphs and
|
||||
* notices older entries opened with. Everything
|
||||
* else — prose, callouts, block quotes, fenced code, images, video and embed
|
||||
* HTML, tables, nested lists — is carried through verbatim and handed to the
|
||||
* Markdown renderer. A new block kind in the changelog therefore needs no
|
||||
@@ -17,8 +20,8 @@
|
||||
*/
|
||||
|
||||
export interface ChangelogSection {
|
||||
/** The `###` heading text, or null for content that precedes the first one. */
|
||||
title: string | null;
|
||||
/** The `###` heading text. Empty for a bare `###`. */
|
||||
title: string;
|
||||
/** Verbatim markdown under the heading. */
|
||||
body: string;
|
||||
}
|
||||
@@ -28,7 +31,9 @@ export interface ChangelogRelease {
|
||||
version: string;
|
||||
/** The heading tail after the version. Empty when the heading carried none. */
|
||||
date: string;
|
||||
/** Sections in document order; the untitled lead section comes first. */
|
||||
/** Verbatim markdown before the first section. Empty when the release opens with one. */
|
||||
highlights: string;
|
||||
/** The `###` sections in document order. */
|
||||
sections: ChangelogSection[];
|
||||
}
|
||||
|
||||
@@ -46,13 +51,14 @@ const LEADING_V = /^v(?=\d)/i;
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
interface SectionDraft {
|
||||
title: string | null;
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface ReleaseDraft {
|
||||
version: string;
|
||||
date: string;
|
||||
highlights: string[];
|
||||
sections: SectionDraft[];
|
||||
}
|
||||
|
||||
@@ -75,7 +81,8 @@ export function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
if (releaseHeading) {
|
||||
releases.push({
|
||||
...splitReleaseHeading(releaseHeading[1] ?? ""),
|
||||
sections: [{ title: null, lines: [] }],
|
||||
highlights: [],
|
||||
sections: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -83,7 +90,7 @@ export function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
const sectionHeading = line.match(SECTION_HEADING);
|
||||
if (sectionHeading && releases.length > 0) {
|
||||
releases[releases.length - 1].sections.push({
|
||||
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim() || null,
|
||||
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim(),
|
||||
lines: [],
|
||||
});
|
||||
continue;
|
||||
@@ -91,19 +98,22 @@ export function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
}
|
||||
|
||||
const release = releases.at(-1);
|
||||
if (release) release.sections[release.sections.length - 1].lines.push(line);
|
||||
if (release) (release.sections.at(-1)?.lines ?? release.highlights).push(line);
|
||||
}
|
||||
|
||||
return releases.map(materializeRelease);
|
||||
}
|
||||
|
||||
function materializeRelease(draft: ReleaseDraft): ChangelogRelease {
|
||||
const sections = draft.sections
|
||||
.map((section) => ({ title: section.title, body: trimBlankLines(section.lines) }))
|
||||
// A heading with an empty body still names something; an empty lead section
|
||||
// is just the gap between the release heading and the first section.
|
||||
.filter((section) => section.title !== null || section.body.length > 0);
|
||||
return { version: draft.version, date: draft.date, sections };
|
||||
return {
|
||||
version: draft.version,
|
||||
date: draft.date,
|
||||
highlights: trimBlankLines(draft.highlights),
|
||||
sections: draft.sections.map((section) => ({
|
||||
title: section.title,
|
||||
body: trimBlankLines(section.lines),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function trimBlankLines(lines: string[]): string {
|
||||
|
||||
@@ -1177,6 +1177,8 @@ export const ar: TranslationResources = {
|
||||
title: "ما الجديد",
|
||||
installed: "مثبّت",
|
||||
showMore: "عرض المزيد",
|
||||
showAllChanges: "عرض كل التغييرات",
|
||||
hideAllChanges: "إخفاء كل التغييرات",
|
||||
openWebsite: "سجل التغييرات الكامل",
|
||||
error: {
|
||||
title: "تعذّر تحميل سجل التغييرات",
|
||||
|
||||
@@ -1185,6 +1185,8 @@ export const en = {
|
||||
title: "What's new",
|
||||
installed: "Installed",
|
||||
showMore: "Show more",
|
||||
showAllChanges: "Show all changes",
|
||||
hideAllChanges: "Hide all changes",
|
||||
openWebsite: "Full changelog",
|
||||
error: {
|
||||
title: "Unable to load the changelog",
|
||||
|
||||
@@ -1213,6 +1213,8 @@ export const es: TranslationResources = {
|
||||
title: "Novedades",
|
||||
installed: "Instalada",
|
||||
showMore: "Mostrar más",
|
||||
showAllChanges: "Mostrar todos los cambios",
|
||||
hideAllChanges: "Ocultar todos los cambios",
|
||||
openWebsite: "Registro de cambios completo",
|
||||
error: {
|
||||
title: "No se pudo cargar el registro de cambios",
|
||||
|
||||
@@ -1206,6 +1206,8 @@ export const fr: TranslationResources = {
|
||||
title: "Nouveautés",
|
||||
installed: "Installée",
|
||||
showMore: "Afficher plus",
|
||||
showAllChanges: "Afficher toutes les modifications",
|
||||
hideAllChanges: "Masquer toutes les modifications",
|
||||
openWebsite: "Journal des modifications complet",
|
||||
error: {
|
||||
title: "Impossible de charger le journal des modifications",
|
||||
|
||||
@@ -1190,6 +1190,8 @@ export const ja: TranslationResources = {
|
||||
title: "新着情報",
|
||||
installed: "インストール済み",
|
||||
showMore: "もっと見る",
|
||||
showAllChanges: "すべての変更を表示",
|
||||
hideAllChanges: "すべての変更を非表示",
|
||||
openWebsite: "変更履歴をすべて表示",
|
||||
error: {
|
||||
title: "変更履歴を読み込めません",
|
||||
|
||||
@@ -1184,6 +1184,8 @@ export const ko: TranslationResources = {
|
||||
title: "새로운 소식",
|
||||
installed: "설치됨",
|
||||
showMore: "더 보기",
|
||||
showAllChanges: "모든 변경 사항 보기",
|
||||
hideAllChanges: "모든 변경 사항 숨기기",
|
||||
openWebsite: "전체 변경 내역",
|
||||
error: {
|
||||
title: "변경 내역을 불러오지 못했습니다",
|
||||
|
||||
@@ -1203,6 +1203,8 @@ export const ptBR: TranslationResources = {
|
||||
title: "Novidades",
|
||||
installed: "Instalada",
|
||||
showMore: "Mostrar mais",
|
||||
showAllChanges: "Mostrar todas as mudanças",
|
||||
hideAllChanges: "Ocultar todas as mudanças",
|
||||
openWebsite: "Changelog completo",
|
||||
error: {
|
||||
title: "Não foi possível carregar o changelog",
|
||||
|
||||
@@ -1194,6 +1194,8 @@ export const ru: TranslationResources = {
|
||||
title: "Что нового",
|
||||
installed: "Установлена",
|
||||
showMore: "Показать ещё",
|
||||
showAllChanges: "Показать все изменения",
|
||||
hideAllChanges: "Скрыть все изменения",
|
||||
openWebsite: "Полный список изменений",
|
||||
error: {
|
||||
title: "Не удалось загрузить список изменений",
|
||||
|
||||
@@ -1169,6 +1169,8 @@ export const zhCN: TranslationResources = {
|
||||
title: "新功能",
|
||||
installed: "已安装",
|
||||
showMore: "显示更多",
|
||||
showAllChanges: "显示全部变更",
|
||||
hideAllChanges: "收起全部变更",
|
||||
openWebsite: "完整更新日志",
|
||||
error: {
|
||||
title: "无法加载更新日志",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test, type Locator, type Page } from "./fixtures";
|
||||
|
||||
// The page renders the repository's CHANGELOG.md, so releases are found by shape, never by version:
|
||||
// a release with highlights has the toggle, a release without them does not.
|
||||
function releases(page: Page): Locator {
|
||||
return page.getByRole("region");
|
||||
}
|
||||
|
||||
function releaseWithHighlights(page: Page): Locator {
|
||||
return releases(page)
|
||||
.filter({ has: page.getByText("Show all changes", { exact: true }) })
|
||||
.first();
|
||||
}
|
||||
|
||||
function releaseWithoutHighlights(page: Page): Locator {
|
||||
return releases(page)
|
||||
.filter({ hasNot: page.getByText(/^(Show|Hide) all changes$/) })
|
||||
.first();
|
||||
}
|
||||
|
||||
function sectionHeadings(release: Locator): Locator {
|
||||
return release.getByRole("heading", { level: 4 });
|
||||
}
|
||||
|
||||
async function showAllChanges(release: Locator): Promise<void> {
|
||||
await release.getByText("Show all changes", { exact: true }).click();
|
||||
await expect(release.getByText("Hide all changes", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function hideAllChanges(release: Locator): Promise<void> {
|
||||
await release.getByText("Hide all changes", { exact: true }).click();
|
||||
await expect(release.getByText("Show all changes", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("shows each release's highlights and opens its sections on request", async ({ page }) => {
|
||||
await page.goto("/changelog");
|
||||
const highlighted = releaseWithHighlights(page);
|
||||
const plain = releaseWithoutHighlights(page);
|
||||
|
||||
await test.step("tucks away the sections of a release with highlights", async () => {
|
||||
await expect(highlighted.getByRole("listitem").first()).toBeVisible();
|
||||
await expect(sectionHeadings(highlighted)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("opens and closes them again", async () => {
|
||||
await showAllChanges(highlighted);
|
||||
await expect(sectionHeadings(highlighted).first()).toBeVisible();
|
||||
|
||||
await hideAllChanges(highlighted);
|
||||
await expect(sectionHeadings(highlighted)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("shows a release without highlights whole", async () => {
|
||||
await expect(sectionHeadings(plain).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("opens a release from a deep link with its highlights in view", async ({ page }) => {
|
||||
await page.goto("/changelog");
|
||||
const anchor = await releaseWithHighlights(page)
|
||||
.getByRole("link", { name: /^Link to Paseo / })
|
||||
.getAttribute("href");
|
||||
|
||||
await page.goto(`/changelog${anchor}`);
|
||||
|
||||
const linked = page.locator(`[id="${anchor!.slice(1)}"]`);
|
||||
await expect(linked).toBeInViewport();
|
||||
await expect(releaseWithHighlights(page).getByRole("listitem").first()).toBeVisible();
|
||||
});
|
||||
@@ -16,6 +16,42 @@ const ENGLISH = parseChangelog(
|
||||
"en",
|
||||
);
|
||||
|
||||
describe("parseChangelog", () => {
|
||||
it("splits a release into the highlights it leads with and its sections", () => {
|
||||
const [release] = parseChangelog(
|
||||
[
|
||||
"## 0.3.0 - 2026-05-03",
|
||||
"",
|
||||
"- **Faster diffs:** Diffs load sooner.",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"- Added a picker",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a crash",
|
||||
"",
|
||||
].join("\n"),
|
||||
"en",
|
||||
);
|
||||
|
||||
expect(release).toEqual({
|
||||
version: "0.3.0",
|
||||
date: "2026-05-03",
|
||||
highlights: "- **Faster diffs:** Diffs load sooner.",
|
||||
sections: "### Added\n\n- Added a picker\n\n### Fixed\n\n- Fixed a crash",
|
||||
locale: "en",
|
||||
});
|
||||
});
|
||||
|
||||
it("has no highlights when a release opens with a section", () => {
|
||||
const [release] = parseChangelog("## 0.3.0 - 2026-05-03\n\n### Fixed\n\n- One\n", "en");
|
||||
|
||||
expect(release).toMatchObject({ highlights: "", sections: "### Fixed\n\n- One" });
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -5,8 +5,11 @@ import { DEFAULT_LOCALE, type Locale, localizePath } from "~/i18n";
|
||||
export interface ChangelogRelease {
|
||||
version: string;
|
||||
date: string;
|
||||
markdown: string;
|
||||
/** The language of `markdown`; English when the release has no translation yet. */
|
||||
/** Markdown before the first `###` section; empty when the release opens with a section. */
|
||||
highlights: string;
|
||||
/** Markdown from the first `###` section to the end of the release; the full record. */
|
||||
sections: string;
|
||||
/** The language of the notes; English when the release has no translation yet. */
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
@@ -16,6 +19,7 @@ export interface ChangelogReleaseGroup {
|
||||
}
|
||||
|
||||
const releaseHeadingPattern = /^## (.+?) - (\d{4}-\d{2}-\d{2})$/;
|
||||
const sectionHeadingPattern = /^### /;
|
||||
|
||||
export function releaseAnchor(version: string): string {
|
||||
return `release-${version}`;
|
||||
@@ -25,10 +29,17 @@ function minorVersion(version: string): string {
|
||||
return version.split(".").slice(0, 2).join(".");
|
||||
}
|
||||
|
||||
interface ReleaseDraft {
|
||||
version: string;
|
||||
date: string;
|
||||
highlights: string[];
|
||||
sections: string[];
|
||||
}
|
||||
|
||||
export function parseChangelog(markdown: string, locale: Locale): ChangelogRelease[] {
|
||||
const releases: ChangelogRelease[] = [];
|
||||
const releases: ReleaseDraft[] = [];
|
||||
const versions = new Set<string>();
|
||||
let currentRelease: ChangelogRelease | null = null;
|
||||
let currentRelease: ReleaseDraft | null = null;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const heading = line.match(releaseHeadingPattern);
|
||||
@@ -40,20 +51,26 @@ export function parseChangelog(markdown: string, locale: Locale): ChangelogRelea
|
||||
versions.add(version);
|
||||
|
||||
if (currentRelease) releases.push(currentRelease);
|
||||
currentRelease = {
|
||||
version,
|
||||
date: heading[2],
|
||||
markdown: "",
|
||||
locale,
|
||||
};
|
||||
currentRelease = { version, date: heading[2], highlights: [], sections: [] };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentRelease) currentRelease.markdown += `${line}\n`;
|
||||
if (!currentRelease) continue;
|
||||
if (currentRelease.sections.length > 0 || sectionHeadingPattern.test(line)) {
|
||||
currentRelease.sections.push(line);
|
||||
} else {
|
||||
currentRelease.highlights.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRelease) releases.push(currentRelease);
|
||||
return releases;
|
||||
return releases.map((release) => ({
|
||||
version: release.version,
|
||||
date: release.date,
|
||||
highlights: release.highlights.join("\n").trim(),
|
||||
sections: release.sections.join("\n").trim(),
|
||||
locale,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -651,6 +651,8 @@
|
||||
"metaDescription": "Product updates, bug fixes, and improvements shipped in each Paseo release. Track new agent providers, mobile features, and daemon changes over time.",
|
||||
"title": "Changelog",
|
||||
"linkTo": "Link to Paseo {{version}}",
|
||||
"showAllChanges": "Show all changes",
|
||||
"hideAllChanges": "Hide all changes",
|
||||
"untranslatedRelease": "These release notes have not been translated yet, so they are shown in English."
|
||||
},
|
||||
"blog": {
|
||||
|
||||
@@ -651,6 +651,8 @@
|
||||
"metaDescription": "每个 Paseo 版本发布的产品更新、问题修复和改进。了解新的 Agent Provider、移动端功能和 daemon 变化。",
|
||||
"title": "更新日志",
|
||||
"linkTo": "链接到 Paseo {{version}}",
|
||||
"showAllChanges": "显示全部变更",
|
||||
"hideAllChanges": "收起全部变更",
|
||||
"untranslatedRelease": "此版本的发布说明尚未翻译,因此以英文显示。"
|
||||
},
|
||||
"blog": {
|
||||
|
||||
@@ -41,14 +41,44 @@ function HeadingAnchor({ version }: { version: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A release that has highlights shows only them; its sections, the full record, open on request.
|
||||
* A release without highlights shows its sections, since nothing stands in for them.
|
||||
*/
|
||||
function ReleaseNotes({ release }: { release: ChangelogRelease }) {
|
||||
const { t } = useTranslation();
|
||||
if (!release.highlights) {
|
||||
return <ReactMarkdown components={patchMarkdownComponents}>{release.sections}</ReactMarkdown>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ReactMarkdown components={patchMarkdownComponents}>{release.highlights}</ReactMarkdown>
|
||||
{release.sections ? (
|
||||
<details className="changelog-all-changes">
|
||||
<summary className="changelog-all-changes-summary">
|
||||
<span className="changelog-all-changes-show">{t("changelog.showAllChanges")}</span>
|
||||
<span className="changelog-all-changes-hide">{t("changelog.hideAllChanges")}</span>
|
||||
</summary>
|
||||
<ReactMarkdown components={patchMarkdownComponents}>{release.sections}</ReactMarkdown>
|
||||
</details>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PatchRelease({ release }: { release: ChangelogRelease }) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<section className="changelog-patch">
|
||||
<section
|
||||
className="changelog-patch"
|
||||
aria-labelledby={`${releaseAnchor(release.version)}-title`}
|
||||
>
|
||||
<div id={releaseAnchor(release.version)} className="changelog-patch-heading">
|
||||
<HeadingAnchor version={release.version} />
|
||||
<h3 className="changelog-patch-title">{release.version}</h3>
|
||||
<h3 id={`${releaseAnchor(release.version)}-title`} className="changelog-patch-title">
|
||||
{release.version}
|
||||
</h3>
|
||||
<time dateTime={release.date} className="changelog-release-date">
|
||||
{formatDate(release.date, locale, "long")}
|
||||
</time>
|
||||
@@ -58,7 +88,7 @@ function PatchRelease({ release }: { release: ChangelogRelease }) {
|
||||
contentLocale={release.locale}
|
||||
message={t("changelog.untranslatedRelease")}
|
||||
/>
|
||||
<ReactMarkdown components={patchMarkdownComponents}>{release.markdown}</ReactMarkdown>
|
||||
<ReleaseNotes release={release} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -277,6 +277,52 @@
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.changelog-all-changes {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.changelog-all-changes-summary {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.changelog-all-changes-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-all-changes-summary::before {
|
||||
content: "+";
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.changelog-all-changes-summary:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.changelog-all-changes[open] > .changelog-all-changes-summary::before {
|
||||
content: "−";
|
||||
}
|
||||
|
||||
.changelog-all-changes[open] > .changelog-all-changes-summary {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.changelog-all-changes-hide,
|
||||
.changelog-all-changes[open] .changelog-all-changes-show {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-all-changes[open] .changelog-all-changes-hide {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Docs page prose styles — mirrors the original docs/*.tsx components */
|
||||
.docs-prose {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
Reference in New Issue
Block a user