Files
com.gregkilmer.www/services/site/src/lib/world.ts
T
Claude 57721efbcd Link roundup posts from Shelf cards and keep long link lists short
Shelf game cards show the post about the game, as Library rows already
do. A make's chip prefers its own build log and says Blog for other
posts. Post chips and Connected pages count the links past the first
few, so a roundup linking a hundred items stays readable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 17:09:43 +00:00

127 lines
5.3 KiB
TypeScript

/**
* 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 post a make links to: its own build log first, else the newest post about it. */
export function makeLog(s: SiteIndex, make: Note<"make">): Note<"post"> | undefined {
const posts = s.backlinks(make, { type: "post" }) as Note<"post">[];
return posts.find((p) => p.data.postType === "build log") ?? posts[0];
}
/** 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}`;
/** Values Archidekt syncs onto a deck (see withSynced in content.ts). */
interface DeckSynced {
commanderArt?: string;
colors?: string[];
commanders?: string[];
}
/** The deck's own cover, else its synced commander art. */
export function deckArt(deck: Note<"deck">): string | undefined {
const d = deck.data as DeckSynced & { cover?: string };
return mediaSrc(d.cover ?? d.commanderArt);
}
const COLOR_NAMES: Record<string, string> = { W: "White", U: "Blue", B: "Black", R: "Red", G: "Green" };
/** The deck's color identity as names, WUBRG order; empty when not synced. */
export function deckColors(deck: Note<"deck">): string[] {
return ((deck.data as DeckSynced).colors ?? []).map((c) => COLOR_NAMES[c]).filter((c): c is string => !!c);
}
/** The deck's commanders, when synced. */
export const deckCommanders = (deck: Note<"deck">): string[] => (deck.data as DeckSynced).commanders ?? [];
/** 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;
}