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,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" }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user