Notes for future Claude sessions working on this repo. Read this before editing.
georgemain.com — a static personal site. Custom Node SSG, deployed via GitHub
Actions to GitHub Pages. Source of truth is the working tree on main.
-
scripts/build.mjs— the entire SSG. Readscontent/, writes_site/. Single file on purpose. Don't pull in a framework. -
templates/— Eta templates._base.etais the layout; everything else is rendered then injected asbody. -
templates/_entries.eta— the one list component. Every page that lists things (/musings,/rv12is,/foia, plus their category, tag, year and agency sub-pages) includes it. It owns the entry row (thumbnail, meta line, title, subtitle, optional excerpt and tags, the>link), the pinned strip, the sort toggle and the reading pane, plus the two inline scripts driving the last two. Callers pass a normalizedentriesarray built byentryFromPostorentryFromRequestinbuild.mjs; the component knows nothing about musings, build-log entries or records requests.Don't hand-roll another list. If a new section needs one, write an
entryFrom*normalizer and include the partial. Changing how a row looks is one edit in_entries.etaplus its CSS block insite.css. The reading pane pulls.musings-mainout of the target page and strips.crumbs,.sp-postnavand.sp-backlink, so any new post-page template needs that wrapper to be peekable. -
design/—tokens.cssandsite.cssfrom Claude Design. Copied verbatim into_site/at build time. Don't hand-edit these as the primary source of design changes — request a new bundle from Claude Design and replace the files. Small ad-hoc additions are appended at the end with a comment. -
copy.yaml— every human-facing string on the site. Edit here, not in templates. If you're tempted to add a template literal in an Eta file, put it incopy.yamlinstead.ui:holds the chrome shared by more than one template (breadcrumb labels, the reading-pane buttons, unit words). -
site.config.yaml— build tunables: which sections exist and whether their index is a timeline, reserved FOIA routes, reading-speed and excerpt lengths, every image width and JPEG quality, RSS and keyword limits. A number that affects output belongs here, not as a literal inbuild.mjs. -
content/musings/<category>/<slug>/index.md— blog posts. Markdown + frontmatter.media/subfolder for images. -
content/media/<path>/metadata.yaml— standalone photo galleries. -
content/foia/<slug>/request.yaml— public-records requests. See below. -
content/sites/<name>/— verbatim passthrough to/sites/<name>/. -
design/vendor/pdfjs/— the only vendored library. Copied to_site/vendor/. Provenance and re-vendoring steps in itsVENDORED.md. -
scripts/lib/— the two things more than one script needs:pii-patterns.mjs(shared by the hook and the FOIA pre-flight) andzip.mjs(zero-dep ZIP writer overnode:zlib). -
staging/— gitignored intake for media.npm run mediaingests. -
dump/— gitignored intake for FOIA documents.npm run foia:publishconsumes and deletes on successful upload.
npm run setup # one-time: installs git hooks
npm run build # writes _site/
npm run dev # build + local server on :4321
npm run media # interactive media ingest from staging/
npm run foia:inspect dump/<folder> # hash, page-count and PII-scan a dump
npm run foia:publish <slug> # upload to S3, rewrite yaml, clear dump/
npm run check:pii # manually run the PII scannerShell out to git with execFileSync, never execSync. execSync hands the
command to a shell, so anything in a path or a commit subject gets re-parsed.
execFileSync passes argv straight through. The scripts already do this; keep
it that way.
The user iterates on the visual design in Claude Design (claude.ai/design). When a new bundle arrives:
- Read the bundle's
README.mdand the chat transcript first. - Replace
design/tokens.cssanddesign/site.cssverbatim with the new files. - Update templates to match new structural patterns (new class names, nested elements, etc.) — the chat transcript usually highlights what moved.
- Build, push, then summarize the deltas back so the user can paste them into Claude Design to keep the prototype in sync with the live site.
Outstanding: the .foia-* rules at the end of site.css are hand-written
(2026-08-22) and marked as ad-hoc. Feed them to Claude Design and fold them
into the next bundle so the prototype and the live site don't drift.
The Eta <%- raw-output tag is NOT valid in Eta 3. Use <%~ for
raw or <%= (which is also raw since the build runs with autoEscape:false).
Eta has an ASI gotcha: <% (foo).bar() %> is parsed as a method call on
the preceding string output. Prefix with ;: <% ;(foo).bar() %>.
Any musing with images in its body or featured_image automatically
becomes a media gallery at a derived path. Default:
- Post at
content/musings/travel/japan/suggestions/→ gallery at/media/japan/suggestions(first category segment dropped). - Post at
content/musings/code/foo/→ gallery at/media/code/foo.
Override or opt out in frontmatter:
gallery:
path: australia/cairns/great-barrier-reef # explicit override
title: Great Barrier Reef # default = post title
subtitle: Photos from a rough day.
location: "Cairns, QLD, Australia"
date: 2024-08-15gallery: false # skip synthesis for this postSynthesized galleries:
- Reuse the post's media files in place (no duplication, no separate thumb/med).
- Auto-create virtual ancestor gallery nodes so breadcrumbs + album tree work.
- Show a
↩ from the musing: <title>link back to the source post.
- Create
content/musings/<category-path>/<slug>/index.mdwith the required frontmatter (title,date,tags,seo.description,seo.keywords). - Generate
seo.description(single natural sentence, < 160 chars — never "Blog post about X") andseo.keywords(obvious terms + 2–3 long-tail). - Write the body in markdown. Image syntax:
- Full-width:
 - Inline (floated):
{.inline}
- Full-width:
- Alt text is the caption and the accessibility label and the image SEO. Keep it short. Don't pile in extra observations the user didn't ask for. "Vending machine on a random mountain." beats "A drink vending machine on a quiet path on a wooded mountain in Japan, with a small wooden bench beside it."
# put images in the post's media/ folder OR use staging/:
cp my-photos/*.jpg staging/
npm run mediaThe ingest script scrubs all EXIF (including GPS), groups bursts by filename + capture time, and asks per-image alt + per-batch gallery/date/location. Alt is required.
If you process images by hand instead, use sharp to resize (max 1800px
wide) and mozjpeg quality 85 — and strip metadata (sharp's default
behavior, just don't call .withMetadata()).
A public archive of documents obtained under FOIA and state public-records law. No commentary — the documents are the point; everything written around them is a finding aid.
One content/foia/<slug>/request.yaml per request. Full field reference lives
in .claude/skills/add-foia-request/reference/schema.md; use the
add-foia-request skill rather than hand-authoring.
The modelling decision: a release IS a timeline event. There is no
separate list of releases — any event carrying files: is one. The timeline
is therefore the complete account of a request, and "the file listing for this
dump" falls out of it for free. A request that fanned out across agencies or
reference numbers declares tracks:, which renders a JS-free lane filter; the
Clow entry interleaves twenty events across three.
Documents live on S3, never in the repo — GitHub Pages caps a published
site at 1 GB and one release here is already 23 MB. Each file records its
size, page count and sha256. A local fallback at
content/foia/<slug>/files/<event-id>/<file> exists for working without S3;
don't commit PDFs that way.
- Rights language is per-jurisdiction. 17 U.S.C. § 105 puts federal works outside copyright and does not cover state, county or municipal records. Never paste that boilerplate onto a non-federal request — it would be a false claim on the face of the site.
- S3 needs CORS, because pdf.js fetches bytes via XHR (unlike
<img>). The bucket allowshttps://georgemain.comandhttp://localhost:4321, and exposesContent-Length/Content-Range/Accept-Ranges/ETagso range requests work — without those the viewer downloads a whole 23 MB file before painting page 1. <a download>is ignored cross-origin. The viewer's download button builds a Blob from the bytes pdf.js already holds; a plain link to the S3 object would just navigate.serve.mjsmust map.mjsto a real JS type or the module script silently never executes undernpm run dev.- Correspondence logs (
dump/**/*.txt) are the source for timelines, not content. They are never published — they routinely carry portal session tokens, client IPs, and the user's home address and personal mobile.
This was built wrong the first time: a stack of bordered notice boxes above a seven-column table, with the same requests repeated underneath as cards, and timeline entries wearing a glyph column, a rail and bordered pills. It read like a dashboard on a site that is a narrow flat column.
The idiom to reach for is the build log's dated list (timeline-index.eta):
rules rather than borders, one muted sub-line for everything secondary, and a
single quiet footnote instead of stacked notices. Procedural events
(acknowledgements, extensions, follow-ups, the requester narrowing again)
collapse into one foldable line. Two constraints on that folding: a run may
never span tracks, or the lane filter can't reach inside it; and anything
carrying documents stays out of a fold whatever its type.
The timelines, titles, descriptions and summaries are machine-written and must
say so. /foia and every request page carry a standing disclosure, and the
build fails if summary.text has no generated_by + generated_on. A
summary must be descriptive — what the records are, their counts, dates,
correspondents, cited exemptions — and never interpretive. That is what "no
commentary" requires and what keeps a machine-written summary defensible.
v<N> shown in the footer is git rev-list --count HEAD. Auto-increments
per commit. No manual bumping.
scripts/check-pii.mjs runs on every commit (installed via npm run setup,
which sets core.hooksPath to .githooks). Scans the staged diff for:
- emails (outside
.pii-allowlist.yaml) - phone numbers, SSNs, credit-card-like (Luhn-checked)
- GPS coordinates in text
- precise ISO timestamps
- US street addresses (heuristic)
- portal session tokens and bank/DOB shapes (added for the FOIA archive)
- EXIF GPS in staged image files
The patterns live in scripts/lib/pii-patterns.mjs so the FOIA ingest
pre-flight scans documents against exactly the same rules. Edit them there,
not in the hook.
Allowlist known-public strings in .pii-allowlist.yaml. Bypass once with
git commit --no-verify. Don't bypass habitually.
Two modes. npm run check:pii scans the staged diff, which is what the hook wants.
npm run check:pii:all sweeps every tracked file, which is what CI wants, because a
fresh checkout has nothing staged and the staged mode would pass having read nothing.
content/sites/ is skipped entirely, not just its assets/. Sub-sites are
self-contained artifacts reviewed as a whole when they go up, and their payload is the
data: coordinate arrays and minified bundles where 4294967296 is 2³². Scanning them
was 498 hits of the thing the page exists to show. The cost is real: a new sub-site is
unscanned, so review it before adding it.
All git invocations pass -c core.quotepath=false. Without it git quotes and
octal-escapes any path with a non-ASCII byte, the +++ b/ match fails, and the file is
skipped. That was 1373 of 2585 tracked paths.
If it starts crying wolf, fix the pattern, not the allowlist. Records
reference numbers (S106782-061225) and sha256 substrings both read as phone
numbers until the pattern gained lookarounds rejecting digit runs inside a
longer alphanumeric token. A scanner that fires on every commit in a section
just teaches you --no-verify, which is worse than no scanner. Minified
vendored bundles are skipped wholesale for the same reason — 4294967296 is
2³², not a phone number.
The hook fails closed: if it can't read the diff it refuses the commit rather than waving it through. That is correct, and it means an unrelated breakage in how the hook shells out to git presents as "every commit is blocked" rather than as a scanner error.
- Broken internal link (page route doesn't exist; asset extensions skipped)
- Missing image referenced from markdown
- Malformed frontmatter
- Missing required fields (
title,dateon musings;titleon galleries;title/agency/filedon FOIA requests) - Missing
alton ametadata.yamlimage entry gallery.pathcollision (musing-synthesized vs. real gallery)- FOIA: unknown event
type, an event referencing an undeclaredtrack, duplicate event or document ids, a request slug shadowing a reserved route (agency,tag,tags,index), a document with neither ans3URL nor a local file, an unknownfees.waivervalue - FOIA:
summary.textwithoutsummary.generated_by+summary.generated_on— an AI summary cannot ship without its disclosure
.github/workflows/deploy.yml — on push to main: checkout (fetch-depth:
0 so the version-count works), npm ci, npm run check:pii:all, npm run build,
upload _site as the Pages artifact, deploy. No tests yet.
The scan runs before the build, so a leak blocks the deploy rather than being
spotted after it is live. It uses --all rather than the staged-diff mode on purpose:
nothing is staged on a CI checkout, so plain check:pii would exit 0 having read
nothing.
GitHub Pages is configured for workflow deploy (not legacy branch).
If you ever re-clone the repo and Pages is set to legacy, switch via:
gh api -X PUT repos/<owner>/<repo>/pages -f build_type=workflow.
When writing copy, alt text, descriptions, or anything in the user's voice: be terse. The user prefers short, plain, human writing over descriptive AI-flavored prose. If asked to write a sentence, write one sentence, not three with adjectives. When in doubt, write less.
When in doubt about content decisions, ASK before generating placeholder text. The user wants to own the copy; the system is theirs to fill in.
Terseness governs what I write, never what the user wrote. Reproduce their copy verbatim. Do not tighten, compress, or drop sentences to fit the rule above; the only reason to remove their words is PII or a change already agreed. If a caption looks long for alt text, ship it and say why you'd shorten it. Flag every editorial change to their wording individually, even a name swap. (Learned the hard way 2026-08-24: nine build-log captions came back compressed, four of them missing real content.)
Site-wide rule as of 2026-08-24: never use — (U+2014). Use a period or a
comma. A period where the dash joined two independent clauses, a comma where
it set off an appositive or introduced a list.
This covers prose, alt text, copy.yaml, template strings, page <title>
separators, commit messages, and code comments. Where — was doing duty as
an empty-value glyph (an unpriced table cell, a missing stat) use -. Where
it was a leading marker before a parenthetical, drop it.
scripts/build.mjs keeps mdash: '—' in its HTML-entity decode map. That is
a decoder, not output; leave it.
The one place em dashes survive is /changelog, which renders git commit
subjects and so reflects ten historical commits written before this rule.
- Frameworks, build tools beyond what's already here.
- "Smart" features the user didn't ask for (dark-mode auto-detect, reading progress bars, share buttons, comments, analytics).
- Hand-written SVGs more complex than basic shapes.
- Drop shadows, gradients, rounded SaaS-style cards. The aesthetic is late-90s/early-blogspot — flat, narrow column, mono chrome.
- A second copy of the entry list. See
templates/_entries.etaabove. style="..."in a template for anything that repeats. Add a class to the ad-hoc block at the end ofsite.css. Inline styles are for values that are genuinely dynamic (abackground-imagebuilt from a photo URL).- Literal strings or tunable numbers in
build.mjsor an.etafile. They go incopy.yamlandsite.config.yamlrespectively. - Bordered boxes stacked on bordered boxes. Rules beat borders; one muted sub-line beats a grid of labelled cells; a footnote beats a callout. If a new page type is growing notice blocks above the content, or showing the same records twice in two shapes, it has drifted into dashboard and needs subtracting from. Check what an existing page type already does before inventing a layout — the answer is usually the dated list.