Files
com.gregkilmer.www/docs/site.md
T
Claude e3b0cf60c5 Phase 6: sync jobs, inquiry and order forms
- Sync scheduler in the content service with a per-job cache (sync_cache),
  sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek,
  cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading
  progress, Bambuddy filament and Homebox material stock. A note's own value
  always wins over a synced one.
- PersonalReader: updates only Library notes with a matching anilistId,
  changes to Greg's notes are held for review; Reading series with no note
  show as suggestions on the admin Overview.
- /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the
  content service (intake token) plus email over SMTP. Orders are re-checked
  against the live notes: accepted only when the category's ordersOpen is
  true and the line has capacity.
- Admin Inbox for saved submissions. Options' inStock is optional so synced
  stock can fill it. All settings from process.env; .env.example per service.
- Sources could not be reached from the build environment; parsers are tested
  against fixtures labelled as not live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:50:06 +00:00

7.8 KiB

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

---
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/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) 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

# 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

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 Greg Kilmer letterhead (src/assets/letterhead, a copy of branding/letterhead/Greg-Kilmer-Letterhead.pdf; re-copy it when the 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), 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

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=<code> 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.