mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58: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:
@@ -24,3 +24,5 @@ data/
|
||||
# Astro build output and generated types
|
||||
packages/ui/dist/
|
||||
packages/ui/.astro/
|
||||
services/site/dist/
|
||||
services/site/.astro/
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# The site
|
||||
|
||||
`services/site` (`@gk/site`) is the public GregKilmer.com. It is an Astro app
|
||||
rendered on request by the Node adapter. It reads only the content service,
|
||||
with a reader token, and only published notes.
|
||||
|
||||
## How a page gets its data
|
||||
|
||||
```astro
|
||||
---
|
||||
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||
import { site } from "../lib/page.ts";
|
||||
const s = await site(); // the SiteIndex: every published note in memory
|
||||
const games = s.all("board-game"); // typed: games[0].data.bggId is a number
|
||||
---
|
||||
```
|
||||
|
||||
`src/lib/content.ts` loads every published note from the content service and
|
||||
refreshes in the background every 30 seconds (`CONTENT_REFRESH_SECONDS`). If
|
||||
the service is down, the last good copy keeps serving.
|
||||
|
||||
`src/lib/site.ts` (`SiteIndex`) answers everything a page asks:
|
||||
|
||||
| Method | Gives |
|
||||
| --- | --- |
|
||||
| `all(type)`, `one(type)`, `bySlug(type, slug)`, `byName(name or [[link]])` | Notes. |
|
||||
| `href(note)` | The address that shows a note (see the URL map below). |
|
||||
| `link("[[Name]]")` | `{ title, href, note }`; `href` is null when the note is not published, so render plain text. |
|
||||
| `backlinks(note, { type })` | Notes linking to it, newest first. Links to a make count for its craft, a deck for its game, a campaign for its system. |
|
||||
| `linksFrom(note)` | Notes it links to. |
|
||||
| `posts({ topic })`, `caseStudies()` | Blog posts and case studies, newest first. |
|
||||
| `statuses()`, `statusFor(note)` | The status log (newest per subject, pinned first) and a note's current status. |
|
||||
| `kind(label)` | A status kind's brand icon and page. |
|
||||
| `pageText(path)` | The Page text note for an address: lede, body, quick facts. |
|
||||
| `settings()` | Site settings: name, tagline, availability, socials. |
|
||||
| `render(markdown)` | Safe HTML with wikilinks resolved. |
|
||||
| `redirect(type, slug)` | Where an old slug now lives; answer it with a 301. |
|
||||
|
||||
`mediaSrc(value)` turns an image field into a `/media/<file>` address, which
|
||||
passes vault images through from the content service. `formatDate("2026-10-11")`
|
||||
gives "Oct 11, 2026".
|
||||
|
||||
## URL map
|
||||
|
||||
| Address | Shows |
|
||||
| --- | --- |
|
||||
| `/` | Landing |
|
||||
| `/about` | About me |
|
||||
| `/resume`, `/resume.pdf` | Resume and its PDF |
|
||||
| `/work`, `/work/<slug>` | Case studies and each study |
|
||||
| `/inquiries`, `/inquiries/sent` | Inquiry form and its confirmation |
|
||||
| `/library`, `/library/shelf` | The Library and The Shelf; items are anchors `#<slug>` |
|
||||
| `/workshop`, `/workshop/<craft>` | Workshop and each craft; makes are anchors on their craft |
|
||||
| `/tabletop` | Systems, campaigns, card games and decks as anchors |
|
||||
| `/now` | Every status; each is an anchor |
|
||||
| `/blog`, `/blog/topic/<topic>`, `/blog/<slug>`, `/rss.xml` | Blog, topics, posts, feed |
|
||||
| `/store`, `/store/<line>`, `/store/order` | Store, product lines, order form |
|
||||
| anything else | 404, Off the star chart |
|
||||
|
||||
## Rules for pages
|
||||
|
||||
- Build from the components in `@gk/ui` (see [components.md](components.md)) and
|
||||
the gk-* classes. Every page uses `PageShell` with its `family` (edge seal)
|
||||
and `current` (nav entry); its hero goes in the `hero` slot.
|
||||
- All dark. Panels are Midnight with a gold frame, not Starlight.
|
||||
- Never invent facts or numbers. Where a note has no value, leave the element
|
||||
out, or show `[PLACEHOLDER]` where the design needs text Greg has not
|
||||
written yet.
|
||||
- No phone numbers. Email addresses stay as they are in the notes.
|
||||
- Lists of posts are fixed-height rows, not stretching cards.
|
||||
- Small client scripts (filters, tabs) are fine; the page must read correctly
|
||||
without them.
|
||||
|
||||
## Running it locally
|
||||
|
||||
```sh
|
||||
# the content service, against a copy of the Site folder
|
||||
SITE_DIR=… DATA_DIR=… CONTENT_TOKENS="reader:<24+ chars>" npm run content
|
||||
# the site
|
||||
CONTENT_URL=http://localhost:4310 CONTENT_TOKEN=<reader token> npm run site # http://localhost:4330
|
||||
```
|
||||
Generated
+39
@@ -317,6 +317,20 @@
|
||||
"satteri": "^0.10.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/node": {
|
||||
"version": "11.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/node/-/node-11.1.6.tgz",
|
||||
"integrity": "sha512-jNZ10/vAT+HuXkX0E/tITsIYCSn3BmAvsooVAN1OjagG+FEzelpUWToy1bA3x6Irmu0VaknWRfReY+1twd35sQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/internal-helpers": "0.11.0",
|
||||
"send": "^1.2.1",
|
||||
"server-destroy": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": "^7.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/prism": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz",
|
||||
@@ -1118,6 +1132,10 @@
|
||||
"resolved": "packages/schema",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@gk/site": {
|
||||
"resolved": "services/site",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@gk/ui": {
|
||||
"resolved": "packages/ui",
|
||||
"link": true
|
||||
@@ -5122,6 +5140,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/server-destroy": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
|
||||
"integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -6609,6 +6633,21 @@
|
||||
"zod": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"services/site": {
|
||||
"name": "@gk/site",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@astrojs/node": "11.1.6",
|
||||
"@gk/schema": "*",
|
||||
"@gk/ui": "*",
|
||||
"astro": "7.3.5",
|
||||
"marked": "18.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "0.9.10",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
},
|
||||
"tools/migrate": {
|
||||
"name": "@gk/migrate",
|
||||
"version": "0.1.0",
|
||||
|
||||
+3
-2
@@ -12,7 +12,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p packages/schema && tsc -p tools/migrate && tsc -p services/content && tsc -p services/admin && npm run check -w @gk/ui",
|
||||
"typecheck": "tsc -p packages/schema && tsc -p tools/migrate && tsc -p services/content && tsc -p services/admin && npm run check -w @gk/ui && npm run check -w @gk/site",
|
||||
"test": "vitest run",
|
||||
"check": "npm run typecheck && npm test",
|
||||
"schema:json": "npm run json -w @gk/schema",
|
||||
@@ -21,7 +21,8 @@
|
||||
"content": "npm run start -w @gk/content",
|
||||
"admin": "npm run start -w @gk/admin",
|
||||
"ui": "npm run dev -w @gk/ui",
|
||||
"ui:build": "npm run build -w @gk/ui"
|
||||
"ui:build": "npm run build -w @gk/ui",
|
||||
"site": "npm run dev -w @gk/site"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// The public site. Pages render on request from the content service's
|
||||
// published notes, so a saved note is live within a minute without a rebuild.
|
||||
import node from "@astrojs/node";
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: node({ mode: "standalone" }),
|
||||
trailingSlash: "never",
|
||||
build: { format: "file", assets: "assets" },
|
||||
devToolbar: { enabled: false },
|
||||
server: { host: true, port: Number(process.env.PORT ?? 4330) },
|
||||
vite: {
|
||||
ssr: { noExternal: ["@gk/ui"] },
|
||||
// See packages/ui/astro.config.mjs: astro needs its own cookie, not express's.
|
||||
environments: { prerender: { resolve: { noExternal: ["cookie"] } } },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@gk/site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "GregKilmer.com: the public site, rendered from the content service's published notes.",
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 4330",
|
||||
"build": "astro build",
|
||||
"start": "node dist/server/entry.mjs",
|
||||
"check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "11.1.6",
|
||||
"@gk/schema": "*",
|
||||
"@gk/ui": "*",
|
||||
"astro": "7.3.5",
|
||||
"marked": "18.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "0.9.10",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/**
|
||||
* The hero inside the site header: a gold eyebrow, the page title in Cinzel
|
||||
* and a lede. Extra content (buttons, chips, a filter) goes in the slot.
|
||||
*/
|
||||
interface Props {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
lede?: string;
|
||||
}
|
||||
const { eyebrow, title, lede } = Astro.props;
|
||||
---
|
||||
{eyebrow && <div class="eyebrow">{eyebrow}</div>}
|
||||
<h1 class="title">{title}</h1>
|
||||
{lede && <p class="lede">{lede}</p>}
|
||||
<slot />
|
||||
|
||||
<style>
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 6vw, 48px);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
color: var(--gk-soft);
|
||||
max-width: 62ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
/**
|
||||
* A status as a live pill: "Reading" with a lit dot, linking to the page the
|
||||
* status is about when there is one.
|
||||
*/
|
||||
import StatusPill from "@gk/ui/components/StatusPill.astro";
|
||||
import type { Note } from "../lib/content.ts";
|
||||
import type { SiteIndex } from "../lib/site.ts";
|
||||
|
||||
interface Props {
|
||||
status: Note<"status">;
|
||||
site: SiteIndex;
|
||||
/** Show the subject after the kind, as the Now strip does. */
|
||||
withSubject?: boolean;
|
||||
}
|
||||
const { status, site, withSubject } = Astro.props;
|
||||
const subject = status.data.subject ? site.link(status.data.subject) : null;
|
||||
const label = withSubject && subject ? `${status.data.kind} · ${subject.title}` : status.data.kind;
|
||||
---
|
||||
{
|
||||
subject?.href && withSubject ? (
|
||||
<a href={subject.href} class="status-link">
|
||||
<StatusPill>{label}</StatusPill>
|
||||
</a>
|
||||
) : (
|
||||
<StatusPill>{label}</StatusPill>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.status-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The site's copy of the published notes.
|
||||
*
|
||||
* The site reads only the content service, with a reader token, and only
|
||||
* published notes. It keeps every note in memory and refreshes in the
|
||||
* background, so a saved note shows within a minute and a slow or restarting
|
||||
* content service never takes a page down: the last good copy keeps serving.
|
||||
*/
|
||||
import type { NoteData, NoteType } from "@gk/schema";
|
||||
import { SiteIndex } from "./site.ts";
|
||||
|
||||
export interface Note<T extends NoteType = NoteType> {
|
||||
path: string;
|
||||
type: T;
|
||||
/** The file name without `.md`: what wikilinks point at. */
|
||||
name: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
data: NoteData<T>;
|
||||
body: string;
|
||||
acceptedAt: string;
|
||||
}
|
||||
|
||||
export interface Redirect {
|
||||
type: string;
|
||||
fromSlug: string;
|
||||
toPath: string;
|
||||
}
|
||||
|
||||
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;
|
||||
const refreshMs = () => Number(env("CONTENT_REFRESH_SECONDS", "30")) * 1000;
|
||||
|
||||
async function api<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${baseUrl()}${path}`, {
|
||||
headers: { authorization: `Bearer ${token()}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Content service answered ${res.status} for ${path}`);
|
||||
return (await res.json()) as 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}`);
|
||||
notes.push(...page);
|
||||
if (page.length < 1000) break;
|
||||
}
|
||||
const redirects = await api<Redirect[]>("/api/redirects").catch(() => []);
|
||||
return new SiteIndex(notes, redirects);
|
||||
}
|
||||
|
||||
let current: SiteIndex | null = null;
|
||||
let loadedAt = 0;
|
||||
let inflight: Promise<SiteIndex> | null = null;
|
||||
|
||||
function refresh(): Promise<SiteIndex> {
|
||||
inflight ??= loadAll()
|
||||
.then((index) => {
|
||||
current = index;
|
||||
loadedAt = Date.now();
|
||||
return index;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** The current index. Waits only on the very first load. */
|
||||
export async function site(): Promise<SiteIndex> {
|
||||
if (!current) return refresh();
|
||||
if (Date.now() - loadedAt > refreshMs()) {
|
||||
refresh().catch((err) => console.error("Content refresh failed; serving the last copy.", err));
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Fetch a vault image from the content service, for the /media route. */
|
||||
export async function attachment(file: string): Promise<Response> {
|
||||
return fetch(`${baseUrl()}/attachments/${encodeURIComponent(file)}`, {
|
||||
headers: { authorization: `Bearer ${token()}` },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
}
|
||||
|
||||
/** Use a fixed set of notes instead of the content service, for tests. */
|
||||
export function useIndex(index: SiteIndex) {
|
||||
current = index;
|
||||
loadedAt = Number.POSITIVE_INFINITY;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Note bodies as HTML. Raw HTML in a note is shown as text and only http,
|
||||
* https, mailto and relative links survive, so nothing in a note can run on
|
||||
* the site. Wikilinks to published notes become links; the rest plain text.
|
||||
*/
|
||||
import { Marked, type Tokens } from "marked";
|
||||
|
||||
const ESC: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
export const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ESC[c]!);
|
||||
|
||||
const safeUrl = (href: string) => {
|
||||
const h = href.trim();
|
||||
return /^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i.test(h) || !/^[a-z][a-z0-9+.-]*:/i.test(h) ? h : "#";
|
||||
};
|
||||
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
renderer: {
|
||||
html: ({ text }: Tokens.HTML | Tokens.Tag) => escapeHtml(text),
|
||||
link({ href, title, tokens }: Tokens.Link) {
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
const external = /^https?:/i.test(href) ? ' rel="noreferrer"' : "";
|
||||
return `<a href="${escapeHtml(safeUrl(href))}"${t}${external}>${this.parser.parseInline(tokens)}</a>`;
|
||||
},
|
||||
image: ({ href, text }: Tokens.Image) =>
|
||||
`<img src="${escapeHtml(safeUrl(href))}" alt="${escapeHtml(text)}" loading="lazy" decoding="async">`,
|
||||
},
|
||||
});
|
||||
|
||||
export function renderMarkdown(
|
||||
body: string,
|
||||
opts: { link: (name: string) => { title: string; href: string | null }; imageSrc: (file: string) => string | undefined },
|
||||
): string {
|
||||
const withLinks = body
|
||||
.replace(/!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, f: string, alt?: string) => `) ?? ""})`)
|
||||
.replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g, (_, n: string, label?: string) => {
|
||||
const l = opts.link(n.trim());
|
||||
const text = label ?? l.title;
|
||||
return l.href ? `[${text}](${l.href})` : text;
|
||||
});
|
||||
return marked.parse(withLinks, { async: false }) as string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Small helpers every page uses. */
|
||||
import { site } from "./content.ts";
|
||||
|
||||
export { site };
|
||||
|
||||
/** Group items by a key, keeping first-seen order. */
|
||||
export function groupBy<T>(items: T[], key: (item: T) => string): [string, T[]][] {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const k = key(item);
|
||||
map.set(k, [...(map.get(k) ?? []), item]);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}
|
||||
|
||||
/** "1 game" / "3 games". */
|
||||
export const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
@@ -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" }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||
import { site } from "../lib/page.ts";
|
||||
const s = await site();
|
||||
---
|
||||
<PageShell title="Greg Kilmer" family="landing" current="home">
|
||||
<p>{s.notes.length} notes, {s.statuses().length} statuses.</p>
|
||||
</PageShell>
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Vault images, passed through from the content service with a day's cache. */
|
||||
import type { APIRoute } from "astro";
|
||||
import { attachment } from "../../lib/content.ts";
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const file = params.file ?? "";
|
||||
if (!file || file.includes("..")) return new Response("Not found", { status: 404 });
|
||||
const upstream = await attachment(file).catch(() => null);
|
||||
if (!upstream?.ok || !upstream.body) return new Response("Not found", { status: 404 });
|
||||
return new Response(upstream.body, {
|
||||
headers: {
|
||||
"content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
|
||||
"cache-control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": ["src", ".astro/types.d.ts"],
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user