Swaps the letterhead copy for Greg's personal version: "Software Developer · System Architect · Team Lead" in the header and "Greg Kilmer ✦ GregKilmer.com" in the footer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
7.9 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 usesPageShellwith itsfamily(edge seal) andcurrent(nav entry); its hero goes in theheroslot. - 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
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), 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.
- A filled honeypot field (
leave_blank) gets the confirmation page and nothing else. - The answers are checked. Inquiries take only the chosen type's
job_*,freelance_*orpatiun_*fields; others are ignored. - A per-address rate limit, then Cloudflare Turnstile when
TURNSTILE_SECRET_KEYis set (a missing or bad token, or Cloudflare being unreachable, turns the post down). - 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. - The copy is saved through the content service with
CONTENT_INTAKE_TOKEN, then emailed (SMTP via nodemailer) toCONTACT_TOorORDER_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. - Success redirects to
/inquiries/sent(cookiegk-inquiry) or/store/order/sent(cookiegk-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 agk-inquiry-draftorgk-order-draftcookie 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.