Skip to content

Commit 5c9dd68

Browse files
ryan-williamsclaude
andcommitted
www: per-station HTML stubs with og meta (gen-station-stubs.js postbuild)
GH Pages serves `/s/<slug>` deep links via the `404.html` SPA fallback — HTTP 404 + homepage og meta, so link-preview crawlers (no JS) never saw station-specific cards. Postbuild step now fetches the new `/api/stations/slugs` worker endpoint (D1 `stations` rows with a slug, ~2.6k) and emits `dist/s/<slug>/index.html`: a copy of the built `index.html` with `<title>` + og:title/description/url swapped and og:image pointed at the worker's dynamic `/og/s/<slug>.png` renderer. Browsers still boot the same SPA bundles (asset URLs are absolute); crawlers now get a real 200 with per-station meta. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 01ab08b commit 5c9dd68

3 files changed

Lines changed: 68 additions & 0 deletions

File tree

gbfs/api/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,6 +1516,17 @@ export default {
15161516
}
15171517
}
15181518

1519+
// /api/stations/slugs — all slugged stations, for build-time og:image
1520+
// stub generation in www (one row per station page to emit).
1521+
if (url.pathname === '/api/stations/slugs') {
1522+
const { results } = await env.DB.prepare(
1523+
`SELECT slug, short_name, name FROM stations WHERE slug IS NOT NULL ORDER BY slug`
1524+
).all();
1525+
return jsonResponse({ stations: results }, env, {
1526+
headers: { 'Cache-Control': 'public, max-age=3600' },
1527+
});
1528+
}
1529+
15191530
// /api/stations/:id/info — accepts slug, UUID, or short_name
15201531
const infoMatch = url.pathname.match(/^\/api\/stations\/([^/]+)\/info$/);
15211532
if (infoMatch) {

www/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"dev": "vite",
6060
"prebuild": "node scripts/gen-ymdgtb-index.js",
6161
"build": "vite build",
62+
"postbuild": "node scripts/gen-station-stubs.js",
6263
"preview": "vite preview",
6364
"lint": "eslint .",
6465
"tc": "tsc",

www/scripts/gen-station-stubs.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Emit per-station HTML stubs (`dist/s/<slug>/index.html`) after `vite build`.
4+
*
5+
* GH Pages serves the SPA via a `404.html` fallback, so `/s/<slug>` deep
6+
* links return HTTP 404 with the generic homepage og meta — link-preview
7+
* crawlers (Slack, Twitter, iMessage) don't run JS and some reject 404s
8+
* outright. Each stub is a copy of the built `index.html` with the
9+
* `<title>` + og meta swapped for the station (og:image → the worker's
10+
* dynamic `/og/s/<slug>.png` renderer), served with a real 200. Browsers
11+
* load the same SPA bundles (asset URLs are absolute), so UX is unchanged.
12+
*
13+
* Station list comes from the API worker's `/api/stations/slugs` (D1
14+
* `stations` rows with a slug — ~2k). Failure is fatal: a deploy without
15+
* stubs would silently regress share previews.
16+
*/
17+
18+
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
19+
import { join, dirname } from 'path'
20+
import { fileURLToPath } from 'url'
21+
22+
const __dirname = dirname(fileURLToPath(import.meta.url))
23+
const distDir = join(__dirname, '..', 'dist')
24+
25+
const API_BASE = process.env.VITE_API_BASE ?? 'https://ctbk-gbfs-api.ryan-0dc.workers.dev'
26+
const SITE = 'https://ctbk.dev'
27+
28+
const escapeHtml = (s) =>
29+
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
30+
31+
const setMeta = (html, property, content) => {
32+
const re = new RegExp(`(<meta property="${property}" content=")[^"]*(")`)
33+
if (!re.test(html)) throw new Error(`index.html missing <meta property="${property}">`)
34+
return html.replace(re, `$1${escapeHtml(content)}$2`)
35+
}
36+
37+
const res = await fetch(`${API_BASE}/api/stations/slugs`)
38+
if (!res.ok) throw new Error(`${API_BASE}/api/stations/slugs: HTTP ${res.status}`)
39+
const { stations } = await res.json()
40+
if (!stations?.length) throw new Error('no slugged stations returned')
41+
42+
const template = readFileSync(join(distDir, 'index.html'), 'utf8')
43+
if (!/<title>/.test(template)) throw new Error('dist/index.html missing <title>')
44+
45+
for (const { slug, name } of stations) {
46+
const title = `${name} — Citi Bike station | ctbk.dev`
47+
let html = template.replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(title)}</title>`)
48+
html = setMeta(html, 'og:title', title)
49+
html = setMeta(html, 'og:description', `Live availability + trip history for the ${name} Citi Bike station.`)
50+
html = setMeta(html, 'og:image', `${API_BASE}/og/s/${slug}.png`)
51+
html = setMeta(html, 'og:url', `${SITE}/s/${slug}`)
52+
const dir = join(distDir, 's', slug)
53+
mkdirSync(dir, { recursive: true })
54+
writeFileSync(join(dir, 'index.html'), html)
55+
}
56+
console.error(`wrote ${stations.length} station stubs under dist/s/`)

0 commit comments

Comments
 (0)