Skip to content

Commit b81a478

Browse files
pirateclaude
andcommitted
Index: dynamic / handler showing live deployed + queued users
The CI-regen approach went stale between deploys (and worse, every deploy_now during the mining loop reset cloudflare/public/index.html from the committed template). Replace it with a Worker-served dynamic homepage: - Worker reads /users.txt (now also deployed as a static asset by CI), probes /{user}.html via ASSETS for each, and renders two sections: 'deployed' (link) and 'queued/mining' (dimmed). - Response cached in Workers Cache for 30s so repeated visits don't fan out to N internal asset probes. - Falls back to the static index.html in /public if anything throws. Drops the per-run regen step; adds a cp step to stage users.txt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent adcc568 commit b81a478

2 files changed

Lines changed: 144 additions & 60 deletions

File tree

.github/workflows/mine-and-deploy.yml

Lines changed: 4 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -215,66 +215,10 @@ jobs:
215215
git diff --staged --quiet || git commit -m "Add ${{ inputs.user }} to users.txt [skip ci]"
216216
git push || echo "::warning::push failed (no commit permission?)"
217217
218-
# Rebuild the homepage user list. Shows two groups:
219-
# - Deployed: a dashboard HTML already exists in cloudflare/public/
220-
# - Queued: listed in users.txt but no HTML yet (next run will mine)
221-
# The Queued set surfaces in-progress / future mines so visitors know
222-
# what's coming without having to dig through CI runs.
223-
- name: Regenerate user index
224-
run: |
225-
python3 <<'PY'
226-
from pathlib import Path
227-
import re
228-
pub = Path("cloudflare/public")
229-
deployed = sorted(
230-
(p.stem for p in pub.glob("*.html")
231-
if p.stem not in {"index", "404"}),
232-
key=str.lower,
233-
)
234-
# Read users.txt for queued users.
235-
queued: list[str] = []
236-
ut = Path("cloudflare/users.txt")
237-
if ut.exists():
238-
for line in ut.read_text().splitlines():
239-
line = line.split("#", 1)[0].strip()
240-
if not line:
241-
continue
242-
if line in deployed:
243-
continue
244-
queued.append(line)
245-
queued.sort(key=str.lower)
246-
247-
def row_deployed(u: str) -> str:
248-
suffix = " — Nick Sweeting (enhanced)" if u == "pirate" else ""
249-
return (f' <li class="ready"><a href="/{u}">/{u}</a>'
250-
f'{suffix}</li>')
251-
252-
def row_queued(u: str) -> str:
253-
return (f' <li class="mining"><span>/{u}</span>'
254-
f' <em>· queued/mining</em></li>')
255-
256-
rows = [row_deployed(u) for u in deployed]
257-
if queued:
258-
rows.append(
259-
' <li class="section-hdr">Queued for next CI run</li>')
260-
rows += [row_queued(u) for u in queued]
261-
262-
html = (pub / "index.html").read_text()
263-
html = re.sub(
264-
r"<ul>.*?</ul>",
265-
"<ul>\n" + "\n".join(rows) + "\n </ul>",
266-
html,
267-
count=1,
268-
flags=re.S,
269-
)
270-
html = html.replace(
271-
'Want to add yourself? <a href="https://github.com/ArchiveBox/githubusers/edit/main/cloudflare/users.txt">Open a PR on users.txt</a>.',
272-
'Want your dashboard here? Just visit <code>/&lt;your-login&gt;</code> — mining kicks off automatically.',
273-
)
274-
(pub / "index.html").write_text(html)
275-
print(f"Wrote index.html: {len(deployed)} deployed, "
276-
f"{len(queued)} queued")
277-
PY
218+
# Deploy users.txt as a static asset so the Worker's dynamic /
219+
# handler can read it to build the queued/mining list.
220+
- name: Stage users.txt as a public asset
221+
run: cp cloudflare/users.txt cloudflare/public/users.txt
278222

279223
- name: Final deploy
280224
working-directory: cloudflare

cloudflare/worker/index.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export default {
3131
return handleProgress(req, env, url);
3232
}
3333

34+
// -- Dynamic homepage: render live deployed + queued user lists -------
35+
if (url.pathname === "/" || url.pathname === "/index.html") {
36+
try {
37+
return await handleIndex(env, url);
38+
} catch (e) {
39+
// Fall through to the static index.html in /public on any error.
40+
}
41+
}
42+
3443
// -- Static assets ----------------------------------------------------
3544
const assetResp = await env.ASSETS.fetch(req);
3645
if (assetResp.status !== 404) return assetResp;
@@ -419,6 +428,137 @@ async function handleStatus(
419428
}
420429

421430

431+
// Dynamic homepage. Reads /users.txt (also deployed as a static asset
432+
// by CI) for the canonical list of users we want dashboards for, then
433+
// probes /{user}.html via the ASSETS binding to see which are ready vs
434+
// still queued/mining. Output is cached in Workers Cache for 30s so
435+
// repeated visits don't fan out to N internal asset probes each time.
436+
async function handleIndex(env: Env, url: URL): Promise<Response> {
437+
const cache = caches.default;
438+
const cacheKey = new Request("https://internal-index.invalid/v1");
439+
const cached = await cache.match(cacheKey);
440+
if (cached) return cached;
441+
442+
// Read users.txt from the deployed assets.
443+
const probeBase = new URL(url.toString());
444+
probeBase.search = "";
445+
const usersTxtUrl = new URL(probeBase.toString());
446+
usersTxtUrl.pathname = "/users.txt";
447+
let users: string[] = [];
448+
try {
449+
const r = await env.ASSETS.fetch(new Request(usersTxtUrl.toString()));
450+
if (r.ok) {
451+
const txt = await r.text();
452+
users = txt.split("\n")
453+
.map((l) => l.split("#", 1)[0].trim())
454+
.filter((l) => l.length > 0);
455+
}
456+
} catch {}
457+
458+
// Add pirate (intentionally not in users.txt — built locally).
459+
if (!users.includes("pirate")) users.unshift("pirate");
460+
461+
// Probe each user's /<u>.html for deploy status in parallel.
462+
const states = await Promise.all(users.map(async (u) => {
463+
const probeUrl = new URL(probeBase.toString());
464+
probeUrl.pathname = `/${u}.html`;
465+
try {
466+
const r = await env.ASSETS.fetch(new Request(probeUrl.toString()));
467+
return { user: u, deployed: r.status === 200 };
468+
} catch {
469+
return { user: u, deployed: false };
470+
}
471+
}));
472+
473+
const deployed = states
474+
.filter((s) => s.deployed)
475+
.map((s) => s.user)
476+
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
477+
const queued = states
478+
.filter((s) => !s.deployed)
479+
.map((s) => s.user)
480+
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
481+
482+
const html = indexPage(deployed, queued);
483+
const resp = new Response(html, {
484+
headers: {
485+
"content-type": "text/html; charset=utf-8",
486+
"cache-control": "public, max-age=30",
487+
},
488+
});
489+
// Stash a clone for future hits (the response itself can only be
490+
// consumed once; cache.put is fine with the cloned Response).
491+
await cache.put(cacheKey, resp.clone());
492+
return resp;
493+
}
494+
495+
496+
function indexPage(deployed: string[], queued: string[]): string {
497+
const escape = (s: string) =>
498+
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
499+
const deployedRows = deployed.map((u) => {
500+
const suffix = u === "pirate" ? " — Nick Sweeting (enhanced)" : "";
501+
return ` <li class="ready"><a href="/${escape(u)}">/${escape(u)}</a>${suffix}</li>`;
502+
}).join("\n");
503+
const queuedRows = queued.map((u) =>
504+
` <li class="mining"><span>/${escape(u)}</span> <em>· queued / mining</em></li>`
505+
).join("\n");
506+
const queuedSection = queued.length
507+
? `\n <li class="section-hdr">Queued for next CI run (${queued.length})</li>\n${queuedRows}`
508+
: "";
509+
return `<!doctype html>
510+
<html lang="en">
511+
<head>
512+
<meta charset="utf-8">
513+
<meta name="viewport" content="width=device-width,initial-scale=1">
514+
<title>githubusers.archivebox.io</title>
515+
<style>
516+
html, body {
517+
background: #0d1117; color: #e6edf3;
518+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
519+
margin: 0; padding: 0; min-height: 100%;
520+
}
521+
.wrap { max-width: 640px; margin: 0 auto; padding: 48px 24px; }
522+
h1 { font-size: 24px; margin: 0 0 8px; }
523+
p { color: #8b949e; line-height: 1.5; }
524+
a { color: #58a6ff; }
525+
ul { list-style: none; padding: 0; }
526+
ul li { padding: 8px 0; border-bottom: 1px solid #21262d; }
527+
ul li.mining { color: #8b949e; }
528+
ul li.mining em {
529+
color: #d29922; font-style: normal; font-size: 11px;
530+
background: #1f1810; border: 1px solid #443322;
531+
padding: 1px 6px; border-radius: 4px; margin-left: 6px;
532+
}
533+
ul li.section-hdr {
534+
color: #6e7681; font-size: 11px;
535+
border: 0; padding: 16px 0 4px;
536+
text-transform: uppercase; letter-spacing: 0.06em;
537+
}
538+
code { background: #21262d; padding: 2px 6px; border-radius: 4px; font-size: 90%; }
539+
.meta { font-size: 11px; color: #6e7681; margin-top: 24px; }
540+
</style>
541+
</head>
542+
<body>
543+
<div class="wrap">
544+
<h1>githubusers.archivebox.io</h1>
545+
<p>
546+
Precomputed contribution dashboards for selected GitHub users.
547+
Navigate to <code>/&lt;login&gt;</code> for any user listed below
548+
(or any other login — mining auto-triggers on first visit).
549+
</p>
550+
<ul>
551+
${deployedRows}${queuedSection}
552+
</ul>
553+
<p class="meta">
554+
${deployed.length} deployed · ${queued.length} queued · refreshed every 30s
555+
</p>
556+
</div>
557+
</body>
558+
</html>`;
559+
}
560+
561+
422562
function loadingPage(user: string): string {
423563
// Inline HTML for the "mining…" view. Polls /api/status for the GH
424564
// workflow run's step list + /<user>.html for the first partial deploy.

0 commit comments

Comments
 (0)