-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild-pages.mjs
More file actions
243 lines (239 loc) · 14.8 KB
/
Copy pathbuild-pages.mjs
File metadata and controls
243 lines (239 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
#!/usr/bin/env node
// Data sources: local package.json, README.md, CHANGELOG.md, reports/benchmarks.md,
// and optionally https://api.github.com/repos/CodeWithJuber/forgekit when BUILD_PAGES_LIVE=1.
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { BRAND, rootTokensCss } from "../src/brand.js";
// The deployed site base (no trailing slash), from the single brand.json source.
// Absolute URLs are required for og:image/canonical and must resolve on the Pages
// project site (/forgekit/...), so both public surfaces derive them from here.
const SITE = (BRAND.site?.url ?? "").replace(/\/+$/, "");
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const out = join(root, "public", "index.html");
const cacheFile = join(root, ".cache", "pages", "github-repo.json");
const api = "https://api.github.com/repos/CodeWithJuber/forgekit";
const esc = (s) =>
String(s ?? "").replace(
/[&<>"]/g,
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
);
const read = (p) => readFileSync(join(root, p), "utf8");
const pkg = JSON.parse(read("package.json"));
const readme = read("README.md");
const changelog = read("CHANGELOG.md");
const benchmarks = read("reports/benchmarks.md");
// A repo-sourced metric MUST parse from the README — a silent fallback is how the
// site drifted into showing hardcoded numbers the README no longer stated. If the
// phrasing changes, fail the build loudly so the regex (or the README) gets fixed.
function mustMatch(text, re, label) {
const m = text.match(re);
if (!m?.[1]?.trim()) {
throw new Error(
`build-pages: metric "${label}" no longer matches ${re} in README.md — ` +
`update the regex or the README (no silent fallback).`,
);
}
return m[1].trim();
}
function latestChanges() {
// Walk version sections in order and use the first one that actually has bullets —
// an empty "## [Unreleased]" (the normal state right after a release) must not win
// over the dated section below it that has the real content.
const sections = changelog.matchAll(/## \[[^\]]+\][\s\S]*?(?=\n## \[|\n\[Unreleased\]:|$)/g);
let section = "";
for (const m of sections) {
if (/^- /m.test(m[0])) {
section = m[0];
break;
}
}
// Bullets wrap across several lines in the CHANGELOG; join each "- …" with its indented
// continuation lines so the status page shows the whole item, not a truncated fragment.
const items = [];
let cur = null;
for (const line of section.split("\n")) {
const m = /^- (.*)$/.exec(line);
if (m) {
if (cur !== null) items.push(cur);
cur = m[1];
} else if (cur !== null) {
// A blank line or heading ends the bullet; anything else — indented OR a column-0
// "lazy continuation" (valid CommonMark, and how the formatter reflows wrapped
// bullets) — is still part of the current item.
if (line.trim() === "" || /^#{1,6}\s/.test(line)) {
items.push(cur);
cur = null;
} else {
cur += ` ${line.trim()}`;
}
}
}
if (cur !== null) items.push(cur);
// Strip inline markdown (bold/code) — these bullets render as plain HTML text.
return items.slice(0, 4).map((s) =>
s
.replace(/\s+/g, " ")
.trim()
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/`([^`]+)`/g, "$1"),
);
}
function git(args, fallback = "unknown") {
try {
return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim() || fallback;
} catch {
return fallback;
}
}
async function fetchJsonWithRetry(url, { timeoutMs = 5000, attempts = 3 } = {}) {
let last;
for (let i = 0; i < attempts; i++) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const headers = {
accept: "application/vnd.github+json",
"user-agent": "forgekit-pages-build",
};
try {
const cached = JSON.parse(readFileSync(cacheFile, "utf8"));
if (cached.etag) headers["if-none-match"] = cached.etag;
if (cached.lastModified) headers["if-modified-since"] = cached.lastModified;
} catch {}
const res = await fetch(url, { headers, signal: ac.signal });
if (res.status === 304) return JSON.parse(readFileSync(cacheFile, "utf8")).data;
if (res.status === 403 || res.status === 429) throw new Error(`rate limited (${res.status})`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
mkdirSync(dirname(cacheFile), { recursive: true });
writeFileSync(
cacheFile,
JSON.stringify(
{
etag: res.headers.get("etag"),
lastModified: res.headers.get("last-modified"),
data,
},
null,
2,
),
);
return data;
} catch (e) {
last = e;
await new Promise((r) =>
setTimeout(r, Math.min(1000, 100 * 2 ** i) + Math.floor(Math.random() * 50)),
);
} finally {
clearTimeout(timer);
}
}
throw last;
}
export async function collect({ live = process.env.BUILD_PAGES_LIVE === "1" } = {}) {
let github = null;
if (live) {
try {
const data = await fetchJsonWithRetry(api);
github = {
stars: Number.isFinite(data?.stargazers_count) ? data.stargazers_count : null,
forks: Number.isFinite(data?.forks_count) ? data.forks_count : null,
issues: Number.isFinite(data?.open_issues_count) ? data.open_issues_count : null,
};
} catch (e) {
console.warn(`GitHub live data unavailable: ${e.message}`);
}
}
return {
name: pkg.name,
version: pkg.version,
description: pkg.description,
node: pkg.engines?.node ?? "",
license: pkg.license,
deps: Object.keys(pkg.dependencies ?? {}).length,
commit: git(["rev-parse", "--short", "HEAD"]),
branch: git(["branch", "--show-current"]),
generated: new Date().toISOString(),
github,
speed: mustMatch(readme, /\*\*A full pre-action gate in ([^*]+)\*\*/m, "speed"),
impact: mustMatch(readme, /\*\*Blast radius in ([^*]+)\*\*/m, "impact"),
saved: mustMatch(readme, /\*\*([\d.]+% cost saved)/m, "saved"),
benchUpdated: statSync(join(root, "reports/benchmarks.md")).mtime.toISOString().slice(0, 10),
latest: latestChanges(),
benchMentions: (benchmarks.match(/^## /gm) ?? []).length,
};
}
// The status page shares the landing page's design system verbatim: the same warm
// ember/near-black color tokens, one accent, a system font stack. test/pages.test.js
// enforces token parity, a non-empty changes list, and no phantom webfont — so the two
// public surfaces can't silently drift into two different "school-project" looks again.
export function render(d) {
const live = d.github
? `<span class="chip">${esc(d.github.stars)} stars</span><span class="chip">${esc(d.github.forks)} forks</span><span class="chip">${esc(d.github.issues)} open issues</span>`
: `<span class="chip">live GitHub stats disabled</span>`;
const jsonLd = JSON.stringify({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: d.name,
applicationCategory: "DeveloperApplication",
operatingSystem: "macOS, Linux, Windows",
softwareVersion: d.version,
url: `${SITE}/status/`,
description: d.description,
offers: { "@type": "Offer", price: "0" },
});
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>forgekit status — live repository data</title><meta name="description" content="${esc(d.description)}"><meta name="theme-color" content="#171310"><link rel="canonical" href="${SITE}/status/"><link rel="icon" type="image/svg+xml" href="${SITE}/favicon.svg"><link rel="apple-touch-icon" href="${SITE}/apple-touch-icon.png"><meta property="og:type" content="website"><meta property="og:site_name" content="forgekit"><meta property="og:title" content="forgekit status — live repository data"><meta property="og:description" content="${esc(d.description)}"><meta property="og:url" content="${SITE}/status/"><meta property="og:image" content="${SITE}/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="forgekit status — live repository data"><meta name="twitter:description" content="${esc(d.description)}"><meta name="twitter:image" content="${SITE}/og.png"><script type="application/ld+json">${jsonLd}</script><style>
${rootTokensCss()}
:root{--r-s:6px;--r-m:12px;--r-pill:999px}
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(960px 400px at 85% -10%,rgba(242,100,48,.16),transparent 70%),var(--bg);color:var(--text);font:16px/1.65 var(--sans);-webkit-font-smoothing:antialiased}
::selection{background:var(--brand);color:var(--bg)}
p{margin:16px 0}
a{color:inherit}
a:focus-visible,button:focus-visible{outline:2px solid var(--brand);outline-offset:4px;border-radius:var(--r-s)}
.wrap{width:min(1080px,calc(100% - 48px));margin:0 auto}
.nav{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 0;border-bottom:1px solid var(--line)}
.brand{font:600 16px var(--mono);text-decoration:none}
.brand em{font-style:normal;color:var(--brand)}
.links{display:flex;gap:24px;flex-wrap:wrap;font-size:14px}
.links a{color:var(--muted);text-decoration:none}
.links a:hover{color:var(--text)}
.hero{padding:64px 0 48px}
.eyebrow{font:500 13px var(--mono);color:var(--brand);letter-spacing:.12em;text-transform:uppercase;margin:0 0 16px}
h1{font-size:clamp(32px,5vw,56px);line-height:1.05;letter-spacing:-.02em;margin:0 0 16px;max-width:800px;font-weight:700}
.lead{font-size:18px;color:var(--muted);max-width:680px;margin:0 0 32px}
.btn{display:inline-block;text-decoration:none;font:600 15px var(--sans);border-radius:var(--r-pill);padding:12px 28px;border:1px solid var(--line)}
.btn.primary{background:var(--brand);border-color:var(--brand);color:var(--bg)}
.btn.primary:hover{background:var(--text);border-color:var(--text)}
.btn:not(.primary):hover{border-color:var(--faint)}
.meta{display:flex;gap:12px;flex-wrap:wrap;margin-top:32px}
.chip{font:500 13px var(--mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-pill);padding:8px 16px}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:48px 0}
.cell{background:var(--panel);border:1px solid var(--line);border-radius:var(--r-m);padding:32px 24px}
.metric{font:600 32px var(--mono);letter-spacing:-.02em}
.metric em{font-style:normal;color:var(--brand)}
.cell strong{display:block;margin-top:8px;font-size:14px}
.muted{color:var(--muted);font-size:14px}
.src{font:400 12px var(--mono);color:var(--faint);margin-top:12px}
section{padding:48px 0;border-bottom:1px solid var(--line)}
h2{font-size:24px;letter-spacing:-.01em;margin:0 0 16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r-m);padding:32px}
.terminal{font:400 14px/1.7 var(--mono);overflow-x:auto;white-space:pre;background:var(--panel-2);color:var(--text);border-radius:var(--r-m);padding:24px;border:1px solid var(--line);box-shadow:var(--shadow)}
.list{display:grid;gap:12px;padding:0;list-style:none;margin:0}
.list li{border-left:2px solid var(--brand);padding-left:16px;color:var(--muted);font-size:15px}
code{font:500 13px var(--mono);background:var(--panel-2);border:1px solid var(--line);border-radius:var(--r-s);padding:0 8px}
footer{padding:32px 0;color:var(--faint);font-size:14px}
@media(max-width:800px){.grid{grid-template-columns:1fr}}
</style></head><body><div class="wrap"><header class="nav"><a class="brand" href="../">forge<em>kit</em></a><nav class="links" aria-label="Primary"><a href="../">Landing</a><a href="#quickstart">Quickstart</a><a href="#changes">Latest</a><a href="#sources">Data Sources</a><a href="https://github.com/CodeWithJuber/forgekit">GitHub ↗</a></nav></header><main id="top"><section class="hero" style="border-bottom:0;padding-bottom:0"><p class="eyebrow">${esc(d.name)} · v${esc(d.version)} · Node ${esc(d.node)}</p><h1>Live status, straight from the repository.</h1><p class="lead">${esc(d.description)}</p><p><a class="btn primary" href="#quickstart">Install in 60 seconds</a> <a class="btn" href="https://github.com/CodeWithJuber/forgekit#readme">Read the docs</a></p><div class="meta"><span class="chip">${esc(d.license)} license</span><span class="chip">${esc(d.deps)} runtime dependencies</span><span class="chip">${esc(d.branch)} @ ${esc(d.commit)}</span>${live}</div></section><div class="grid" aria-label="Measured outcomes"><article class="cell"><div class="metric"><em>${esc(d.impact)}</em></div><strong>blast-radius lookup</strong><p class="muted">Measured from this repo's benchmark report, not a marketing placeholder.</p><p class="src">reports/benchmarks.md</p></article><article class="cell"><div class="metric"><em>${esc(d.speed)}</em></div><strong>pre-action gate</strong><p class="muted">Assumptions, routing, reuse, context, impact, scope, and anchoring.</p><p class="src">reports/benchmarks.md</p></article><article class="cell"><div class="metric"><em>${esc(d.saved.match(/^[\d.]+\s*%?/)?.[0] ?? d.saved)}</em></div><strong>${esc(d.saved.replace(/^[\d.]+\s*%?\s*/, "") || "routing signal")}</strong><p class="muted">Documented from the white-paper prototype and exposed by Forge cost reports.</p><p class="src">whitepaper prototype</p></article></div><section id="quickstart"><h2>Quickstart</h2><div class="terminal">npm install -g @codewithjuber/forgekit
forge init
forge doctor
forge substrate "Change auth validation and update tests"</div></section><section id="changes"><h2>Latest repo changes</h2><div class="card"><ul class="list">${d.latest.map((x) => `<li>${esc(x)}</li>`).join("")}</ul><p class="muted">Benchmark sections indexed: ${esc(d.benchMentions)} · benchmarks file updated ${esc(d.benchUpdated)}.</p></div></section><section id="sources"><h2>Data Sources</h2><div class="card"><p class="muted">No mock data is used. This page is regenerated from repository files during CI (generated ${esc(d.generated)} from ${esc(d.commit)}). Enable <code>BUILD_PAGES_LIVE=1</code> to refresh public GitHub counters with ETag/Last-Modified caching.</p><ul class="list"><li>package.json</li><li>README.md</li><li>CHANGELOG.md</li><li>reports/benchmarks.md</li><li>${api} (optional, no auth, only when BUILD_PAGES_LIVE=1)</li></ul></div></section></main><footer>WCAG-minded semantic HTML, keyboard focus, responsive 320px–1920px+, and reduced-motion-safe. Same design tokens as the landing page — parity enforced in test/pages.test.js.</footer></div></body></html>`;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const data = await collect();
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, render(data));
console.log(`wrote ${out}`);
}