Site app foundation on the content service

services/site is an Astro app rendered on request by the Node adapter. It
keeps every published note in memory from the content service, refreshes
in the background, and keeps serving the last good copy if the service is
down. SiteIndex answers lookups, addresses, links both ways with roll-ups,
the status log and safe Markdown. Vault images pass through /media.
docs/site.md has the URL map and page rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 21:15:39 +00:00
parent 1023882e19
commit 7025e95f89
15 changed files with 674 additions and 2 deletions
+93
View File
@@ -0,0 +1,93 @@
/**
* The site's copy of the published notes.
*
* The site reads only the content service, with a reader token, and only
* published notes. It keeps every note in memory and refreshes in the
* background, so a saved note shows within a minute and a slow or restarting
* content service never takes a page down: the last good copy keeps serving.
*/
import type { NoteData, NoteType } from "@gk/schema";
import { SiteIndex } from "./site.ts";
export interface Note<T extends NoteType = NoteType> {
path: string;
type: T;
/** The file name without `.md`: what wikilinks point at. */
name: string;
slug: string;
title: string;
data: NoteData<T>;
body: string;
acceptedAt: string;
}
export interface Redirect {
type: string;
fromSlug: string;
toPath: string;
}
const env = (k: string, fallback?: string) => process.env[k] ?? import.meta.env[k] ?? fallback;
const baseUrl = () => (env("CONTENT_URL", "http://localhost:4310") as string).replace(/\/$/, "");
const token = () => env("CONTENT_TOKEN", "") as string;
const refreshMs = () => Number(env("CONTENT_REFRESH_SECONDS", "30")) * 1000;
async function api<T>(path: string): Promise<T> {
const res = await fetch(`${baseUrl()}${path}`, {
headers: { authorization: `Bearer ${token()}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`Content service answered ${res.status} for ${path}`);
return (await res.json()) as T;
}
async function loadAll(): Promise<SiteIndex> {
const notes: Note[] = [];
for (let offset = 0; ; offset += 1000) {
const page = await api<Note[]>(`/api/notes?full=true&published=true&limit=1000&offset=${offset}`);
notes.push(...page);
if (page.length < 1000) break;
}
const redirects = await api<Redirect[]>("/api/redirects").catch(() => []);
return new SiteIndex(notes, redirects);
}
let current: SiteIndex | null = null;
let loadedAt = 0;
let inflight: Promise<SiteIndex> | null = null;
function refresh(): Promise<SiteIndex> {
inflight ??= loadAll()
.then((index) => {
current = index;
loadedAt = Date.now();
return index;
})
.finally(() => {
inflight = null;
});
return inflight;
}
/** The current index. Waits only on the very first load. */
export async function site(): Promise<SiteIndex> {
if (!current) return refresh();
if (Date.now() - loadedAt > refreshMs()) {
refresh().catch((err) => console.error("Content refresh failed; serving the last copy.", err));
}
return current;
}
/** Fetch a vault image from the content service, for the /media route. */
export async function attachment(file: string): Promise<Response> {
return fetch(`${baseUrl()}/attachments/${encodeURIComponent(file)}`, {
headers: { authorization: `Bearer ${token()}` },
signal: AbortSignal.timeout(15_000),
});
}
/** Use a fixed set of notes instead of the content service, for tests. */
export function useIndex(index: SiteIndex) {
current = index;
loadedAt = Number.POSITIVE_INFINITY;
}