Skip to content

Commit 7b8fe32

Browse files
Show GitHub stars in API reference header (#94)
Point the API reference header at the repository root and load the public GitHub star count into a session-cached badge.
1 parent 31e3b43 commit 7b8fe32

5 files changed

Lines changed: 120 additions & 6 deletions

File tree

scripts/api-reference-site/api-reference-site.test.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { strict as assert } from "node:assert"
22
import { test } from "node:test"
33
import {
44
highlightCode,
5+
githubRepository,
56
moduleRoute,
67
normalizeBasePath,
78
normalizeOrigin,
@@ -76,6 +77,7 @@ const site = {
7677
package: {
7778
name: "@typeonce/effect-machine",
7879
description: "Schema-first state machines",
80+
repositoryUrl: "https://github.com/typeonce-dev/effect-machine",
7981
sourceUrl: "https://github.com/typeonce-dev/effect-machine",
8082
version: "0.4.0"
8183
},
@@ -98,6 +100,30 @@ test("renders canonical and social metadata without exposing the internal channe
98100
assert.doesNotMatch(html, /v4 API reference/)
99101
})
100102

103+
test("links the header to the repository root and exposes its star-count target", () => {
104+
const html = renderLayout(site, {
105+
content: "",
106+
currentRoute: "",
107+
pageKind: "overview",
108+
title: "Effect Machine"
109+
})
110+
assert.match(
111+
html,
112+
/href="https:\/\/github\.com\/typeonce-dev\/effect-machine" aria-label="View typeonce-dev\/effect-machine on GitHub"/
113+
)
114+
assert.match(html, /data-github-stars="typeonce-dev\/effect-machine" hidden/)
115+
assert.doesNotMatch(html, /github-link[^>]+\/tree\//)
116+
})
117+
118+
test("accepts only root GitHub repository URLs for the header integration", () => {
119+
assert.equal(githubRepository("https://github.com/typeonce-dev/effect-machine"), "typeonce-dev/effect-machine")
120+
assert.throws(
121+
() => githubRepository("https://github.com/typeonce-dev/effect-machine/tree/main"),
122+
/GitHub repository URL/
123+
)
124+
assert.throws(() => githubRepository("https://example.com/owner/repository"), /GitHub repository URL/)
125+
})
126+
101127
test("keeps the internal Effect channel out of the homepage label", () => {
102128
const html = renderIndexPage({ ...site, channel: "v4" })
103129
assert.match(html, /<div class="eyebrow">API reference<\/div>/)

scripts/api-reference-site/assets/client.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const searchDialog = document.querySelector("[data-search-dialog]")
88
const searchInput = document.querySelector("[data-search-input]")
99
const searchStatus = document.querySelector("[data-search-status]")
1010
const searchResults = document.querySelector("[data-search-results]")
11+
const githubStars = document.querySelector("[data-github-stars]")
1112

1213
const themes = ["auto", "light", "dark"]
1314
const themeLabels = { auto: "System theme", light: "Light theme", dark: "Dark theme" }
@@ -27,6 +28,39 @@ themeButton?.addEventListener("click", () => {
2728
})
2829
updateThemeButton()
2930

31+
const showGitHubStars = (count) => {
32+
const countElement = githubStars?.querySelector("[data-github-star-count]")
33+
if (githubStars === null || countElement === null || !Number.isSafeInteger(count) || count < 0) return
34+
countElement.textContent = new Intl.NumberFormat(undefined, {
35+
maximumFractionDigits: 1,
36+
notation: count >= 1_000 ? "compact" : "standard"
37+
}).format(count)
38+
githubStars.title = `${count.toLocaleString()} GitHub star${count === 1 ? "" : "s"}`
39+
githubStars.hidden = false
40+
}
41+
42+
const loadGitHubStars = async () => {
43+
const repository = githubStars?.dataset.githubStars
44+
if (repository === undefined) return
45+
const cacheKey = `api-reference:github-stars:${repository}`
46+
try {
47+
const cached = sessionStorage.getItem(cacheKey)
48+
if (cached !== null) {
49+
showGitHubStars(Number(cached))
50+
return
51+
}
52+
const response = await fetch(`https://api.github.com/repos/${repository}`)
53+
if (!response.ok) return
54+
const body = await response.json()
55+
if (!Number.isSafeInteger(body.stargazers_count) || body.stargazers_count < 0) return
56+
sessionStorage.setItem(cacheKey, String(body.stargazers_count))
57+
showGitHubStars(body.stargazers_count)
58+
} catch {
59+
// The repository link remains usable when storage or GitHub is unavailable.
60+
}
61+
}
62+
void loadGitHubStars()
63+
3064
const setNavigationOpen = (open) => {
3165
document.body.classList.toggle("navigation-is-open", open)
3266
navigationButton?.setAttribute("aria-expanded", String(open))

scripts/api-reference-site/assets/styles.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,40 @@ kbd {
215215
font-weight: 600;
216216
}
217217

218+
.github-link {
219+
align-items: stretch;
220+
border: 1px solid var(--border);
221+
border-radius: 0.55rem;
222+
display: inline-flex;
223+
overflow: hidden;
224+
}
225+
226+
.github-link:hover {
227+
border-color: var(--accent);
228+
color: var(--accent);
229+
}
230+
231+
.github-link__label,
232+
.github-stars {
233+
align-items: center;
234+
display: inline-flex;
235+
padding: 0.45rem 0.6rem;
236+
}
237+
238+
.github-stars {
239+
border-left: 1px solid var(--border);
240+
gap: 0.3rem;
241+
font-variant-numeric: tabular-nums;
242+
}
243+
244+
.github-stars[hidden] {
245+
display: none;
246+
}
247+
248+
.github-stars svg {
249+
fill: currentColor;
250+
}
251+
218252
.icon-button {
219253
background: transparent;
220254
border: 0;

scripts/api-reference-site/generate.mjs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const readSiteModel = (inputDirectory, config) => {
5050
const packageManifestPath = safeResolve(inputDirectory, packageEntry.manifest)
5151
const packageDirectory = dirname(packageManifestPath)
5252
const packageManifest = readJson(packageManifestPath)
53-
if (packageManifest.schemaVersion !== 3 || !Array.isArray(packageManifest.modules)) {
53+
if (packageManifest.schemaVersion !== 4 || !Array.isArray(packageManifest.modules)) {
5454
throw new Error("Unsupported API reference package manifest")
5555
}
5656

@@ -294,7 +294,9 @@ export const renderLayout = (site, { content, currentRoute, description, pageKin
294294
`
295295
}
296296

297-
const renderHeader = (site) => `
297+
const renderHeader = (site) => {
298+
const repository = githubRepository(site.package.repositoryUrl)
299+
return `
298300
<a class="skip-link" href="#main-content">Skip to content</a>
299301
<header class="site-header" data-pagefind-ignore>
300302
<div class="site-header__brand">
@@ -310,10 +312,26 @@ const renderHeader = (site) => `
310312
<span>Search the API</span>
311313
<kbd>⌘ K</kbd>
312314
</button>
313-
<a class="header-link" href="${escapeAttribute(site.package.sourceUrl)}">GitHub</a>
315+
<a class="header-link github-link" href="${escapeAttribute(site.package.repositoryUrl)}" aria-label="View ${escapeAttribute(repository)} on GitHub">
316+
<span class="github-link__label">GitHub</span>
317+
<span class="github-stars" data-github-stars="${escapeAttribute(repository)}" hidden>
318+
<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>
319+
<span data-github-star-count></span>
320+
</span>
321+
</a>
314322
<button class="icon-button theme-button" type="button" aria-label="Change color theme" title="Change color theme">Theme</button>
315323
</div>
316324
</header>`
325+
}
326+
327+
export const githubRepository = (value) => {
328+
const url = new URL(value)
329+
const segments = url.pathname.split("/").filter(Boolean)
330+
if (url.protocol !== "https:" || url.hostname !== "github.com" || segments.length !== 2) {
331+
throw new Error(`Expected a GitHub repository URL, received ${value}`)
332+
}
333+
return segments.join("/")
334+
}
317335

318336
const renderNavigation = (site, currentRoute) => `
319337
<aside class="module-navigation" id="module-navigation" aria-label="API modules" data-pagefind-ignore>

scripts/api-reference/generate.mjs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,14 @@ const generateDataset = async (config, outputDirectory) => {
139139
const packageSourceUrl = `${repositoryUrl}/tree/${revision}${packageSourcePath === "" ? "" : `/${packageSourcePath}`}`
140140
const packageManifestOutput = join(packageOutputDirectory, "manifest.json")
141141
writeJson(packageManifestOutput, {
142-
schemaVersion: 3,
142+
schemaVersion: 4,
143143
channel: config.channel,
144144
name: packageManifest.name,
145145
version: packageManifest.version,
146146
revision,
147147
description: packageManifest.description ?? packageManifest.name,
148148
npmUrl: `https://www.npmjs.com/package/${packageManifest.name}`,
149+
repositoryUrl,
149150
sourceUrl: packageSourceUrl,
150151
barrels: barrels.map((barrel) => ({
151152
export: barrel.export,
@@ -177,11 +178,12 @@ export const validateDataset = (outputDirectory) => {
177178
const packageManifestPath = safeResolve(outputDirectory, packageEntry.manifest)
178179
const packageManifest = readJson(packageManifestPath)
179180
if (
180-
packageManifest.schemaVersion !== 3 ||
181+
packageManifest.schemaVersion !== 4 ||
181182
packageManifest.channel !== dataset.channel ||
182183
packageManifest.revision !== dataset.revision ||
183184
packageManifest.name !== packageEntry.name ||
184-
packageManifest.version !== packageEntry.version
185+
packageManifest.version !== packageEntry.version ||
186+
typeof packageManifest.repositoryUrl !== "string"
185187
) {
186188
throw new Error(`Package manifest does not match dataset entry: ${packageManifestPath}`)
187189
}

0 commit comments

Comments
 (0)