Files
com.gregkilmer.www/services/site/src/lib/site.ts
T
Claude 99ecdc2113 Phase 5: every page of the site, the resume PDF and RSS
Landing, About, Now, Resume (with a generated PDF), Work and case studies,
Inquiries and the sent page, Library and the Shelf, Workshop and craft pages,
Tabletop, Blog with topics and posts, RSS, the Store, product lines, the
order form and the 404, all rendered from the content service's notes.

Schema: experience previousRoles, skills stack core items, page stats,
make tags and deck favorite. Icon names from notes map to brand icons in one
place, and HTML comments are dropped when Markdown renders.

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

274 lines
10 KiB
TypeScript

/**
* 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())}`;
}
/** Notes 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",
notebook: "bookshelf",
headphones: "play",
"television-classic": "tv",
television: "tv",
"gamepad-variant": "gamepad",
"dice-multiple": "dice",
"hammer-wrench": "hammer",
school: "graduation",
"map-marker": "location",
"map-marker-outline": "location",
"briefcase-outline": "briefcase",
paw: "dog",
"cup-outline": "tea",
coffee: "tea",
"home-outline": "home",
"heart-outline": "heart",
"code-tags": "web",
laptop: "monitor",
"cards-playing": "proxy-cards",
"cards-playing-outline": "proxy-cards",
cards: "proxy-cards",
"printer-3d-nozzle": "print",
"printer-3d": "print",
printer: "print",
};
/** The brand icons in @gk/ui. */
const ICONS = new Set(
Object.keys(import.meta.glob("../../../../packages/ui/src/assets/icons/*.svg")).map((p) => p.replace(/^.*\/(.+)\.svg$/, "$1")),
);
/** A brand icon for a note's icon name, or the fallback when none matches. */
export function brandIcon(name: string | undefined, fallback = "favorite"): string {
const mapped = name ? (MDI[name] ?? name) : fallback;
return ICONS.has(mapped) ? mapped : fallback;
}
/** "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" }),
});
}