mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
60dfeecd7f
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
97 lines
3.1 KiB
TypeScript
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;
|
|
}
|