diff --git a/.gitignore b/.gitignore index 8bbb8ff..a30ef5a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ data/ # Astro build output and generated types packages/ui/dist/ packages/ui/.astro/ +services/site/dist/ +services/site/.astro/ diff --git a/docs/site.md b/docs/site.md new file mode 100644 index 0000000..ea109fc --- /dev/null +++ b/docs/site.md @@ -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/` 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/` | Case studies and each study | +| `/inquiries`, `/inquiries/sent` | Inquiry form and its confirmation | +| `/library`, `/library/shelf` | The Library and The Shelf; items are anchors `#` | +| `/workshop`, `/workshop/` | 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/`, `/blog/`, `/rss.xml` | Blog, topics, posts, feed | +| `/store`, `/store/`, `/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= npm run site # http://localhost:4330 +``` diff --git a/package-lock.json b/package-lock.json index 6b9ca22..28b3690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ffd0059..594d166 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/services/site/astro.config.mjs b/services/site/astro.config.mjs new file mode 100644 index 0000000..5bf9924 --- /dev/null +++ b/services/site/astro.config.mjs @@ -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"] } } }, + }, +}); diff --git a/services/site/package.json b/services/site/package.json new file mode 100644 index 0000000..6902a48 --- /dev/null +++ b/services/site/package.json @@ -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" + } +} diff --git a/services/site/src/components/PageHero.astro b/services/site/src/components/PageHero.astro new file mode 100644 index 0000000..6fc4d11 --- /dev/null +++ b/services/site/src/components/PageHero.astro @@ -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 &&
{eyebrow}
} +

{title}

+{lede &&

{lede}

} + + + diff --git a/services/site/src/components/StatusChip.astro b/services/site/src/components/StatusChip.astro new file mode 100644 index 0000000..1dd14b8 --- /dev/null +++ b/services/site/src/components/StatusChip.astro @@ -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 ? ( + + {label} + + ) : ( + {label} + ) +} + + diff --git a/services/site/src/lib/content.ts b/services/site/src/lib/content.ts new file mode 100644 index 0000000..b5d9e94 --- /dev/null +++ b/services/site/src/lib/content.ts @@ -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 { + path: string; + type: T; + /** The file name without `.md`: what wikilinks point at. */ + name: string; + slug: string; + title: string; + data: NoteData; + 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(path: string): Promise { + 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 { + const notes: Note[] = []; + for (let offset = 0; ; offset += 1000) { + const page = await api(`/api/notes?full=true&published=true&limit=1000&offset=${offset}`); + notes.push(...page); + if (page.length < 1000) break; + } + const redirects = await api("/api/redirects").catch(() => []); + return new SiteIndex(notes, redirects); +} + +let current: SiteIndex | null = null; +let loadedAt = 0; +let inflight: Promise | null = null; + +function refresh(): Promise { + 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 { + 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 { + 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; +} diff --git a/services/site/src/lib/markdown.ts b/services/site/src/lib/markdown.ts new file mode 100644 index 0000000..0457823 --- /dev/null +++ b/services/site/src/lib/markdown.ts @@ -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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; +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 `${this.parser.parseInline(tokens)}`; + }, + image: ({ href, text }: Tokens.Image) => + `${escapeHtml(text)}`, + }, +}); + +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) => `![${alt ?? ""}](${opts.imageSrc(f.trim()) ?? ""})`) + .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; +} diff --git a/services/site/src/lib/page.ts b/services/site/src/lib/page.ts new file mode 100644 index 0000000..18f5239 --- /dev/null +++ b/services/site/src/lib/page.ts @@ -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(items: T[], key: (item: T) => string): [string, T[]][] { + const map = new Map(); + 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}`; diff --git a/services/site/src/lib/site.ts b/services/site/src/lib/site.ts new file mode 100644 index 0000000..30c4d36 --- /dev/null +++ b/services/site/src/lib/site.ts @@ -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> = { make: "craft", deck: "game", campaign: "system" }; + +export class SiteIndex { + readonly notes: Note[]; + private readonly byNameMap = new Map(); + private readonly byTypeMap = new Map(); + private readonly outgoing = new Map(); + private readonly incoming = new Map(); + private readonly redirectMap = new Map(); + + constructor(notes: Note[], redirects: Redirect[] = []) { + this.notes = notes; + for (const n of notes) { + this.byNameMap.set(n.name.toLowerCase(), n); + const list = this.byTypeMap.get(n.type) ?? []; + list.push(n); + this.byTypeMap.set(n.type, list); + } + for (const n of notes) { + const names = [...new Set([...linksIn(n.data), ...wikilinksIn(n.body ?? "")].map((x) => x.toLowerCase()))]; + this.outgoing.set(n.path, names); + for (const name of names) { + const target = this.byNameMap.get(name); + if (!target || target.path === n.path) continue; + const list = this.incoming.get(target.path) ?? []; + list.push(n); + this.incoming.set(target.path, list); + } + } + const byPath = new Map(notes.map((n) => [n.path, n])); + for (const r of redirects) { + const to = byPath.get(r.toPath); + const href = to && this.href(to); + if (href) this.redirectMap.set(`${r.type}/${r.fromSlug}`, href); + } + } + + /** Every published note of a type. */ + all(type: T): Note[] { + return (this.byTypeMap.get(type) ?? []) as Note[]; + } + + /** The one note of a single-note type, like site settings. */ + one(type: T): Note | undefined { + return this.all(type)[0]; + } + + bySlug(type: T, slug: string): Note | undefined { + return this.all(type).find((n) => n.slug === slug); + } + + /** A note by the name a wikilink uses, or by a `[[wikilink]]` itself. */ + byName(nameOrLink: string | undefined): Note | undefined { + if (!nameOrLink) return undefined; + const name = wikilinkTarget(nameOrLink) ?? nameOrLink; + return this.byNameMap.get(name.trim().toLowerCase()); + } + + /** Where an old slug of a type now lives. */ + redirect(type: NoteType, slug: string): string | undefined { + return this.redirectMap.get(`${type}/${slug}`); + } + + /** The address that shows a note, or null for notes no page shows. */ + href(n: Note): string | null { + const d = n.data as Record; + switch (n.type) { + case "post": + return d.postType === "case study" ? `/work/${n.slug}` : `/blog/${n.slug}`; + case "library-item": + return `/library#${n.slug}`; + case "board-game": + return `/library/shelf#${n.slug}`; + case "craft": + return `/workshop/${n.slug}`; + case "make": { + const craft = this.byName(d.craft as string); + return craft ? `/workshop/${craft.slug}#${n.slug}` : `/workshop#${n.slug}`; + } + case "station": + case "tool": + return `/workshop#${n.slug}`; + case "tabletop-system": + case "campaign": + case "card-game": + case "deck": + return `/tabletop#${n.slug}`; + case "experience": + return `/resume#${n.slug}`; + case "status": + return `/now#${n.slug}`; + case "product-line": + return `/store/${n.slug}`; + case "page": + return (d.page as string) ?? null; + default: + return null; + } + } + + /** A wikilink as `{ title, href }`; href is null when the note is not published. */ + link(nameOrLink: string): { title: string; href: string | null; note?: Note } { + const note = this.byName(nameOrLink); + const fallback = (wikilinkTarget(nameOrLink) ?? nameOrLink).trim(); + return note ? { title: note.title, href: this.href(note), note } : { title: fallback, href: null }; + } + + /** + * Notes that link to this one, newest first. Links to a make count for its + * craft, to a deck for its game and to a campaign for its system. + */ + backlinks(n: Note, opts: { type?: NoteType } = {}): Note[] { + const seen = new Map(); + const add = (list: Note[] | undefined) => list?.forEach((b) => b.path !== n.path && seen.set(b.path, b)); + add(this.incoming.get(n.path)); + for (const [childType, field] of Object.entries(ROLLUPS)) { + for (const child of this.all(childType as NoteType)) { + const parent = this.byName((child.data as Record)[field] as string); + if (parent?.path === n.path) add(this.incoming.get(child.path)); + } + } + return [...seen.values()] + .filter((b) => !opts.type || b.type === opts.type) + .sort((a, b) => dateOf(b).localeCompare(dateOf(a))); + } + + /** The notes this one links to. */ + linksFrom(n: Note): Note[] { + return (this.outgoing.get(n.path) ?? []).map((name) => this.byNameMap.get(name)).filter((x): x is Note => !!x); + } + + /** Published blog posts, newest first. Case studies are listed on Work instead. */ + posts(opts: { topic?: string; includeCaseStudies?: boolean } = {}): Note<"post">[] { + return this.all("post") + .filter((p) => (opts.includeCaseStudies || p.data.postType !== "case study") && (!opts.topic || slugify(p.data.topic) === slugify(opts.topic))) + .sort((a, b) => b.data.date.localeCompare(a.data.date)); + } + + caseStudies(): Note<"post">[] { + return this.all("post") + .filter((p) => p.data.postType === "case study") + .sort((a, b) => b.data.date.localeCompare(a.data.date)); + } + + /** The status log: newest status per subject, pinned first, then newest. */ + statuses(): Note<"status">[] { + const latest = new Map>(); + const sorted = [...this.all("status")].sort((a, b) => b.data.date.localeCompare(a.data.date) || b.acceptedAt.localeCompare(a.acceptedAt)); + for (const s of sorted) { + const key = s.data.subject ? (this.byName(s.data.subject)?.path ?? s.data.subject.toLowerCase()) : s.path; + if (!latest.has(key)) latest.set(key, s); + } + return [...latest.values()].sort((a, b) => Number(!!b.data.pinned) - Number(!!a.data.pinned) || b.data.date.localeCompare(a.data.date)); + } + + /** The newest status about a note, for its Now chip. */ + statusFor(n: Note): Note<"status"> | undefined { + return this.statuses().find((s) => this.byName(s.data.subject)?.path === n.path); + } + + /** A status kind's label, icon and page. */ + kind(label: string): { label: string; icon: string; page?: string } { + const kinds = this.one("status-kinds")?.data.kinds ?? []; + const k = kinds.find((x) => x.label.toLowerCase() === label.toLowerCase()); + return { label, icon: brandIcon(k?.icon), page: k?.page }; + } + + /** Text for a page from its Page text note (lede, intro, quick facts). */ + pageText(path: string): Note<"page"> | undefined { + return this.all("page").find((p) => p.data.page === path); + } + + settings() { + return this.one("site-settings")?.data; + } + + /** A note's Markdown as safe HTML, wikilinks resolved, unpublished links as plain text. */ + render(markdown: string | undefined): string { + if (!markdown?.trim()) return ""; + return renderMarkdown(markdown, { link: (name) => this.link(name), imageSrc: mediaSrc }); + } +} + +/** Every `[[link]]` in a note's fields, skipping image fields. */ +function linksIn(value: unknown, key = ""): string[] { + if (IMAGE_KEYS.has(key)) return []; + if (typeof value === "string") { + const t = wikilinkTarget(value); + return t ? [t] : []; + } + if (Array.isArray(value)) return value.flatMap((v) => linksIn(v, key)); + if (value && typeof value === "object") return Object.entries(value).flatMap(([k, v]) => linksIn(v, k)); + return []; +} + +function dateOf(n: Note): string { + const d = n.data as Record; + return String(d.date ?? d.updated ?? d.created ?? n.acceptedAt ?? ""); +} + +/** An image field (`[[file.jpg]]` or an https URL) as a src. */ +export function mediaSrc(value: string | undefined): string | undefined { + if (!value) return undefined; + if (/^https:\/\//.test(value)) return value; + const file = wikilinkTarget(value) ?? value; + return `/media/${encodeURIComponent(file.trim())}`; +} + +/** Status kinds name icons; older notes use Material names, mapped here to brand icons. */ +const MDI: Record = { + "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" }), + }); +} diff --git a/services/site/src/pages/index.astro b/services/site/src/pages/index.astro new file mode 100644 index 0000000..f9121d6 --- /dev/null +++ b/services/site/src/pages/index.astro @@ -0,0 +1,8 @@ +--- +import PageShell from "@gk/ui/components/PageShell.astro"; +import { site } from "../lib/page.ts"; +const s = await site(); +--- + +

{s.notes.length} notes, {s.statuses().length} statuses.

+
diff --git a/services/site/src/pages/media/[...file].ts b/services/site/src/pages/media/[...file].ts new file mode 100644 index 0000000..2d6f8cc --- /dev/null +++ b/services/site/src/pages/media/[...file].ts @@ -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", + }, + }); +}; diff --git a/services/site/tsconfig.json b/services/site/tsconfig.json new file mode 100644 index 0000000..4669a29 --- /dev/null +++ b/services/site/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": ["src", ".astro/types.d.ts"], + "compilerOptions": { + "allowImportingTsExtensions": true, + "noUncheckedIndexedAccess": true + } +}