diff --git a/scripts/api-reference-site/api-reference-site.test.mjs b/scripts/api-reference-site/api-reference-site.test.mjs
index 9b47853..0bcba5d 100644
--- a/scripts/api-reference-site/api-reference-site.test.mjs
+++ b/scripts/api-reference-site/api-reference-site.test.mjs
@@ -2,6 +2,7 @@ import { strict as assert } from "node:assert"
import { test } from "node:test"
import {
highlightCode,
+ githubRepository,
moduleRoute,
normalizeBasePath,
normalizeOrigin,
@@ -76,6 +77,7 @@ const site = {
package: {
name: "@typeonce/effect-machine",
description: "Schema-first state machines",
+ repositoryUrl: "https://github.com/typeonce-dev/effect-machine",
sourceUrl: "https://github.com/typeonce-dev/effect-machine",
version: "0.4.0"
},
@@ -98,6 +100,30 @@ test("renders canonical and social metadata without exposing the internal channe
assert.doesNotMatch(html, /v4 API reference/)
})
+test("links the header to the repository root and exposes its star-count target", () => {
+ const html = renderLayout(site, {
+ content: "",
+ currentRoute: "",
+ pageKind: "overview",
+ title: "Effect Machine"
+ })
+ assert.match(
+ html,
+ /href="https:\/\/github\.com\/typeonce-dev\/effect-machine" aria-label="View typeonce-dev\/effect-machine on GitHub"/
+ )
+ assert.match(html, /data-github-stars="typeonce-dev\/effect-machine" hidden/)
+ assert.doesNotMatch(html, /github-link[^>]+\/tree\//)
+})
+
+test("accepts only root GitHub repository URLs for the header integration", () => {
+ assert.equal(githubRepository("https://github.com/typeonce-dev/effect-machine"), "typeonce-dev/effect-machine")
+ assert.throws(
+ () => githubRepository("https://github.com/typeonce-dev/effect-machine/tree/main"),
+ /GitHub repository URL/
+ )
+ assert.throws(() => githubRepository("https://example.com/owner/repository"), /GitHub repository URL/)
+})
+
test("keeps the internal Effect channel out of the homepage label", () => {
const html = renderIndexPage({ ...site, channel: "v4" })
assert.match(html, /
API reference<\/div>/)
diff --git a/scripts/api-reference-site/assets/client.js b/scripts/api-reference-site/assets/client.js
index 8aa3731..c10014c 100644
--- a/scripts/api-reference-site/assets/client.js
+++ b/scripts/api-reference-site/assets/client.js
@@ -8,6 +8,7 @@ const searchDialog = document.querySelector("[data-search-dialog]")
const searchInput = document.querySelector("[data-search-input]")
const searchStatus = document.querySelector("[data-search-status]")
const searchResults = document.querySelector("[data-search-results]")
+const githubStars = document.querySelector("[data-github-stars]")
const themes = ["auto", "light", "dark"]
const themeLabels = { auto: "System theme", light: "Light theme", dark: "Dark theme" }
@@ -27,6 +28,39 @@ themeButton?.addEventListener("click", () => {
})
updateThemeButton()
+const showGitHubStars = (count) => {
+ const countElement = githubStars?.querySelector("[data-github-star-count]")
+ if (githubStars === null || countElement === null || !Number.isSafeInteger(count) || count < 0) return
+ countElement.textContent = new Intl.NumberFormat(undefined, {
+ maximumFractionDigits: 1,
+ notation: count >= 1_000 ? "compact" : "standard"
+ }).format(count)
+ githubStars.title = `${count.toLocaleString()} GitHub star${count === 1 ? "" : "s"}`
+ githubStars.hidden = false
+}
+
+const loadGitHubStars = async () => {
+ const repository = githubStars?.dataset.githubStars
+ if (repository === undefined) return
+ const cacheKey = `api-reference:github-stars:${repository}`
+ try {
+ const cached = sessionStorage.getItem(cacheKey)
+ if (cached !== null) {
+ showGitHubStars(Number(cached))
+ return
+ }
+ const response = await fetch(`https://api.github.com/repos/${repository}`)
+ if (!response.ok) return
+ const body = await response.json()
+ if (!Number.isSafeInteger(body.stargazers_count) || body.stargazers_count < 0) return
+ sessionStorage.setItem(cacheKey, String(body.stargazers_count))
+ showGitHubStars(body.stargazers_count)
+ } catch {
+ // The repository link remains usable when storage or GitHub is unavailable.
+ }
+}
+void loadGitHubStars()
+
const setNavigationOpen = (open) => {
document.body.classList.toggle("navigation-is-open", open)
navigationButton?.setAttribute("aria-expanded", String(open))
diff --git a/scripts/api-reference-site/assets/styles.css b/scripts/api-reference-site/assets/styles.css
index 78a7e88..5bec996 100644
--- a/scripts/api-reference-site/assets/styles.css
+++ b/scripts/api-reference-site/assets/styles.css
@@ -215,6 +215,40 @@ kbd {
font-weight: 600;
}
+.github-link {
+ align-items: stretch;
+ border: 1px solid var(--border);
+ border-radius: 0.55rem;
+ display: inline-flex;
+ overflow: hidden;
+}
+
+.github-link:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.github-link__label,
+.github-stars {
+ align-items: center;
+ display: inline-flex;
+ padding: 0.45rem 0.6rem;
+}
+
+.github-stars {
+ border-left: 1px solid var(--border);
+ gap: 0.3rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.github-stars[hidden] {
+ display: none;
+}
+
+.github-stars svg {
+ fill: currentColor;
+}
+
.icon-button {
background: transparent;
border: 0;
diff --git a/scripts/api-reference-site/generate.mjs b/scripts/api-reference-site/generate.mjs
index f416582..3d3e15d 100644
--- a/scripts/api-reference-site/generate.mjs
+++ b/scripts/api-reference-site/generate.mjs
@@ -50,7 +50,7 @@ const readSiteModel = (inputDirectory, config) => {
const packageManifestPath = safeResolve(inputDirectory, packageEntry.manifest)
const packageDirectory = dirname(packageManifestPath)
const packageManifest = readJson(packageManifestPath)
- if (packageManifest.schemaVersion !== 3 || !Array.isArray(packageManifest.modules)) {
+ if (packageManifest.schemaVersion !== 4 || !Array.isArray(packageManifest.modules)) {
throw new Error("Unsupported API reference package manifest")
}
@@ -294,7 +294,9 @@ export const renderLayout = (site, { content, currentRoute, description, pageKin
`
}
-const renderHeader = (site) => `
+const renderHeader = (site) => {
+ const repository = githubRepository(site.package.repositoryUrl)
+ return `
Skip to content
`
+}
+
+export const githubRepository = (value) => {
+ const url = new URL(value)
+ const segments = url.pathname.split("/").filter(Boolean)
+ if (url.protocol !== "https:" || url.hostname !== "github.com" || segments.length !== 2) {
+ throw new Error(`Expected a GitHub repository URL, received ${value}`)
+ }
+ return segments.join("/")
+}
const renderNavigation = (site, currentRoute) => `