mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
e3b0cf60c5
- 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
151 lines
7.8 KiB
Markdown
151 lines
7.8 KiB
Markdown
# 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/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=<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.
|