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
This commit is contained in:
Claude
2026-10-11 21:33:49 +00:00
parent 7025e95f89
commit 99ecdc2113
83 changed files with 11681 additions and 14 deletions
+242
View File
@@ -0,0 +1,242 @@
/**
* What the Blog, topic, post and case study pages ask beyond the SiteIndex:
* topics, reading time, page-link chips, Now chips, related posts, the
* archive and the post's table of contents.
*/
import { slugify, topics } from "@gk/schema";
import type { Note } from "./content.ts";
import { formatDate, mediaSrc, type SiteIndex } from "./site.ts";
export type Post = Note<"post">;
export type Topic = (typeof topics)[number];
export { topics };
/** The address of a topic page. */
export const topicHref = (topic: string) => `/blog/topic/${slugify(topic)}`;
/** The topic a URL segment names, or undefined. */
export const topicFromSlug = (slug: string | undefined): Topic | undefined => topics.find((t) => slugify(t) === slug);
/** Where each topic lives on the site, for the topic page's sidebar. */
export const topicHome: Record<Topic, { label: string; href: string; blurb: string }> = {
Making: { label: "Workshop", href: "/workshop", blurb: "Crafts, tools and makes" },
Tabletop: { label: "Tabletop", href: "/tabletop", blurb: "Campaigns, systems, decks and card games" },
Library: { label: "Library", href: "/library", blurb: "Books, shows, anime and games" },
"Work and tech": { label: "Work", href: "/work", blurb: "Case studies and what I build" },
Life: { label: "Now", href: "/now", blurb: "Everything I am into right now" },
};
/** Minutes to read the body, at 220 words a minute. */
export function readMinutes(body: string | undefined): number {
const words = (body ?? "")
.replace(/<!--[\s\S]*?-->/g, " ")
.split(/\s+/)
.filter(Boolean).length;
return Math.max(1, Math.round(words / 220));
}
/** "Oct 11, 2026 · 4 min read". */
export const dateLine = (p: Post) => `${formatDate(p.data.date)} · ${readMinutes(p.body)} min read`;
/** The top slot: the newest pinned post (Featured), else the newest post (Latest). */
export function leadPost(posts: Post[]): { post: Post; label: "Featured" | "Latest" } | undefined {
const pinned = posts.find((p) => p.data.pinned);
if (pinned) return { post: pinned, label: "Featured" };
return posts[0] ? { post: posts[0], label: "Latest" } : undefined;
}
/** The section a note lives in, for chip labels like "Workshop · Leather". */
const SECTION: Record<string, string> = {
post: "Blog",
"library-item": "Library",
"board-game": "Shelf",
craft: "Workshop",
make: "Workshop",
station: "Workshop",
tool: "Workshop",
"tabletop-system": "Tabletop",
campaign: "Tabletop",
"card-game": "Tabletop",
deck: "Tabletop",
experience: "Resume",
status: "Now",
"product-line": "Store",
};
export function sectionOf(n: Note): string | undefined {
if (n.type === "post") return (n as Post).data.postType === "case study" ? "Work" : "Blog";
return SECTION[n.type];
}
/** A smaller label under the section, for the Connected pages panel. */
const KIND: Record<string, string> = {
"library-item": "Entry",
"board-game": "Board game",
craft: "Craft",
make: "Make",
station: "Station",
tool: "Tool",
"tabletop-system": "System",
campaign: "Campaign",
"card-game": "Card game",
deck: "Deck",
experience: "Role",
status: "Status",
"product-line": "Product line",
};
export function kindOf(n: Note): string | undefined {
if (n.type === "post") return (n as Post).data.postType === "case study" ? "Case study" : "Post";
return KIND[n.type];
}
/** "Workshop · Leather", or just the title for a page or unknown type. */
export function chipLabel(n: Note): string {
const section = sectionOf(n);
return section && section !== n.title ? `${section} · ${n.title}` : n.title;
}
export interface PageLink {
note: Note;
href: string;
label: string;
}
/** The published pages a post links to, as chips. Statuses are left to the Now chips. */
export function pageLinks(s: SiteIndex, post: Post): PageLink[] {
return s
.linksFrom(post)
.filter((n) => n.type !== "status" && n.path !== post.path)
.map((n) => ({ note: n, href: s.href(n), label: chipLabel(n) }))
.filter((l): l is PageLink => !!l.href);
}
/** The current statuses of the notes a post links to, one per kind and subject. */
export function nowStatuses(s: SiteIndex, post: Post): Note<"status">[] {
const seen = new Set<string>();
const out: Note<"status">[] = [];
for (const n of s.linksFrom(post)) {
const st = s.statusFor(n);
if (st && !seen.has(st.path)) {
seen.add(st.path);
out.push(st);
}
}
return out;
}
/** A note's first image: its cover, else its first photo or product image. */
export function imageOf(n: Note): string | undefined {
const d = n.data as Record<string, unknown>;
const photos = d.photos as string[] | undefined;
const images = d.images as { file: string }[] | undefined;
return mediaSrc((d.cover as string) ?? photos?.[0] ?? images?.[0]?.file);
}
/** Case-insensitive match on title and summary. */
export function searchPosts(posts: Post[], q: string): Post[] {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return posts;
return posts.filter((p) => {
const hay = `${p.title} ${p.data.summary ?? ""}`.toLowerCase();
return words.every((w) => hay.includes(w));
});
}
/** Posts by month, newest first: [{ month: "2026-10", label: "October 2026", count }]. */
export function archive(posts: Post[]): { month: string; label: string; count: number }[] {
const map = new Map<string, number>();
for (const p of posts) {
const m = p.data.date.slice(0, 7);
map.set(m, (map.get(m) ?? 0) + 1);
}
return [...map.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([month, count]) => ({ month, label: formatDate(`${month}-01`, { month: "long", day: false }), count }));
}
/**
* Posts related to this one: posts that link to it or that it links to count
* most, then posts sharing linked pages, newest first among equals.
*/
export function relatedPosts(s: SiteIndex, post: Post, pool: Post[], limit = 3): Post[] {
const mine = new Set(s.linksFrom(post).map((n) => n.path));
const backlinks = new Set(s.backlinks(post, { type: "post" }).map((n) => n.path));
const scored = pool
.filter((p) => p.path !== post.path)
.map((p) => {
let score = backlinks.has(p.path) || mine.has(p.path) ? 10 : 0;
for (const n of s.linksFrom(p)) if (mine.has(n.path)) score += 1;
return { p, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score || b.p.data.date.localeCompare(a.p.data.date));
return scored.slice(0, limit).map((x) => x.p);
}
/** The posts before and after this one in a newest-first list. */
export function neighbours(list: Post[], post: Post): { previous?: Post; next?: Post } {
const i = list.findIndex((p) => p.path === post.path);
if (i < 0) return {};
return { previous: list[i + 1], next: list[i - 1] };
}
/**
* Give each h2 in rendered HTML an id and return the list, for the
* "On this page" panel.
*/
export function withHeadings(html: string): { html: string; headings: { id: string; text: string }[] } {
const headings: { id: string; text: string }[] = [];
const used = new Set<string>();
const out = html.replace(/<h2>([\s\S]*?)<\/h2>/g, (_, inner: string) => {
const text = inner.replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, "&").trim();
let id = slugify(text) || "section";
for (let n = 2; used.has(id); n++) id = `${slugify(text)}-${n}`;
used.add(id);
headings.push({ id, text });
return `<h2 id="${id}">${inner}</h2>`;
});
return { html: out, headings };
}
/** Escape text for XML. */
export const xml = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]!);
export interface Connected {
href: string;
kicker: string;
title: string;
sub: string;
img?: string;
}
/**
* The pages a post links to and the pages that link back to it, each with
* its section, picture and a line on how it connects.
*/
export function connectedPages(s: SiteIndex, post: Post): Connected[] {
const seen = new Map<string, Note>();
for (const n of [...s.linksFrom(post), ...s.backlinks(post)]) {
if (n.path === post.path || n.type === "post" || n.type === "status" || !s.href(n)) continue;
seen.set(n.path, n);
}
return [...seen.values()].map((n) => {
const parts: string[] = [];
if (s.linksFrom(n).some((x) => x.path === post.path)) parts.push("Links back here");
if (n.type === "craft") {
const makes = s.all("make").filter((m) => s.byName(m.data.craft)?.path === n.path).length;
if (makes) parts.push(`${makes} ${makes === 1 ? "make" : "makes"}`);
}
const posts = s.backlinks(n, { type: "post" }).length;
if (posts > 1) parts.push(`${posts} posts`);
return {
href: s.href(n)!,
kicker: [sectionOf(n), kindOf(n)].filter(Boolean).join(" · "),
title: n.title,
sub: parts.join(" · "),
img: imageOf(n),
};
});
}
+4 -1
View File
@@ -30,6 +30,8 @@ export interface Redirect {
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;
/** 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> {
@@ -44,7 +46,8 @@ async function api<T>(path: string): Promise<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}`);
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;
}
+216
View File
@@ -0,0 +1,216 @@
/**
* Helpers for the Landing, About and Now pages: how a status reads (its
* subject, a meta line, the page chip), how the Now page groups statuses, and
* small lookups for the landing doors.
*/
import { world } from "@gk/ui/components/nav.ts";
import type { Note } from "./content.ts";
import { brandIcon, mediaSrc, type SiteIndex } from "./site.ts";
const MEDIUM_LABEL: Record<string, string> = {
book: "Books",
"light novel": "Light novels",
webnovel: "Webnovels",
manga: "Manga",
manhwa: "Manhwa",
"video game": "Video games",
anime: "Anime",
TV: "TV",
movie: "Movies",
};
const SUB_LABEL: Partial<Record<string, string>> = {
"tabletop-system": "Systems",
campaign: "Campaigns",
"card-game": "Card games",
deck: "Decks",
"board-game": "The Shelf",
station: "Stations",
tool: "Tools",
};
const PAGE_LABEL: Record<string, string> = {
...Object.fromEntries(world.map((w) => [w.href, w.label])),
"/library/shelf": "The Shelf",
"/blog": "Blog",
"/store": "Store",
"/work": "Work",
"/resume": "Resume",
"/about": "About me",
};
/** "Library" for "/library", else the last path part with a capital. */
export function pageLabel(path: string): string {
const clean = path.split("#")[0]!.replace(/\/$/, "") || "/";
if (PAGE_LABEL[clean]) return PAGE_LABEL[clean]!;
const last = clean.split("/").filter(Boolean).pop() ?? "Home";
return last.charAt(0).toUpperCase() + last.slice(1).replace(/-/g, " ");
}
export interface StatusView {
status: Note<"status">;
slug: string;
kind: string;
icon: string;
pinned: boolean;
title: string;
href: string | null;
subject?: Note;
/** A short line about the subject: creator, medium or platform. */
meta?: string;
note?: string;
date: string;
cover?: string;
chip?: { label: string; href: string };
upNext: boolean;
}
/** Everything a page shows about one status. */
export function viewStatus(s: SiteIndex, status: Note<"status">): StatusView {
const d = status.data;
const link = d.subject ? s.link(d.subject) : null;
const subject = link?.note;
const kind = s.kind(d.kind);
const sd = (subject?.data ?? {}) as Record<string, unknown>;
const metaParts: string[] = [];
if (subject?.type === "library-item") {
if (sd.creator) metaParts.push(String(sd.creator));
else if (sd.medium) metaParts.push(capital(String(sd.medium)));
if (sd.platform) metaParts.push(String(sd.platform));
} else if (subject?.type === "deck" && sd.format) {
metaParts.push(String(sd.format));
} else if (subject?.type === "campaign" && sd.platform) {
metaParts.push(String(sd.platform));
}
let sub: string | undefined;
if (subject?.type === "library-item") sub = MEDIUM_LABEL[String(sd.medium)];
else if (subject?.type === "make" || subject?.type === "craft") {
const craft = subject.type === "craft" ? subject : s.byName(String(sd.craft ?? ""));
sub = craft?.title;
} else if (subject) sub = SUB_LABEL[subject.type];
const page = kind.page;
const chip = page ? { label: sub ? `${pageLabel(page)} · ${sub}` : pageLabel(page), href: page } : undefined;
return {
status,
slug: status.slug,
kind: d.kind,
icon: brandIcon(kind.icon),
pinned: !!d.pinned,
title: link?.title ?? status.title,
href: link?.href ?? null,
subject,
meta: metaParts.join(" · ") || undefined,
note: d.note,
date: d.date,
cover: mediaSrc((sd.cover as string | undefined) ?? (status.data as { cover?: string }).cover),
chip,
upNext: isUpNext(d.kind, subject),
};
}
function isUpNext(kind: string, subject?: Note): boolean {
if (/up next|want to|queued|next/i.test(kind)) return true;
const sd = (subject?.data ?? {}) as Record<string, unknown>;
return subject?.type === "library-item" && sd.state === "up next";
}
const capital = (x: string) => x.charAt(0).toUpperCase() + x.slice(1);
/** The Now page's groups, in page order. */
export const NOW_GROUPS = [
{ key: "making", title: "Making" },
{ key: "table", title: "At the table" },
{ key: "playing", title: "Playing" },
{ key: "reading", title: "Reading and listening" },
{ key: "watching", title: "Watching" },
{ key: "other", title: "Elsewhere" },
{ key: "next", title: "Up next" },
] as const;
export type NowGroupKey = (typeof NOW_GROUPS)[number]["key"];
const READ = new Set(["book", "light novel", "webnovel", "manga", "manhwa"]);
const WATCH = new Set(["anime", "TV", "movie"]);
const TABLE = new Set(["tabletop-system", "campaign", "card-game", "deck", "board-game"]);
const MAKE = new Set(["craft", "make", "station", "tool"]);
/** Which Now group a status belongs to: its subject decides, else its kind. */
export function nowGroup(v: StatusView, s: SiteIndex): NowGroupKey {
if (v.upNext) return "next";
const t = v.subject?.type;
if (t && MAKE.has(t)) return "making";
if (t && TABLE.has(t)) return "table";
if (t === "library-item") {
const medium = String((v.subject!.data as Record<string, unknown>).medium);
if (READ.has(medium)) return "reading";
if (WATCH.has(medium)) return "watching";
if (medium === "video game") return "playing";
}
const k = v.kind.toLowerCase();
if (/read|listen/.test(k)) return "reading";
if (/watch|caught up/.test(k)) return "watching";
if (/play/.test(k)) return "playing";
const page = s.kind(v.kind).page ?? "";
if (page.startsWith("/workshop")) return "making";
if (page.startsWith("/tabletop")) return "table";
return "other";
}
/** Every status grouped for the Now page, empty groups left out. */
export function groupStatuses(s: SiteIndex): { key: NowGroupKey; title: string; items: StatusView[] }[] {
const views = s.statuses().map((st) => viewStatus(s, st));
return NOW_GROUPS.map((g) => ({ ...g, items: views.filter((v) => nowGroup(v, s) === g.key) })).filter((g) => g.items.length);
}
/** The newest status date, for "Last updated". */
export function lastUpdated(s: SiteIndex): string | undefined {
return s
.statuses()
.map((x) => x.data.date)
.sort()
.pop();
}
/** The current role (an experience with no end), newest first. */
export function currentRole(s: SiteIndex): Note<"experience"> | undefined {
return [...s.all("experience")].filter((e) => !e.data.end).sort((a, b) => b.data.start.localeCompare(a.data.start))[0];
}
/** A make photo for the My world door: a favorite first, then Galaxy dice, then any. */
export function worldPhoto(s: SiteIndex): { src: string; alt: string } | undefined {
const makes = s.all("make").filter((m) => m.data.cover || m.data.photos?.length);
const pick =
makes.find((m) => m.data.favorite) ?? makes.find((m) => m.slug === "galaxy-dice") ?? makes[0];
if (!pick) return undefined;
const src = mediaSrc(pick.data.cover ?? pick.data.photos?.[0]);
return src ? { src, alt: pick.data.blurb ?? pick.title } : undefined;
}
/** Minutes to read a body at 220 words a minute, at least one. */
export function readMinutes(body: string): number {
const words = body.trim().split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 220));
}
/** The featured post (pinned, else newest) and the two most recent after it. */
export function blogStrip(s: SiteIndex): { featured?: Note<"post">; recent: Note<"post">[] } {
const posts = s.posts();
const featured = posts.find((p) => p.data.pinned) ?? posts[0];
return { featured, recent: posts.filter((p) => p !== featured).slice(0, 2) };
}
/** Split a Page text body into its intro (before the first heading) and the sections after. */
export function splitIntro(body: string): { intro: string; sections: { heading: string; body: string }[] } {
const parts = body.split(/^##\s+/m);
const intro = parts.shift()?.trim() ?? "";
return {
intro,
sections: parts.map((p) => {
const [heading, ...rest] = p.split("\n");
return { heading: heading!.trim(), body: rest.join("\n").trim() };
}),
};
}
+164
View File
@@ -0,0 +1,164 @@
/**
* The Library and The Shelf: how items group, filter and sort. The pages
* filter on the server from the query string (so they work without JS) and a
* small script repeats the same rules live in the browser.
*/
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
export type Item = Note<"library-item">;
export type Game = Note<"board-game">;
// ---------------------------------------------------------------- Library
/** The list's three groups. Dropped items are left out. */
export const sections = [
{ key: "now", label: "Now", states: ["now"] },
{ key: "next", label: "Up next", states: ["up next"] },
{ key: "done", label: "Finished", states: ["finished", "caught up", "played"] },
] as const;
export type SectionKey = (typeof sections)[number]["key"];
export const sectionOf = (item: Item): SectionKey | undefined =>
sections.find((s) => (s.states as readonly string[]).includes(item.data.state))?.key;
/** The Type filter: media gathered the way the rail lists them. */
export const mediaGroups = [
{ key: "books", label: "Books", media: ["book", "light novel"] },
{ key: "serials", label: "Webnovels, manga and manhwa", media: ["webnovel", "manga", "manhwa"] },
{ key: "games", label: "Video games", media: ["video game"] },
{ key: "screen", label: "Anime and TV", media: ["anime", "TV"] },
{ key: "movies", label: "Movies", media: ["movie"] },
] as const;
export type MediaKey = (typeof mediaGroups)[number]["key"];
export const mediaGroupOf = (item: Item): MediaKey | undefined =>
mediaGroups.find((g) => (g.media as readonly string[]).includes(item.data.medium))?.key;
/** The short medium label on the right of a row. */
export const mediumLabel = (medium: string) => (medium === "video game" ? "Game" : medium === "TV" ? "TV" : medium);
/** How Finished is ordered: favorites, then items with a verdict, then A to Z. */
export function byStanding(a: Item, b: Item): number {
return (
Number(!!b.data.favorite) - Number(!!a.data.favorite) ||
Number(!!b.data.verdict) - Number(!!a.data.verdict) ||
a.title.localeCompare(b.title)
);
}
export interface LibraryFilter {
type?: MediaKey;
q: string;
show: Set<SectionKey>;
favorites: boolean;
blog: boolean;
}
export function libraryFilter(params: URLSearchParams): LibraryFilter {
const type = mediaGroups.find((g) => g.key === params.get("type"))?.key;
// A submitted form sends `f`; before that every group shows.
const submitted = params.has("f");
const show = new Set<SectionKey>(
submitted ? (params.getAll("show").filter((v) => sections.some((s) => s.key === v)) as SectionKey[]) : sections.map((s) => s.key),
);
return {
type,
q: (params.get("q") ?? "").trim().toLowerCase(),
show,
favorites: params.get("fav") === "1",
blog: params.get("blog") === "1",
};
}
/** Text the search matches against. Kept in step with the row's visible text. */
export const searchText = (parts: (string | number | undefined)[]) =>
parts
.filter((p) => p !== undefined && p !== "")
.join(" ")
.toLowerCase();
/**
* The favorites constellation: favorites with a cover, taken in turn from each
* medium so the strip is not all one kind.
*/
export function constellation(items: Item[], max = 7): Item[] {
const favs = items.filter((i) => i.data.favorite && i.data.cover).sort(byStanding);
const byMedium = new Map<string, Item[]>();
for (const i of favs) byMedium.set(i.data.medium, [...(byMedium.get(i.data.medium) ?? []), i]);
const order = mediaGroups.flatMap((g) => g.media as readonly string[]);
const queues = [...byMedium.entries()].sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([, q]) => q);
const out: Item[] = [];
while (out.length < max && queues.some((q) => q.length)) {
for (const q of queues) {
const next = q.shift();
if (next && out.length < max) out.push(next);
}
}
return out;
}
// ---------------------------------------------------------------- Shelf
export const sorts = [
{ key: "played", label: "Last played" },
{ key: "az", label: "A to Z" },
{ key: "added", label: "Recently added" },
] as const;
export type SortKey = (typeof sorts)[number]["key"];
export function sortGames(games: Game[], sort: SortKey): Game[] {
const az = (a: Game, b: Game) => a.title.localeCompare(b.title);
const desc = (x?: string, y?: string) => (y ?? "").localeCompare(x ?? "");
const cmp: Record<SortKey, (a: Game, b: Game) => number> = {
played: (a, b) => desc(a.data.lastPlayed, b.data.lastPlayed) || az(a, b),
az,
added: (a, b) => desc(a.data.created, b.data.created) || az(a, b),
};
return [...games].sort(cmp[sort]);
}
export interface ShelfFilter {
q: string;
plays: Set<string>;
owned: boolean;
want: boolean;
favorites: boolean;
sort: SortKey;
}
export function shelfFilter(params: URLSearchParams): ShelfFilter {
const submitted = params.has("f");
return {
q: (params.get("q") ?? "").trim().toLowerCase(),
plays: new Set(params.getAll("plays").map((p) => p.toLowerCase())),
owned: submitted ? params.get("owned") === "1" : true,
want: params.get("want") === "1",
favorites: params.get("fav") === "1",
sort: sorts.find((s) => s.key === params.get("sort"))?.key ?? "played",
};
}
/**
* Owned and Want to play add games to the list (either one); with neither
* ticked, every game shows. Favorites only and How it plays narrow it.
*/
export function gameShows(g: Game, f: ShelfFilter, text: string): boolean {
const owned = !!g.data.owned;
const want = !!g.data.wantToPlay;
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false;
if (f.favorites && !g.data.favorite) return false;
if (f.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
return !f.q || text.includes(f.q);
}
/** Every "how it plays" value on the shelf, A to Z. */
export const playStyles = (games: Game[]) =>
[...new Set(games.flatMap((g) => g.data.howItPlays ?? []))].sort((a, b) => a.localeCompare(b));
/** Statuses about a board game: what is on the table now. */
export const onTheTable = (s: SiteIndex) =>
s
.statuses()
.map((status) => ({ status, game: s.byName(status.data.subject) }))
.filter((x): x is { status: Note<"status">; game: Game } => x.game?.type === "board-game");
+1
View File
@@ -32,6 +32,7 @@ export function renderMarkdown(
opts: { link: (name: string) => { title: string; href: string | null }; imageSrc: (file: string) => string | undefined },
): string {
const withLinks = body
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, f: string, alt?: string) => `![${alt ?? ""}](${opts.imageSrc(f.trim()) ?? ""})`)
.replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g, (_, n: string, label?: string) => {
const l = opts.link(n.trim());
+215
View File
@@ -0,0 +1,215 @@
/**
* The printable resume. Built from the same ResumeData as the /resume page,
* laid out as one plain column so applicant tracking systems read it in order.
* Paper colors: ink on white, with the brand gold and teal darkened for print.
*/
import PDFDocument from "pdfkit";
import cinzelBold from "../assets/pdf-fonts/Cinzel-Bold.ttf?inline";
import cinzelSemi from "../assets/pdf-fonts/Cinzel-SemiBold.ttf?inline";
import montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline";
import montMed from "../assets/pdf-fonts/Montserrat-Medium.ttf?inline";
import montSemi from "../assets/pdf-fonts/Montserrat-SemiBold.ttf?inline";
import { month, period, type ResumeData } from "./resume.ts";
const fromDataUrl = (u: string) => Buffer.from(u.slice(u.indexOf(",") + 1), "base64");
const FONTS = {
title: fromDataUrl(cinzelBold),
heading: fromDataUrl(cinzelSemi),
body: fromDataUrl(montReg),
medium: fromDataUrl(montMed),
strong: fromDataUrl(montSemi),
};
const INK = "#131c2b";
const MUTED = "#566173";
const GOLD = "#a8842f";
const TEAL = "#0f7f78";
const RULE = "#d9cfb8";
const PAGE = { size: "LETTER" as const, margin: 50 };
const SITE = "gregkilmer.com";
export function renderResumePdf(r: ResumeData): Promise<Buffer> {
const doc = new PDFDocument({
size: PAGE.size,
margins: { top: PAGE.margin, bottom: PAGE.margin + 14, left: PAGE.margin, right: PAGE.margin },
bufferPages: true,
info: { Title: `${r.name} – Resume`, Author: r.name, Subject: r.headline, Keywords: "resume" },
lang: "en-US",
displayTitle: true,
});
for (const [name, data] of Object.entries(FONTS)) doc.registerFont(name, data);
const left = doc.page.margins.left;
const width = doc.page.width - left - doc.page.margins.right;
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
/** Starts a new page when fewer than `need` points remain. */
const room = (need: number) => {
if (doc.y + need > doc.page.height - doc.page.margins.bottom) doc.addPage();
};
const diamond = (x: number, y: number, s: number, color = GOLD) => {
doc.save().moveTo(x, y - s).lineTo(x + s, y).lineTo(x, y + s).lineTo(x - s, y).closePath().fill(color).restore();
};
/** A thin gold rule with a small star at its center. */
const starRule = (y: number) => {
const mid = left + width / 2;
doc.save().lineWidth(0.6).strokeColor(GOLD);
doc.moveTo(left, y).lineTo(mid - 10, y).stroke();
doc.moveTo(mid + 10, y).lineTo(left + width, y).stroke();
doc.restore();
diamond(mid, y, 4);
};
const section = (title: string) => {
room(60);
doc.moveDown(0.75);
const y = doc.y;
doc.font("heading").fontSize(11).fillColor(GOLD).text(title.toUpperCase(), left, y, { characterSpacing: 1.6 });
const ruleY = doc.y + 2;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, ruleY).lineTo(left + width, ruleY).stroke().restore();
doc.y = ruleY + 8;
};
const bullet = (text: string, indent = 12) => {
doc.font("body").fontSize(9.5);
const h = doc.heightOfString(text, { width: width - indent, lineGap: 1.5 });
room(h + 4);
const y = doc.y;
diamond(left + 3.5, y + 6, 2.2);
doc.fillColor(INK).text(text, left + indent, y, { width: width - indent, lineGap: 1.5 });
doc.moveDown(0.15);
};
// Header
doc.font("title").fontSize(28).fillColor(INK).text(r.name.toUpperCase(), left, doc.y, { characterSpacing: 2 });
if (r.headline) doc.font("medium").fontSize(11).fillColor(TEAL).text(r.headline, { width });
// Only the professional profiles belong on paper; the hobby links stay on the site.
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
const contact = [r.location, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))].filter(Boolean).join(" · ");
doc.moveDown(0.3).font("body").fontSize(9).fillColor(MUTED).text(contact, { width });
if (r.availability) doc.font("medium").fontSize(9).fillColor(INK).text(r.availability, { width });
doc.moveDown(0.7);
starRule(doc.y);
doc.moveDown(0.6);
if (r.summary) {
doc.font("body").fontSize(10).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), left, doc.y, { width, lineGap: 2 });
}
if (r.stats.length) {
section("Highlights");
const cols = Math.min(r.stats.length, 4);
const colW = width / cols;
let top = doc.y;
r.stats.forEach((st, i) => {
if (i > 0 && i % cols === 0) top = doc.y + 6;
const x = left + (i % cols) * colW;
doc.font("title").fontSize(16).fillColor(GOLD).text(st.value, x, top, { width: colW - 10 });
doc.font("strong").fontSize(9).fillColor(INK).text(st.label, x, doc.y, { width: colW - 10 });
if (st.detail) doc.font("body").fontSize(8.5).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 });
});
doc.x = left;
}
if (r.roles.length) {
section("Experience");
r.roles.forEach((role, i) => {
const d = role.data;
if (i > 0) doc.moveDown(0.6);
room(64);
const y = doc.y;
const when = period(role);
doc.font("medium").fontSize(9).fillColor(GOLD);
const whenW = doc.widthOfString(when);
doc.text(when, left + width - whenW, y + 1.5, { lineBreak: false });
doc.font("strong").fontSize(11).fillColor(INK).text(d.role, left, y, { width: width - whenW - 16 });
const at = [d.company, d.location].filter(Boolean).join(" · ");
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(at, left, doc.y, { width });
if (d.previousRoles?.length) {
doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${d.previousRoles.join(", ")} at ${d.company}`, { width });
}
if (d.focus) doc.moveDown(0.2).font("body").fontSize(9).fillColor(MUTED).text(d.focus, { width, lineGap: 1 });
doc.moveDown(0.3);
for (const b of d.bullets ?? []) bullet(b);
if (d.tech?.length) {
doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.tech.join(" · "), left + 12, doc.y, { width: width - 12 });
}
doc.x = left;
});
}
if (r.skills?.stack.length) {
section("Skills");
for (const g of r.skills.stack) {
const items = g.items.join(", ");
doc.font("body").fontSize(9.5);
room(doc.heightOfString(items, { width: width - 110 }) + 4);
const y = doc.y;
doc.font("strong").fontSize(9.5).fillColor(INK).text(g.label, left, y, { width: 100 });
const afterLabel = doc.y;
doc.font("body").fontSize(9.5).fillColor(INK).text(items, left + 110, y, { width: width - 110, lineGap: 1 });
doc.y = Math.max(doc.y, afterLabel) + 3;
}
if (r.skills.core?.length) {
room(30);
const y = doc.y;
doc.font("strong").fontSize(9.5).fillColor(INK).text("Strengths", left, y, { width: 100 });
doc.font("body").fontSize(9.5).fillColor(INK).text(r.skills.core.join(", "), left + 110, y, { width: width - 110, lineGap: 1 });
}
doc.x = left;
}
if (r.education?.degrees.length) {
section("Education");
for (const dg of r.education.degrees) {
room(40);
const y = doc.y;
doc.font("medium").fontSize(9).fillColor(GOLD);
const w = doc.widthOfString(dg.period);
doc.text(dg.period, left + width - w, y + 1.5, { lineBreak: false });
doc.font("strong").fontSize(10.5).fillColor(INK).text(dg.credential, left, y, { width: width - w - 16 });
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(dg.institution, left, doc.y, { width });
if (dg.detail) doc.font("body").fontSize(9).fillColor(MUTED).text(dg.detail, { width });
doc.moveDown(0.4);
}
}
if (r.education?.certifications?.length) {
section("Certifications");
for (const c of r.education.certifications) {
room(20);
const line = [c.issuer, c.year].filter(Boolean).join(" · ");
doc.font("strong").fontSize(9.5).fillColor(INK).text(c.name, left, doc.y, { continued: !!line });
if (line) doc.font("body").fillColor(MUTED).text(` ${line}`);
}
}
// Footer on every page.
const range = doc.bufferedPageRange();
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
const y = doc.page.height - PAGE.margin + 4;
const bottom = doc.page.margins.bottom;
doc.page.margins.bottom = 0;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, y - 8).lineTo(left + width, y - 8).stroke().restore();
doc.font("body").fontSize(8).fillColor(MUTED);
doc.text([`${SITE}/resume`, updated].filter(Boolean).join(" · "), left, y, { lineBreak: false });
if (range.count > 1) {
const label = `${i - range.start + 1} of ${range.count}`;
doc.text(label, left + width - doc.widthOfString(label), y, { lineBreak: false });
}
doc.page.margins.bottom = bottom;
}
doc.end();
return done;
}
+49
View File
@@ -0,0 +1,49 @@
/** The resume's data, shared by the page and the PDF so they never disagree. */
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
export interface ResumeData {
name: string;
headline: string;
location?: string;
summary?: string;
availability?: string;
stats: { value: string; label: string; detail?: string }[];
roles: Note<"experience">[];
skills?: Note<"skills">["data"];
education?: Note<"education">["data"];
socials: { label: string; handle: string; url: string }[];
/** The newest change to any note the resume is built from (YYYY-MM-DD). */
updated: string;
}
/** "Mar 2019", from "2019-03". */
export const month = (ym: string | undefined) => {
if (!ym) return "Present";
const [y, m] = ym.split("-").map(Number);
return new Date(Date.UTC(y!, (m ?? 1) - 1, 1)).toLocaleDateString("en-US", { timeZone: "UTC", month: "short", year: "numeric" });
};
export const period = (r: Note<"experience">) => `${month(r.data.start)} – ${month(r.data.end)}`;
export function resumeData(s: SiteIndex): ResumeData {
const settings = s.settings();
const page = s.pageText("/resume");
const roles = [...s.all("experience")].sort((a, b) => (b.data.end ?? "9999").localeCompare(a.data.end ?? "9999") || b.data.start.localeCompare(a.data.start));
const current = roles.find((r) => !r.data.end) ?? roles[0];
const sources: Note[] = [...roles, ...(page ? [page] : []), ...[s.one("skills"), s.one("education")].filter((n): n is Note<"skills"> | Note<"education"> => !!n)];
const updated = sources.map((n) => n.acceptedAt?.slice(0, 10) ?? "").sort().at(-1) ?? "";
return {
name: settings?.name ?? "Greg Kilmer",
headline: page?.data.lede ?? current?.data.role ?? "",
location: settings?.location,
summary: page?.body?.trim() || undefined,
availability: settings?.availability,
stats: page?.data.stats ?? [],
roles,
skills: s.one("skills")?.data,
education: s.one("education")?.data,
socials: settings?.socials ?? [],
updated,
};
}
+27 -4
View File
@@ -218,10 +218,11 @@ export function mediaSrc(value: string | undefined): string | undefined {
return `/media/${encodeURIComponent(file.trim())}`;
}
/** Status kinds name icons; older notes use Material names, mapped here to brand icons. */
/** 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",
@@ -229,11 +230,33 @@ const MDI: Record<string, string> = {
"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",
};
export function brandIcon(name: string | undefined): string {
if (!name) return "favorite";
return MDI[name] ?? name;
/** 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". */
+133
View File
@@ -0,0 +1,133 @@
/**
* The Store's rules in one place: which lines show, in what order, whether a
* line can take an order, its status line and its price summary.
*/
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
import { brandIcon, formatDate } from "./site.ts";
export type Line = Note<"product-line">;
export type Category = Line["data"]["category"];
export type Channel = Line["data"]["channel"];
type Settings = Note<"store-settings">["data"];
export type CategorySettings = Settings["categories"][number];
export type Term = NonNullable<Line["data"]["terms"]>[number];
export const CATEGORIES: Category[] = ["Tabletop", "Paper", "Leather"];
/** Proxy cards are sold locally only, never on Etsy. */
export function isProxy(line: Line): boolean {
return /prox/i.test(`${line.title} ${line.slug} ${line.data.summary}`);
}
/**
* Where a line is sold. A proxy line a note marks as Etsy is treated as sold
* here, since proxies are never sold on Etsy.
*/
export function channelOf(line: Line): Channel {
if (line.data.channel === "Etsy" && isProxy(line)) return "here";
return line.data.channel;
}
/** The shop link for a line sold elsewhere, never for a proxy line on Etsy. */
export function shopLink(line: Line): { href: string; label: string } | null {
const ch = channelOf(line);
if ((ch !== "Etsy" && ch !== "Patiun.com") || !line.data.channelUrl) return null;
return { href: line.data.channelUrl, label: `Shop on ${ch}` };
}
export interface StoreState {
lines: Line[];
groups: [Category, Line[]][];
settings: Settings | undefined;
category(name: Category): CategorySettings;
/** Categories that have a line sold here and are closed. */
closed: CategorySettings[];
/** True when some line sold here sits in an open category. */
anyOpen: boolean;
terms: Term[];
}
export function storeState(s: SiteIndex): StoreState {
const settings = s.one("store-settings")?.data;
const lines = [...s.all("product-line")].sort(
(a, b) =>
CATEGORIES.indexOf(a.data.category) - CATEGORIES.indexOf(b.data.category) ||
(a.data.order ?? 999) - (b.data.order ?? 999) ||
a.title.localeCompare(b.title),
);
const category = (name: Category): CategorySettings =>
settings?.categories.find((c) => c.name === name) ?? { name, ordersOpen: false };
const groups = CATEGORIES.map((c) => [c, lines.filter((l) => l.data.category === c)] as [Category, Line[]]).filter(([, ls]) => ls.length);
const sellingCats = CATEGORIES.filter((c) => lines.some((l) => l.data.category === c && channelOf(l) === "here"));
return {
lines,
groups,
settings,
category,
closed: sellingCats.map(category).filter((c) => !c.ordersOpen),
anyOpen: sellingCats.some((c) => category(c).ordersOpen),
terms: settings?.terms ?? [],
};
}
/** Whether a line has room: open slots when made to order, stock when in stock. */
export function hasCapacity(line: Line): boolean {
return line.data.mode === "made to order" ? (line.data.slots?.open ?? 0) > 0 : (line.data.quantity ?? 0) > 0;
}
/** Whether the order button shows for this line. */
export function canOrder(line: Line, st: StoreState): boolean {
return channelOf(line) === "here" && st.category(line.data.category).ordersOpen && hasCapacity(line);
}
/** A short status: slots, stock, or where it is sold. */
export function statusText(line: Line): string {
const ch = channelOf(line);
if (ch === "coming soon") return "Coming soon";
if (ch !== "here") return `Sold on ${ch}`;
if (line.data.mode === "made to order") {
const sl = line.data.slots;
return sl ? `${Math.min(sl.open, sl.total)} of ${sl.total} ${sl.total === 1 ? "slot" : "slots"} open` : "Made to order";
}
const q = line.data.quantity ?? 0;
return q > 0 ? `${q} in stock` : "Sold out";
}
/** "Orders closed. Reopens Oct 11, 2026." for a category. */
export function closedText(c: CategorySettings): string {
return c.reopens ? `Reopens ${formatDate(c.reopens)}.` : "";
}
const money = (p: string) => {
const m = /^\s*\$?\s*([\d,]+(?:\.\d+)?)\s*$/.exec(p);
return m ? Number(m[1]!.replace(/,/g, "")) : null;
};
/** The lowest price, its note, and up to two other priced tiers. */
export function priceSummary(line: Line): { from: string; note?: string; more: string[] } | null {
const priced = line.data.priceTiers.map((t, i) => ({ ...t, i, n: money(t.price) })).filter((t) => t.n !== null);
if (!priced.length) return null;
const low = priced.reduce((a, b) => (b.n! < a.n! ? b : a));
const more = priced
.filter((t) => t !== low)
.sort((a, b) => b.n! - a.n!)
.slice(0, 2)
.sort((a, b) => a.i - b.i)
.map((t) => `${t.band} ${t.price}`);
return { from: low.price, note: low.note, more };
}
/** Option groups in note order, with their options. */
export function optionGroups(line: Line): [string, NonNullable<Line["data"]["options"]>][] {
const map = new Map<string, NonNullable<Line["data"]["options"]>>();
for (const o of line.data.options ?? []) map.set(o.group, [...(map.get(o.group) ?? []), o]);
return [...map.entries()];
}
const CATEGORY_ICONS: Record<Category, string> = { Tabletop: "dice", Paper: "bookshelf", Leather: "leather" };
/** A brand icon for a line: its own when it maps to one, else its category's. */
export function lineIcon(line: Line): string {
return brandIcon(line.data.icon, CATEGORY_ICONS[line.data.category]);
}
+114
View File
@@ -0,0 +1,114 @@
/** Helpers for the Work index and the Inquiries pages. */
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
export type WorkGroup = "employer" | "personal" | "homelab";
export const groupLabels: Record<WorkGroup, string> = {
employer: "Employer",
personal: "Personal",
homelab: "Homelab",
};
/**
* Which group a case study belongs to: employer work has an employer; a
* project without one is homelab when it is tagged or described as such,
* otherwise personal.
*/
export function workGroup(p: Note<"post">): WorkGroup {
if (p.data.employer) return "employer";
const tags = (p.data.tags ?? []).map((t) => t.toLowerCase());
if (tags.includes("homelab") || /\bhome\s?lab\b/i.test(`${p.data.summary} ${p.body}`)) return "homelab";
return "personal";
}
/** "Implico Inc." as "Implico" for kickers. */
export function shortEmployer(name: string): string {
return name.replace(/,?\s+(Inc\.?|LLC|Ltd\.?|Corp\.?|GmbH)$/i, "").trim();
}
/** The gold kicker over a case study: "Employer · Implico", "Homelab". */
export function workKicker(p: Note<"post">): string {
const g = workGroup(p);
return g === "employer" && p.data.employer ? `${groupLabels[g]} · ${shortEmployer(p.data.employer)}` : groupLabels[g];
}
/**
* The featured study: the newest one marked favorite or pinned, else the
* newest. Same-day ties go to employer work, then to the one with more facts.
*/
export function featuredStudy(studies: Note<"post">[]): Note<"post"> | undefined {
const marked = studies.filter((p) => p.data.favorite || p.data.pinned);
const pool = marked.length ? marked : studies;
const newest = pool[0]?.data.date;
return pool
.filter((p) => p.data.date === newest)
.sort((a, b) => Number(!!b.data.employer) - Number(!!a.data.employer) || (b.data.facts?.length ?? 0) - (a.data.facts?.length ?? 0))[0];
}
const groupOrder: WorkGroup[] = ["employer", "homelab", "personal"];
/** The rest of the studies in group order (employer, homelab, personal), newest first within a group. */
export function otherStudies(studies: Note<"post">[], featured: Note<"post"> | undefined): Note<"post">[] {
return studies
.filter((p) => p !== featured)
.map((p, i) => ({ p, i }))
.sort((a, b) => groupOrder.indexOf(workGroup(a.p)) - groupOrder.indexOf(workGroup(b.p)) || a.i - b.i)
.map((x) => x.p);
}
/** Tech the resume outlines as the core stack, lower-cased, for the teal-edged chips. */
export function coreTech(s: SiteIndex): Set<string> {
const stack = s.one("skills")?.data.stack ?? [];
return new Set(stack.flatMap((g) => g.core ?? []).map((t) => t.toLowerCase()));
}
// ---------------------------------------------------------------- Inquiries
export type InquiryType = "job" | "freelance" | "patiun";
export const inquiryTypes: { value: InquiryType; label: string; blurb: string; summary: string }[] = [
{ value: "job", label: "A job or role", blurb: "Full-time roles and contracts", summary: "A job or role" },
{ value: "freelance", label: "Freelance web work", blurb: "Sites, apps and integrations", summary: "Freelance web work" },
{ value: "patiun", label: "Custom Patiun order", blurb: "Cards, deck boxes and more", summary: "Patiun custom order" },
];
/** `?type=` as an inquiry type; also accepts a few natural spellings. */
export function parseInquiryType(raw: string | null | undefined): InquiryType | undefined {
const v = (raw ?? "").trim().toLowerCase();
if (!v) return undefined;
if (["job", "role", "jobs", "hire", "hiring"].includes(v)) return "job";
if (["freelance", "web", "freelance-web"].includes(v)) return "freelance";
if (["patiun", "order", "custom", "custom-order", "store"].includes(v)) return "patiun";
return undefined;
}
/**
* What the sent page shows under "What you sent". The inquiries API (phase 6)
* sets this as the short-lived `gk-inquiry` cookie (URL-encoded JSON) before
* redirecting to /inquiries/sent.
*/
export interface SentSummary {
type?: InquiryType;
name?: string;
email?: string;
/** Extra rows in order, like { label: "How many", value: "60 cards" }. */
rows?: { label: string; value: string }[];
}
export function parseSentSummary(raw: string | undefined): SentSummary | undefined {
if (!raw) return undefined;
try {
const v = JSON.parse(raw.startsWith("%7B") || raw.startsWith("%7b") ? decodeURIComponent(raw) : raw) as Record<string, unknown>;
const str = (x: unknown) => (typeof x === "string" && x.trim() ? x.trim().slice(0, 300) : undefined);
const rows = Array.isArray(v.rows)
? v.rows
.map((r) => ({ label: str((r as Record<string, unknown>)?.label), value: str((r as Record<string, unknown>)?.value) }))
.filter((r): r is { label: string; value: string } => !!r.label && !!r.value)
.slice(0, 8)
: [];
return { type: parseInquiryType(str(v.type)), name: str(v.name), email: str(v.email), rows };
} catch {
return undefined;
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Helpers for the Workshop, craft and Tabletop pages: ordering, craft seals,
* make photos, the sword rack, and which pill a state wears.
*/
import type { SealName } from "@gk/ui/components/seals.ts";
import type { Note } from "./content.ts";
import { mediaSrc, type SiteIndex } from "./site.ts";
type Pill = "live" | "state" | "current" | "neutral";
/** By `order`, notes without one last, then by title. */
export function byOrder<T extends Note>(notes: T[]): T[] {
const o = (n: Note) => ((n.data as { order?: number }).order ?? Number.MAX_SAFE_INTEGER);
return [...notes].sort((a, b) => o(a) - o(b) || a.title.localeCompare(b.title));
}
/** Newest `date` first; undated notes keep their order after the dated ones. */
export function byDateDesc<T extends Note>(notes: T[]): T[] {
const d = (n: Note) => String((n.data as { date?: string }).date ?? "");
return [...notes].sort((a, b) => d(b).localeCompare(d(a)));
}
/** A craft's own seal: dice crafts, leather, print and paper. Undefined means the family seal. */
export function craftSeal(craft: Note<"craft">): SealName | undefined {
const text = `${craft.title} ${craft.data.blurb ?? ""}`.toLowerCase();
if (/leather/.test(text)) return "craft-leather";
if (/dice|resin/.test(text)) return "craft-dice";
if (/print|paper|bookbind|card|decal/.test(text)) return "craft-print";
return undefined;
}
/** A make's photos as image addresses (the `photos` list, else its `cover`). */
export function makePhotos(make: Note<"make">): string[] {
const d = make.data as { photos?: string[]; cover?: string };
const list = d.photos?.length ? d.photos : d.cover ? [d.cover] : [];
return list.map((p) => mediaSrc(p)).filter((x): x is string => !!x);
}
/** Makes tagged for the sword rack. */
export function isSword(make: Note<"make">): boolean {
const tags = make.data.tags ?? [];
return tags.some((t) => /sword/i.test(t));
}
/** The makes of a craft. */
export function makesOf(s: SiteIndex, craft: Note<"craft">): Note<"make">[] {
return s.all("make").filter((m) => s.byName(m.data.craft)?.path === craft.path);
}
/** Build logs for a craft: posts linking to it or its makes. */
export function buildLogs(s: SiteIndex, craft: Note<"craft">): Note<"post">[] {
return s.backlinks(craft, { type: "post" }) as Note<"post">[];
}
/** The tools of a craft, as notes where published, in the craft's order. */
export function toolsOf(s: SiteIndex, craft: Note<"craft">): { title: string; note?: Note<"tool"> }[] {
return (craft.data.tools ?? []).map((t) => {
const l = s.link(t);
return { title: l.title, note: l.note?.type === "tool" ? (l.note as Note<"tool">) : undefined };
});
}
/** The tools placed at a station. */
export function toolsAt(s: SiteIndex, station: Note<"station">): Note<"tool">[] {
return s.all("tool").filter((t) => s.byName(t.data.station)?.path === station.path);
}
export const craftPill = (status: string): Pill => (status === "Paused" ? "neutral" : "live");
export const systemPill = (state: string): Pill => (state === "running" ? "live" : "neutral");
/** Card game stances: the main event is gold, active ones live, the rest plain. */
export function stancePill(stance: string): Pill {
const s = stance.toLowerCase();
if (s === "main event") return "current";
if (/getting into|exploring|playing|learning/.test(s)) return "live";
return "neutral";
}
/** "want to run" -> "Want to run". */
export const sentence = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** Order systems are grouped in. */
export const systemStates = ["running", "played", "want to run", "want to play", "want to try"] as const;
/** Statuses whose kind belongs to a page (Making and Learning for /workshop). */
export function statusesFor(s: SiteIndex, page: string) {
return s.statuses().filter((st) => s.kind(st.data.kind).page === page);
}
/** Archidekt deck address. */
export const archidektUrl = (id: number) => `https://archidekt.com/decks/${id}`;
/** Deck's list address: Archidekt when set, else its own list link. */
export function deckUrl(deck: Note<"deck">): string | undefined {
return deck.data.archidektId ? archidektUrl(deck.data.archidektId) : deck.data.listUrl;
}