/** * 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> = { make: "craft", deck: "game", campaign: "system" }; export class SiteIndex { readonly notes: Note[]; private readonly byNameMap = new Map(); private readonly byTypeMap = new Map(); private readonly outgoing = new Map(); private readonly incoming = new Map(); private readonly redirectMap = new Map(); 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(type: T): Note[] { return (this.byTypeMap.get(type) ?? []) as Note[]; } /** The one note of a single-note type, like site settings. */ one(type: T): Note | undefined { return this.all(type)[0]; } bySlug(type: T, slug: string): Note | 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; 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(); 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)[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>(); 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; 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 = { "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" }), }); }