diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index fd82eb1..39d1129 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -30,6 +30,12 @@ jobs: - run: pnpm install --frozen-lockfile - id: pages uses: actions/configure-pages@v5 + - name: Read repository stars + env: + GH_TOKEN: ${{ github.token }} + run: | + stars="$(gh api "repos/$GITHUB_REPOSITORY" --jq .stargazers_count)" + echo "API_REFERENCE_GITHUB_STARS=$stars" >> "$GITHUB_ENV" - run: pnpm docs:site env: API_REFERENCE_BASE_PATH: ${{ steps.pages.outputs.base_path }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 653b362..df4447f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,4 +77,7 @@ The release workflow calls the GitHub Pages workflow after Changesets publishes a package. The Pages workflow can also be run manually to deploy the current commit before a release without invoking the package-release job. Pages supplies `API_REFERENCE_BASE_PATH` during the build so project URLs and custom domains -use the same generated site without configuration edits. +use the same generated site without configuration edits. It also reads the +repository star count through GitHub's API and supplies it as +`API_REFERENCE_GITHUB_STARS`, keeping the deployed badge independent of +browser-side API access. diff --git a/scripts/api-reference-site/api-reference-site.test.mjs b/scripts/api-reference-site/api-reference-site.test.mjs index 0bcba5d..21576af 100644 --- a/scripts/api-reference-site/api-reference-site.test.mjs +++ b/scripts/api-reference-site/api-reference-site.test.mjs @@ -5,6 +5,7 @@ import { githubRepository, moduleRoute, normalizeBasePath, + normalizeGitHubStars, normalizeOrigin, renderIndexPage, renderLayout, @@ -66,6 +67,7 @@ test("normalizes root, project, and custom-domain base paths", () => { const site = { basePath: "/docs/", description: "Schema-first state machines", + githubStars: 1_234, modules: [{ api: { declarationCount: 12, description: "State machine APIs" }, export: "./Machine", @@ -109,12 +111,36 @@ test("links the header to the repository root and exposes its star-count target" }) assert.match( html, - /href="https:\/\/github\.com\/typeonce-dev\/effect-machine" aria-label="View typeonce-dev\/effect-machine on GitHub"/ + /href="https:\/\/github\.com\/typeonce-dev\/effect-machine" aria-label="View typeonce-dev\/effect-machine on GitHub \(1,234 GitHub stars\)"/ ) - assert.match(html, /data-github-stars="typeonce-dev\/effect-machine" hidden/) + assert.match(html, /class="github-stars" title="1,234 GitHub stars"/) + assert.match(html, /1,234<\/span>/) + assert.doesNotMatch(html, /class="github-stars"[^>]*(?:data-github-stars|hidden)/) assert.doesNotMatch(html, /github-link[^>]+\/tree\//) }) +test("omits the star badge when the build does not supply a count", () => { + const html = renderLayout({ ...site, githubStars: undefined }, { + content: "", + currentRoute: "", + pageKind: "overview", + title: "Effect Machine" + }) + assert.match(html, /aria-label="View typeonce-dev\/effect-machine on GitHub"/) + assert.doesNotMatch(html, /class="github-stars"/) +}) + +test("accepts only non-negative safe integers for build-time GitHub stars", () => { + assert.equal(normalizeGitHubStars(undefined), undefined) + assert.equal(normalizeGitHubStars(""), undefined) + assert.equal(normalizeGitHubStars("0"), 0) + assert.equal(normalizeGitHubStars("1234"), 1_234) + assert.throws(() => normalizeGitHubStars("-1"), /non-negative integer/) + assert.throws(() => normalizeGitHubStars("1.5"), /non-negative integer/) + assert.throws(() => normalizeGitHubStars("01"), /non-negative integer/) + assert.throws(() => normalizeGitHubStars("9007199254740992"), /safe integer range/) +}) + 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( diff --git a/scripts/api-reference-site/assets/client.js b/scripts/api-reference-site/assets/client.js index c10014c..1ce30e2 100644 --- a/scripts/api-reference-site/assets/client.js +++ b/scripts/api-reference-site/assets/client.js @@ -8,15 +8,15 @@ 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" } +const themeLabels = { auto: "System", light: "Light", dark: "Dark" } const updateThemeButton = () => { const theme = root.dataset.theme ?? "auto" - themeButton.textContent = theme === "dark" ? "Light" : theme === "light" ? "Dark" : "Theme" - themeButton.title = themeLabels[theme] + const label = themeLabels[theme] + themeButton.textContent = label + themeButton.title = `Current theme: ${label}` } themeButton?.addEventListener("click", () => { @@ -28,39 +28,6 @@ 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 5bec996..c3f1adc 100644 --- a/scripts/api-reference-site/assets/styles.css +++ b/scripts/api-reference-site/assets/styles.css @@ -241,10 +241,6 @@ kbd { font-variant-numeric: tabular-nums; } -.github-stars[hidden] { - display: none; -} - .github-stars svg { fill: currentColor; } diff --git a/scripts/api-reference-site/generate.mjs b/scripts/api-reference-site/generate.mjs index 3d3e15d..25ca7c0 100644 --- a/scripts/api-reference-site/generate.mjs +++ b/scripts/api-reference-site/generate.mjs @@ -296,6 +296,10 @@ export const renderLayout = (site, { content, currentRoute, description, pageKin const renderHeader = (site) => { const repository = githubRepository(site.package.repositoryUrl) + const stars = renderGitHubStars(site.githubStars) + const githubLabel = stars === "" + ? `View ${repository} on GitHub` + : `View ${repository} on GitHub (${githubStarsLabel(site.githubStars)})` return ` ` } +const renderGitHubStars = (count) => count === undefined ? "" : ` + + + ${new Intl.NumberFormat("en-US").format(count)} + ` + +const githubStarsLabel = (count) => + `${new Intl.NumberFormat("en-US").format(count)} GitHub star${count === 1 ? "" : "s"}` + export const githubRepository = (value) => { const url = new URL(value) const segments = url.pathname.split("/").filter(Boolean) @@ -542,8 +552,21 @@ const readConfig = (path) => { return { ...config, origin: normalizeOrigin(config.origin), - basePath: normalizeBasePath(process.env.API_REFERENCE_BASE_PATH ?? config.basePath) + basePath: normalizeBasePath(process.env.API_REFERENCE_BASE_PATH ?? config.basePath), + githubStars: normalizeGitHubStars(process.env.API_REFERENCE_GITHUB_STARS) + } +} + +export const normalizeGitHubStars = (value) => { + if (value === undefined || value === "") return undefined + if (!/^(0|[1-9]\d*)$/.test(value)) { + throw new Error("API_REFERENCE_GITHUB_STARS must be a non-negative integer") + } + const count = Number(value) + if (!Number.isSafeInteger(count)) { + throw new Error("API_REFERENCE_GITHUB_STARS exceeds the safe integer range") } + return count } export const normalizeOrigin = (value) => {