Files
com.gregkilmer.www/services/site/src/lib/content.ts
T
Claude 60dfeecd7f Reply time setting, and server settings from process.env only
Inquiries and the sent page promise a reply within the site setting
replyTime (2 days, Greg 2026-10-11). The site reads its settings through one
helper that uses process.env only, so a .env file can never be copied into the
built bundle through import.meta.env.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:18:07 +00:00

97 lines
3.1 KiB
TypeScript

/**
* 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 { env } from "./env.ts";
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 baseUrl = () => (env("CONTENT_URL", "http://localhost:4310") as string).replace(/\/$/, "");
const token = () => env("CONTENT_TOKEN", "") as string;
/** Preview only: include unpublished notes. Needs an editor or admin token; never set in production. */
const includeDrafts = () => env("CONTENT_INCLUDE_DRAFTS", "") === "true";
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 published = includeDrafts() ? "" : "&published=true";
const page = await api<(Note & { publish?: boolean })[]>(`/api/notes?full=true${published}&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;
}