mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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:
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/**
|
||||
* The hero inside the site header: a gold eyebrow, the page title in Cinzel
|
||||
* and a lede. Extra content (buttons, chips, a filter) goes in the slot.
|
||||
*/
|
||||
interface Props {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
lede?: string;
|
||||
}
|
||||
const { eyebrow, title, lede } = Astro.props;
|
||||
---
|
||||
{eyebrow && <div class="eyebrow">{eyebrow}</div>}
|
||||
<h1 class="title">{title}</h1>
|
||||
{lede && <p class="lede">{lede}</p>}
|
||||
<slot />
|
||||
|
||||
<style>
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 6vw, 48px);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
color: var(--gk-soft);
|
||||
max-width: 62ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
/**
|
||||
* A status as a live pill: "Reading" with a lit dot, linking to the page the
|
||||
* status is about when there is one.
|
||||
*/
|
||||
import StatusPill from "@gk/ui/components/StatusPill.astro";
|
||||
import type { Note } from "../lib/content.ts";
|
||||
import type { SiteIndex } from "../lib/site.ts";
|
||||
|
||||
interface Props {
|
||||
status: Note<"status">;
|
||||
site: SiteIndex;
|
||||
/** Show the subject after the kind, as the Now strip does. */
|
||||
withSubject?: boolean;
|
||||
}
|
||||
const { status, site, withSubject } = Astro.props;
|
||||
const subject = status.data.subject ? site.link(status.data.subject) : null;
|
||||
const label = withSubject && subject ? `${status.data.kind} · ${subject.title}` : status.data.kind;
|
||||
---
|
||||
{
|
||||
subject?.href && withSubject ? (
|
||||
<a href={subject.href} class="status-link">
|
||||
<StatusPill>{label}</StatusPill>
|
||||
</a>
|
||||
) : (
|
||||
<StatusPill>{label}</StatusPill>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.status-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Note bodies as HTML. Raw HTML in a note is shown as text and only http,
|
||||
* https, mailto and relative links survive, so nothing in a note can run on
|
||||
* the site. Wikilinks to published notes become links; the rest plain text.
|
||||
*/
|
||||
import { Marked, type Tokens } from "marked";
|
||||
|
||||
const ESC: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
export const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ESC[c]!);
|
||||
|
||||
const safeUrl = (href: string) => {
|
||||
const h = href.trim();
|
||||
return /^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i.test(h) || !/^[a-z][a-z0-9+.-]*:/i.test(h) ? h : "#";
|
||||
};
|
||||
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
renderer: {
|
||||
html: ({ text }: Tokens.HTML | Tokens.Tag) => escapeHtml(text),
|
||||
link({ href, title, tokens }: Tokens.Link) {
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
const external = /^https?:/i.test(href) ? ' rel="noreferrer"' : "";
|
||||
return `<a href="${escapeHtml(safeUrl(href))}"${t}${external}>${this.parser.parseInline(tokens)}</a>`;
|
||||
},
|
||||
image: ({ href, text }: Tokens.Image) =>
|
||||
`<img src="${escapeHtml(safeUrl(href))}" alt="${escapeHtml(text)}" loading="lazy" decoding="async">`,
|
||||
},
|
||||
});
|
||||
|
||||
export function renderMarkdown(
|
||||
body: string,
|
||||
opts: { link: (name: string) => { title: string; href: string | null }; imageSrc: (file: string) => string | undefined },
|
||||
): string {
|
||||
const withLinks = body
|
||||
.replace(/!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, f: string, alt?: string) => `) ?? ""})`)
|
||||
.replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g, (_, n: string, label?: string) => {
|
||||
const l = opts.link(n.trim());
|
||||
const text = label ?? l.title;
|
||||
return l.href ? `[${text}](${l.href})` : text;
|
||||
});
|
||||
return marked.parse(withLinks, { async: false }) as string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Small helpers every page uses. */
|
||||
import { site } from "./content.ts";
|
||||
|
||||
export { site };
|
||||
|
||||
/** Group items by a key, keeping first-seen order. */
|
||||
export function groupBy<T>(items: T[], key: (item: T) => string): [string, T[]][] {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const k = key(item);
|
||||
map.set(k, [...(map.get(k) ?? []), item]);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}
|
||||
|
||||
/** "1 game" / "3 games". */
|
||||
export const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Everything the pages ask of the notes: lookups, addresses, links both ways,
|
||||
* statuses and Markdown. Pure, so tests build one from plain notes.
|
||||
*/
|
||||
import { slugify, wikilinkTarget, wikilinksIn, type NoteType } from "@gk/schema";
|
||||
import type { Note, Redirect } from "./content.ts";
|
||||
import { renderMarkdown } from "./markdown.ts";
|
||||
|
||||
/** Fields that hold images, not links to notes. */
|
||||
const IMAGE_KEYS = new Set(["cover", "photos", "file", "images"]);
|
||||
|
||||
/** Child types whose links also count for their parent (a make for its craft). */
|
||||
const ROLLUPS: Partial<Record<NoteType, string>> = { make: "craft", deck: "game", campaign: "system" };
|
||||
|
||||
export class SiteIndex {
|
||||
readonly notes: Note[];
|
||||
private readonly byNameMap = new Map<string, Note>();
|
||||
private readonly byTypeMap = new Map<string, Note[]>();
|
||||
private readonly outgoing = new Map<string, string[]>();
|
||||
private readonly incoming = new Map<string, Note[]>();
|
||||
private readonly redirectMap = new Map<string, string>();
|
||||
|
||||
constructor(notes: Note[], redirects: Redirect[] = []) {
|
||||
this.notes = notes;
|
||||
for (const n of notes) {
|
||||
this.byNameMap.set(n.name.toLowerCase(), n);
|
||||
const list = this.byTypeMap.get(n.type) ?? [];
|
||||
list.push(n);
|
||||
this.byTypeMap.set(n.type, list);
|
||||
}
|
||||
for (const n of notes) {
|
||||
const names = [...new Set([...linksIn(n.data), ...wikilinksIn(n.body ?? "")].map((x) => x.toLowerCase()))];
|
||||
this.outgoing.set(n.path, names);
|
||||
for (const name of names) {
|
||||
const target = this.byNameMap.get(name);
|
||||
if (!target || target.path === n.path) continue;
|
||||
const list = this.incoming.get(target.path) ?? [];
|
||||
list.push(n);
|
||||
this.incoming.set(target.path, list);
|
||||
}
|
||||
}
|
||||
const byPath = new Map(notes.map((n) => [n.path, n]));
|
||||
for (const r of redirects) {
|
||||
const to = byPath.get(r.toPath);
|
||||
const href = to && this.href(to);
|
||||
if (href) this.redirectMap.set(`${r.type}/${r.fromSlug}`, href);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every published note of a type. */
|
||||
all<T extends NoteType>(type: T): Note<T>[] {
|
||||
return (this.byTypeMap.get(type) ?? []) as Note<T>[];
|
||||
}
|
||||
|
||||
/** The one note of a single-note type, like site settings. */
|
||||
one<T extends NoteType>(type: T): Note<T> | undefined {
|
||||
return this.all(type)[0];
|
||||
}
|
||||
|
||||
bySlug<T extends NoteType>(type: T, slug: string): Note<T> | undefined {
|
||||
return this.all(type).find((n) => n.slug === slug);
|
||||
}
|
||||
|
||||
/** A note by the name a wikilink uses, or by a `[[wikilink]]` itself. */
|
||||
byName(nameOrLink: string | undefined): Note | undefined {
|
||||
if (!nameOrLink) return undefined;
|
||||
const name = wikilinkTarget(nameOrLink) ?? nameOrLink;
|
||||
return this.byNameMap.get(name.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/** Where an old slug of a type now lives. */
|
||||
redirect(type: NoteType, slug: string): string | undefined {
|
||||
return this.redirectMap.get(`${type}/${slug}`);
|
||||
}
|
||||
|
||||
/** The address that shows a note, or null for notes no page shows. */
|
||||
href(n: Note): string | null {
|
||||
const d = n.data as Record<string, unknown>;
|
||||
switch (n.type) {
|
||||
case "post":
|
||||
return d.postType === "case study" ? `/work/${n.slug}` : `/blog/${n.slug}`;
|
||||
case "library-item":
|
||||
return `/library#${n.slug}`;
|
||||
case "board-game":
|
||||
return `/library/shelf#${n.slug}`;
|
||||
case "craft":
|
||||
return `/workshop/${n.slug}`;
|
||||
case "make": {
|
||||
const craft = this.byName(d.craft as string);
|
||||
return craft ? `/workshop/${craft.slug}#${n.slug}` : `/workshop#${n.slug}`;
|
||||
}
|
||||
case "station":
|
||||
case "tool":
|
||||
return `/workshop#${n.slug}`;
|
||||
case "tabletop-system":
|
||||
case "campaign":
|
||||
case "card-game":
|
||||
case "deck":
|
||||
return `/tabletop#${n.slug}`;
|
||||
case "experience":
|
||||
return `/resume#${n.slug}`;
|
||||
case "status":
|
||||
return `/now#${n.slug}`;
|
||||
case "product-line":
|
||||
return `/store/${n.slug}`;
|
||||
case "page":
|
||||
return (d.page as string) ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A wikilink as `{ title, href }`; href is null when the note is not published. */
|
||||
link(nameOrLink: string): { title: string; href: string | null; note?: Note } {
|
||||
const note = this.byName(nameOrLink);
|
||||
const fallback = (wikilinkTarget(nameOrLink) ?? nameOrLink).trim();
|
||||
return note ? { title: note.title, href: this.href(note), note } : { title: fallback, href: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes that link to this one, newest first. Links to a make count for its
|
||||
* craft, to a deck for its game and to a campaign for its system.
|
||||
*/
|
||||
backlinks(n: Note, opts: { type?: NoteType } = {}): Note[] {
|
||||
const seen = new Map<string, Note>();
|
||||
const add = (list: Note[] | undefined) => list?.forEach((b) => b.path !== n.path && seen.set(b.path, b));
|
||||
add(this.incoming.get(n.path));
|
||||
for (const [childType, field] of Object.entries(ROLLUPS)) {
|
||||
for (const child of this.all(childType as NoteType)) {
|
||||
const parent = this.byName((child.data as Record<string, unknown>)[field] as string);
|
||||
if (parent?.path === n.path) add(this.incoming.get(child.path));
|
||||
}
|
||||
}
|
||||
return [...seen.values()]
|
||||
.filter((b) => !opts.type || b.type === opts.type)
|
||||
.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
|
||||
}
|
||||
|
||||
/** The notes this one links to. */
|
||||
linksFrom(n: Note): Note[] {
|
||||
return (this.outgoing.get(n.path) ?? []).map((name) => this.byNameMap.get(name)).filter((x): x is Note => !!x);
|
||||
}
|
||||
|
||||
/** Published blog posts, newest first. Case studies are listed on Work instead. */
|
||||
posts(opts: { topic?: string; includeCaseStudies?: boolean } = {}): Note<"post">[] {
|
||||
return this.all("post")
|
||||
.filter((p) => (opts.includeCaseStudies || p.data.postType !== "case study") && (!opts.topic || slugify(p.data.topic) === slugify(opts.topic)))
|
||||
.sort((a, b) => b.data.date.localeCompare(a.data.date));
|
||||
}
|
||||
|
||||
caseStudies(): Note<"post">[] {
|
||||
return this.all("post")
|
||||
.filter((p) => p.data.postType === "case study")
|
||||
.sort((a, b) => b.data.date.localeCompare(a.data.date));
|
||||
}
|
||||
|
||||
/** The status log: newest status per subject, pinned first, then newest. */
|
||||
statuses(): Note<"status">[] {
|
||||
const latest = new Map<string, Note<"status">>();
|
||||
const sorted = [...this.all("status")].sort((a, b) => b.data.date.localeCompare(a.data.date) || b.acceptedAt.localeCompare(a.acceptedAt));
|
||||
for (const s of sorted) {
|
||||
const key = s.data.subject ? (this.byName(s.data.subject)?.path ?? s.data.subject.toLowerCase()) : s.path;
|
||||
if (!latest.has(key)) latest.set(key, s);
|
||||
}
|
||||
return [...latest.values()].sort((a, b) => Number(!!b.data.pinned) - Number(!!a.data.pinned) || b.data.date.localeCompare(a.data.date));
|
||||
}
|
||||
|
||||
/** The newest status about a note, for its Now chip. */
|
||||
statusFor(n: Note): Note<"status"> | undefined {
|
||||
return this.statuses().find((s) => this.byName(s.data.subject)?.path === n.path);
|
||||
}
|
||||
|
||||
/** A status kind's label, icon and page. */
|
||||
kind(label: string): { label: string; icon: string; page?: string } {
|
||||
const kinds = this.one("status-kinds")?.data.kinds ?? [];
|
||||
const k = kinds.find((x) => x.label.toLowerCase() === label.toLowerCase());
|
||||
return { label, icon: brandIcon(k?.icon), page: k?.page };
|
||||
}
|
||||
|
||||
/** Text for a page from its Page text note (lede, intro, quick facts). */
|
||||
pageText(path: string): Note<"page"> | undefined {
|
||||
return this.all("page").find((p) => p.data.page === path);
|
||||
}
|
||||
|
||||
settings() {
|
||||
return this.one("site-settings")?.data;
|
||||
}
|
||||
|
||||
/** A note's Markdown as safe HTML, wikilinks resolved, unpublished links as plain text. */
|
||||
render(markdown: string | undefined): string {
|
||||
if (!markdown?.trim()) return "";
|
||||
return renderMarkdown(markdown, { link: (name) => this.link(name), imageSrc: mediaSrc });
|
||||
}
|
||||
}
|
||||
|
||||
/** Every `[[link]]` in a note's fields, skipping image fields. */
|
||||
function linksIn(value: unknown, key = ""): string[] {
|
||||
if (IMAGE_KEYS.has(key)) return [];
|
||||
if (typeof value === "string") {
|
||||
const t = wikilinkTarget(value);
|
||||
return t ? [t] : [];
|
||||
}
|
||||
if (Array.isArray(value)) return value.flatMap((v) => linksIn(v, key));
|
||||
if (value && typeof value === "object") return Object.entries(value).flatMap(([k, v]) => linksIn(v, k));
|
||||
return [];
|
||||
}
|
||||
|
||||
function dateOf(n: Note): string {
|
||||
const d = n.data as Record<string, unknown>;
|
||||
return String(d.date ?? d.updated ?? d.created ?? n.acceptedAt ?? "");
|
||||
}
|
||||
|
||||
/** An image field (`[[file.jpg]]` or an https URL) as a src. */
|
||||
export function mediaSrc(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (/^https:\/\//.test(value)) return value;
|
||||
const file = wikilinkTarget(value) ?? value;
|
||||
return `/media/${encodeURIComponent(file.trim())}`;
|
||||
}
|
||||
|
||||
/** Status kinds name icons; older notes use Material names, mapped here to brand icons. */
|
||||
const MDI: Record<string, string> = {
|
||||
"book-open-variant": "bookshelf",
|
||||
"book-open-page-variant": "bookshelf",
|
||||
headphones: "play",
|
||||
"television-classic": "tv",
|
||||
television: "tv",
|
||||
"gamepad-variant": "gamepad",
|
||||
"dice-multiple": "dice",
|
||||
"hammer-wrench": "hammer",
|
||||
school: "graduation",
|
||||
};
|
||||
|
||||
export function brandIcon(name: string | undefined): string {
|
||||
if (!name) return "favorite";
|
||||
return MDI[name] ?? name;
|
||||
}
|
||||
|
||||
/** "Oct 11, 2026" from "2026-10-11". */
|
||||
export function formatDate(iso: string | undefined, opts: { month?: "short" | "long"; day?: boolean } = {}): string {
|
||||
if (!iso) return "";
|
||||
const [y, m, d] = iso.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(y!, (m ?? 1) - 1, d ?? 1));
|
||||
return date.toLocaleDateString("en-US", {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: opts.month ?? "short",
|
||||
...(opts.day === false || !d ? {} : { day: "numeric" }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||
import { site } from "../lib/page.ts";
|
||||
const s = await site();
|
||||
---
|
||||
<PageShell title="Greg Kilmer" family="landing" current="home">
|
||||
<p>{s.notes.length} notes, {s.statuses().length} statuses.</p>
|
||||
</PageShell>
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Vault images, passed through from the content service with a day's cache. */
|
||||
import type { APIRoute } from "astro";
|
||||
import { attachment } from "../../lib/content.ts";
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const file = params.file ?? "";
|
||||
if (!file || file.includes("..")) return new Response("Not found", { status: 404 });
|
||||
const upstream = await attachment(file).catch(() => null);
|
||||
if (!upstream?.ok || !upstream.body) return new Response("Not found", { status: 404 });
|
||||
return new Response(upstream.body, {
|
||||
headers: {
|
||||
"content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
|
||||
"cache-control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user