Skip to content

Commit f99f21a

Browse files
Add API website metadata and branding
1 parent ea7c522 commit f99f21a

15 files changed

Lines changed: 280 additions & 5 deletions

api-reference-site.config.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
"input": ".data/api-reference/v4",
33
"output": ".data/api-reference-site/v4",
44
"basePath": "/",
5+
"origin": "https://effect-machine.typeonce.dev",
56
"title": "Effect Machine",
6-
"description": "API reference for Effect Machine"
7+
"description": "Schema-first state machines and statecharts for Effect",
8+
"themeColor": {
9+
"light": "#fbfbfd",
10+
"dark": "#111115"
11+
},
12+
"socialImage": "social-card.png"
713
}

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import {
44
highlightCode,
55
moduleRoute,
66
normalizeBasePath,
7+
normalizeOrigin,
8+
renderIndexPage,
9+
renderLayout,
710
renderMarkdown,
11+
renderRobots,
12+
renderSitemap,
13+
siteManifest,
814
uniqueDeclarationIds
915
} from "./generate.mjs"
1016

@@ -55,3 +61,59 @@ test("normalizes root, project, and custom-domain base paths", () => {
5561
assert.throws(() => normalizeBasePath("effect-machine"), /start with a slash/)
5662
assert.throws(() => normalizeBasePath("/../effect-machine"), /Invalid/)
5763
})
64+
65+
const site = {
66+
basePath: "/docs/",
67+
description: "Schema-first state machines",
68+
modules: [{
69+
api: { declarationCount: 12, description: "State machine APIs" },
70+
export: "./Machine",
71+
label: "Machine",
72+
route: "Machine"
73+
}],
74+
navigation: [],
75+
origin: "https://docs.example.com",
76+
package: {
77+
name: "@typeonce/effect-machine",
78+
description: "Schema-first state machines",
79+
sourceUrl: "https://github.com/typeonce-dev/effect-machine",
80+
version: "0.4.0"
81+
},
82+
socialImage: "social-card.png",
83+
themeColor: { light: "#fbfbfd", dark: "#111115" },
84+
title: "Effect Machine"
85+
}
86+
87+
test("renders canonical and social metadata without exposing the internal channel", () => {
88+
const html = renderLayout(site, {
89+
content: '<section class="reference-hero"><div class="eyebrow">API reference</div></section>',
90+
currentRoute: "Machine",
91+
pageKind: "module",
92+
title: "Machine · Effect Machine"
93+
})
94+
assert.match(html, /rel="canonical" href="https:\/\/docs\.example\.com\/docs\/Machine\/"/)
95+
assert.match(html, /property="og:image" content="https:\/\/docs\.example\.com\/docs\/social-card\.png"/)
96+
assert.match(html, /name="twitter:card" content="summary_large_image"/)
97+
assert.match(html, /rel="manifest" href="\/docs\/site\.webmanifest"/)
98+
assert.doesNotMatch(html, /v4 API reference/)
99+
})
100+
101+
test("keeps the internal Effect channel out of the homepage label", () => {
102+
const html = renderIndexPage({ ...site, channel: "v4" })
103+
assert.match(html, /<div class="eyebrow">API reference<\/div>/)
104+
assert.doesNotMatch(html, /v4 API reference/)
105+
})
106+
107+
test("generates manifest, robots, and sitemap URLs from the deployment base", () => {
108+
const manifest = siteManifest(site)
109+
assert.equal(manifest.start_url, "/docs/")
110+
assert.equal(manifest.icons[2].purpose, "maskable")
111+
assert.match(renderRobots(site), /Sitemap: https:\/\/docs\.example\.com\/docs\/sitemap\.xml/)
112+
assert.match(renderSitemap(site), /<loc>https:\/\/docs\.example\.com\/docs\/Machine\/<\/loc>/)
113+
})
114+
115+
test("accepts only pathless HTTPS production origins", () => {
116+
assert.equal(normalizeOrigin("https://docs.example.com"), "https://docs.example.com")
117+
assert.throws(() => normalizeOrigin("http://docs.example.com"), /HTTPS origin/)
118+
assert.throws(() => normalizeOrigin("https://docs.example.com/api"), /without a path/)
119+
})
Lines changed: 15 additions & 0 deletions
Loading
Lines changed: 31 additions & 0 deletions
Loading

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,23 @@ button {
151151
gap: 0.65rem;
152152
}
153153

154+
.brand-mark {
155+
display: block;
156+
filter: invert(1);
157+
height: 1.55rem;
158+
width: 1.55rem;
159+
}
160+
161+
:root[data-theme="dark"] .brand-mark {
162+
filter: none;
163+
}
164+
165+
@media (prefers-color-scheme: dark) {
166+
:root[data-theme="auto"] .brand-mark {
167+
filter: none;
168+
}
169+
}
170+
154171
.brand-name {
155172
font-size: 0.95rem;
156173
font-weight: 700;

scripts/api-reference-site/generate.mjs

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
cpSync,
23
copyFileSync,
34
mkdirSync,
45
mkdtempSync,
@@ -101,9 +102,15 @@ const writeSite = (outputDirectory, site) => {
101102
for (const asset of ["styles.css", "client.js"]) {
102103
copyFileSync(join(scriptDirectory, "assets", asset), join(assetDirectory, asset))
103104
}
105+
for (const entry of readdirSync(join(scriptDirectory, "public"))) {
106+
cpSync(join(scriptDirectory, "public", entry), join(outputDirectory, entry), { recursive: true })
107+
}
108+
writeJson(join(outputDirectory, "site.webmanifest"), siteManifest(site))
109+
writeFileSync(join(outputDirectory, "robots.txt"), renderRobots(site))
110+
writeFileSync(join(outputDirectory, "sitemap.xml"), renderSitemap(site))
104111
}
105112

106-
const renderIndexPage = (site) => {
113+
export const renderIndexPage = (site) => {
107114
const moduleCards = site.modules.map((module) => `
108115
<a class="module-card" href="${siteUrl(site, module.route)}">
109116
<span class="module-card__path">${escapeHtml(displayExport(module.export))}</span>
@@ -113,7 +120,7 @@ const renderIndexPage = (site) => {
113120
</a>`).join("")
114121
const content = `
115122
<section class="reference-hero" data-pagefind-meta="type:overview">
116-
<div class="eyebrow">${escapeHtml(site.channel)} API reference</div>
123+
<div class="eyebrow">API reference</div>
117124
<h1>${escapeHtml(site.package.name)}</h1>
118125
<p>${escapeHtml(site.package.description)}</p>
119126
<div class="hero-meta">
@@ -233,15 +240,39 @@ const renderDeclaration = (declaration, id) => {
233240
</article>`
234241
}
235242

236-
const renderLayout = (site, { content, currentRoute, description, pageKind, title, toc = "" }) => `<!doctype html>
243+
export const renderLayout = (site, { content, currentRoute, description, pageKind, title, toc = "" }) => {
244+
const pageDescription = description ?? site.description
245+
const canonical = canonicalUrl(site, currentRoute)
246+
const socialImage = absoluteSiteUrl(site, site.socialImage)
247+
return `<!doctype html>
237248
<html lang="en">
238249
<head>
239250
<meta charset="utf-8">
240251
<meta name="viewport" content="width=device-width, initial-scale=1">
241-
<meta name="description" content="${escapeAttribute(description ?? site.description)}">
252+
<meta name="description" content="${escapeAttribute(pageDescription)}">
253+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="${escapeAttribute(site.themeColor.light)}">
254+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="${escapeAttribute(site.themeColor.dark)}">
242255
<meta name="api-reference-base" content="${escapeAttribute(site.basePath)}">
243256
<meta name="generator" content="effect-machine-api-reference-site">
257+
<meta property="og:type" content="website">
258+
<meta property="og:site_name" content="${escapeAttribute(site.title)}">
259+
<meta property="og:title" content="${escapeAttribute(title)}">
260+
<meta property="og:description" content="${escapeAttribute(pageDescription)}">
261+
<meta property="og:url" content="${escapeAttribute(canonical)}">
262+
<meta property="og:image" content="${escapeAttribute(socialImage)}">
263+
<meta property="og:image:width" content="1200">
264+
<meta property="og:image:height" content="630">
265+
<meta property="og:image:alt" content="${escapeAttribute(`${site.package.name} API reference`)}">
266+
<meta name="twitter:card" content="summary_large_image">
267+
<meta name="twitter:title" content="${escapeAttribute(title)}">
268+
<meta name="twitter:description" content="${escapeAttribute(pageDescription)}">
269+
<meta name="twitter:image" content="${escapeAttribute(socialImage)}">
244270
<title>${escapeHtml(title)}</title>
271+
<link rel="canonical" href="${escapeAttribute(canonical)}">
272+
<link rel="icon" href="${siteUrl(site, "favicon.svg")}" type="image/svg+xml">
273+
<link rel="icon" href="${siteUrl(site, "favicon.ico")}" sizes="any">
274+
<link rel="apple-touch-icon" href="${siteUrl(site, "apple-touch-icon.png")}">
275+
<link rel="manifest" href="${siteUrl(site, "site.webmanifest")}">
245276
<script>document.documentElement.dataset.theme=localStorage.getItem("api-theme")||"auto"</script>
246277
<link rel="stylesheet" href="${siteUrl(site, "assets/styles.css")}">
247278
<script type="module" src="${siteUrl(site, "assets/client.js")}"></script>
@@ -261,13 +292,15 @@ const renderLayout = (site, { content, currentRoute, description, pageKind, titl
261292
</body>
262293
</html>
263294
`
295+
}
264296

265297
const renderHeader = (site) => `
266298
<a class="skip-link" href="#main-content">Skip to content</a>
267299
<header class="site-header" data-pagefind-ignore>
268300
<div class="site-header__brand">
269301
<button class="icon-button mobile-navigation-button" type="button" aria-label="Open navigation" aria-controls="module-navigation" aria-expanded="false">Menu</button>
270302
<a href="${siteUrl(site, "")}" aria-label="${escapeAttribute(site.package.name)} API reference home">
303+
<img class="brand-mark" src="${siteUrl(site, "logo.svg")}" alt="" width="28" height="28">
271304
<span class="brand-name">${escapeHtml(site.package.name)}</span>
272305
</a>
273306
<span class="version">v${escapeHtml(site.package.version.replace(/^v/, ""))}</span>
@@ -433,6 +466,39 @@ export const uniqueDeclarationIds = (groups) => {
433466
}
434467

435468
const siteUrl = (site, path) => `${site.basePath}${path}`.replace(/\/{2,}/g, "/")
469+
const absoluteSiteUrl = (site, path) => new URL(siteUrl(site, path), `${site.origin}/`).href
470+
const canonicalUrl = (site, route) => absoluteSiteUrl(site, route === "" ? "" : `${route}/`)
471+
472+
export const siteManifest = (site) => ({
473+
name: `${site.package.name} API reference`,
474+
short_name: "effect-machine",
475+
description: site.description,
476+
id: site.basePath,
477+
start_url: site.basePath,
478+
scope: site.basePath,
479+
display: "standalone",
480+
background_color: site.themeColor.light,
481+
theme_color: site.themeColor.light,
482+
icons: [
483+
{ src: siteUrl(site, "icon-192.png"), sizes: "192x192", type: "image/png" },
484+
{ src: siteUrl(site, "icon-512.png"), sizes: "512x512", type: "image/png" },
485+
{ src: siteUrl(site, "icon-maskable-512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" }
486+
]
487+
})
488+
489+
export const renderRobots = (site) => `User-agent: *
490+
Allow: ${site.basePath}
491+
492+
Sitemap: ${absoluteSiteUrl(site, "sitemap.xml")}
493+
`
494+
495+
export const renderSitemap = (site) => `<?xml version="1.0" encoding="UTF-8"?>
496+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
497+
${["", ...site.modules.map((module) => `${module.route}/`)]
498+
.map((route) => ` <url><loc>${escapeXml(absoluteSiteUrl(site, route))}</loc></url>`)
499+
.join("\n")}
500+
</urlset>
501+
`
436502
const displayExport = (value) => value === "." ? "root" : value.replace(/^\.\//, "")
437503
const titleCase = (value) => value.replace(/(^|\s)(\p{L})/gu, (_, space, letter) => `${space}${letter.toUpperCase()}`)
438504
const slugify = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "api"
@@ -447,12 +513,41 @@ const readConfig = (path) => {
447513
if (typeof config.title !== "string" || typeof config.description !== "string") {
448514
throw new Error("API reference site title and description must be configured")
449515
}
516+
if (
517+
typeof config.origin !== "string" ||
518+
typeof config.socialImage !== "string" ||
519+
typeof config.themeColor?.light !== "string" ||
520+
typeof config.themeColor?.dark !== "string"
521+
) {
522+
throw new Error("API reference site origin, social image, and theme colors must be configured")
523+
}
450524
return {
451525
...config,
526+
origin: normalizeOrigin(config.origin),
452527
basePath: normalizeBasePath(process.env.API_REFERENCE_BASE_PATH ?? config.basePath)
453528
}
454529
}
455530

531+
export const normalizeOrigin = (value) => {
532+
let url
533+
try {
534+
url = new URL(value)
535+
} catch {
536+
throw new Error("API reference site origin must be an absolute URL")
537+
}
538+
if (
539+
url.protocol !== "https:" ||
540+
url.username !== "" ||
541+
url.password !== "" ||
542+
url.pathname !== "/" ||
543+
url.search !== "" ||
544+
url.hash !== ""
545+
) {
546+
throw new Error("API reference site origin must be an HTTPS origin without a path")
547+
}
548+
return url.origin
549+
}
550+
456551
export const normalizeBasePath = (value) => {
457552
if (typeof value !== "string" || (value.length > 0 && !value.startsWith("/"))) {
458553
throw new Error("API reference site basePath must be empty or start with a slash")
@@ -489,6 +584,17 @@ const validateSite = (outputDirectory, site) => {
489584
"index.html",
490585
"assets/styles.css",
491586
"assets/client.js",
587+
"apple-touch-icon.png",
588+
"favicon.ico",
589+
"favicon.svg",
590+
"icon-192.png",
591+
"icon-512.png",
592+
"icon-maskable-512.png",
593+
"logo.svg",
594+
"robots.txt",
595+
"site.webmanifest",
596+
"sitemap.xml",
597+
site.socialImage,
492598
...site.modules.map((module) => join(module.route, "index.html"))
493599
]) {
494600
if (!isFile(safeResolve(outputDirectory, path))) throw new Error(`Missing generated site file: ${path}`)
@@ -510,6 +616,7 @@ const writePage = (path, html) => {
510616
writeFileSync(path, html)
511617
}
512618
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"))
619+
const writeJson = (path, value) => writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`)
513620
const isFile = (path) => statSync(path, { throwIfNoEntry: false })?.isFile() === true
514621
const escapeHtml = (value) => String(value)
515622
.replaceAll("&", "&amp;")
@@ -518,6 +625,7 @@ const escapeHtml = (value) => String(value)
518625
.replaceAll('"', "&quot;")
519626
.replaceAll("'", "&#39;")
520627
const escapeAttribute = escapeHtml
628+
const escapeXml = escapeHtml
521629

522630
const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
523631
if (isMain) {
7.46 KB
Loading
4.21 KB
Binary file not shown.
Lines changed: 15 additions & 0 deletions
Loading
8.05 KB
Loading

0 commit comments

Comments
 (0)