feat: prerender every route + Gitea Actions deploy
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:
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
61
ui/src/entry-server.tsx
Normal 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
71
ui/src/lib/ranges.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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
133
ui/src/prerender/meta.ts
Normal 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 thijssen’s 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 thijssen’s contributions to ${repo}: ${counts}.`
|
||||
: `rob thijssen’s open source activity and readme for ${repo}.`,
|
||||
jsonLd,
|
||||
};
|
||||
}
|
||||
|
||||
// Home / dashboard.
|
||||
return {
|
||||
title: 'rob thijssen — developer activity and contribution history',
|
||||
description: DEFAULT_DESCRIPTION,
|
||||
jsonLd,
|
||||
};
|
||||
}
|
||||
153
ui/src/prerender/prefetch.ts
Normal file
153
ui/src/prerender/prefetch.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
ui/src/prerender/routes.ts
Normal file
22
ui/src/prerender/routes.ts
Normal 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
14
ui/src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user