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 ` ` +} + +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) => `