Shared links unfurl: per-route OG meta for match and team pages
Crawler-visible <title> + og:title/description are injected into the SPA shell server-side: match links carry the score or the model's probability split, team links the current title odds — honest framing included. Plain shell for every other route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+45
-4
@@ -1,5 +1,5 @@
|
||||
import path from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
@@ -184,12 +184,53 @@ export function buildServer() {
|
||||
}, 24 * 60 * 60 * 1000);
|
||||
app.addHook('onClose', async () => clearInterval(refitTimer));
|
||||
|
||||
// ---- static SPA (prod) with history fallback ----
|
||||
// ---- static SPA (prod) with history fallback + per-route OG meta ----
|
||||
if (existsSync(STATIC_DIR)) {
|
||||
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
|
||||
|
||||
// Shared links unfurl with the model's numbers: swap <title> and inject
|
||||
// og: meta for /match/:num and /team/:name before serving the SPA shell.
|
||||
const indexHtml = readFileSync(`${STATIC_DIR}/index.html`, 'utf8');
|
||||
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
const withMeta = (title: string, desc: string): string =>
|
||||
indexHtml.replace(
|
||||
/<title>[^<]*<\/title>/,
|
||||
`<title>${esc(title)}</title>\n <meta property="og:title" content="${esc(title)}" />\n <meta property="og:description" content="${esc(desc)}" />\n <meta name="description" content="${esc(desc)}" />`,
|
||||
);
|
||||
const ogFor = (url: string): string | null => {
|
||||
try {
|
||||
const mMatch = /^\/match\/(\d+)(?:\?|$)/.exec(url);
|
||||
if (mMatch) {
|
||||
const f = state.allFixtures().find((x) => x.num === Number(mMatch[1]));
|
||||
if (!f) return null;
|
||||
const pred = model.current().matches.find((m) => m.num === f.num);
|
||||
const score = f.homeScore != null && f.status !== 'scheduled' ? ` ${f.homeScore}–${f.awayScore}` : '';
|
||||
const odds = pred ? ` · Model: ${Math.round(pred.probs.home * 100)}/${Math.round(pred.probs.draw * 100)}/${Math.round(pred.probs.away * 100)}` : '';
|
||||
return withMeta(
|
||||
`${f.home.label}${score || ' vs'} ${f.away.label} — Cup26`,
|
||||
`World Cup 2026 · ${f.venue}${odds} (model probabilities, not betting advice)`,
|
||||
);
|
||||
}
|
||||
const mTeam = /^\/team\/([^/?]+)(?:\?|$)/.exec(url);
|
||||
if (mTeam) {
|
||||
const team = decodeURIComponent(mTeam[1]!);
|
||||
const odds = model.current().odds.find((o) => o.team === team);
|
||||
if (!odds) return null;
|
||||
return withMeta(
|
||||
`${team} — Cup26`,
|
||||
`World Cup 2026 · title odds ${(odds.champion * 100).toFixed(1)}% by the Cup26 model`,
|
||||
);
|
||||
}
|
||||
} catch { /* fall through to the plain shell */ }
|
||||
return null;
|
||||
};
|
||||
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.raw.url?.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
|
||||
if (req.raw.url?.startsWith('/api')) return reply.code(404).send({ error: 'not found' });
|
||||
const url = req.raw.url ?? '/';
|
||||
if (url.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
|
||||
if (url.startsWith('/api')) return reply.code(404).send({ error: 'not found' });
|
||||
const html = ogFor(url);
|
||||
if (html) return reply.type('text/html; charset=utf-8').send(html);
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user