Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"docs:preview": "vite preview --outDir dist --port 4173",
"dev": "npm run docs:dev",
"build": "npm run docs:build",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand All @@ -31,6 +33,7 @@
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"typescript": "^6.0.3",
"vite": "^8.2.1"
"vite": "^8.2.1",
"vitest": "^4.1.10"
}
}
159 changes: 159 additions & 0 deletions packages/docs/src/pages/__tests__/changelogEntries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import { changelogEntries, changelogEntryFor, changelogStaticPaths, parseChangelogEntries } from "../changelogEntries";

describe("parseChangelogEntries", () => {
it("parses semantic-release's linked heading format, capturing version and date", () => {
const markdown = `## [2.5.0](https://github.com/randomdevpete/jarl/compare/v2.4.0...v2.5.0) (2026-08-15)

### Features

* **docs:** add GitHub, Discord and npm links to the toolbar ([ceb3ec8](https://github.com/randomdevpete/jarl/commit/ceb3ec8))
`;

const [entry] = parseChangelogEntries(markdown);

expect(entry).toMatchObject({
version: "2.5.0",
date: "2026-08-15",
heading: "## [2.5.0](https://github.com/randomdevpete/jarl/compare/v2.4.0...v2.5.0) (2026-08-15)",
});
expect(entry.body).toContain("add GitHub, Discord and npm links to the toolbar");
});

it("does not mistake a ## heading inside a fenced code block for a version heading", () => {
const markdown = `## [2.5.0](https://example.com/compare) (2026-08-15)

Before the fence.

\`\`\`md
## 9.9.9
This looks like a version heading but it's example markdown, not real content.
\`\`\`

After the fence.

## [2.4.0](https://example.com/compare) (2026-08-10)

Second entry body.
`;

const entries = parseChangelogEntries(markdown);

expect(entries).toHaveLength(2);
expect(entries[0]!.version).toBe("2.5.0");
expect(entries[0]!.body).toBe(
[
"Before the fence.",
"",
"```md",
"## 9.9.9",
"This looks like a version heading but it's example markdown, not real content.",
"```",
"",
"After the fence.",
].join("\n"),
);
expect(entries[1]!.version).toBe("2.4.0");
expect(entries[1]!.body).toBe("Second entry body.");
});

it("returns no entries for an empty file", () => {
expect(parseChangelogEntries("")).toEqual([]);
});

it("drops content that appears before the first heading", () => {
const markdown = `This changelog is generated by semantic-release. Do not edit by hand.

## [1.0.0](https://example.com/compare) (2026-01-01)

First real entry.
`;

const entries = parseChangelogEntries(markdown);

expect(entries).toHaveLength(1);
expect(entries[0]!.version).toBe("1.0.0");
expect(entries[0]!.body).toBe("First real entry.");
});

it("gives a heading immediately followed by the next heading an empty body", () => {
const markdown = `## v1.0.0-beta.2

## v1.0.0-beta.1

Only this one has a body.
`;

const entries = parseChangelogEntries(markdown);

expect(entries).toHaveLength(2);
expect(entries[0]!.version).toBe("1.0.0-beta.2");
expect(entries[0]!.body).toBe("");
expect(entries[1]!.version).toBe("1.0.0-beta.1");
expect(entries[1]!.body).toBe("Only this one has a body.");
});

it("closes the current entry on a non-version # or ## heading without starting a new one", () => {
const markdown = `## [1.0.0](https://example.com/compare) (2026-01-01)

Body before the stray heading.

# Not a version

This line is orphaned: no entry is open to receive it.

## [0.9.0](https://example.com/compare) (2025-12-01)

Next real entry.
`;

const entries = parseChangelogEntries(markdown);

expect(entries).toHaveLength(2);
expect(entries[0]!.body).toBe("Body before the stray heading.");
expect(entries[0]!.body).not.toContain("orphaned");
expect(entries[1]!.version).toBe("0.9.0");
});
});

describe("changelogEntries (the real CHANGELOG.md)", () => {
it("parses at least the entries known at the time this suite was written", () => {
// The file is regenerated by semantic-release on every release, so this only pins a
// floor — new releases add entries above these, they never remove old ones.
expect(changelogEntries.length).toBeGreaterThanOrEqual(39);
});

it("gives every entry a semver-shaped version", () => {
for (const entry of changelogEntries) {
expect(entry.version).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/);
}
});

it("has no duplicate versions", () => {
const versions = changelogEntries.map((entry) => entry.version);
expect(new Set(versions).size).toBe(versions.length);
});

it("looks up a known historical entry by version", () => {
const entry = changelogEntryFor("2.6.0");

expect(entry).toBeDefined();
expect(entry?.body).toContain("asyncRouteAtom");
});

it("returns undefined for a version that was never released", () => {
expect(changelogEntryFor("999.999.999")).toBeUndefined();
});
});

describe("changelogStaticPaths", () => {
it("includes /changelog plus one path per entry", () => {
const paths = changelogStaticPaths();

expect(paths[0]).toBe("/changelog");
expect(paths).toHaveLength(changelogEntries.length + 1);
for (const entry of changelogEntries) {
expect(paths).toContain(`/changelog/${entry.version}`);
}
});
});
18 changes: 17 additions & 1 deletion packages/docs/src/pages/changelogEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ export type ChangelogEntry = { version: string; date?: string; heading: string;
const HEADING_RE = /^(#{1,2})\s+(.*)$/;
const VERSION_RE = /v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)/;
const DATE_RE = /\((\d{4}-\d{2}-\d{2})\)/;
const FENCE_RE = /^```/;

/**
* Splits the generated CHANGELOG.md into one entry per `##` version heading.
* A non-version `#`/`##` heading closes the current entry without starting a new one.
* Lines inside a fenced code block are never treated as headings, so an example release
* note that happens to show a `##`/version-shaped line can't be mistaken for a real one.
*/
const parseChangelogEntries = (markdown: string): ChangelogEntry[] => {
// Exported for unit tests, which feed it fixture markdown directly rather than the real
// CHANGELOG.md — the module-level `changelogEntries` below is what production code uses.
export const parseChangelogEntries = (markdown: string): ChangelogEntry[] => {
const entries: ChangelogEntry[] = [];
let current: { version: string; date?: string; heading: string; bodyLines: string[] } | null = null;
let inFence = false;

const flush = () => {
if (current) {
Expand All @@ -29,6 +35,16 @@ const parseChangelogEntries = (markdown: string): ChangelogEntry[] => {
};

for (const line of markdown.split("\n")) {
if (FENCE_RE.test(line)) {
inFence = !inFence;
current?.bodyLines.push(line);
continue;
}
if (inFence) {
current?.bodyLines.push(line);
continue;
}

const heading = line.match(HEADING_RE);
const versionMatch = heading && heading[1] === "##" ? heading[2].match(VERSION_RE) : null;
if (versionMatch) {
Expand Down
7 changes: 7 additions & 0 deletions packages/docs/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["src/**/__tests__/**/*.test.ts"],
},
});
Loading