From 7790bcbcbb49180361b3648a88856aa4ce848343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Thu, 15 Oct 2026 00:16:33 -0300 Subject: [PATCH 1/2] chore: start #126 From 9b1a4ff53d6f051f1ce9e5b591d518e6c1e06f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Thu, 15 Oct 2026 00:41:08 -0300 Subject: [PATCH 2/2] feat(site): real-time /changelog from GitHub Releases at the edge (#126) --- .changeset/release-list-prose.md | 5 + .gitignore | 3 + AGENTS.md | 1 + CONTRIBUTING.md | 2 + bun.lock | 6 + docs/css-api.md | 2 +- package.json | 2 +- .../components/Docs/Releases.svelte.test.ts | 47 ++++ .../lib/components/Docs/Releases.test.svelte | 29 ++ packages/ui/src/lib/styles/prose.css | 77 +++++ site/functions/_lib/changelog.test.ts | 222 +++++++++++++++ site/functions/_lib/changelog.ts | 262 ++++++++++++++++++ site/functions/_lib/edge.test.ts | 104 +++++++ site/functions/_lib/edge.ts | 88 ++++++ site/functions/_lib/page.test.ts | 34 +++ site/functions/_lib/page.ts | 63 +++++ site/functions/changelog.json.ts | 29 ++ site/functions/changelog/feed.xml.ts | 26 ++ site/functions/changelog/index.test.ts | 63 +++++ site/functions/changelog/index.ts | 38 +++ site/functions/tsconfig.json | 14 + site/functions/tsconfig.test.json | 6 + site/package.json | 5 +- site/src/lib/nav.ts | 1 + site/src/pages/changelog/index.astro | 39 +++ site/tests/changelog.spec.ts | 64 +++++ site/tsconfig.json | 2 +- 27 files changed, 1230 insertions(+), 4 deletions(-) create mode 100644 .changeset/release-list-prose.md create mode 100644 packages/ui/src/lib/components/Docs/Releases.svelte.test.ts create mode 100644 packages/ui/src/lib/components/Docs/Releases.test.svelte create mode 100644 site/functions/_lib/changelog.test.ts create mode 100644 site/functions/_lib/changelog.ts create mode 100644 site/functions/_lib/edge.test.ts create mode 100644 site/functions/_lib/edge.ts create mode 100644 site/functions/_lib/page.test.ts create mode 100644 site/functions/_lib/page.ts create mode 100644 site/functions/changelog.json.ts create mode 100644 site/functions/changelog/feed.xml.ts create mode 100644 site/functions/changelog/index.test.ts create mode 100644 site/functions/changelog/index.ts create mode 100644 site/functions/tsconfig.json create mode 100644 site/functions/tsconfig.test.json create mode 100644 site/src/pages/changelog/index.astro create mode 100644 site/tests/changelog.spec.ts diff --git a/.changeset/release-list-prose.md b/.changeset/release-list-prose.md new file mode 100644 index 00000000..d0499412 --- /dev/null +++ b/.changeset/release-list-prose.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +`.ca-prose` styles release lists, so a changelog page needs no custom CSS (#126). Wrap one `
` per version (heading with its channel `.ca-chip`, then a `.ca-release__meta` row of date and links, then the rendered notes), add a `.ca-release-jump` pill index and close with `.ca-release-thanks`. Anchored releases clear a sticky `DocsShell` header (`scroll-margin-top`), and the meta links and pills keep 44px touch targets. Classes only, no JS or component: the markup can come from Markdown rendered at build time or at the edge. diff --git a/.gitignore b/.gitignore index ee30e3b5..59b8e9e8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ site/.astro/ # Generated brand assets (site/scripts/brand.ts, caBrandPlugin): hashed icons, social cards, URL module site/public/brand/ site/src/generated/ + +# wrangler local state +.wrangler/ diff --git a/AGENTS.md b/AGENTS.md index 7a7b8c55..9af417f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ packages/ui/ → publishable library (@codeatlasdev/ui) utils/ → debounce, clipboard, localStorage, palette-generator index.ts → main barrel export site/ → Astro site (landing + MDX docs + templates) at ui.codeatlas.com.br + functions/ → Cloudflare Pages Functions: /changelog/ (live GitHub Releases), /changelog.json, /changelog/feed.xml; tested with `bun run test:unit` ``` --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61220dc9..7d3b4058 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,6 +130,8 @@ Publishing happens only in CI (`.github/workflows/release.yml`), triggered by a 2. When it's time to ship, a maintainer runs `bun run release` on an up-to-date, clean `main` (`--dry-run` first to see what goes out). It applies the pending changesets (version + CHANGELOG), commits `chore(release): @codeatlasdev/ui@X.Y.Z`, tags it, pushes `main` and the tag atomically and watches the run. 3. The tag starts the Release workflow: `scripts/release-meta.ts` checks the tag against the package version, `bun run validate` and the tarball smoke test run on the tagged commit (it does not depend on CI), then it publishes to npm with [provenance](https://docs.npmjs.com/generating-provenance-statements) through Trusted Publishing (OIDC, no npm token), creates the GitHub Release from the CHANGELOG and deploys the docs to ui.codeatlas.com.br. +The docs site shows releases without a redeploy: `/changelog/` is rendered at the edge by Cloudflare Pages Functions (`site/functions/`), which read the GitHub Releases of `codeatlasdev/ui` (cached about a minute, last good copy kept if GitHub is down). The page, `/changelog.json` and the Atom feed `/changelog/feed.xml` share that source. The build also renders `packages/ui/CHANGELOG.md` into the page as the fallback. Optional: set a `GITHUB_TOKEN` secret on the Pages project to lift the 60 requests/hour unauthenticated API limit; the cache keeps a single deployment far below it. Local check: `bun run build && cd site && bunx wrangler pages dev dist`. + Prereleases: `bunx changeset pre enter beta`, commit `.changeset/pre.json`, then `bun run release` → `X.Y.Z-beta.N` on the `beta` dist-tag (never `latest`), docs at beta.codeatlas-ui.pages.dev. `bunx changeset pre exit` before the next stable. A failed or half-finished release is re-run for the same tag with `bun run release --publish @codeatlasdev/ui@X.Y.Z`; steps already done (npm version, GitHub Release) are skipped. diff --git a/bun.lock b/bun.lock index 1447c68d..8f9ded1e 100644 --- a/bun.lock +++ b/bun.lock @@ -92,6 +92,7 @@ "@astrojs/mdx": "8.0.3", "@astrojs/sitemap": "3.7.4", "@astrojs/svelte": "9.0.1", + "@cloudflare/workers-types": "5.20261015.1", "@codeatlasdev/ui": "workspace:*", "@fontsource-variable/inter": "5.3.0", "@fontsource-variable/jetbrains-mono": "5.3.0", @@ -102,6 +103,7 @@ "echarts": "6.1.0", "gsap": "3.15.0", "lenis": "1.3.26", + "marked": "18.1.1", "png-to-ico": "3.0.2", "shiki": "4.5.0", "svelte": "5.57.2", @@ -260,6 +262,8 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20261006.1", "", { "os": "win32", "cpu": "x64" }, "sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20261015.1", "", {}, "sha512-ef6KOceBMzxf1O/CelPF2ei8Mtlaw+7vQ9cdaUKFG8CC0xAe8/SuPdRpmbogEE18u6JbfFWWAOGQLaav8UTFWg=="], + "@codeatlasdev/ui": ["@codeatlasdev/ui@workspace:packages/ui"], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -956,6 +960,8 @@ "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="], + "marked": ["marked@18.1.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], diff --git a/docs/css-api.md b/docs/css-api.md index b13f8cf7..e71cf5a4 100644 --- a/docs/css-api.md +++ b/docs/css-api.md @@ -119,7 +119,7 @@ Total: 791 unique classes across 80 components. ## Docs -`.ca-alert__content` `.ca-callout` `.ca-card-grid` `.ca-code` `.ca-codetab` `.ca-codetabs` `.ca-codetabs__bar` `.ca-codetabs__copy` `.ca-codetabs__panels` `.ca-codetabs__tab` `.ca-codetabs__tabs` `.ca-docs` `.ca-docs--bare` `.ca-docs-brand` `.ca-docs-content` `.ca-docs-header` `.ca-docs-header__inner` `.ca-docs-icon` `.ca-docs-main` `.ca-docs-menu` `.ca-docs-nav` `.ca-docs-nav__group` `.ca-docs-nav__label` `.ca-docs-nav__link` `.ca-docs-nav__title` `.ca-docs-search` `.ca-docs-search-btn` `.ca-docs-search-btn__label` `.ca-docs-search-icon` `.ca-docs-search-in` `.ca-docs-search-opt` `.ca-docs-search-out` `.ca-docs-search-pop-in` `.ca-docs-search-pop-out` `.ca-docs-search-results` `.ca-docs-search__bar` `.ca-docs-search__body` `.ca-docs-search__cancel` `.ca-docs-search__context` `.ca-docs-search__empty` `.ca-docs-search__enter` `.ca-docs-search__esc` `.ca-docs-search__icon` `.ca-docs-search__input` `.ca-docs-search__overlay` `.ca-docs-search__result` `.ca-docs-search__result--on` `.ca-docs-search__results` `.ca-docs-search__sr` `.ca-docs-search__text` `.ca-docs-search__title` `.ca-docs-section` `.ca-docs-sections` `.ca-docs-sheet-sections` `.ca-docs-sidebar` `.ca-docs-skip` `.ca-docs-spacer` `.ca-docs-theme` `.ca-docs-toc` `.ca-docs-toc-inline` `.ca-kbd` `.ca-link-card` `.ca-link-card__desc` `.ca-link-card__title` `.ca-method` `.ca-method--pill` `.ca-method--plain` `.ca-overlay` `.ca-prose` `.ca-rail-btn` `.ca-step` `.ca-steps` `.ca-toc` `.ca-toc--inline` `.ca-toc--rail` `.ca-toc__current` `.ca-toc__label` `.ca-toc__list` `.ca-toc__sub` `.ca-toc__summary-label` +`.ca-alert__content` `.ca-callout` `.ca-card-grid` `.ca-code` `.ca-codetab` `.ca-codetabs` `.ca-codetabs__bar` `.ca-codetabs__copy` `.ca-codetabs__panels` `.ca-codetabs__tab` `.ca-codetabs__tabs` `.ca-docs` `.ca-docs--bare` `.ca-docs-brand` `.ca-docs-content` `.ca-docs-header` `.ca-docs-header__inner` `.ca-docs-icon` `.ca-docs-main` `.ca-docs-menu` `.ca-docs-nav` `.ca-docs-nav__group` `.ca-docs-nav__label` `.ca-docs-nav__link` `.ca-docs-nav__title` `.ca-docs-search` `.ca-docs-search-btn` `.ca-docs-search-btn__label` `.ca-docs-search-icon` `.ca-docs-search-in` `.ca-docs-search-opt` `.ca-docs-search-out` `.ca-docs-search-pop-in` `.ca-docs-search-pop-out` `.ca-docs-search-results` `.ca-docs-search__bar` `.ca-docs-search__body` `.ca-docs-search__cancel` `.ca-docs-search__context` `.ca-docs-search__empty` `.ca-docs-search__enter` `.ca-docs-search__esc` `.ca-docs-search__icon` `.ca-docs-search__input` `.ca-docs-search__overlay` `.ca-docs-search__result` `.ca-docs-search__result--on` `.ca-docs-search__results` `.ca-docs-search__sr` `.ca-docs-search__text` `.ca-docs-search__title` `.ca-docs-section` `.ca-docs-sections` `.ca-docs-sheet-sections` `.ca-docs-sidebar` `.ca-docs-skip` `.ca-docs-spacer` `.ca-docs-theme` `.ca-docs-toc` `.ca-docs-toc-inline` `.ca-kbd` `.ca-link-card` `.ca-link-card__desc` `.ca-link-card__title` `.ca-method` `.ca-method--pill` `.ca-method--plain` `.ca-overlay` `.ca-prose` `.ca-rail-btn` `.ca-step` `.ca-steps` `.ca-toc` `.ca-toc--inline` `.ca-toc--rail` `.ca-toc__current` `.ca-toc__label` `.ca-toc__list` `.ca-toc__sub` `.ca-toc__summary-label` `.ca-release` `.ca-release-jump` `.ca-release-thanks` `.ca-release__meta` ## Drawer diff --git a/package.json b/package.json index 9887fa3f..8cbd9c5a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "validate": "bun scripts/validate.ts", "postinstall": "node scripts/fix-ts-symlink.js && node scripts/install-hooks.js && bunx playwright install chromium", "test": "bun run test:unit && bun run test:browser && bun run test:e2e", - "test:unit": "bun run --cwd packages/ui test:unit", + "test:unit": "bun run --cwd packages/ui test:unit && bun run --cwd site test:unit", "test:browser": "bun run --cwd packages/ui test:browser", "test:watch": "bun run --cwd packages/ui test:watch", "test:e2e": "bun run --cwd site test:e2e", diff --git a/packages/ui/src/lib/components/Docs/Releases.svelte.test.ts b/packages/ui/src/lib/components/Docs/Releases.svelte.test.ts new file mode 100644 index 00000000..115119cf --- /dev/null +++ b/packages/ui/src/lib/components/Docs/Releases.svelte.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { render } from "vitest-browser-svelte"; +import { checkA11y } from "../../../../tests/a11y"; +import Releases from "./Releases.test.svelte"; + +const css = (el: Element, prop: string) => getComputedStyle(el).getPropertyValue(prop); + +describe("ca-release (changelog markup inside .ca-prose)", () => { + it("passes axe", async () => { + const screen = await render(Releases); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); + + it("separates releases with a rule and keeps anchored headings clear of a sticky header", async () => { + const screen = await render(Releases); + const section = screen.container.querySelector(".ca-release"); + expect(section).toBeTruthy(); + expect(css(section as Element, "border-top-width")).not.toBe("0px"); + expect(css(section as Element, "scroll-margin-top")).not.toBe("0px"); + }); + + it("lays the meta line out as a wrapping row of 44px-tall links", async () => { + const screen = await render(Releases); + const meta = screen.container.querySelector(".ca-release__meta") as Element; + expect(css(meta, "display")).toBe("flex"); + expect(css(meta, "flex-wrap")).toBe("wrap"); + for (const a of meta.querySelectorAll("a")) { + expect(a.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + } + }); + + it("the version jump list is a wrapping row of pills", async () => { + const screen = await render(Releases); + const list = screen.container.querySelector(".ca-release-jump ul") as Element; + expect(css(list, "display")).toBe("flex"); + expect(css(list, "list-style-type")).toBe("none"); + for (const a of screen.container.querySelectorAll(".ca-release-jump a")) { + expect(a.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + } + }); + + it("the thanks footer is set apart from the last release", async () => { + const screen = await render(Releases); + const thanks = screen.container.querySelector(".ca-release-thanks") as Element; + expect(css(thanks, "border-top-width")).not.toBe("0px"); + }); +}); diff --git a/packages/ui/src/lib/components/Docs/Releases.test.svelte b/packages/ui/src/lib/components/Docs/Releases.test.svelte new file mode 100644 index 00000000..6edd8282 --- /dev/null +++ b/packages/ui/src/lib/components/Docs/Releases.test.svelte @@ -0,0 +1,29 @@ + + +
+ +
+

3.0.0 latest

+

+ + npm + GitHub release +

+
  • A change
+
+
+

2.4.0-beta.1

+
  • Another
+
+
+

Special thanks

+

To our supporters.

+
+
diff --git a/packages/ui/src/lib/styles/prose.css b/packages/ui/src/lib/styles/prose.css index b7746e9a..98cb58af 100644 --- a/packages/ui/src/lib/styles/prose.css +++ b/packages/ui/src/lib/styles/prose.css @@ -266,6 +266,83 @@ /* Inside CodeTabs the tab already names the language. */ .ca-codetabs .ca-code[data-lang]::before { display: none; } +/* ── Release list (changelogs) ───────────────────────────────────────────── */ +/* One `
` per version inside `.ca-prose`: heading (version + a `.ca-chip` + * for its channel), a `.ca-release__meta` row (date, links), then the rendered notes. `.ca-release-jump` + * is the version index (pills); `.ca-release-thanks` closes the page. */ + +.ca-prose .ca-release { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: var(--ca-border-width) solid var(--ca-outline-subtle); + scroll-margin-top: calc(var(--ca-docs-header-h, 56px) + var(--ca-safe-top) + var(--ca-space-6)); +} + +.ca-prose .ca-release > h2 { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--ca-space-2); + margin: 0 0 var(--ca-space-1); + padding: 0; +} + +.ca-prose .ca-release > h2 > a { + color: inherit; + text-decoration: none; + font-weight: inherit; +} + +.ca-prose .ca-release > h2 > a:hover { text-decoration: underline; } + +.ca-prose .ca-release__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 var(--ca-space-4); + margin: 0 0 var(--ca-space-2); + font-size: var(--ca-font-size-body2); + color: var(--ca-text-secondary); +} + +.ca-prose .ca-release__meta a { + display: inline-flex; + align-items: center; + min-height: var(--ca-touch-target); +} + +.ca-prose .ca-release-jump ul { + display: flex; + flex-wrap: wrap; + gap: var(--ca-space-1-5); + margin: 0; + padding: 0; + list-style: none; +} + +.ca-prose .ca-release-jump li { margin: 0; padding: 0; } + +.ca-prose .ca-release-jump a { + display: inline-flex; + align-items: center; + min-height: var(--ca-touch-target); + padding: 0 var(--ca-space-3); + border: var(--ca-border-width) solid var(--ca-outline-subtle); + border-radius: var(--ca-radius-pill); + font-size: var(--ca-font-size-body2); + text-decoration: none; +} + +.ca-prose .ca-release-jump a:hover { border-color: var(--ca-outline-default); } + +.ca-prose .ca-release-thanks { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: var(--ca-border-width) solid var(--ca-outline-subtle); +} + +.ca-prose .ca-release-thanks > h2 { margin-top: 0; } + @media (max-width: 899.98px) { .ca-prose { line-height: 1.7; } .ca-code pre, diff --git a/site/functions/_lib/changelog.test.ts b/site/functions/_lib/changelog.test.ts new file mode 100644 index 00000000..7fa79aa0 --- /dev/null +++ b/site/functions/_lib/changelog.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test"; +import { + atomFeed, + channelOf, + cmpVersionDesc, + parseChangelogMd, + parseGithubReleases, + renderMarkdown, + renderRelease, + splitThanks, + versionId, +} from "./changelog"; + +const TAG = "@codeatlasdev/ui@"; + +const gh = (over: Record) => ({ + tag_name: `${TAG}1.0.0`, + name: `${TAG}1.0.0`, + body: "### Patch Changes\n\n- abc: fix\n", + draft: false, + prerelease: false, + published_at: "2026-10-12T10:00:00Z", + html_url: `https://github.com/codeatlasdev/ui/releases/tag/${TAG}1.0.0`, + ...over, +}); + +describe("versionId / channelOf", () => { + test("anchor ids are stable and URL-safe", () => { + expect(versionId("3.0.0")).toBe("v3-0-0"); + expect(versionId("2.4.0-beta.1")).toBe("v2-4-0-beta-1"); + }); + test("stable is latest, a prerelease id is its own channel", () => { + expect( + ["3.0.0", "2.4.0-beta.1", "2.4.0-alpha.3", "2.4.0-rc.1", "garbage"].map(channelOf) + ).toEqual(["latest", "beta", "alpha", "rc", "latest"]); + }); +}); + +describe("cmpVersionDesc", () => { + test("numeric, newest first, a release before its own prerelease", () => { + const v = ["1.2.0", "1.10.0", "1.2.0-rc.1", "1.1.2", "0.9.9"]; + expect(v.sort(cmpVersionDesc)).toEqual(["1.10.0", "1.2.0", "1.2.0-rc.1", "1.1.2", "0.9.9"]); + }); + test("prerelease numbers compare as numbers (beta.10 > beta.9)", () => { + expect(["2.0.0-beta.9", "2.0.0-beta.10"].sort(cmpVersionDesc)).toEqual([ + "2.0.0-beta.10", + "2.0.0-beta.9", + ]); + }); +}); + +describe("parseGithubReleases", () => { + test("keeps only @codeatlasdev/ui@ tags, never drafts, sorted newest first", () => { + const out = parseGithubReleases([ + gh({ tag_name: `${TAG}2.3.2`, published_at: "2026-10-14T00:52:42Z" }), + gh({ tag_name: "v9.9.9" }), + gh({ tag_name: `${TAG}3.0.0`, draft: true }), + gh({ tag_name: `${TAG}3.0.0`, published_at: "2026-10-15T03:07:23Z" }), + gh({ tag_name: `${TAG}2.4.0-beta.1`, prerelease: true }), + ]); + expect(out.map((r) => r.version)).toEqual( + ["3.0.0", "2.4.0-beta.1", "2.3.2"].sort(cmpVersionDesc) + ); + expect(out[0]).toMatchObject({ + version: "3.0.0", + date: "2026-10-15", + channel: "latest", + id: "v3-0-0", + }); + }); + test("tolerates garbage from the API instead of throwing", () => { + expect(parseGithubReleases(null)).toEqual([]); + expect(parseGithubReleases([null, 1, {}, gh({ body: null })])).toHaveLength(1); + }); + test("the release url is always on github.com/codeatlasdev/ui", () => { + const [r] = parseGithubReleases([gh({ html_url: "https://evil.example/x" })]); + expect(r.url).toBe( + `https://github.com/codeatlasdev/ui/releases/tag/${encodeURIComponent(`${TAG}1.0.0`)}` + ); + }); +}); + +const MD = `# @codeatlasdev/ui + +## 3.0.0 + +### Major Changes + +- a1d14f5: Sober default + +## 2.3.2 + +### Patch Changes + +- 9b7015f: DocsShell keeps its place + +## 2.3.1-beta.1 + +- beta thing +`; + +describe("parseChangelogMd (build-time fallback)", () => { + test("one release per version heading, with its body and no date", () => { + const out = parseChangelogMd(MD); + expect(out.map((r) => [r.version, r.date, r.channel])).toEqual([ + ["3.0.0", "", "latest"], + ["2.3.2", "", "latest"], + ["2.3.1-beta.1", "", "beta"], + ]); + expect(out[1].body).toBe("### Patch Changes\n\n- 9b7015f: DocsShell keeps its place"); + }); + test("handles CRLF and an empty file", () => { + expect(parseChangelogMd(MD.replace(/\n/g, "\r\n"))).toHaveLength(3); + expect(parseChangelogMd("")).toEqual([]); + }); + test("an Unreleased heading is never public", () => { + expect(parseChangelogMd("## Unreleased\n\n- secret\n\n## 1.0.0\n\n- ok")).toHaveLength(1); + }); +}); + +describe("splitThanks", () => { + test("moves the Install and Special thanks sections out of the body", () => { + const body = + "### Patch Changes\n\n- x\n\n## Install\n\n```bash\nbun add y\n```\n\n## Special thanks\n\nTo A and B. 💛\n"; + const out = splitThanks(body); + expect(out.changes).toBe("### Patch Changes\n\n- x"); + expect(out.thanks).toBe("To A and B. 💛"); + }); + test("a body without them is untouched", () => { + expect(splitThanks("- x")).toEqual({ changes: "- x", thanks: "" }); + }); +}); + +describe("renderMarkdown (sanitized)", () => { + test("escapes raw HTML from the source", () => { + const html = renderMarkdown("- A and "); + expect(html).not.toContain(" { + for (const href of [ + "javascript:alert(1)", + "JaVaScRiPt:alert(1)", + "data:text/html,x", + "vbscript:x", + " javascript:x", + ]) { + expect(renderMarkdown(`[x](${href})`)).toContain('href="#"'); + } + }); + test("rejects markdown images with unsafe sources and never emits onerror", () => { + const html = renderMarkdown( + '![a](javascript:alert(1)) ![b](https://x.test/p.png "t" onerror=alert(1))' + ); + expect(html).not.toContain("javascript:"); + expect(html).not.toMatch(/<[^>]+\sonerror=/); + }); + test("external links get rel=noopener noreferrer, same-site and anchors do not", () => { + expect(renderMarkdown("[x](https://example.com)")).toContain('rel="noopener noreferrer"'); + expect(renderMarkdown("[x](#v3-0-0)")).not.toContain("rel="); + expect(renderMarkdown("[x](/docs/)")).not.toContain("rel="); + }); + test("quotes in titles cannot break out of the attribute", () => { + const html = renderMarkdown('[x](https://example.com "a\\" onmouseover=\\"alert(1)")'); + const tag = html.match(/]*>/)?.[0] ?? ""; + // Every attribute value is closed by its own quote: stripping the quoted values leaves only names. + expect(tag.replace(/="[^"]*"/g, "")).toBe(""); + }); + test("scopes heading ids to the version so anchors never collide", () => { + const html = renderMarkdown("### Patch Changes\n\n### Patch Changes", "v2-3-2"); + expect(html).toContain('id="v2-3-2-patch-changes"'); + expect(html).toContain('id="v2-3-2-patch-changes-2"'); + }); + test("renders GFM tables inside a scrollable wrapper and code fences as pre", () => { + const html = renderMarkdown("| a | b |\n|---|---|\n| 1 | 2 |\n\n```bash\nbun add x\n```"); + expect(html).toContain('
"); + expect(html).toContain(" { + const [r] = parseGithubReleases([ + gh({ tag_name: `${TAG}2.3.2`, body: "- x\n\n## Special thanks\n\nTo A. 💛" }), + ]); + test("carries what the page and the json feed need", () => { + const e = renderRelease(r); + expect(e).toMatchObject({ + version: "2.3.2", + id: "v2-3-2", + date: "2026-10-12", + channel: "latest", + npm: "https://www.npmjs.com/package/@codeatlasdev/ui/v/2.3.2", + thanks: "To A. 💛", + }); + expect(e.html).toContain("
  • x
  • "); + expect(e.html).not.toContain("Special thanks"); + }); +}); + +describe("atomFeed", () => { + const rels = parseGithubReleases([ + gh({ tag_name: `${TAG}3.0.0`, body: "- a &", published_at: "2026-10-15T03:07:23Z" }), + gh({ tag_name: `${TAG}2.3.2`, published_at: "2026-10-14T00:52:42Z" }), + ]); + const xml = atomFeed(rels.map(renderRelease), "https://ui.codeatlas.com.br"); + test("is an Atom feed with one entry per release, newest first", () => { + expect(xml.startsWith('')).toBe(true); + expect(xml).toContain(''); + expect(xml.match(//g)).toHaveLength(2); + expect(xml.indexOf("3.0.0")).toBeLessThan(xml.indexOf("2.3.2")); + expect(xml).toContain("2026-10-15T03:07:23Z"); + expect(xml).toContain( + '' + ); + }); + test("entry content is escaped, never raw markup", () => { + expect(xml).toContain(''); + expect(xml).not.toMatch(/[^<]*<[^/]/); + }); +}); diff --git a/site/functions/_lib/changelog.ts b/site/functions/_lib/changelog.ts new file mode 100644 index 00000000..52b74264 --- /dev/null +++ b/site/functions/_lib/changelog.ts @@ -0,0 +1,262 @@ +// Pure pieces of the changelog engine (no I/O, unit-tested in changelog.test.ts): turn GitHub Releases +// (edge) or packages/ui/CHANGELOG.md (build-time fallback) into one list of releases, render each to +// sanitized HTML, and build the Atom feed. Fetching and caching live in edge.ts. +import { marked } from "marked"; + +export const REPO = "codeatlasdev/ui"; +export const TAG_PREFIX = "@codeatlasdev/ui@"; +export const SITE = "https://ui.codeatlas.com.br"; + +export type Channel = "latest" | "alpha" | "beta" | "rc" | (string & {}); + +export interface Release { + /** "3.0.0" */ + version: string; + /** Anchor id, "v3-0-0" */ + id: string; + /** "2026-10-15" ("" when unknown: the build fallback has no dates) */ + date: string; + /** ISO instant ("" when unknown); used by the Atom feed */ + published: string; + /** npm dist-tag the version went to: "latest" or its prerelease id */ + channel: Channel; + /** Markdown of the release body (changes, plus the Install and Special thanks sections when GitHub has them) */ + body: string; + /** GitHub release page */ + url: string; +} + +export interface RenderedRelease { + version: string; + id: string; + date: string; + published: string; + channel: Channel; + url: string; + npm: string; + html: string; + /** Plain text of the Special thanks section ("" when the release has none) */ + thanks: string; +} + +export const versionId = (v: string) => + `v${v.replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "")}`; + +const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +/** npm dist-tag a version is published under: stable is `latest`, `X.Y.Z-beta.N` is `beta`. */ +export function channelOf(version: string): Channel { + const pre = SEMVER.exec(version)?.[4]; + return pre ? (pre.split(".")[0] as string) : "latest"; +} + +/** SemVer 2.0 precedence for `sort(cmpVersionDesc)`: newest first. A string that is not SemVer sorts last. */ +export function cmpVersionDesc(a: string, b: string): number { + const x = SEMVER.exec(a); + const y = SEMVER.exec(b); + if (!x || !y) return Number(!x) - Number(!y); + for (let i = 1; i <= 3; i++) { + const d = Number(y[i]) - Number(x[i]); + if (d) return d; + } + // A release outranks its own prereleases. + if (!x[4] || !y[4]) return Number(!!x[4]) - Number(!!y[4]); + const p = (x[4] as string).split("."); + const q = (y[4] as string).split("."); + for (let i = 0; i < Math.max(p.length, q.length); i++) { + if (p[i] === undefined) return 1; + if (q[i] === undefined) return -1; + const num = /^\d+$/.test(p[i] as string) && /^\d+$/.test(q[i] as string); + const d = num + ? Number(q[i]) - Number(p[i]) + : (q[i] as string).localeCompare(p[i] as string, "en"); + if (d) return d; + } + return 0; +} + +/** Narrows a value read back from our own cache to a `Release` (every field the renderer reads). */ +export function isRelease(v: unknown): v is Release { + if (!v || typeof v !== "object") return false; + return ["version", "id", "date", "published", "channel", "body", "url"].every( + (k) => k in v && typeof (v as Record)[k] === "string" + ); +} + +const releaseUrl = (tag: string) => + `https://github.com/${REPO}/releases/tag/${encodeURIComponent(tag)}`; + +const str = (v: unknown) => (typeof v === "string" ? v : ""); + +/** Maps the GitHub REST `releases` payload. Drafts and other packages' tags are dropped; anything malformed is skipped. */ +export function parseGithubReleases(payload: unknown): Release[] { + if (!Array.isArray(payload)) return []; + const out: Release[] = []; + for (const raw of payload) { + if (!raw || typeof raw !== "object") continue; + const r = raw as Record; + const tag = str(r.tag_name); + if (r.draft === true || !tag.startsWith(TAG_PREFIX)) continue; + const version = tag.slice(TAG_PREFIX.length); + if (!SEMVER.test(version)) continue; + const published = str(r.published_at); + out.push({ + version, + id: versionId(version), + date: published.slice(0, 10), + published, + channel: channelOf(version), + body: str(r.body).replace(/\r\n/g, "\n").trim(), + url: releaseUrl(tag), + }); + } + return out.sort((a, b) => cmpVersionDesc(a.version, b.version)); +} + +const HEADING = /^## (\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\s*$/gm; + +/** Splits packages/ui/CHANGELOG.md (Changesets format) into one release per `## `. Dates are unknown here. */ +export function parseChangelogMd(md: string): Release[] { + const text = md.replace(/\r\n/g, "\n"); + const marks = [...text.matchAll(HEADING)]; + return marks + .map((m, i) => { + const version = m[1] as string; + const start = (m.index ?? 0) + m[0].length; + const end = marks[i + 1]?.index ?? text.length; + return { + version, + id: versionId(version), + date: "", + published: "", + channel: channelOf(version), + body: text.slice(start, end).trim(), + url: releaseUrl(`${TAG_PREFIX}${version}`), + }; + }) + .sort((a, b) => cmpVersionDesc(a.version, b.version)); +} + +const THANKS = /^## Special thanks\s*$/im; +const INSTALL = /^## Install\s*$/im; + +/** Release bodies end with `## Install` and `## Special thanks`; the page draws them itself (install once, thanks in the footer). */ +export function splitThanks(body: string): { changes: string; thanks: string } { + const at = (re: RegExp) => body.search(re); + const install = at(INSTALL); + const thanks = at(THANKS); + const cut = [install, thanks].filter((i) => i >= 0); + const changes = (cut.length ? body.slice(0, Math.min(...cut)) : body).trim(); + if (thanks < 0) return { changes, thanks: "" }; + const rest = body.slice(thanks).replace(THANKS, ""); + const next = rest.search(/^## /m); + return { changes, thanks: (next < 0 ? rest : rest.slice(0, next)).trim() }; +} + +const esc = (s: string) => + s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +const slug = (s: string) => + s + .toLowerCase() + .replace(/<[^>]+>/g, "") + .replace(/&[a-z]+;/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +// Only these schemes survive; the check strips the control characters and spaces browsers ignore inside a scheme. +const SAFE_URL = /^(https?:|mailto:|#|\/(?!\/))/i; +const safeUrl = (href: string) => + SAFE_URL.test(href.replace(/[\p{Cc}\s]/gu, "")) ? href.trim() : "#"; + +/** + * Markdown to HTML for an untrusted source (release bodies carry pull request text). + * Raw HTML is escaped, never rendered. Links keep http(s), mailto, anchors and site paths only; + * external ones get `rel="noopener noreferrer"`. Images become links (no remote loads, which the + * site CSP would block anyway). Wide tables scroll in their own wrapper. `###` headings get ids + * scoped to `scope` (the version), so anchors never collide across versions. + */ +export function renderMarkdown(md: string, scope = ""): string { + const seen = new Map(); + const renderer = new marked.Renderer(); + renderer.html = ({ text }) => esc(text); + renderer.heading = function ({ tokens, depth }) { + const inner = this.parser.parseInline(tokens); + const base = `${scope ? `${scope}-` : ""}${slug(inner)}`; + const n = (seen.get(base) ?? 0) + 1; + seen.set(base, n); + return `${inner}\n`; + }; + renderer.link = function ({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const safe = safeUrl(href); + const external = /^https?:/i.test(safe) && !safe.startsWith(SITE); + return `
    ${text}`; + }; + renderer.image = ({ href, text }) => { + const safe = safeUrl(href); + return safe === "#" + ? esc(text) + : `${esc(text || safe)}`; + }; + renderer.table = function (token) { + const head = this.tablerow({ text: token.header.map((c) => this.tablecell(c)).join("") }); + const body = token.rows + .map((row) => this.tablerow({ text: row.map((c) => this.tablecell(c)).join("") })) + .join(""); + return `
    ${head}${body}
    \n`; + }; + return marked.parse(md, { renderer, gfm: true, async: false }) as string; +} + +export function renderRelease(r: Release): RenderedRelease { + const { changes, thanks } = splitThanks(r.body); + return { + version: r.version, + id: r.id, + date: r.date, + published: r.published, + channel: r.channel, + url: r.url, + npm: `https://www.npmjs.com/package/@codeatlasdev/ui/v/${r.version}`, + html: renderMarkdown(changes, r.id), + thanks, + }; +} + +/** Atom 1.0 feed of the releases (already newest first). `updated` is the newest release's instant. */ +export function atomFeed(releases: RenderedRelease[], site = SITE): string { + const updated = releases.map((r) => r.published).find(Boolean) ?? "1970-01-01T00:00:00Z"; + const entries = releases + .map((r) => { + const when = r.published || `${r.date || "1970-01-01"}T00:00:00Z`; + return [ + "", + `tag:ui.codeatlas.com.br,2026:${esc(r.version)}`, + `${esc(`@codeatlasdev/ui ${r.version}`)}`, + ``, + `${esc(when)}`, + `${esc(when)}`, + ``, + `${esc(r.html)}`, + "", + ].join("\n"); + }) + .join("\n"); + return [ + '', + '', + `${esc(site)}/changelog/`, + "CodeAtlas UI changelog", + ``, + ``, + `${esc(updated)}`, + entries, + "", + "", + ].join("\n"); +} diff --git a/site/functions/_lib/edge.test.ts b/site/functions/_lib/edge.test.ts new file mode 100644 index 00000000..4fdfac8a --- /dev/null +++ b/site/functions/_lib/edge.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { CACHE_CONTROL, loadReleases, TTL } from "./edge"; + +// In-memory Cache API + scripted GitHub, so the freshness and outage rules are tested without a network. +const store = new Map(); +const fakeCache = { + match: async (k: Request | string) => store.get(typeof k === "string" ? k : k.url)?.clone(), + put: async (k: Request | string, v: Response) => + void store.set(typeof k === "string" ? k : k.url, v.clone()), +}; +const realFetch = globalThis.fetch; +let down = false; +let calls = 0; +let seen: Request[] = []; +let payload: unknown[] = []; + +const rel = (v: string, extra: Record = {}) => ({ + tag_name: `@codeatlasdev/ui@${v}`, + body: `- ${v}`, + draft: false, + prerelease: v.includes("-"), + published_at: "2026-10-12T10:00:00Z", + ...extra, +}); + +beforeEach(() => { + store.clear(); + down = false; + calls = 0; + seen = []; + payload = [rel("3.0.0"), rel("2.3.2")]; + (globalThis as unknown as { caches: unknown }).caches = { open: async () => fakeCache }; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls++; + seen.push(new Request(String(url instanceof Request ? url.url : url), init)); + if (down) throw new Error("GitHub is down"); + return Response.json(payload); + }) as unknown as typeof fetch; +}); +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("loadReleases", () => { + test("reads the releases of codeatlasdev/ui and keeps only @codeatlasdev/ui@ tags", async () => { + payload = [rel("3.0.0"), { ...rel("1.0.0"), tag_name: "v1.0.0" }]; + const r = await loadReleases(); + expect(r.stale).toBe(false); + expect(r.releases.map((x) => x.version)).toEqual(["3.0.0"]); + expect(seen[0].url).toContain( + "https://api.github.com/repos/codeatlasdev/ui/releases?per_page=100" + ); + }); + test("a cached answer is reused inside the TTL (no extra GitHub calls)", async () => { + await loadReleases(); + const before = calls; + await loadReleases(); + expect(calls).toBe(before); + }); + test("?fresh skips the cache: a new release shows immediately", async () => { + await loadReleases(); + payload = [rel("3.1.0"), ...payload]; + expect((await loadReleases()).releases[0].version).toBe("3.0.0"); + expect((await loadReleases({ fresh: true })).releases[0].version).toBe("3.1.0"); + }); + test("GitHub down: serves the last good copy, flagged stale", async () => { + await loadReleases(); + store.delete("https://cache.invalid/releases"); + down = true; + const r = await loadReleases(); + expect(r.stale).toBe(true); + expect(r.releases[0].version).toBe("3.0.0"); + }); + test("GitHub down and nothing cached: the error surfaces (the page falls back to its built body)", async () => { + down = true; + await expect(loadReleases()).rejects.toThrow(); + }); + test("a non-200 answer (rate limit) counts as down", async () => { + await loadReleases(); + store.delete("https://cache.invalid/releases"); + globalThis.fetch = (async () => + new Response("rate limited", { status: 403 })) as unknown as typeof fetch; + expect((await loadReleases()).stale).toBe(true); + }); + test("a token, when configured, is sent as a bearer", async () => { + await loadReleases({ token: "t0k" }); + expect(seen[0].headers.get("authorization")).toBe("Bearer t0k"); + store.clear(); + await loadReleases(); + expect(seen.at(-1)?.headers.get("authorization")).toBeNull(); + }); + test("an empty list is an error, never an empty page", async () => { + payload = []; + await expect(loadReleases()).rejects.toThrow("no published release"); + }); +}); + +describe("CACHE_CONTROL", () => { + test("a minute fresh, long stale-while-revalidate", () => { + expect(TTL).toBe(60); + expect(CACHE_CONTROL).toContain("s-maxage=60"); + expect(CACHE_CONTROL).toContain("stale-while-revalidate"); + }); +}); diff --git a/site/functions/_lib/edge.ts b/site/functions/_lib/edge.ts new file mode 100644 index 00000000..934f4503 --- /dev/null +++ b/site/functions/_lib/edge.ts @@ -0,0 +1,88 @@ +// The release engine: the page never has a copy of "the latest release", it asks GitHub at the edge. +// +// source api.github.com/repos/codeatlasdev/ui/releases?per_page=100 (one request, newest first; tags other than +// @codeatlasdev/ui@* are dropped by parseGithubReleases). 60 requests/hour per IP without a token, so the +// answer is cached for TTL seconds in the Cache API (a new release shows within ~1 minute) and a +// GITHUB_TOKEN binding, when configured, lifts the limit. +// outage if GitHub is down, slow or rate-limited, the last good copy is served (kept for a week) and flagged +// `stale`; with nothing cached the error surfaces, and the page serves its own build-time body. +import { isRelease, parseGithubReleases, REPO, type Release } from "./changelog"; + +export const TTL = 60; +const KEEP = 86400 * 7; +const TIMEOUT = 8000; +const CACHE = "ca-ui-changelog-v1"; + +export interface Loaded { + releases: Release[]; + /** true when GitHub could not be reached and the last good copy was served */ + stale: boolean; +} + +interface Options { + /** Skip the short-TTL copy and ask GitHub right now (`?fresh=1`). */ + fresh?: boolean; + /** GitHub token; optional, only raises the API rate limit. */ + token?: string; +} + +/** What this module stored itself: already parsed `Release[]`, so only the shape of the list is checked. */ +async function readCached(res: Response): Promise { + const stored: unknown = await res.json(); + if (!Array.isArray(stored)) throw new Error("corrupt cache entry"); + return stored.filter(isRelease); +} + +async function fetchReleases(token?: string): Promise { + const headers: Record = { + accept: "application/vnd.github+json", + "user-agent": "codeatlas-ui-site-edge", + "x-github-api-version": "2022-11-28", + }; + if (token) headers.authorization = `Bearer ${token}`; + const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100`, { + headers, + signal: AbortSignal.timeout(TIMEOUT), + }); + if (!res.ok) throw new Error(`releases HTTP ${res.status}`); + const releases = parseGithubReleases(await res.json()); + if (!releases.length) throw new Error("no published release"); + return releases; +} + +/** Releases of the package, newest first: a fresh-enough cached copy, else GitHub, else the last good copy. */ +export async function loadReleases({ fresh = false, token }: Options = {}): Promise { + const cache = await caches.open(CACHE); + const key = new Request("https://cache.invalid/releases"); + const lastKey = new Request("https://cache.invalid/releases/last"); + const hit = fresh ? undefined : await cache.match(key); + if (hit) return { releases: await readCached(hit), stale: false }; + try { + const releases = await fetchReleases(token); + const body = JSON.stringify(releases); + await cache.put( + key, + new Response(body, { headers: { "cache-control": `public, max-age=${TTL}` } }) + ); + await cache.put( + lastKey, + new Response(body, { headers: { "cache-control": `public, max-age=${KEEP}` } }) + ); + return { releases, stale: false }; + } catch (err) { + const last = await cache.match(lastKey); + if (last) return { releases: await readCached(last), stale: true }; + throw err; + } +} + +/** Shared response headers: a minute fresh at the edge and in browsers, a day of stale-while-revalidate, stale-if-error for outages. */ +export const CACHE_CONTROL = `public, max-age=${TTL}, s-maxage=${TTL}, stale-while-revalidate=86400, stale-if-error=604800`; + +/** Headers for every edge answer. A `stale` answer is short-lived so the next request retries GitHub. */ +export function edgeHeaders(extra: Record = {}, stale = false): Headers { + const h = new Headers(extra); + h.set("cache-control", stale ? "public, max-age=15, stale-while-revalidate=60" : CACHE_CONTROL); + h.set("x-changelog-source", stale ? "stale-last-good" : "github-releases"); + return h; +} diff --git a/site/functions/_lib/page.test.ts b/site/functions/_lib/page.test.ts new file mode 100644 index 00000000..4d088545 --- /dev/null +++ b/site/functions/_lib/page.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { parseChangelogMd, renderRelease } from "./changelog"; +import { changelogBody } from "./page"; + +const rels = parseChangelogMd( + "## 3.0.0\n\n- big\n\n## Special thanks\n\nTo A. 💛\n\n## 2.4.0-beta.1\n\n- b\n\n## 2.3.2\n\n- fix x\n" +).map(renderRelease); + +describe("changelogBody", () => { + const html = changelogBody(rels, { stale: false }); + test("one section per version with an anchor, newest first", () => { + expect(html.indexOf('id="v3-0-0"')).toBeLessThan(html.indexOf('id="v2-4-0-beta-1"')); + expect(html.indexOf('id="v2-4-0-beta-1"')).toBeLessThan(html.indexOf('id="v2-3-2"')); + expect(html.match(/
    { + for (const r of rels) expect(html).toContain(`
  • ${r.version}
  • `); + }); + test("channel badge: latest for stable, the prerelease id otherwise", () => { + expect(html).toContain('data-channel="latest"'); + expect(html).toContain('beta'); + }); + test("links to npm and the GitHub release, and shows the special thanks once", () => { + expect(html).toContain('href="https://www.npmjs.com/package/@codeatlasdev/ui/v/3.0.0"'); + expect(html).toContain("github.com/codeatlasdev/ui/releases/tag/"); + expect(html.match(/Special thanks/g)).toHaveLength(1); + expect(html).toContain("To A. 💛"); + }); + test("release markup is escaped and a stale copy says so", () => { + expect(html).not.toContain("x"); + expect(changelogBody(rels, { stale: true })).toContain("last copy we fetched"); + expect(html).not.toContain("last copy we fetched"); + }); +}); diff --git a/site/functions/_lib/page.ts b/site/functions/_lib/page.ts new file mode 100644 index 00000000..3a52a04d --- /dev/null +++ b/site/functions/_lib/page.ts @@ -0,0 +1,63 @@ +// HTML of the changelog article, shared by the static build (offline fallback) and the edge function so both render +// the same markup. Only public `ca-*` classes and `data-*` attributes of @codeatlasdev/ui are used; the page chrome +// (header, nav, search, theme) is the package's DocsShell around it. +import type { Channel, RenderedRelease } from "./changelog"; + +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +const fmtDate = (iso: string) => + iso + ? new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }) + : ""; + +const CHIP: Record = { + latest: "brand", + alpha: "warning", + beta: "info", + rc: "info", +}; + +/** The channel badge: `latest` for a stable release, the prerelease id otherwise. */ +export const channelBadge = (c: Channel) => + `${esc(c)}`; + +export interface BodyOpts { + /** GitHub could not be reached; this is the last good copy */ + stale: boolean; + /** Rendered at build time from the checkout, not from the latest release */ + fallback?: boolean; +} + +/** The special thanks every release closes with (identical across releases): shown once, in the footer, from the newest release that has it. */ +export const thanksOf = (releases: RenderedRelease[]) => + releases.find((r) => r.thanks)?.thanks ?? ""; + +/** The inside of `
    `: one `
    ` per version, newest first, then the thanks footer. */ +export function changelogBody(releases: RenderedRelease[], o: BodyOpts): string { + const note = o.stale + ? `

    GitHub is unreachable right now, so this is the last copy we fetched. It refreshes by itself.

    ` + : ""; + const sections = releases + .map( + (r) => + `
    ` + + `

    ${esc(r.version)} ${channelBadge(r.channel)}

    ` + + `

    ${r.date ? `` : ""}` + + `npm` + + `GitHub release

    ` + + `${r.html}
    ` + ) + .join("\n"); + const thanks = thanksOf(releases); + const foot = thanks + ? `

    Special thanks

    ${esc(thanks)}

    ` + : ""; + const jump = releases.map((r) => `
  • ${esc(r.version)}
  • `).join(""); + return `${note}\n${sections}\n${foot}`; +} diff --git a/site/functions/changelog.json.ts b/site/functions/changelog.json.ts new file mode 100644 index 00000000..50b11a7e --- /dev/null +++ b/site/functions/changelog.json.ts @@ -0,0 +1,29 @@ +// GET /changelog.json -> every release as data, newest first: +// { releases: [{ version, id, date, channel, url, npm, html, thanks }] }. Same source and cache as the page. +import { renderRelease } from "./_lib/changelog"; +import { edgeHeaders, loadReleases } from "./_lib/edge"; + +interface Env { + GITHUB_TOKEN?: string; +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => { + try { + const { releases, stale } = await loadReleases({ + fresh: new URL(request.url).searchParams.get("fresh") === "1", + token: env.GITHUB_TOKEN, + }); + return Response.json( + { releases: releases.map(renderRelease) }, + { headers: edgeHeaders({ "access-control-allow-origin": "*" }, stale) } + ); + } catch (err) { + return Response.json( + { error: "changelog_unavailable", detail: String(err) }, + { status: 502, headers: { "cache-control": "no-store" } } + ); + } +}; + +// HEAD answers like GET (Pages routes methods separately); the body is dropped by the runtime. +export const onRequestHead = onRequestGet; diff --git a/site/functions/changelog/feed.xml.ts b/site/functions/changelog/feed.xml.ts new file mode 100644 index 00000000..e5a8dbe0 --- /dev/null +++ b/site/functions/changelog/feed.xml.ts @@ -0,0 +1,26 @@ +// GET /changelog/feed.xml -> Atom feed of the releases, from the same source and cache as the page. +import { atomFeed, renderRelease } from "../_lib/changelog"; +import { edgeHeaders, loadReleases } from "../_lib/edge"; + +interface Env { + GITHUB_TOKEN?: string; +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => { + try { + const { releases, stale } = await loadReleases({ + fresh: new URL(request.url).searchParams.get("fresh") === "1", + token: env.GITHUB_TOKEN, + }); + return new Response(atomFeed(releases.map(renderRelease)), { + headers: edgeHeaders({ "content-type": "application/atom+xml; charset=utf-8" }, stale), + }); + } catch (err) { + return new Response(`changelog unavailable: ${String(err)}`, { + status: 502, + headers: { "cache-control": "no-store", "content-type": "text/plain; charset=utf-8" }, + }); + } +}; + +export const onRequestHead = onRequestGet; diff --git a/site/functions/changelog/index.test.ts b/site/functions/changelog/index.test.ts new file mode 100644 index 00000000..6a9abab3 --- /dev/null +++ b/site/functions/changelog/index.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { onRequestGet } from "./index"; + +const SHELL = "

    BUILD COPY

    "; +const store = new Map(); +const cache = { + match: async (k: Request | string) => store.get(typeof k === "string" ? k : k.url)?.clone(), + put: async (k: Request | string, v: Response) => + void store.set(typeof k === "string" ? k : k.url, v.clone()), +}; +const realFetch = globalThis.fetch; +let down = false; + +const run = (url = "https://ui.codeatlas.com.br/changelog/", shell = SHELL) => + onRequestGet({ + request: new Request(url), + next: async () => new Response(shell), + env: {}, + } as unknown as Parameters[0]); + +beforeEach(() => { + store.clear(); + down = false; + (globalThis as unknown as { caches: unknown }).caches = { open: async () => cache }; + globalThis.fetch = (async () => { + if (down) throw new Error("GitHub is down"); + return Response.json([ + { tag_name: "@codeatlasdev/ui@9.9.9", body: "- shiny", published_at: "2026-10-15T00:00:00Z" }, + ]); + }) as unknown as typeof fetch; +}); +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("GET /changelog/", () => { + test("swaps the build copy for the releases GitHub lists, keeping the shell around it", async () => { + const res = await run(); + const html = await res.text(); + expect(html).toContain('id="v9-9-9"'); + expect(html).not.toContain("BUILD COPY"); + expect(html.startsWith("")).toBe(true); + expect(res.headers.get("x-changelog-latest")).toBe("9.9.9"); + expect(res.headers.get("x-changelog-source")).toBe("github-releases"); + }); + test("GitHub down and nothing cached: the build copy is served untouched", async () => { + down = true; + const res = await run(); + expect(await res.text()).toBe(SHELL); + expect(res.headers.get("x-changelog-source")).toBe("stale-last-good"); + }); + test("GitHub down after a good fetch: the last good releases, with the stale notice", async () => { + await run(); + store.delete("https://cache.invalid/releases"); + down = true; + const html = await (await run()).text(); + expect(html).toContain('id="v9-9-9"'); + expect(html).toContain("last copy we fetched"); + }); + test("a shell without the markers is served as is", async () => { + expect(await (await run(undefined, "

    x

    ")).text()).toBe("

    x

    "); + }); +}); diff --git a/site/functions/changelog/index.ts b/site/functions/changelog/index.ts new file mode 100644 index 00000000..a5cb8084 --- /dev/null +++ b/site/functions/changelog/index.ts @@ -0,0 +1,38 @@ +// GET /changelog/ -> the static page shell (header, nav, theme, styles from the last build) with the article swapped for the +// releases GitHub lists right now (edge-cached about a minute). If GitHub is down the last good copy is served; with none the +// static build's own body is served untouched (the offline fallback), so the page is never empty. +import { renderRelease } from "../_lib/changelog"; +import { edgeHeaders, loadReleases } from "../_lib/edge"; +import { changelogBody } from "../_lib/page"; + +const OPEN = ""; +const CLOSE = ""; +const HTML = { "content-type": "text/html; charset=utf-8" }; + +interface Env { + GITHUB_TOKEN?: string; +} + +export const onRequestGet: PagesFunction = async ({ request, next, env }) => { + // `next()` serves the static asset for this path: the shell, and the offline fallback body between the markers. + const html = await (await next()).text(); + const a = html.indexOf(OPEN); + const b = html.indexOf(CLOSE); + if (a < 0 || b < a) return new Response(html, { headers: edgeHeaders(HTML, true) }); + try { + const { releases, stale } = await loadReleases({ + fresh: new URL(request.url).searchParams.get("fresh") === "1", + token: env.GITHUB_TOKEN, + }); + // The build copy (between the markers) only matters when GitHub fails, so it is not parsed again here. + const body = changelogBody(releases.map(renderRelease), { stale }); + return new Response(`${html.slice(0, a)}${body}${html.slice(b + CLOSE.length)}`, { + headers: edgeHeaders({ ...HTML, "x-changelog-latest": releases[0]?.version ?? "" }, stale), + }); + } catch { + return new Response(html, { headers: edgeHeaders(HTML, true) }); + } +}; + +// HEAD answers like GET (Pages routes methods separately); the body is dropped by the runtime. +export const onRequestHead = onRequestGet; diff --git a/site/functions/tsconfig.json b/site/functions/tsconfig.json new file mode 100644 index 00000000..35b79614 --- /dev/null +++ b/site/functions/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "lib": ["ES2023"], + "types": ["@cloudflare/workers-types"] + }, + "include": ["./**/*"], + "exclude": ["./**/*.test.ts"] +} diff --git a/site/functions/tsconfig.test.json b/site/functions/tsconfig.test.json new file mode 100644 index 00000000..825ee1f5 --- /dev/null +++ b/site/functions/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "types": ["bun", "@cloudflare/workers-types"], "lib": ["ES2023"] }, + "include": ["./**/*"], + "exclude": [] +} diff --git a/site/package.json b/site/package.json index afbc9aa4..30813bd2 100644 --- a/site/package.json +++ b/site/package.json @@ -10,7 +10,8 @@ "precheck": "bun run brand", "build": "astro build", "preview": "astro preview --port 4321 --force", - "check": "astro check --minimumFailingSeverity warning", + "check": "astro check --minimumFailingSeverity warning && tsc -p functions/tsconfig.json && tsc -p functions/tsconfig.test.json", + "test:unit": "bun test ./functions", "test:e2e": "bun ../scripts/test-run.ts playwright test", "test:a11y": "bun ../scripts/test-run.ts playwright test --project=a11y" }, @@ -18,6 +19,7 @@ "@astrojs/mdx": "8.0.3", "@astrojs/sitemap": "3.7.4", "@astrojs/svelte": "9.0.1", + "@cloudflare/workers-types": "5.20261015.1", "@codeatlasdev/ui": "workspace:*", "@fontsource-variable/inter": "5.3.0", "@fontsource-variable/jetbrains-mono": "5.3.0", @@ -28,6 +30,7 @@ "echarts": "6.1.0", "gsap": "3.15.0", "lenis": "1.3.26", + "marked": "18.1.1", "png-to-ico": "3.0.2", "shiki": "4.5.0", "svelte": "5.57.2" diff --git a/site/src/lib/nav.ts b/site/src/lib/nav.ts index 5bd66bba..69b77f57 100644 --- a/site/src/lib/nav.ts +++ b/site/src/lib/nav.ts @@ -19,6 +19,7 @@ export const SECTIONS: DocsSection[] = [ { id: "docs", label: "Docs", href: "/docs/" }, { id: "templates", label: "Templates", href: "/templates/" }, { id: "theme", label: "Theme editor", href: "/theme/" }, + { id: "changelog", label: "Changelog", href: "/changelog/" }, ]; export interface DocMeta { diff --git a/site/src/pages/changelog/index.astro b/site/src/pages/changelog/index.astro new file mode 100644 index 00000000..14227c5e --- /dev/null +++ b/site/src/pages/changelog/index.astro @@ -0,0 +1,39 @@ +--- +import { readFileSync } from "node:fs"; +import { breadcrumbList, graph } from "@codeatlasdev/ui/seo"; +import SiteDocsShell from "~/components/docs/SiteDocsShell.svelte"; +import Base from "~/layouts/Base.astro"; +import { SECTIONS } from "~/lib/nav"; +import { THANKS } from "../../../../scripts/release-meta"; +import { parseChangelogMd, renderRelease } from "../../../functions/_lib/changelog"; +import { changelogBody } from "../../../functions/_lib/page"; + +// Offline fallback: the same renderer the edge function uses, over the checkout's packages/ui/CHANGELOG.md. The edge +// function (functions/changelog/index.ts) swaps everything between the CHANGELOG markers for the releases GitHub lists +// right now. What is built here is correct as of the last deploy and never relied on. +// Release bodies on GitHub close with the Special thanks section (scripts/release-meta.ts); the repo CHANGELOG does not, so it is added here. +const releases = parseChangelogMd(readFileSync("../packages/ui/CHANGELOG.md", "utf8")).map((r) => ({ ...r, body: `${r.body}\n\n${THANKS}` })); +const body = changelogBody(releases.map(renderRelease), { stale: false, fallback: true }); + +const SITE = "https://ui.codeatlas.com.br"; +const jsonLd = graph(breadcrumbList([{ name: "CodeAtlas UI", href: `${SITE}/` }, { name: "Changelog", href: `${SITE}/changelog/` }])); +--- + + + +
    +

    Releases

    +

    Changelog

    +

    + Every published version of @codeatlasdev/ui, newest first. + Follow it as an Atom feed or JSON. +

    + +
    +
    + diff --git a/site/tests/changelog.spec.ts b/site/tests/changelog.spec.ts new file mode 100644 index 00000000..35bff427 --- /dev/null +++ b/site/tests/changelog.spec.ts @@ -0,0 +1,64 @@ +import { expect, gotoReady, test } from "./fixtures"; + +// `astro preview` serves the static build, so this is the build-time fallback body (packages/ui/CHANGELOG.md). +// The edge function that swaps it for live GitHub Releases is covered by site/functions/**/*.test.ts and wrangler. +test.describe("changelog", () => { + test("lists every released version with its anchor, newest first", async ({ page }) => { + await gotoReady(page, "/changelog/"); + await expect(page.getByRole("heading", { level: 1, name: "Changelog" })).toBeVisible(); + const ids = await page.locator("section.ca-release").evaluateAll((els) => els.map((e) => e.id)); + expect(ids.slice(0, 2)).toEqual(["v3-0-0", "v2-3-2"]); + expect(ids).toContain("v2-3-1"); + for (const id of ["v3-0-0", "v2-3-2"]) { + await expect(page.locator(`#${id} > h2 a`)).toHaveAttribute("href", `#${id}`); + await expect(page.locator(`.ca-release-jump a[href="#${id}"]`)).toBeVisible(); + } + }); + + test("each release shows its channel and links to npm and GitHub", async ({ page }) => { + await gotoReady(page, "/changelog/"); + const release = page.locator("#v3-0-0"); + await expect(release.locator(".ca-chip")).toHaveText("latest"); + await expect(release.getByRole("link", { name: "npm" })).toHaveAttribute( + "href", + "https://www.npmjs.com/package/@codeatlasdev/ui/v/3.0.0" + ); + await expect(release.getByRole("link", { name: "GitHub release" })).toHaveAttribute( + "href", + /github\.com\/codeatlasdev\/ui\/releases\/tag\// + ); + }); + + test("jumping to a version lands on its section", async ({ page }) => { + await gotoReady(page, "/changelog/"); + await page.locator('.ca-release-jump a[href="#v2-3-2"]').click(); + await expect(page).toHaveURL(/#v2-3-2$/); + await expect(page.locator("#v2-3-2")).toBeInViewport(); + }); + + test("closes with the special thanks, once", async ({ page }) => { + await gotoReady(page, "/changelog/"); + await expect(page.getByRole("heading", { name: "Special thanks" })).toHaveCount(1); + await expect(page.locator(".ca-release-thanks")).toContainText("Fábio Henrique Araújo"); + }); + + test("is discoverable: nav link, canonical, feed and structured data", async ({ page }) => { + await gotoReady(page, "/changelog/"); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + "https://ui.codeatlas.com.br/changelog/" + ); + await expect(page.locator('link[type="application/atom+xml"]')).toHaveAttribute( + "href", + "/changelog/feed.xml" + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + /\/brand\/og\/changelog\.[0-9a-f]+\.png$/ + ); + expect(await page.locator('script[type="application/ld+json"]').textContent()).toContain( + "BreadcrumbList" + ); + await expect(page.locator(".ca-docs-sections a[aria-current=page]")).toHaveText("Changelog"); + }); +}); diff --git a/site/tsconfig.json b/site/tsconfig.json index 21320722..4fdc031e 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist"], + "exclude": ["dist", "functions"], "compilerOptions": { "paths": { "~/*": ["./src/*"] } } }