# 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/order/sent` | Store, product lines, order form and its confirmation | | 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 ``` For a local preview of drafts, set `CONTENT_INCLUDE_DRAFTS=true` with an editor or admin token. Never set it where the public can reach the site. ## The resume PDF `/resume.pdf` is drawn with pdfkit from the same `resumeData()` as `/resume` (`src/lib/resume.ts`), so the two never disagree. Each page is laid over the personal Greg Kilmer letterhead, the version without Patiun LLC (`src/assets/letterhead`, a copy of `Greg-Kilmer-Personal-Letterhead.pdf` from the branding letterheads; re-copy it when that letterhead changes) with pdf-lib, which keeps both as vectors. It reads top to bottom so applicant tracking systems take it in order, aims for one page (roles marked `earlier` print as a short list; skills share a row with education and projects), and when a one-page resume ends well above the footer it is laid out again with wider gaps (up to twice as wide) so the page reads as full. It uses the brand TTFs in `src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change. The highlights row and Selected projects come from the `/resume` Page text note's `stats` and `projects`. Greg's Resume Interview answers live in `tools/migrate/src/seed.ts`. ## Where each page lives | Page | File | Helpers | | --- | --- | --- | | Landing, About, Now | `pages/index.astro`, `about.astro`, `now.astro` | `lib/home.ts`, `components/home` | | Resume and PDF | `pages/resume.astro`, `resume.pdf.ts` | `lib/resume.ts`, `lib/resume-pdf.ts` | | Work, case studies | `pages/work/` | `lib/work.ts`, `components/work`, `components/blog/PostLayout` | | Inquiries, sent, 404 | `pages/inquiries/`, `404.astro` | `lib/work.ts` | | Library, Shelf | `pages/library/` | `lib/library.ts`, `components/library` | | Workshop, crafts, Tabletop | `pages/workshop/`, `tabletop.astro` | `lib/world.ts`, `components/world` | | Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` | | Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` | ## Filters on world pages - **Shelf**: How it plays, Players, Play time, Owned, Want to play, Favorites and Has a blog post, plus four sorts. Players and Play time read the BoardGameGeek sync and only appear once some game has those values. With no filter narrowing the list, the first 12 games show and "Show all" opens the rest. - **Craft pages**: when a craft's makes carry two or more `tags` (Dice, Rings, and so on; `sword rack` does not count), pills above the grid filter them. Makes without a tag fall under Other. ## Forms The Inquiries form posts to `POST /api/inquiries` and the order form to `POST /api/orders` (`src/pages/api/`). Both are plain multipart posts that answer with a 303 redirect, so they work without JavaScript. The rules are plain functions in `src/lib/forms.ts` (tested in `services/site/test`); the network calls are in `src/lib/forms-server.ts`. 1. A filled honeypot field (`leave_blank`) gets the confirmation page and nothing else. 2. The answers are checked. Inquiries take only the chosen type's `job_*`, `freelance_*` or `patiun_*` fields; others are ignored. 3. A per-address rate limit, then Cloudflare Turnstile when `TURNSTILE_SECRET_KEY` is set (a missing or bad token, or Cloudflare being unreachable, turns the post down). 4. Orders only: the line is checked against the live store notes, fetched from the content service then and there. An order goes through only for a line sold here, in a category with `ordersOpen: true`, with an open slot (made to order) or stock, and with in-stock options. Otherwise it is turned down, whatever was posted. 5. The copy is saved through the content service with `CONTENT_INTAKE_TOKEN`, then emailed (SMTP via nodemailer) to `CONTACT_TO` or `ORDER_TO`, and the email's result is recorded on the copy. A failed email still shows the visitor the confirmation; the admin Inbox flags it. If saving fails the email is still tried; only when neither works does the form show an error. 6. Success redirects to `/inquiries/sent` (cookie `gk-inquiry`) or `/store/order/sent` (cookie `gk-order`): URL-encoded JSON, httpOnly, SameSite Lax, ten minutes, secure in production. A turned-down post goes back to the form with `?error=` and a `gk-inquiry-draft` or `gk-order-draft` cookie holding the reason, the marked fields and the answers (an attachment has to be added again). Settings are in `services/site/.env.example`: `CONTENT_INTAKE_TOKEN`, `PUBLIC_TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_ALLOW_SELFSIGNED`, `CONTACT_TO`, `ORDER_TO`, `CONTACT_RATE_LIMIT`, `CONTACT_RATE_WINDOW_MIN`, `ORDER_RATE_LIMIT`, `ORDER_RATE_WINDOW_MIN`, `FORM_MAX_ATTACHMENT_MB`. The Turnstile widget needs JavaScript; with the secret set, a visitor without it cannot send a form.