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
26 changes: 26 additions & 0 deletions scripts/api-reference-site/api-reference-site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { strict as assert } from "node:assert"
import { test } from "node:test"
import {
highlightCode,
githubRepository,
moduleRoute,
normalizeBasePath,
normalizeOrigin,
Expand Down Expand Up @@ -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"
},
Expand All @@ -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, /<div class="eyebrow">API reference<\/div>/)
Expand Down
34 changes: 34 additions & 0 deletions scripts/api-reference-site/assets/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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))
Expand Down
34 changes: 34 additions & 0 deletions scripts/api-reference-site/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 21 additions & 3 deletions scripts/api-reference-site/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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 `
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="site-header" data-pagefind-ignore>
<div class="site-header__brand">
Expand All @@ -310,10 +312,26 @@ const renderHeader = (site) => `
<span>Search the API</span>
<kbd>⌘ K</kbd>
</button>
<a class="header-link" href="${escapeAttribute(site.package.sourceUrl)}">GitHub</a>
<a class="header-link github-link" href="${escapeAttribute(site.package.repositoryUrl)}" aria-label="View ${escapeAttribute(repository)} on GitHub">
<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>
</a>
<button class="icon-button theme-button" type="button" aria-label="Change color theme" title="Change color theme">Theme</button>
</div>
</header>`
}

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) => `
<aside class="module-navigation" id="module-navigation" aria-label="API modules" data-pagefind-ignore>
Expand Down
8 changes: 5 additions & 3 deletions scripts/api-reference/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,14 @@ const generateDataset = async (config, outputDirectory) => {
const packageSourceUrl = `${repositoryUrl}/tree/${revision}${packageSourcePath === "" ? "" : `/${packageSourcePath}`}`
const packageManifestOutput = join(packageOutputDirectory, "manifest.json")
writeJson(packageManifestOutput, {
schemaVersion: 3,
schemaVersion: 4,
channel: config.channel,
name: packageManifest.name,
version: packageManifest.version,
revision,
description: packageManifest.description ?? packageManifest.name,
npmUrl: `https://www.npmjs.com/package/${packageManifest.name}`,
repositoryUrl,
sourceUrl: packageSourceUrl,
barrels: barrels.map((barrel) => ({
export: barrel.export,
Expand Down Expand Up @@ -177,11 +178,12 @@ export const validateDataset = (outputDirectory) => {
const packageManifestPath = safeResolve(outputDirectory, packageEntry.manifest)
const packageManifest = readJson(packageManifestPath)
if (
packageManifest.schemaVersion !== 3 ||
packageManifest.schemaVersion !== 4 ||
packageManifest.channel !== dataset.channel ||
packageManifest.revision !== dataset.revision ||
packageManifest.name !== packageEntry.name ||
packageManifest.version !== packageEntry.version
packageManifest.version !== packageEntry.version ||
typeof packageManifest.repositoryUrl !== "string"
) {
throw new Error(`Package manifest does not match dataset entry: ${packageManifestPath}`)
}
Expand Down