Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 28 additions & 2 deletions scripts/api-reference-site/api-reference-site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
githubRepository,
moduleRoute,
normalizeBasePath,
normalizeGitHubStars,
normalizeOrigin,
renderIndexPage,
renderLayout,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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, /<span>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(
Expand Down
41 changes: 4 additions & 37 deletions scripts/api-reference-site/assets/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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))
Expand Down
4 changes: 0 additions & 4 deletions scripts/api-reference-site/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,6 @@ kbd {
font-variant-numeric: tabular-nums;
}

.github-stars[hidden] {
display: none;
}

.github-stars svg {
fill: currentColor;
}
Expand Down
35 changes: 29 additions & 6 deletions scripts/api-reference-site/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="site-header" data-pagefind-ignore>
Expand All @@ -312,18 +316,24 @@ const renderHeader = (site) => {
<span>Search the API</span>
<kbd>⌘ K</kbd>
</button>
<a class="header-link github-link" href="${escapeAttribute(site.package.repositoryUrl)}" aria-label="View ${escapeAttribute(repository)} on GitHub">
<a class="header-link github-link" href="${escapeAttribute(site.package.repositoryUrl)}" aria-label="${escapeAttribute(githubLabel)}">
<span class="github-link__label">GitHub</span>
<span class="github-stars" data-github-stars="${escapeAttribute(repository)}" hidden>
<svg aria-hidden="true" viewBox="0 0 16 16" width="14" height="14"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.193a.75.75 0 0 1-1.088.79L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194-3.047-2.97a.75.75 0 0 1 .416-1.278l4.21-.612L7.327.668A.75.75 0 0 1 8 .25Z"/></svg>
<span data-github-star-count></span>
</span>
${stars}
</a>
<button class="icon-button theme-button" type="button" aria-label="Change color theme" title="Change color theme">Theme</button>
</div>
</header>`
}

const renderGitHubStars = (count) => count === undefined ? "" : `
<span class="github-stars" title="${escapeAttribute(githubStarsLabel(count))}">
<svg aria-hidden="true" viewBox="0 0 16 16" width="14" height="14"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.193a.75.75 0 0 1-1.088.79L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194-3.047-2.97a.75.75 0 0 1 .416-1.278l4.21-.612L7.327.668A.75.75 0 0 1 8 .25Z"/></svg>
<span>${new Intl.NumberFormat("en-US").format(count)}</span>
</span>`

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)
Expand Down Expand Up @@ -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) => {
Expand Down