diff --git a/packages/docs/package.json b/packages/docs/package.json index 685dcfc..535b9c4 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -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": { @@ -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" } } diff --git a/packages/docs/src/pages/__tests__/changelogEntries.test.ts b/packages/docs/src/pages/__tests__/changelogEntries.test.ts new file mode 100644 index 0000000..bc3203d --- /dev/null +++ b/packages/docs/src/pages/__tests__/changelogEntries.test.ts @@ -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}`); + } + }); +}); diff --git a/packages/docs/src/pages/changelogEntries.ts b/packages/docs/src/pages/changelogEntries.ts index 6eeab54..9394886 100644 --- a/packages/docs/src/pages/changelogEntries.ts +++ b/packages/docs/src/pages/changelogEntries.ts @@ -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) { @@ -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) { diff --git a/packages/docs/vitest.config.ts b/packages/docs/vitest.config.ts new file mode 100644 index 0000000..d5cfff3 --- /dev/null +++ b/packages/docs/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/__tests__/**/*.test.ts"], + }, +});