feat: prerender every route + Gitea Actions deploy
Some checks failed
deploy / Build api + worker + web (push) Failing after 53s
deploy / Deploy moments-api to nikola (push) Has been skipped
deploy / Deploy moments-worker to frootmig (push) Has been skipped
deploy / Deploy web to oolon (push) Has been skipped

Make the site fully prerendered so a plain curl returns complete content
for every route (crawlers / AI screening tools see real text, not an empty
#root), while humans keep full client interactivity.

Prerender:
- Build-time per-route render: prefetch data, renderToString, inline the
  dehydrated react-query cache as window.__RQ_STATE__; client hydrateRoots
  and refetches live (activity stays fresh; crawlers get the baked snapshot).
- New entry-server.tsx + prerender/{prefetch,routes,meta}.ts + run-prerender.mjs;
  shared lib/ranges.ts keeps SSR and client query keys identical.
- pnpm build now: tsc -b -> vite client build -> ssr build -> prerender.
- API base absolute at build (VITE_API_BASE), relative /api/v1 in the browser.
- CSS imports moved to the client entry so the tree imports under Node.
- schema.org Person + Occupation JSON-LD and per-route title/description/og.
- UTC + explicit field widths on shared date formatting so SSR and client
  hydration match byte-for-byte (fixes hydration mismatch on /activity).
- Strip non-text gist content from the CV fetch (1MB -> 25KB gzipped page).

Deploy (Gitea Actions, replaces script/deploy.sh):
- deploy.yml: on push to main, lint/test gate, build api+worker as static
  musl binaries (pure-rustls, no glibc skew) + prerendered web, deploy each
  over SSH as gitea_ci with scoped sudo.
- refresh.yml: daily cron re-bakes only the web snapshot so gist/activity
  edits propagate without a push or bouncing the api/worker.
- script/infra-setup.sh + asset/sudoers.d/{api,worker,web}-host.conf for
  one-time per-host provisioning. Secrets: RSYNC_SSH_KEY, QUERY_GITHUB_TOKEN,
  QUERY_GITEA_TOKEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
This commit is contained in:
2026-06-25 12:53:46 +03:00
parent 70b4b265c3
commit 1b753f991f
27 changed files with 1390 additions and 24 deletions

View File

@@ -1,9 +1,8 @@
import { Routes, Route } from 'react-router-dom';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'rc-slider/assets/index.css';
import 'react-vertical-timeline-component/style.min.css';
import './App.css';
// CSS imports live in the client entry (`main.tsx`), not here, so this module
// stays importable under Node during the prerender build (renderToString can't
// process CSS imports).
import { Layout } from './components/Layout';
import { DashPage } from './pages/DashPage';

View File

@@ -91,7 +91,10 @@ export interface EventQuery {
limit?: number;
}
const API_BASE = '/api/v1';
// Browser builds use a same-origin relative base (nginx proxies `/api/`).
// The prerender build runs under Node, which has no relative-URL origin, so it
// sets VITE_API_BASE to the absolute public API. See `vite-env.d.ts`.
const API_BASE = import.meta.env.VITE_API_BASE || '/api/v1';
/** Decode base64 content as UTF-8 (atob only handles Latin-1). */
function decodeBase64Utf8(b64: string): string {

View File

@@ -57,13 +57,24 @@ export async function fetchCv(): Promise<CvData> {
}
const gist = (await resp.json()) as GistResponse;
const cfgFile = gist.files[CONFIG_FILENAME];
// Drop inlined `content` for non-text files (the photo, company logos, and
// pdf/docx/odt exports). The CV only ever references those by their raw gist
// URL — keeping the base64 here would bloat every render, and balloons the
// prerendered /cv page by ~1 MB of uncompressible data.
const files: Record<string, GistFile> = {};
for (const [name, file] of Object.entries(gist.files)) {
const isText =
file.type.startsWith('text/') || file.type === 'application/json';
files[name] = isText ? file : { ...file, content: '' };
}
const cfgFile = files[CONFIG_FILENAME];
if (!cfgFile) {
throw new Error(`gist: missing ${CONFIG_FILENAME}`);
}
const config = JSON.parse(cfgFile.content) as CvConfig;
return { config, files: gist.files };
return { config, files };
}
// Pick out the gist files whose names start with the given prefix, applying

View File

@@ -88,11 +88,14 @@ export function Filters({
}
function formatDate(ts: number): string {
// Format in UTC so the prerendered (Node) and client (browser) output match
// regardless of the viewer's timezone — otherwise hydration mismatches.
return new Date(ts)
.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})
.toLowerCase();
}

View File

@@ -74,6 +74,9 @@ function renderSegment(seg: TitleSegment, i: number) {
}
function formatDate(iso: string): string {
// Format in UTC so the prerendered (Node) and client (browser) renders are
// byte-identical regardless of the viewer's timezone — otherwise the dates
// mismatch and React rejects the hydration (error #418).
const d = new Date(iso);
const date = d
.toLocaleDateString('en-GB', {
@@ -81,10 +84,21 @@ function formatDate(iso: string): string {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})
.toLowerCase();
const time = d
.toLocaleTimeString('en-GB', { timeZoneName: 'short' })
.toLocaleTimeString('en-GB', {
timeZone: 'UTC',
// Explicit 2-digit fields + 24h cycle: en-GB's *default* hour rendering
// isn't padded identically across JS engines (Node emits "9:53", Firefox
// "09:53"), which breaks hydration. Pinning the fields makes it match.
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZoneName: 'short',
})
.toLowerCase();
return `${date}${time}`;
}

61
ui/src/entry-server.tsx Normal file
View File

@@ -0,0 +1,61 @@
// Server entry for the prerender. Built once with `vite build --ssr` and then
// driven by `run-prerender.mjs` under Node. For each route it prefetches the
// route's data, renders the real React tree to an HTML string, and returns the
// markup alongside the dehydrated react-query cache and the route's <head>
// metadata. No browser globals are touched on the render path.
import { StrictMode } from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import {
QueryClient,
QueryClientProvider,
dehydrate,
type DehydratedState,
} from '@tanstack/react-query';
import App from './App';
import { prefetchRoute } from './prerender/prefetch';
import { headForRoute, type HeadMeta } from './prerender/meta';
export { collectRoutes } from './prerender/routes';
export type { HeadMeta };
export interface RenderResult {
html: string;
state: DehydratedState;
head: HeadMeta;
}
export async function renderRoute(path: string): Promise<RenderResult> {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
// Treat prefetched data as fresh + non-collectable so it survives
// until dehydration and `useQuery` reads it synchronously during
// renderToString instead of trying to refetch.
staleTime: Infinity,
gcTime: Infinity,
},
},
});
await prefetchRoute(queryClient, path);
const html = renderToString(
<StrictMode>
<QueryClientProvider client={queryClient}>
<StaticRouter location={path}>
<App />
</StaticRouter>
</QueryClientProvider>
</StrictMode>,
);
return {
html,
state: dehydrate(queryClient),
head: headForRoute(path, queryClient),
};
}

71
ui/src/lib/ranges.ts Normal file
View File

@@ -0,0 +1,71 @@
// Date-range helpers shared by the dashboard/timeline components and the
// build-time prerender prefetch. Centralising the window maths keeps the
// react-query *keys* identical between the SSR snapshot and the client's
// first render, so hydration reuses the inlined cache instead of refetching.
//
// All windows are stamped to the UTC day (YYYY-MM-DD). Same-day loads hydrate
// from the baked snapshot; loads on a later day compute a different key and
// refetch live — the intended freshness tradeoff for the activity data.
import type { SourceSummary } from '../api/client';
export function fmtDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Contribution graph + language stream: trailing 365 days. */
export function lastYearRange(now: Date = new Date()) {
const to = new Date(now);
const from = new Date(to);
from.setFullYear(from.getFullYear() - 1);
return { from, to, fromStr: fmtDate(from), toStr: fmtDate(to) };
}
/** Earliest activity date across all sources, or null if none reported. */
export function earliestFrom(sources: SourceSummary[]): Date | null {
const dates = sources
.map((s) => s.earliest)
.filter((d): d is string => d != null)
.map((d) => new Date(d));
return dates.length > 0
? new Date(Math.min(...dates.map((d) => d.getTime())))
: null;
}
/** All-time graphs/stats: earliest source date (fallback 5y) through now. */
export function allTimeRange(earliest: Date | null, now: Date = new Date()) {
const to = new Date(now);
const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1);
return { from, to, fromStr: fmtDate(from), toStr: fmtDate(to) };
}
/** Timeline slider upper bound, bucketed to end-of-day (UTC) so the SSR and
* client renders agree on the slider scale and labels (no hydration mismatch). */
export function endOfTodayMs(now: Date = new Date()): number {
const d = new Date(now);
d.setUTCHours(23, 59, 59, 999);
return d.getTime();
}
/** Timeline default window: trailing 30 days, with both bounds bucketed to the
* UTC day so the slider's millisecond values are identical across the
* prerender and the client's first render. */
export function defaultActivityRange(now: number = Date.now()) {
const to = endOfTodayMs(new Date(now));
const from = to - 30 * 24 * 60 * 60 * 1000;
return {
from,
to,
fromStr: fmtDate(new Date(from)),
toStr: fmtDate(new Date(to)),
};
}
/** The hour-of-day histogram buckets in the viewer's timezone. */
export function resolvedTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}

View File

@@ -1,9 +1,30 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
QueryClient,
QueryClientProvider,
hydrate,
type DehydratedState,
} from '@tanstack/react-query';
import App from './App';
// Global stylesheets are imported here, in the client entry only — keeping
// them out of the shared component tree lets the prerender build import that
// tree under Node (where `import './x.css'` would otherwise fail).
import 'bootstrap/dist/css/bootstrap.min.css';
import 'rc-slider/assets/index.css';
import 'react-vertical-timeline-component/style.min.css';
import './App.css';
import './pages/CvPage.css';
declare global {
interface Window {
/** Dehydrated react-query cache inlined by the prerender step. */
__RQ_STATE__?: DehydratedState;
}
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -13,12 +34,29 @@ const queryClient = new QueryClient({
},
});
createRoot(document.getElementById('root')!).render(
// Seed the cache from the prerendered snapshot so the first client render
// matches the server HTML (no loading flash) and refetches only when stale.
const dehydratedState = window.__RQ_STATE__;
if (dehydratedState) {
hydrate(queryClient, dehydratedState);
}
const tree = (
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
</StrictMode>
);
const container = document.getElementById('root')!;
// Hydrate the prerendered markup when present; fall back to a fresh render
// (e.g. SPA-fallback routes that weren't prerendered, or a dev server).
if (dehydratedState && container.firstChild) {
hydrateRoot(container, tree);
} else {
createRoot(container).render(tree);
}

View File

@@ -38,11 +38,14 @@ export function BlogIndexPage() {
}
export function formatDate(iso: string): string {
// UTC-fixed so prerender (Node) and client (browser) agree — see formatDate
// in TimelineEntry for the hydration rationale.
return new Date(iso)
.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})
.toLowerCase();
}

View File

@@ -10,7 +10,8 @@ import { fetchCv, filesForSection } from '../api/cv';
import { CvHeader } from '../components/cv/CvHeader';
import { CvSection } from '../components/cv/CvSection';
import { CvTimeline } from '../components/cv/CvTimeline';
import './CvPage.css';
// CvPage.css is imported from the client entry (`main.tsx`) so this page stays
// importable under Node during the prerender build.
export function CvPage() {
const { hash } = useLocation();

View File

@@ -113,7 +113,9 @@ function forgeIcon(source: string): string {
function formatRange(first: string | null, last: string | null): string {
const fmt = (iso: string) =>
new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase();
new Date(iso)
.toLocaleDateString('en-GB', { month: 'short', year: 'numeric', timeZone: 'UTC' })
.toLowerCase();
if (first && last) return `${fmt(first)} — ${fmt(last)}`;
if (last) return fmt(last);
return '';

View File

@@ -8,9 +8,10 @@ import { VerticalTimeline } from 'react-vertical-timeline-component';
import { fetchDailyCounts, fetchEvents, fetchSources, type Source } from '../api/client';
import { Filters } from '../components/Filters';
import { TimelineEntry } from '../components/TimelineEntry';
import { defaultActivityRange, endOfTodayMs, fmtDate } from '../lib/ranges';
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
const RANGE_MAX = Date.now();
const RANGE_MAX = endOfTodayMs();
function parseDate(s: string): number {
// Accept YYYY-MM-DD or full ISO datetime
@@ -51,9 +52,8 @@ export function TimelineHome() {
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
const parsed = parseTimespan(timespan);
if (parsed) return parsed;
const now = Date.now();
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
return [thirtyDaysAgo, now];
const { from, to } = defaultActivityRange();
return [from, to];
});
const [limit, setLimit] = useState<number>(100);
@@ -69,8 +69,13 @@ export function TimelineHome() {
[enabledSources],
);
// Day-stamped keys (rather than raw millisecond bounds) so the prerendered
// snapshot and the client's first render agree on the same UTC day.
const fromStr = fmtDate(new Date(rangeValue[0]));
const toStr = fmtDate(new Date(rangeValue[1]));
const eventsQ = useQuery({
queryKey: ['events', rangeValue, activeSources, limit],
queryKey: ['events', fromStr, toStr, activeSources, limit],
queryFn: () =>
fetchEvents({
from: new Date(rangeValue[0]),
@@ -83,8 +88,6 @@ export function TimelineHome() {
const events = eventsQ.data ?? [];
const fromStr = new Date(rangeValue[0]).toISOString().slice(0, 10);
const toStr = new Date(rangeValue[1]).toISOString().slice(0, 10);
const dailyQ = useQuery({
queryKey: ['daily-counts', fromStr, toStr],
queryFn: () => fetchDailyCounts(fromStr, toStr),

133
ui/src/prerender/meta.ts Normal file
View File

@@ -0,0 +1,133 @@
// Per-route <head> metadata baked into the prerendered HTML: a real title and
// description for each route, plus a schema.org Person + Occupation JSON-LD
// block so crawlers and AI screening tools get structured identity data even
// without running JS.
import type { QueryClient } from '@tanstack/react-query';
import type { BlogPost, ProjectSummary } from '../api/client';
export interface HeadMeta {
title: string;
description: string;
/** schema.org JSON-LD object, serialised into a <script type=ld+json>. */
jsonLd: object;
}
const SITE_URL = 'https://rob.tn';
const DEFAULT_DESCRIPTION =
'a timeline of open source contributions across github, gitea, and mozilla hg. ranked projects, language trends, and commit activity since 2012.';
/** schema.org Person + Occupation — stable across every route. */
function personJsonLd(): object {
return {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Rob Thijssen',
alternateName: 'grenade',
url: SITE_URL,
jobTitle: 'Software Engineer',
description:
'software engineer working across systems, infrastructure, and web — contributing to open source since 2012.',
sameAs: [
'https://github.com/grenade',
'https://git.lair.cafe/grenade',
'https://linkedin.com/in/thijssen/',
'https://stackoverflow.com/users/68115/grenade',
],
hasOccupation: {
'@type': 'Occupation',
name: 'Software Engineer',
occupationalCategory: '15-1252.00',
description:
'designs, builds, and operates software systems and the infrastructure they run on.',
},
};
}
/** First ~155 chars of a markdown body, stripped of the noisiest syntax. */
function excerpt(markdown: string, limit = 155): string {
const text = markdown
.replace(/^---[\s\S]*?---/, '') // drop frontmatter if present
.replace(/```[\s\S]*?```/g, ' ') // code fences
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links → text
.replace(/[#>*_`~|-]/g, ' ') // residual markdown punctuation
.replace(/\s+/g, ' ')
.trim();
if (text.length <= limit) return text;
return text.slice(0, limit).replace(/\s+\S*$/, '') + '…';
}
export function headForRoute(path: string, qc: QueryClient): HeadMeta {
const jsonLd = personJsonLd();
if (path === '/cv') {
return {
title: 'rob thijssen — cv',
description:
'rob thijssen — software engineer. résumé, work history, and skills, including roles at mozilla and across open source infrastructure.',
jsonLd,
};
}
if (path === '/activity') {
return {
title: 'activity — rob thijssen',
description:
'a chronological timeline of rob thijssens open source activity: commits, pull requests, issues, and releases across github, gitea, and mozilla hg.',
jsonLd,
};
}
if (path === '/blog') {
return {
title: 'blog — rob thijssen',
description: 'writing by rob thijssen on software, infrastructure, and open source.',
jsonLd,
};
}
if (path.startsWith('/blog/')) {
const slug = decodeURIComponent(path.slice('/blog/'.length));
const post = qc.getQueryData<BlogPost>(['blog-post', slug]);
return {
title: post ? `${post.title} — rob thijssen` : 'blog — rob thijssen',
description: post ? excerpt(post.markdown) : DEFAULT_DESCRIPTION,
jsonLd,
};
}
if (path.startsWith('/project/')) {
const rest = path.slice('/project/'.length);
const slash = rest.indexOf('/');
const source = slash === -1 ? rest : rest.slice(0, slash);
const repo = slash === -1 ? '' : rest.slice(slash + 1);
const projects = (qc.getQueryData(['projects']) as ProjectSummary[]) ?? [];
const project = projects.find((p) => p.source === source && p.repo === repo);
const counts = project
? [
project.commit_count ? `${project.commit_count} commits` : null,
project.pr_count ? `${project.pr_count} pull requests` : null,
project.issue_count ? `${project.issue_count} issues` : null,
]
.filter(Boolean)
.join(', ')
: '';
return {
title: `${repo} — rob thijssen`,
description: counts
? `rob thijssens contributions to ${repo}: ${counts}.`
: `rob thijssens open source activity and readme for ${repo}.`,
jsonLd,
};
}
// Home / dashboard.
return {
title: 'rob thijssen — developer activity and contribution history',
description: DEFAULT_DESCRIPTION,
jsonLd,
};
}

View File

@@ -0,0 +1,153 @@
// Build-time data fetching for the prerender. For each route we populate a
// fresh QueryClient with exactly the queries that route's components read,
// under the *same* query keys those components compute at runtime — so the
// dehydrated cache hydrates cleanly on the client. Keys that depend on the
// current day (contribution windows, the activity range) match same-day loads
// and refetch live afterwards; see `lib/ranges.ts`.
import type { QueryClient } from '@tanstack/react-query';
import {
fetchBlogPost,
fetchBlogPosts,
fetchDailyCounts,
fetchEvents,
fetchHourlyAvgs,
fetchLanguageDailyCounts,
fetchProjects,
fetchReadme,
fetchRepoLanguages,
fetchSources,
type ProjectSummary,
type Source,
type SourceSummary,
} from '../api/client';
import { fetchCv } from '../api/cv';
import {
allTimeRange,
defaultActivityRange,
earliestFrom,
lastYearRange,
resolvedTimeZone,
} from '../lib/ranges';
// Sources the timeline shows by default, in the same insertion order as
// TimelineHome's `enabledSources` (the array order is part of the query key).
const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla', 'blog'];
const TIMELINE_LIMIT = 100;
const PROJECT_EVENT_LIMIT = 500;
async function prefetchDash(qc: QueryClient): Promise<void> {
await qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources });
const sources = (qc.getQueryData(['sources']) as SourceSummary[]) ?? [];
const year = lastYearRange();
const all = allTimeRange(earliestFrom(sources));
const tz = resolvedTimeZone();
await Promise.all([
qc.prefetchQuery({ queryKey: ['projects'], queryFn: fetchProjects }),
qc.prefetchQuery({ queryKey: ['repo-languages'], queryFn: fetchRepoLanguages }),
qc.prefetchQuery({
queryKey: ['daily-counts', year.fromStr, year.toStr],
queryFn: () => fetchDailyCounts(year.fromStr, year.toStr),
}),
qc.prefetchQuery({
queryKey: ['language-daily', year.fromStr, year.toStr],
queryFn: () => fetchLanguageDailyCounts(year.fromStr, year.toStr),
}),
qc.prefetchQuery({
queryKey: ['daily-counts-alltime', all.fromStr, all.toStr],
queryFn: () => fetchDailyCounts(all.fromStr, all.toStr),
}),
qc.prefetchQuery({
queryKey: ['language-daily-alltime', all.fromStr, all.toStr],
queryFn: () => fetchLanguageDailyCounts(all.fromStr, all.toStr),
}),
qc.prefetchQuery({
queryKey: ['hourly-avgs-alltime', all.fromStr, all.toStr, tz],
queryFn: () => fetchHourlyAvgs(all.fromStr, all.toStr, tz),
}),
]);
}
async function prefetchActivity(qc: QueryClient): Promise<void> {
// Only the default `/activity` view is prerendered. Timespan-parameterised
// routes (`/activity/:timespan`) are unbounded, so they SPA-fall-back to the
// client and refetch.
const range = defaultActivityRange();
await Promise.all([
qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources }),
qc.prefetchQuery({
queryKey: ['events', range.fromStr, range.toStr, ALL_SOURCES, TIMELINE_LIMIT],
queryFn: () =>
fetchEvents({
from: new Date(range.from),
to: new Date(range.to),
sources: ALL_SOURCES,
limit: TIMELINE_LIMIT,
}),
}),
qc.prefetchQuery({
queryKey: ['daily-counts', range.fromStr, range.toStr],
queryFn: () => fetchDailyCounts(range.fromStr, range.toStr),
}),
]);
}
async function prefetchProject(
qc: QueryClient,
source: Source,
repo: string,
): Promise<void> {
await qc.prefetchQuery({ queryKey: ['projects'], queryFn: fetchProjects });
const projects = (qc.getQueryData(['projects']) as ProjectSummary[]) ?? [];
const host = projects.find((p) => p.source === source && p.repo === repo)?.host ?? '';
const tasks = [
qc.prefetchQuery({
queryKey: ['project-events', source, repo],
queryFn: () =>
fetchEvents({ sources: [source], repo, limit: PROJECT_EVENT_LIMIT }),
}),
qc.prefetchQuery({ queryKey: ['repo-languages'], queryFn: fetchRepoLanguages }),
];
if (host && (source === 'github' || source === 'gitea')) {
tasks.push(
qc.prefetchQuery({
queryKey: ['readme', source, host, repo],
queryFn: () => fetchReadme(source, host, repo),
}),
);
}
await Promise.all(tasks);
}
/** Populate `qc` with the data the given route renders from. */
export async function prefetchRoute(qc: QueryClient, path: string): Promise<void> {
if (path === '/' || path === '/dash') return prefetchDash(qc);
if (path === '/activity' || path.startsWith('/activity/')) return prefetchActivity(qc);
if (path === '/blog') {
await qc.prefetchQuery({ queryKey: ['blog-posts'], queryFn: fetchBlogPosts });
return;
}
if (path.startsWith('/blog/')) {
const slug = decodeURIComponent(path.slice('/blog/'.length));
await qc.prefetchQuery({
queryKey: ['blog-post', slug],
queryFn: () => fetchBlogPost(slug),
});
return;
}
if (path === '/cv') {
await qc.prefetchQuery({ queryKey: ['cv-gist'], queryFn: fetchCv });
return;
}
if (path.startsWith('/project/')) {
const rest = path.slice('/project/'.length);
const slash = rest.indexOf('/');
const source = (slash === -1 ? rest : rest.slice(0, slash)) as Source;
const repo = slash === -1 ? '' : rest.slice(slash + 1);
return prefetchProject(qc, source, repo);
}
}

View File

@@ -0,0 +1,22 @@
// Enumerate every route to prerender. The four static routes are fixed; the
// dynamic ones are derived from the same APIs the site reads, so publishing a
// blog post or gaining a project automatically adds its prerendered page on
// the next build.
import { fetchBlogPosts, fetchProjects } from '../api/client';
export async function collectRoutes(): Promise<string[]> {
const routes = ['/', '/activity', '/blog', '/cv'];
const [posts, projects] = await Promise.all([
fetchBlogPosts().catch(() => []),
fetchProjects().catch(() => []),
]);
for (const post of posts) routes.push(`/blog/${post.slug}`);
for (const project of projects) {
routes.push(`/project/${project.source}/${project.repo}`);
}
return routes;
}

14
ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/**
* Absolute base for API calls during the build-time prerender (Node has no
* relative-URL origin). Unset in the client build so the browser keeps the
* same-origin relative `/api/v1` that nginx proxies. See `api/client.ts`.
*/
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}