31 Commits

Author SHA1 Message Date
Claude 10a7d0a878 Add the North America managed services role to the resume
Implico role becomes "Team Lead, BTP Cloud & Mobile Apps and North
America Managed Services" with a bullet for the practice Greg took on
in June 2026. The resume PDF now tightens spacing when it just spills
onto a second page so it stays on one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 04:07:36 +00:00
Greg Kilmer 5442a8d888 Merge pull request #7 from Patiun/claude/patiun-rebrand-gjoo9f
Match the site to the designs, and write posts and manage images in admin
2026-10-11 23:45:51 -04:00
Claude 9390a7cbc0 Open the My world menu over the page
The header clipped everything to its own box, so the dropdown was cut off
below the nav line. Only the nebula and banner are clipped now, and the
header stacks above the page content.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 03:45:25 +00:00
Claude a9fb5b3e85 Admin: write blog posts and manage images
A Posts page lists every post with Write a post: title, topic, kind,
date, summary, a cover picked from the vault or uploaded, the pages it is
about, the Markdown body with a rendered preview, and Save draft or
Publish. An Images page uploads several images into an _attachments
folder, shows which notes use each one, copies its embed line, and
deletes unused ones. The content service gains the attachment list,
upload and delete endpoints behind the editor and admin tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 03:40:59 +00:00
Claude 151bed230a Match the site to the approved designs
Denser star field, the page nebula, a favicon package, the filament banner
without its box, the About portrait, Kashi and tea photos and Scorpius, the
Store's own header, a header logo glow, a resume PDF that fills its page,
one divider on the Library and Shelf, star-chart art for coverless
favorites, Shelf Players, Play time and blog filters with a Show all fold,
make kind pills on craft pages, and full-width closing bands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 03:33:15 +00:00
Greg Kilmer 3f6167455d Merge pull request #6 from Patiun/claude/patiun-rebrand-gjoo9f
Bambuddy sync: match the real spools API
2026-10-11 22:49:54 -04:00
Claude 449ffcd62d Bambuddy sync: match the real spools API
Checked against Greg's Bambuddy: the key goes in X-API-Key, colors are
color_name (often null on RFID spools) and rgba, grams left is
label_weight minus weight_used, and archived spools carry archived_at.
Unnamed spools get the nearest plain color name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 02:47:48 +00:00
Greg Kilmer bd97720be1 Merge pull request #5 from Patiun/claude/patiun-rebrand-gjoo9f
Deploy safety and Homebox tags fallback
2026-10-11 22:29:16 -04:00
Claude 5c3ab14ba3 Homebox sync: fall back to tags when /labels is gone
Newer Homebox releases renamed labels to tags (/api/v1/tags and the
items?tags= filter), so the labels call answered 404.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 02:25:12 +00:00
Claude a945bb08cc Deploy: never remove containers outside the stack
deploy.sh ran `docker compose up --remove-orphans` under the project name
gregkilmer. Any other container in a project of the same name, such as the
old site's when its compose file sits in a folder called gregkilmer, counted
as an orphan and was removed. The stack's own services are all defined in
compose.yaml, so the flag isn't needed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 02:16:50 +00:00
Greg Kilmer 10f206c756 Merge pull request #4 from Patiun/claude/patiun-rebrand-gjoo9f
Fix the site image build: copy tsconfig.base.json into the build stage
2026-10-11 21:59:58 -04:00
Claude b1f49c63b1 Copy tsconfig.base.json into the site build stage
The schema package's tsconfig extends the root tsconfig.base.json, and
Vite reads it while bundling the site, so the image build failed with
"Tsconfig not found /app/tsconfig.base.json".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 01:57:47 +00:00
Greg Kilmer 7706afbdac Merge pull request #3 from Patiun/claude/patiun-rebrand-gjoo9f
Deploy from GitHub with a systemd timer instead of Gitea Actions
2026-10-11 21:01:29 -04:00
Claude dcfd97e823 Deploy from GitHub with a systemd timer instead of Gitea Actions
Cosmos keeps a deploy-only clone of the GitHub repo. gk-deploy.timer runs
pull-and-deploy.sh every 5 minutes: it fetches master and, when it has
moved, runs deploy.sh (build, check, restart, roll back if unhealthy). A
commit that failed is not retried until master moves (FORCE=1 retries), and
DEPLOY_HEALTHCHECK_URL turns red on a failed fetch or deploy. Removes the
Gitea workflow and updates the runbook, README and inventory note.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 00:57:18 +00:00
Greg Kilmer 79f1c26426 Merge pull request #2 from Patiun/claude/patiun-rebrand-gjoo9f
Phases 1 to 7: note schema, content service, MCP server, admin page, components, site pages, syncs, forms and Cosmos deploy
2026-10-11 20:51:47 -04:00
Claude 754c6482be Phase 7: Cosmos deployment
- Dockerfile with a check target (typecheck and tests) and one image per
  service on a pinned Node 22.20 Alpine base, each with only its own
  production dependencies and a health check.
- deploy/compose.yaml: gk-content, gk-admin and gk-site on homelab_network
  with no host ports, read-only root filesystems, no capabilities, the vault's
  Site folder mounted into the content service, and backup.dump=sqlite on it.
- deploy/deploy.sh builds and checks the commit, restarts the stack, and goes
  back to the last good tag if it doesn't come up healthy; rollback.sh swaps
  tags. .gitea/workflows/deploy.yml runs it on a host runner on Cosmos.
- docs/deploy.md runbook (settings, runner, mirror, NPM hosts, Turnstile,
  Healthchecks, backups, cutover) and the stack's inventory note in vault-kit.
- Site: trusts X-Forwarded-Host/Proto for gregkilmer.com hosts so form posts
  pass Astro's origin check behind the proxy; rate limits read
  CF-Connecting-IP; site URL set for absolute links.
- Content service: the held-change webhook carries a Discord-ready message;
  .env.example lists the core settings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 00:43:03 +00:00
Claude f9a8fb1acd Resume PDF on the personal letterhead (no Patiun LLC)
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
2026-10-12 00:03:58 +00:00
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
Claude 60dfeecd7f Reply time setting, and server settings from process.env only
Inquiries and the sent page promise a reply within the site setting
replyTime (2 days, Greg 2026-10-11). The site reads its settings through one
helper that uses process.env only, so a .env file can never be copied into the
built bundle through import.meta.env.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:18:07 +00:00
Claude 0d0b8e4085 Resume PDF on the Greg Kilmer letterhead
Each page of the PDF is laid over the brand letterhead with pdf-lib, both
kept as vectors. The letterhead carries the name, email, website and city, so
the resume's own header becomes the title, availability, profiles and date.
Type and two earlier-role lines are tightened so it stays on one page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 22:36:19 +00:00
Claude a834451def Resume: stronger wording throughout
A review pass over the resume: the summary leads with what Greg does, the
Implico bullets lead with ownership and put Carrier Connect's scale next to
the work, and the earlier roles lead with their strongest title. No new
facts; every number is Greg's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 22:25:46 +00:00
Claude b1aec13eba Resume: customer work, network administration and team management
Adds Greg's sales demos, customer presentations, requirement gathering,
training and network engineering, and the career path, yearly review and
resource planning side of leading the team. Wording is tightened so the PDF
stays on one page, and the PDF skips a role's focus line when it has bullets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 22:13:55 +00:00
Claude 963ad0df23 Resume from Greg's interview, one-page PDF, store slots closed
The experience notes, skills, education and a new /resume Page text now come
from Greg's Resume Interview answers (tools/migrate/src/seed.ts). Comcast and
Drexel move to a short Earlier list, and the page and PDF gain Selected
projects. Carrier Connect's numbers are Greg's corrected ones.

The PDF fits on one page and carries the email from site settings. Store
lines migrate with no open slots until Greg opens orders.

Schema: experience earlier, page projects, site-settings email.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 21:58:21 +00:00
Claude 99ecdc2113 Phase 5: every page of the site, the resume PDF and RSS
Landing, About, Now, Resume (with a generated PDF), Work and case studies,
Inquiries and the sent page, Library and the Shelf, Workshop and craft pages,
Tabletop, Blog with topics and posts, RSS, the Store, product lines, the
order form and the 404, all rendered from the content service's notes.

Schema: experience previousRoles, skills stack core items, page stats,
make tags and deck favorite. Icon names from notes map to brand icons in one
place, and HTML comments are dropped when Markdown renders.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 21:33:49 +00:00
Claude 7025e95f89 Site app foundation on the content service
services/site is an Astro app rendered on request by the Node adapter. It
keeps every published note in memory from the content service, refreshes
in the background, and keeps serving the last good copy if the service is
down. SiteIndex answers lookups, addresses, links both ways with roll-ups,
the status log and safe Markdown. Vault images pass through /media.
docs/site.md has the URL map and page rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 21:15:39 +00:00
Claude 1023882e19 Redraw the leather, round knife and 3D print icons
Leather is now a stretched hide with gold stitching, the round knife has
its crescent blade, gold bevel and handle, and print shows a nozzle laying
a gold layer onto a layered part.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 21:00:45 +00:00
Claude 4c3ff528db Round GK monogram frame, clearer swords and hammer icons
The monogram drops its square background for a round disc with a gold
outer ring and a gold-to-teal filament ring. The crossed swords now have
pointed blades, short guards and gold pommels; the hammer has a face,
a claw and a tapered handle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 20:56:36 +00:00
Claude 807528a613 Phase 4: component library
The approved element rules and ornament kit as Astro components in
packages/ui: buttons, pills, chips, filters, kickers, notices, fields and
cards; star paths, the header divider, tick scales and rings, seals and
edge seals, gilt corners, dust and placeholder art; the site header,
footer and page shell. Brand fonts are self-hosted. A gallery page shows
everything, and astro check now runs with the root typecheck.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 20:44:16 +00:00
Claude 32cf13a5cb Add the admin page (phase 3)
services/admin is a client of the content service's HTTP API, served on
the tailnet only. It renders on the server with plain forms and no client
scripts, and holds the admin token so the browser never sees it.

- Review queue: reasons, line diff against the live version, accept,
  reject, and reject with the live version put back into Obsidian
- Notes and drafts: search, preview, fields, backlinks, publish toggle
- History with compare and roll back
- Quick capture for a status or a Library item (optionally both)
- Store: orders open or closed per category, open slots and stock
- Overview: counts, last scan and error, scan now, sync job health
- Same-origin check on every post, strict CSP, sanitized previews

The content service gains /api/status, sync run reports (/api/syncs) and
lookup of a note by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 19:59:21 +00:00
Claude 3f646048a2 Add the content service and MCP server (phase 2)
services/content watches the vault's Site folder, runs every change through
fixed rules, keeps the last accepted version of each note with its history,
and serves one write API. The HTTP API, the MCP server for agents, and the
admin page and editing tools to come all write through it.

- SQLite store with history, review queue, redirects, links and full-text search
- Rules: invalid, misplaced, duplicate name or slug, text loss, secrets,
  agent contact details, agent edits, agent publishing, agent batches
- Watcher with debounce and a periodic full scan
- HTTP API with reader, agent, editor and admin tokens
- MCP tools: search, get_note, get_schema, list_held, set_status,
  add_library_item, update_fields, draft_post
- Tests, including taking in all 545 migrated notes with nothing held

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 19:10:38 +00:00
Claude a2f723ca2d Start the rebuild: note schema, vault kit and data migration
Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 18:40:55 +00:00
674 changed files with 39605 additions and 4048 deletions
+11 -15
View File
@@ -1,17 +1,13 @@
# Keep the Docker build context lean.
node_modules
dist
.astro
**/node_modules
**/dist
**/.astro
/data
services/*/data
out
.git
.gitignore
.env
.env.*
!.env.example
*.log
npm-debug.log*
.DS_Store
.vscode
.idea
docs
*.md
!README.md
**/.env
**/.env.*
!**/.env.example
legacy/node_modules
legacy/dist
+12 -14
View File
@@ -1,30 +1,28 @@
# build output
dist/
.output/
# dependencies
node_modules/
# generated types
.astro/
# migration output (it goes into the vault, not the repo)
out/
# generated from the schema
packages/schema/json/
# environment
.env
.env.*
!.env.example
# logs
# logs and editor files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# macOS / editor
.DS_Store
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
data/
# misc
*.local
# Astro build output and generated types
packages/ui/dist/
packages/ui/.astro/
services/site/dist/
services/site/.astro/
+80 -38
View File
@@ -1,53 +1,95 @@
# =============================================================================
# Dockerfile — Greg Kilmer Profile Site
# =============================================================================
# Multi-stage build producing a minimal Node server image for the Cosmos homelab.
# Images for the GregKilmer.com stack on Cosmos. One file, one target per service:
#
# Stage 1 (builder): install all deps, run astro build -> dist/server/entry.mjs
# Stage 2 (runtime): copy only dist + prod node_modules, run the Node server
# docker build --target check . runs `npm run check` (typecheck and tests)
# docker build --target content . the content service and MCP server
# docker build --target admin . the admin page (tailnet only)
# docker build --target site . the public site
#
# The @astrojs/node standalone adapter emits a self-contained server, so the
# runtime image needs only node_modules + dist. Build once, run anywhere.
# =============================================================================
# deploy/deploy.sh builds check first, so a red check stops a deploy.
# See docs/deploy.md.
# ---------- Stage 1: build ----------
FROM node:22.12.0-alpine AS builder
# Pinned by version (Node 22.20, released 2025-09). Override with --build-arg to
# pull through a mirror.
ARG NODE_IMAGE=node:22.20.0-alpine
# ---------- package manifests, then every workspace's dependencies ----------
FROM ${NODE_IMAGE} AS manifests
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/schema/package.json packages/schema/
COPY packages/ui/package.json packages/ui/
COPY services/content/package.json services/content/
COPY services/admin/package.json services/admin/
COPY services/site/package.json services/site/
COPY tools/migrate/package.json tools/migrate/
# Install deps. Copy manifests first to leverage Docker layer caching.
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund --legacy-peer-deps
FROM manifests AS deps
RUN npm ci --no-audit --no-fund
# Copy source and build. SITE_URL can be overridden at build time.
# ---------- typecheck and tests ----------
FROM deps AS check
COPY . .
ARG SITE_URL=https://gregkilmer.com
ENV SITE_URL=$SITE_URL
RUN npm run build
RUN npm run check
# Prune to production dependencies for the runtime stage.
RUN npm prune --omit=dev
# ---------- the site's server build ----------
FROM deps AS site-build
# Workspace packages' tsconfigs extend the root one; Vite reads them during the build.
COPY tsconfig.base.json ./
COPY packages packages
COPY services/site services/site
RUN npm run build -w @gk/site
# ---------- Stage 2: runtime ----------
FROM node:20-alpine AS runtime
# ---------- each service's production dependencies only ----------
FROM manifests AS content-deps
RUN npm ci --omit=dev --no-audit --no-fund -w @gk/content && npm cache clean --force
FROM manifests AS admin-deps
RUN npm ci --omit=dev --no-audit --no-fund -w @gk/admin && npm cache clean --force
FROM manifests AS site-deps
RUN npm ci --omit=dev --no-audit --no-fund -w @gk/site && npm cache clean --force
# ---------- shared runtime base ----------
FROM ${NODE_IMAGE} AS runtime
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0
COPY package.json ./
COPY packages/schema/package.json packages/schema/
COPY packages/schema/src packages/schema/src
# Run as a non-root user for defense in depth.
RUN addgroup -S app && adduser -S app -G app
# ---------- content service ----------
FROM runtime AS content
COPY --from=content-deps /app/node_modules node_modules
COPY services/content/package.json services/content/
COPY services/content/src services/content/src
# The vault's Site folder and the data folder (SQLite and saved form attachments).
ENV PORT=4310 SITE_DIR=/vault/Site DATA_DIR=/data
EXPOSE 4310
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:4310/health >/dev/null || exit 1
USER node
CMD ["node", "--disable-warning=ExperimentalWarning", "--import", "tsx", "services/content/src/main.ts"]
# Copy production node_modules and the built server + client assets.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
# ---------- admin page ----------
FROM runtime AS admin
COPY --from=admin-deps /app/node_modules node_modules
COPY services/admin/package.json services/admin/
COPY services/admin/src services/admin/src
COPY services/admin/public services/admin/public
ENV PORT=4320
EXPOSE 4320
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:4320/health >/dev/null || exit 1
USER node
CMD ["node", "--import", "tsx", "services/admin/src/main.ts"]
# Astro Node standalone server reads HOST/PORT from env at runtime.
ENV HOST=0.0.0.0
# ---------- public site ----------
FROM runtime AS site
COPY --from=site-deps /app/node_modules node_modules
COPY --from=site-build /app/services/site/dist services/site/dist
ENV PORT=4321
EXPOSE 4321
USER app
# Healthcheck: the server responds 200 on /.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1
CMD ["node", "./dist/server/entry.mjs"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:4321/ >/dev/null || exit 1
USER node
CMD ["node", "services/site/dist/server/entry.mjs"]
+38 -141
View File
@@ -1,156 +1,53 @@
# Greg Kilmer — Profile & Portfolio Site
# GregKilmer.com
A responsive, content-driven personal site for Greg Kilmer, built as **two wings
of the same house**: a polished professional resume/portfolio on one side, and a
warm, personality-driven interests hub on the other.
The site, rebuilt around Greg's Obsidian vault. Notes in the vault's Site
folder are the content; a content service checks every change and keeps the
last good version; the site serves pages from that service. The full plan,
with the decisions behind it, is the
[Functional Design Plan](https://claude.ai/code/artifact/2a4e8339-7887-4919-a51d-2c11547b80b3).
Built with **Astro 7 + React Islands + TypeScript**, served by a **Node server**
with API routes, ready to deploy as a container on a homelab Cosmos system.
## What is here
---
| Path | What it is |
| --- | --- |
| `packages/schema` | The one definition of every note type: fields, folders, file names, and the checks for one note and for the whole folder. The migration, the content service, the MCP server, the site and any later editing tool all use it. |
| `vault-kit` | What goes into the vault: the Obsidian templates (generated from the schema), the Site guide and the stack's inventory note. |
| `services/content` | The content service: watches the Site folder, runs the rules, keeps the last good version and its history, and serves the one write API. The MCP server for agents is part of it. See [docs/content-service.md](docs/content-service.md). |
| `services/admin` | The admin page: the review queue with diffs, history and rollback, quick capture, store orders, draft previews and sync health. A client of the content service, on the tailnet only. See [docs/admin.md](docs/admin.md). |
| `packages/ui` | The component library: the approved element rules and ornament kit as Astro components, with the brand fonts, seals and icons, and a gallery page that shows them all. See [docs/components.md](docs/components.md). |
| `services/site` | The public site: every page rendered from the content service's published notes, the resume PDF, and the inquiry and order forms. See [docs/site.md](docs/site.md). |
| `deploy`, `Dockerfile` | The Cosmos stack: images, compose file, deploy and rollback scripts, and the systemd timer that deploys each new commit to master. See [docs/deploy.md](docs/deploy.md). |
| `tools/migrate` | The one-time migration from the old site's data into Site folder notes. |
| `docs` | How the pieces fit. Start with [docs/content-model.md](docs/content-model.md). |
| `legacy` | The old site, kept for reference only. Nothing outside it imports from it except the migration. |
## Highlights
Coming next: the cutover from the old site (phase 8).
- **Astro 7** static-first core with React Islands for interactivity (mobile
menu, theme toggle, animated hero background, contact form).
- **Node backend** (`@astrojs/node`, standalone) with a typed `/api/contact`
endpoint (SMTP via nodemailer + webhook fallback, Zod validation, rate limit).
- **Section-aware theming**: the Professional wing reads dark/polished, the
Interests wing reads warm/inviting, plus a global light/dark toggle.
- **Rich motion**: animated mesh-gradient hero with an optional canvas particle
field, IntersectionObserver scroll reveals, View Transitions page swaps,
scroll-triggered nav blur. All `prefers-reduced-motion` aware.
- **Content Collections** (typed): add projects and experience by dropping
markdown/MDX files.
- **mdi icons everywhere** (no emojis, per the spec), inlined at build time from
`@iconify-json/mdi` for zero runtime cost.
- **Self-hosted variable fonts** (Inter, Plus Jakarta Sans, JetBrains Mono).
- **Accessible**: semantic landmarks, skip link, keyboard-operable menu,
focus-visible rings, ARIA on interactive bits, WCAG-AA contrast targets.
- **Deploy artifacts**: multi-stage `Dockerfile` + `docker-compose.yml`.
## Commands
---
Node 22 or newer.
## Quickstart
```bash
# 1. Install dependencies (Node 20+)
```sh
npm install
npm run check # typecheck (with astro check) and tests
npm run ui # the component gallery at http://localhost:4321
npm run migrate # writes out/24.01_PersonalWebsite and out/MIGRATION-REPORT.md
npm run templates # regenerates vault-kit templates after a schema change
npm run schema:json # writes a JSON Schema per note type to packages/schema/json/
# 2. Start the dev server with hot reload
npm run dev
# -> http://localhost:4321
# the content service, against a copy of the Site folder
SITE_DIR=out/24.01_PersonalWebsite/Site DATA_DIR=data \
CONTENT_TOKENS="reader:<24+ chars>,admin:<24+ chars>" npm run content
# 3. Production build (runs astro check + build)
npm run build
# 4. Run the built Node server
npm run serve
# -> http://localhost:4321
# the admin page, in a second terminal
ADMIN_TOKEN=<the admin token> npm run admin # http://localhost:4320
```
Environment is optional for local dev. To enable contact-form delivery, copy
`.env.example` to `.env` and fill in SMTP or webhook vars (see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)).
## The old site
---
## Project structure
The live site still runs from the old code until cutover. It builds the same
way as before, from inside `legacy/`:
```sh
cd legacy && docker compose up -d --build
```
src/
├── components/
│ ├── hero/ AnimatedBackground.tsx (gradient + canvas particles)
│ ├── motion/ Reveal.astro (IntersectionObserver scroll reveals)
│ ├── nav/ NavBar.astro, MobileMenu.tsx, ThemeToggle.tsx
│ ├── cards/ InterestCard.astro
│ ├── professional/ Timeline, SkillGrid, ProjectCard, SectionNav, ContactForm
│ ├── layout/ Footer, SectionHeader, SplitTransition, InterestHero,
│ │ RelatedInterests, Placeholder
│ └── Icon.astro Inline mdi icon renderer (build-time)
├── content/
│ ├── projects/ Project collection entries (markdown/MDX)
│ └── experience/ Experience collection entries
├── layouts/
│ └── BaseLayout.astro <html> shell, head, fonts, theme init, nav, footer
├── pages/
│ ├── index.astro Home (hero + snapshot grid)
│ ├── about.astro About + split transition fork
│ ├── professional/index.astro Single long page: Summary, Experience,
│ │ Skills, Projects, Education, Contact
│ ├── interests/ Hub + 5 sub-pages
│ │ ├── index.astro
│ │ ├── ttrpgs / gaming / reading / making / pokemon .astro
│ ├── contact.astro Two-column connect page + contact form
│ └── api/contact.ts POST endpoint -> SMTP / webhook
├── content.config.ts Collection schemas (projects, experience)
├── lib/ site.ts (nav/socials), motion.ts, content helpers
├── styles/ global.css, themes.css, animations.css
└── scripts/theme-init.ts No-FOUC theme boot (inlined in <head>)
```
---
## The two wings (theming model)
Theming has two independent axes (see [docs/THEMING.md](docs/THEMING.md)):
1. **Global light/dark** on `<html data-theme>`, set before paint by an inline
script, persisted to `localStorage`, toggleable via the nav button.
2. **Section tone** via `data-surface` on `<body>`:
- `neutral` (Home/About/Contact) — balanced middle ground
- `professional` — dark slate, polished
- `interest` — warm cream/amber, inviting
Components only reference semantic CSS variables (`--surface`, `--text`,
`--accent`...), so changing either axis restyles everything automatically.
---
## Adding content
- **Projects / Experience:** drop a markdown or MDX file in
`src/content/projects/` or `src/content/experience/`. Schemas and field
reference are in [docs/CONTENT.md](docs/CONTENT.md).
- **Interest pages:** hand-built `.astro` files under `src/pages/interests/`,
each following the spec's template (Header, Overview, Highlights, Currently,
Related). Edit copy in place.
- **Placeholders:** every `[Greg to fill]` gap from the spec is a styled
`Placeholder` slot with a TODO badge so you can find and replace them.
---
## Deployment (Cosmos homelab)
The site runs as a Node server inside a container. Full walkthrough in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md):
```bash
docker compose up -d --build
# Then point your reverse proxy at port 4321 with TLS for gregkilmer.com.
```
---
## Scripts
| Script | Description |
| ----------------- | ----------------------------------------------------- |
| `npm run dev` | Dev server with hot reload |
| `npm run build` | Type-check (`astro check`) then build the Node server |
| `npm run serve` | Run the built server (`dist/server/entry.mjs`) |
| `npm run preview` | Preview the build locally |
| `npm run check` | Type-check only |
---
## Tech stack
Astro 7, React 19, TypeScript 6, Tailwind CSS v4, Framer Motion, nodemailer,
Zod, `@iconify-json/mdi`, Fontsource variable fonts, `@astrojs/node` standalone.
---
## License
Personal site for Greg Kilmer. All rights reserved.
+3
View File
@@ -0,0 +1,3 @@
# Admin page settings. On Cosmos: /home/greg/homelab/gregkilmer/admin.env
# The content service's admin token (the admin:… entry in content.env's CONTENT_TOKENS).
ADMIN_TOKEN=
+66
View File
@@ -0,0 +1,66 @@
# The GregKilmer.com stack on Cosmos: content service, admin page and public site.
#
# Lives at /home/greg/homelab/gregkilmer/compose.yaml (deploy/deploy.sh
# copies it there from deploy/compose.yaml on every build). Settings:
# .env stack settings: GK_TAG, VAULT_SITE, PUID, PGID (deploy/stack.env.example)
# content.env content service secrets (services/content/.env.example)
# admin.env admin page settings (deploy/admin.env.example)
# site.env public site secrets (services/site/.env.example)
#
# No service publishes a host port. Nginx Proxy Manager reaches them by
# container name on homelab_network. See docs/deploy.md.
name: gregkilmer
x-common: &common
restart: unless-stopped
user: "${PUID:-1000}:${PGID:-1000}"
networks: [homelab_network]
read_only: true
tmpfs: [/tmp]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
services:
gk-content:
<<: *common
image: gregkilmer/content:${GK_TAG:?set GK_TAG in .env}
container_name: gk-content
env_file: content.env
environment:
SITE_DIR: /vault/Site
DATA_DIR: /data
volumes:
# The vault's Site folder. Read-write: accepted API and MCP changes are
# written back into the notes.
- ${VAULT_SITE:?set VAULT_SITE in .env}:/vault/Site
# content.db (notes, history, review queue, saved forms, sync cache) and
# submissions/ (form attachments). Both are backed up.
- ./data/content:/data
labels:
backup.dump: sqlite
gk-admin:
<<: *common
image: gregkilmer/admin:${GK_TAG}
container_name: gk-admin
env_file: admin.env
environment:
CONTENT_URL: http://gk-content:4310
depends_on:
gk-content: { condition: service_healthy }
gk-site:
<<: *common
image: gregkilmer/site:${GK_TAG}
container_name: gk-site
env_file: site.env
environment:
CONTENT_URL: http://gk-content:4310
depends_on:
gk-content: { condition: service_healthy }
networks:
homelab_network:
name: homelab_network
external: true
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Builds the three images for the checked-out commit, runs the checks, and
# restarts the stack on it. If the new containers don't come up healthy, the
# stack goes back to the last good tag. Run from the repo root on Cosmos, by the
# deploy timer (pull-and-deploy.sh) or by hand. See docs/deploy.md.
set -euo pipefail
STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}"
ENV_FILE="$STACK/.env"
TAG="$(git rev-parse --short=12 HEAD)"
compose() { docker compose --project-directory "$STACK" -f "$STACK/compose.yaml" "$@"; }
setting() { grep -E "^$1=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true; }
set_setting() {
if grep -qE "^$1=" "$ENV_FILE"; then sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE"; else echo "$1=$2" >> "$ENV_FILE"; fi
}
for f in .env content.env admin.env site.env; do
[ -f "$STACK/$f" ] || { echo "Missing $STACK/$f (see docs/deploy.md)"; exit 1; }
done
CURRENT="$(setting GK_TAG)"
PREVIOUS="$(setting GK_PREVIOUS_TAG)"
if [ "$CURRENT" = "$TAG" ] && [ "${FORCE:-}" != "1" ]; then
echo "Already running $TAG; nothing to do (FORCE=1 to rebuild)."
exit 0
fi
echo "== Checks"
docker build --target check .
echo "== Images for $TAG"
for t in content admin site; do
docker build --target "$t" -t "gregkilmer/$t:$TAG" .
done
echo "== Deploy"
mkdir -p "$STACK/data/content"
cp deploy/compose.yaml "$STACK/compose.yaml"
set_setting GK_TAG "$TAG"
[ -n "$CURRENT" ] && set_setting GK_PREVIOUS_TAG "$CURRENT"
# No --remove-orphans: another stack may share the project name, and its
# containers must never be touched by this deploy.
if ! compose up -d --wait --wait-timeout 120; then
echo "!! $TAG did not come up healthy"
compose ps
compose logs --tail 50 || true
if [ -n "$CURRENT" ]; then
echo "== Rolling back to $CURRENT"
set_setting GK_TAG "$CURRENT"
set_setting GK_PREVIOUS_TAG "$PREVIOUS"
compose up -d --wait --wait-timeout 120
fi
exit 1
fi
echo "== Prune old images (keeping $TAG and ${CURRENT:-none})"
for t in content admin site; do
docker images "gregkilmer/$t" --format '{{.Tag}}' | while read -r old; do
[ "$old" = "$TAG" ] || [ "$old" = "$CURRENT" ] || docker rmi "gregkilmer/$t:$old" >/dev/null || true
done
done
echo "Deployed $TAG"
+15
View File
@@ -0,0 +1,15 @@
# Deploys GregKilmer.com from the GitHub clone when master moves.
# Install: sudo cp deploy/gk-deploy.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now gk-deploy.timer
# Logs: journalctl -u gk-deploy. See docs/deploy.md.
[Unit]
Description=Deploy GregKilmer.com from GitHub
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=oneshot
User=greg
Environment=GK_STACK_DIR=/home/greg/homelab/gregkilmer
ExecStart=/home/greg/homelab/gregkilmer/repo/deploy/pull-and-deploy.sh
TimeoutStartSec=30min
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Check GitHub for a new GregKilmer.com commit every 5 minutes
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
[Install]
WantedBy=timers.target
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Run by gk-deploy.timer on Cosmos: fetches master from GitHub into the
# deploy-only clone and, when it has moved, runs deploy.sh on it. A commit that
# failed to deploy is not retried until master moves again (or FORCE=1).
# Pings DEPLOY_HEALTHCHECK_URL from the stack's .env when set. See docs/deploy.md.
set -uo pipefail
STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}"
export GK_STACK_DIR="$STACK"
cd "$(dirname "$0")/.."
hc="$(grep -E '^DEPLOY_HEALTHCHECK_URL=' "$STACK/.env" 2>/dev/null | cut -d= -f2- || true)"
ping() { [ -n "$hc" ] && curl -fsS -m 10 --retry 3 "$hc$1" >/dev/null 2>&1 || true; }
if ! git fetch --quiet origin master; then
echo "Could not fetch from GitHub"
ping /fail
exit 1
fi
target="$(git rev-parse --short=12 origin/master)"
running="$(grep -E '^GK_TAG=' "$STACK/.env" 2>/dev/null | cut -d= -f2- || true)"
failed="$(cat "$STACK/.failed-tag" 2>/dev/null || true)"
if [ "$target" = "$running" ] && [ "${FORCE:-}" != "1" ]; then
ping ""
exit 0
fi
if [ "$target" = "$failed" ] && [ "${FORCE:-}" != "1" ]; then
echo "$target failed to deploy before; waiting for a new commit (FORCE=1 to retry)"
ping /fail
exit 0
fi
# The clone is for deploys only, so it always matches master exactly.
git reset --quiet --hard origin/master
git clean -fdq
ping /start
if ./deploy/deploy.sh; then
rm -f "$STACK/.failed-tag"
ping ""
else
echo "$target" > "$STACK/.failed-tag"
ping /fail
exit 1
fi
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Puts the stack back on the previous tag (GK_PREVIOUS_TAG) and swaps the two,
# so running it again rolls forward. See docs/deploy.md.
set -euo pipefail
STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}"
ENV_FILE="$STACK/.env"
cur="$(grep -E '^GK_TAG=' "$ENV_FILE" | cut -d= -f2-)"
prev="$(grep -E '^GK_PREVIOUS_TAG=' "$ENV_FILE" | cut -d= -f2-)"
[ -n "$prev" ] || { echo "No GK_PREVIOUS_TAG in $ENV_FILE"; exit 1; }
sed -i "s|^GK_TAG=.*|GK_TAG=$prev|; s|^GK_PREVIOUS_TAG=.*|GK_PREVIOUS_TAG=$cur|" "$ENV_FILE"
docker compose --project-directory "$STACK" -f "$STACK/compose.yaml" up -d --wait --wait-timeout 120
echo "Now running $prev (previous: $cur)"
+12
View File
@@ -0,0 +1,12 @@
# Stack settings for compose.yaml. On Cosmos: /home/greg/homelab/gregkilmer/.env
# deploy.sh rewrites GK_TAG on every deploy; GK_PREVIOUS_TAG is the rollback.
GK_TAG=
GK_PREVIOUS_TAG=
# The vault's Site folder on the host.
VAULT_SITE=/home/greg/homelab/obsidian-vault/20-29_Makerspace/24_Homelab_Infrastructure/24.01_PersonalWebsite/Site
# The user the containers run as: the owner of the vault files (id -u greg, id -g greg).
PUID=1000
PGID=1000
# Healthchecks ping for the deploy timer (5-minute period): red when GitHub
# can't be reached or a commit fails to deploy.
DEPLOY_HEALTHCHECK_URL=
+57
View File
@@ -0,0 +1,57 @@
# Admin page
`services/admin` is where Greg decides what the rules held, puts back an
earlier version, captures a status or a Library item, opens and closes store
orders, previews drafts, and checks that the content service and the sync
jobs are running.
It is one more client of the content service's HTTP API, like the MCP server
and any editing tool built later. It writes nothing itself: every action is a
call to the write API, so the same rules apply, and changes are written into
the vault so Obsidian shows them too. Its changes are recorded with the author
`admin` ("Admin page" in history).
## Pages
| Page | What it does |
| --- | --- |
| Overview | Live and total notes, what waits for review, folder problems, the service's last scan and last error, a Scan now button, each sync job's last run and last success, and Library suggestions: series moved to Reading in PersonalReader with no Library item, each with Add to the Library (title, medium, AniList id, state now, published) and Dismiss. |
| Review | Held changes with the rules' reasons and a line diff against the live version. Accept puts it live (and into the vault, for API changes). Reject keeps the live version; for an edit made in Obsidian, "Reject and put the live version back" also rewrites the file. Earlier decisions are listed by status. |
| Notes, Drafts | Browse and search every note, or only the unpublished ones. |
| A note | A preview of the body, its fields, what links to it, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
| Posts | Every blog post, drafts first, and **Write a post**: title, topic, kind of post, publish date, summary, a cover picked from the vault or uploaded, the pages it is about, Feature it, case study details, and the Markdown body. Preview shows it rendered with its images; Save draft and Publish write the note to `Posts/`. Images added to the post are uploaded to `_attachments/Posts` and appended as `![[file]]` lines. |
| Images | Everything in `_attachments`, newest first, by folder, with which notes use each one. Upload several at once into a folder, copy an image's `![[file]]` line, and delete images no note uses. |
| Capture | A status for the Now log, or a Library item with the option to post a status about it in the same step. |
| Store | Orders open or closed per category, with the reopen date and turnaround, and open slots or stock per product line. |
| Problems | Whole-folder problems, like links to notes that do not exist. |
| Inbox | Inquiries and order requests from the site, unhandled first and newest first, with whether each email reached you. Open one to read it, reply by email, download its attachment, and mark it handled or not handled. |
## Safety
- **Tailnet only.** Publish it behind the "Tailnet only" access list; it has no
login of its own.
- **The token stays on the server.** The admin page holds the content service's
admin token and calls the API itself; the browser never sees it.
- **No posts from other sites.** Every form post must carry `Sec-Fetch-Site:
same-origin` (or a matching `Origin`), so a page elsewhere cannot make the
browser act on the admin page.
- **Drafts cannot run code.** Previews show raw HTML as text and drop links that
are not http, https, mailto or relative. The content security policy allows
only the page's own `/admin.js` (Copy buttons and a confirm before deleting
an image), never inline script, and every page still works with plain forms
when that script does not run.
- **Only images go into the vault.** Uploads are checked by extension and by
their first bytes, SVG is refused, and an image is deleted only when no note
uses it.
## Running it
| Variable | Default | |
| --- | --- | --- |
| `CONTENT_URL` | `http://localhost:4310` | The content service. |
| `ADMIN_TOKEN` | required | The content service's admin token. |
| `PORT`, `HOST` | `4320`, `0.0.0.0` | |
```sh
ADMIN_TOKEN=… npm run admin
```
+79
View File
@@ -0,0 +1,79 @@
# Component library
`packages/ui` (`@gk/ui`) holds the approved element rules and ornament kit as
Astro components. The site imports them; nothing else defines how a button,
chip or seal looks. The rules come from the design canvas's Element rules and
Ornament kit boards, approved Oct 11.
Run the gallery with `npm run ui` (or `npm run ui:build` for a static copy in
`packages/ui/dist`). It shows every component inside the real page shell.
## Layout
| Path | What it is |
| --- | --- |
| `src/styles/tokens.css` | Colors, fonts, spacing, radii, control sizes and the page width as `--gk-*` variables. |
| `src/styles/fonts.css` | Cinzel and Montserrat, self-hosted as Latin-subset WOFF2 (OFL, see `src/assets/fonts/LICENSE.md`). |
| `src/styles/base.css` | Page defaults: body, headings, body links, the focus ring, `.gk-wrap`, reduced motion. |
| `src/styles/elements.css` | The element rules as global `gk-*` classes, so Markdown output and plain HTML can use them too. |
| `src/lib/ornaments.ts` | The seeded geometry behind star paths, tick scales, tick rings and dust. Tested in `test/ornaments.test.ts`. |
| `src/components/` | The Astro components below. |
| `src/assets/` | Seals and motifs, the monogram, 86 stroke icons, the card tick strip, and the fonts. |
## Element rules
| Rule | Component | Notes |
| --- | --- | --- |
| I Buttons | `Button` | `variant` primary (teal pill), secondary (gold hairline) or text (filament underline on hover); `size="compact"` for 36px; `href` makes it a link. One primary per section. |
| II States | `StatusPill` | Round pill. `live` teal with a lit dot, `state` without the dot, `favorite` and `current` gold with a star. |
| II Labels | `Chip` | Square 6px chip. `core` gives the teal edge; `href` makes it a page link led by a violet star. |
| III Filters | `FilterGroup` | Separate 36px pills. Items with `href` are links (`aria-current`); items without are toggle buttons (`aria-pressed`) a page script drives. |
| IV Kickers | `Kicker`, `SectionHeading` | Gold capitals over a tick scale. `SectionHeading` adds the Cinzel heading and the filament underline. |
| IV Notices | `Notice` | The ledger note: soft gold panel, gold rule on the left. |
| V Fields | `Field`, `SearchField` | Input, select or text area in the 10px box, filament across the base on focus, with `hint` and `error` wired to `aria-describedby`. |
| VI Cards | `Card` | Linked cards turn gold on hover with a tick scale along the top. `featured` adds the gold border and gilt corners; one per page. |
| VII Links | (base.css) | Body links are teal and always underlined. |
Shared: controls are 44px (36px compact), chips 26px, focus is a 2px gold
ring 3px out, disabled is 40% opacity.
## Ornaments
| Component | Use |
| --- | --- |
| `Star` | The four-point star. Gold, violet (only ever a star), teal, starlight or outline. |
| `StarPath` | Greg's star path line. `strength` calm, standard or wild; the same `seed` always draws the same path (it defaults to the page path). Two per page at most. |
| `Divider` | The header divider: gold line, large center star, star paths both sides. Header and footer only. |
| `HeroHorizon` | Gold hairline with a violet star, where a hero has no divider. |
| `TickScale` | Astrolabe ticks; with `value` it is a meter. |
| `TickRing` | A tick ring around a step number or small seal; `medallion` adds the disc and glow. |
| `SlotMeter` | Open order slots as stars over a tick meter. |
| `GiltCorners` | Corner brackets for the one featured panel per page. |
| `Seal`, `EdgeSeals` | A seal as an image; `EdgeSeals` places the page family's seal at the page edges at 6 to 7%. |
| `ConstellationDust` | Sparse static specks, about one in five gold. |
| `StarChartArt` | Placeholder art and the fallback for a missing image: star-chart grid, gold mat, a seal. |
| `Icon` | One of the 86 brand icons, in the current text color. |
Each page family's edge seal is in `src/components/seals.ts` (`pageSeals`):
landing uses transmutation star paths, resume the Kepler nest, work the
squared circle, and so on. A craft page passes its own craft seal.
## Page shell
`PageShell` is every page's frame: the head (title, description), dust and
edge seals, `SiteHeader` (nebula, filament banner, the primary nav with the My
world menu, an optional hero in the `hero` slot, the divider), the page
content, and `SiteFooter`. The nav entries live in `src/components/nav.ts`;
pass `current` to mark the page's entry.
```astro
---
import PageShell from "@gk/ui/components/PageShell.astro";
---
<PageShell title="Blog" family="blog" current="blog" description="What I am making, learning, playing and building.">
<Fragment slot="hero">…</Fragment>
…
</PageShell>
```
The site is dark only for now; light mode comes in a later phase.
+48
View File
@@ -0,0 +1,48 @@
# Content model
Everything the site shows is a Markdown note with YAML frontmatter in the
vault at `20-29_Makerspace/24_Homelab_Infrastructure/24.01_PersonalWebsite/Site/`.
`24.02_PatiunWebsite` is reserved for the Patiun site later.
## Rules every note follows
- A note's `type` field says what it is. The types, their fields and their
folders are defined once, in `packages/schema/src/types.ts`.
- A note is live only when `publish: true`. Missing means false.
- Links between notes are Obsidian wikilinks, `[[Note name]]`, in frontmatter
or in the body. A link resolves by file name, so file names are unique across
the whole Site folder.
- Images live in `Site/_attachments/` and are linked the same way,
`[[galaxy-dice.jpg]]`, or are full https URLs.
- Folders starting with `_` hold templates and attachments, never notes.
Files starting with `_` are single settings notes: `_site.md`,
`Now/_kinds.md`, `Store/_settings.md`.
- Slugs come from the title unless a note sets `slug`. They are unique within
a type.
## Note types
| Type | Folder | Notes |
| --- | --- | --- |
| `post` | `Posts/` | Blog posts. `postType: case study` also appears on the Work page. |
| `library-item` | `Library/<Medium>/` | Books, light novels, webnovels, manga, manhwa, video games, anime, TV, movies. |
| `board-game` | `Shelf/` | The Shelf. Players and play time sync from BoardGameGeek by `bggId`. |
| `craft`, `station`, `tool`, `make` | `Workshop/…` | The Workshop. |
| `tabletop-system`, `campaign`, `card-game`, `deck` | `Tabletop/…` | Tabletop. Magic decks sync from Archidekt. |
| `experience`, `skills`, `education` | `Resume/…` | The resume page and its PDF. |
| `status`, `status-kinds` | `Now/` | The status log behind Now, the Landing strip and Now chips. |
| `product-line`, `store-settings` | `Store/…` | The Store. |
| `site-settings` | `_site.md` | Name, tagline, location and social links. |
| `page` | `Pages/` | The written introduction for a page. |
Each field's title and description live in the schema too, so the
JSON Schema export (`npm run schema:json`) can drive a form or explain a field
to an agent.
## Designed for editing tools
The content service will be the only writer apart from Obsidian. It will
expose one write API, built on the schema's `checkNote`, `writeNote` and
`checkVault`. The MCP server for agents is one client of that API; the admin
page is another; any editing tool built later is a third. None of them define
fields or rules of their own.
+367
View File
@@ -0,0 +1,367 @@
# Content service
The content service sits between the vault and everything that shows or
changes content. It is the only thing that reads or writes the Site folder.
```
Obsidian ──edits──▶ Site folder ──watch──▶ rules ──▶ accepted store ──▶ site, admin page
▲ ▲
└──── accepted API changes ────────┤
write API (HTTP)
▲ ▲
MCP server admin page, later editors
```
- **The vault is the source of truth.** Greg edits in Obsidian; the service
watches the folder and takes each change in.
- **Every change runs the same rules**, whether it came from Obsidian, an
agent or the admin page. A change that passes is accepted. A change that
fails is held for review, and the site keeps showing the last accepted
version, so a bad edit never reaches a page.
- **Changes through the API are written back into the vault** once accepted,
so Obsidian always shows what the site shows. The service marks its own
writes as seen before writing, so the watcher does not take them in twice.
- **Deleting or moving a file unpublishes the note.** Its history stays, and
any version can be rolled back.
## The rules
Pure functions in [`services/content/src/rules.ts`](../services/content/src/rules.ts),
run in this order. The rules live in code, not in the vault, so no agent can
loosen them.
| Code | Holds a change when |
| --- | --- |
| `invalid` | The note fails its type's schema. |
| `misplaced` | A settings note (`_site`, `Now/_kinds`, `Store/_settings`) is not at its fixed path. |
| `duplicate-name`, `duplicate-slug` | Another live note already has the file name, or the same type already uses the address. |
| `text-loss` | A body of 400 or more characters loses more than half in one edit. |
| `secret` | New text looks like a key, token or password. |
| `contact` | An agent adds an email address or phone number. |
| `agent-edit` | An agent changes a note it did not write, unless the note has `agentEdits: true`. |
| `agent-publish` | An agent publishes anything but a status or a Library item. |
| `batch` | An agent makes more than 10 changes in 10 minutes. |
A sync job writing notes (author `sync`) runs every agent rule above, the
`batch` limit counted for syncs on their own; see [Sync jobs](#sync-jobs).
Invalid input through the API is turned down at once with the problems, rather
than held, so callers fix it themselves. A change from Obsidian that fails is
held, because the file is already on disk.
## Addresses
A slug change or a rename keeps the old address working as a redirect. When a
note disappears and a note of the same type with the same text or title
appears within ten minutes, that is treated as a move: redirects to the old
path are pointed at the new one, so redirects never chain. A slug that comes
back into use belongs to its note, not to the redirect.
## The write API
`ContentService.apply` takes one change, `create` (type, fields, body) or
`update` (path, the fields to change, `null` to remove one, and optionally
the body), along with who made it. Every client uses it:
- the MCP server, as `agent`;
- the admin page and later editing tools, as `greg` (editor token) or `admin`;
- the review queue: `acceptHeld`, `rejectHeld` (optionally restoring the live
version into the vault), and `rollback` to any earlier revision.
An update first takes in any unsaved Obsidian edit to that file, so an API
change never overwrites one. A note with a change waiting for review takes no
other API change until that one is decided. A note's type never changes.
### HTTP
Every route but `/health` needs `Authorization: Bearer <token>`. Tokens map to
roles in `CONTENT_TOKENS`.
| Route | Who | What |
| --- | --- | --- |
| `GET /health` | anyone | Note, published and held counts. |
| `GET /api/schema` | all | Every note type with its JSON Schema. |
| `GET /api/notes?type=&q=&limit=&offset=&full=` | all | List or search. Readers see published notes only. With `full=true` each note carries `synced` (see [Sync jobs](#sync-jobs)). |
| `GET /api/note?path=`, `?name=` or `?type=&slug=` | all | One note with its backlinks and `synced`. An old slug answers with `{ redirect }`. |
| `GET /api/backlinks?name=` | all | Notes linking to a note. |
| `GET /api/redirects` | all | Every redirect. |
| `GET /attachments/<file>` | all | An image from `_attachments`. |
| `GET /api/note/history?path=`, `/api/note/revision?path=&rev=` | agent, editor, admin | History and old versions. |
| `GET /api/problems` | agent, editor, admin | Broken links and other whole-folder problems. |
| `GET /api/held`, `/api/held/<id>` | agent, editor, admin | The review queue; one entry comes with the current text for comparison. |
| `GET /api/status` | agent, editor, admin | When the service started, its last full scan and last error, counts, and each sync job's latest run. |
| `GET /api/syncs`, `?job=` | agent, editor, admin | Each sync job's latest run, or one job's recent runs. |
| `POST /api/syncs` | agent, editor, admin | A sync job reports a run: `{ job, ok, message?, counts? }`. The last 50 runs per job are kept. |
| `POST /api/changes` | agent, editor, admin | One change. 422 with reasons when turned down. |
| `POST /api/held/<id>/accept`, `/reject` | editor, admin | Decide a held change. `{ "restore": true }` on reject puts the live version back into the vault. |
| `POST /api/note/rollback` | admin | `{ path, rev }` |
| `POST /api/scan` | editor, admin | Compare the whole folder now. |
| `GET /api/attachments` | editor, admin | Every file in `_attachments`, newest first, with its folder, size and the notes whose text names it (`usedBy`). |
| `POST /api/attachments?folder=&name=` | editor, admin | The raw bytes of a PNG, JPEG, WebP, GIF or AVIF image (up to 15 MB; SVG is refused because it can carry script). It is saved in `_attachments/<folder>` under a lowercase, hyphenated name, with `-2`, `-3` added when the name is taken anywhere in `_attachments`. Answers `{ name }`. |
| `POST /api/attachments/delete` | editor, admin | `{ name }`. Refused with 409 while any note still uses the file. |
| `POST /api/submissions` | intake, admin | File a saved copy of an inquiry or order. Answers `{ id, receipt }`. |
| `POST /api/submissions/<id>/delivery` | intake (with the receipt, once), admin | `{ receipt, status: sent, failed or skipped, error? }`: how the email went. |
| `GET /api/submissions?handled=`, `/api/submissions/<id>` | admin | Saved copies, unhandled first, newest first, with counts. |
| `POST /api/submissions/<id>/handled` | admin | `{ handled: true or false }` |
| `GET /api/submissions/<id>/attachment` | admin | The attached file, always as a download. |
| `/mcp` | agent, editor, admin | The MCP server. |
### MCP
Streamable HTTP at `/mcp`, stateless. Tools: `search`, `get_note`,
`get_schema`, `list_held`, `set_status`, `add_library_item`, `update_fields`
and `draft_post` (always unpublished). There is no tool to delete, rename,
move or write a raw file.
## Running it
| Variable | Default | |
| --- | --- | --- |
| `SITE_DIR` | required | The vault's Site folder. |
| `DATA_DIR` | `data` | Holds `content.db`. Back it up: history and the review queue cannot be rebuilt from the vault. |
| `PORT`, `HOST` | `4310`, `0.0.0.0` | |
| `CONTENT_TOKENS` | none | `role:token` pairs, comma separated. Roles: reader, agent, editor, admin, intake. Tokens are 24 characters or more. |
| `ALLOWED_EMAILS` | none | Addresses an agent may add without a hold. |
| `HEALTHCHECK_URL` | none | Pinged after every full scan. |
| `NOTIFY_WEBHOOK_URL` | none | Receives `{ content, event: "held", held }` when a change is held. `content` is a one-line message, so a Discord webhook URL works as is. |
| `SCAN_MINUTES` | `5` | Full scan interval, on top of the file watch. |
| `SUBMISSION_MAX_MB` | `10` | Largest attachment a form submission may carry. |
| `SYNC_JOBS` | none | Public sync jobs to run, comma separated: `archidekt`, `bgg`, `covers`. See [Sync jobs](#sync-jobs). |
| `SYNC_<JOB>_MINUTES` | per job | Overrides a job's interval, e.g. `SYNC_BGG_MINUTES=720`. |
| `SYNC_<JOB>_HEALTHCHECK_URL` | none | The job's Healthchecks ping URL, e.g. `SYNC_ARCHIDEKT_HEALTHCHECK_URL`. |
| `BGG_TOKEN` | none | BoardGameGeek application token, sent as `Authorization: Bearer`. Register an app with BGG if the XML API answers 401. |
| `TMDB_API_KEY` | none | TMDB v3 API key or v4 read access token. Without it, film and TV covers are skipped. |
```sh
SITE_DIR=/path/to/Site CONTENT_TOKENS="reader:…,agent:…,editor:…,admin:…" npm run content
```
The store is SQLite (`node:sqlite`) with full-text search, so the service has
no database server to run.
## Sync jobs
Outside sources sync on a schedule into a cache the site reads, so a source
being down never takes a page down. Code: [`services/content/src/sync/`](../services/content/src/sync/).
| Job | Every | Reads | Stores per note |
| --- | --- | --- | --- |
| `archidekt` | hour | Deck notes with `archidektId`: `GET https://archidekt.com/api/decks/<id>/` | `commanderArt` (Scryfall art crop of the commander, else the deck's featured image), `colors` (color identity, WUBRG letters), `commanders`, `deckSize` (cards, maybeboard excluded), `format`, `deckUrl` |
| `bgg` | day | Board-game notes' `bggId`: XML API2 `thing`, 20 ids a request, 5 s apart, waits out `202` | `minPlayers`, `maxPlayers`, `playTime`, `minPlayTime`, `maxPlayTime`, `cover` (box art) |
| `covers` | 15 min | Library items with no `cover`: Open Library (books), TMDB (film, TV; needs `TMDB_API_KEY`), AniList (anime, manga, manhwa, light novels, webnovels; by `anilistId` when set) | `cover` |
| `suwayomi` | hour | PersonalReader's manga and manhwa: Suwayomi's GraphQL library (category, furthest chapter read and when, AniList tracker id), matched to Library items by `anilistId`; AniList for covers | `chapterProgress` (`title`, `status`, `chapter`, `readAt`), `cover` (AniList); changes `state` (below) |
| `kavita` | day | PersonalReader's light novels and books: Kavita's API, matched by `kavitaSeriesId`, or by `anilistId` against an anilist.co link in the series' metadata | `bookProgress` (`title`, `percent`, `readAt`); changes `state` (below) |
| `bambuddy` | hour | Filament spools in stock | `filamentStock` on product lines with a Filament option |
| `homebox` | hour | Homebox items with the labels or locations in `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` | `materialStock` on product lines with a Leather or Paper option |
The covers job looks every 15 minutes, so a new Library item gets a cover
soon after it appears, and sweeps slowly: at most 20 items a run, an item
with no cover found is asked again after a day, one with a cover after 30
days. Only image addresses are stored; nothing is downloaded. Video games
have no cover source.
### Homelab sources (Cosmos)
These four run on Cosmos over `homelab_network`, are **read only** against
their source, and are enabled by their own URL and login alone (not
`SYNC_JOBS`). Code: `sync/jobs/{suwayomi,kavita,bambuddy,homebox}.ts`,
settings in `sync/jobs/homelab.ts`. None of them has been run against a real
server from this repository: they are written from each project's API
documentation (Bambuddy: from nothing, see below) and tested with
hand-written sample payloads in `services/content/test/sync/fixtures`.
**PersonalReader (Suwayomi, Kavita)** is private. A job touches a Library
item only when the note carries the series' id: `anilistId` (Suwayomi's
AniList tracker record; Kavita's metadata link), or `kavitaSeriesId`. It never
matches by title. It caches only a title, a chapter or percent, when it was
read and an AniList cover: never a source site, a scan group, a thumbnail or
any address inside Suwayomi or Kavita.
| Reader says | Library `state` |
| --- | --- |
| Suwayomi category Reading | `now` (a note at `caught up` stays) |
| Plan to read | `up next` |
| Completed | `finished` |
| Dropped, or any other category | left alone |
| Kavita: every page read | `finished` when the series is Completed or Ended, else `caught up` (a note at `finished` stays) |
| Kavita: some pages read | `now` (a note at `caught up` stays) |
| Kavita: unread and on Want to Read | `up next` |
State changes go through `ctx.apply` as `sync`, so a note Greg wrote is held
for review (`agent-edit`), as is everything else in the rules above. A job
proposes each change once: it is not proposed again while it waits, nor after
Greg rejects it, until the reader says something different (the job keeps
that memo in its cache under `_memo`, which is not a note and never reaches
the site).
- **Suwayomi** logs in with its own read-only account. `SUWAYOMI_AUTH=ui_login`
(the default with a user set) uses the GraphQL `login` mutation and sends the
access token as `Authorization: Bearer`; `basic` sends HTTP basic auth (for
`AUTH_MODE=basic_auth`); `none` sends nothing. Suwayomi's cookie-based
`simple_login` mode is not supported. It then reads `categories { nodes {
name mangas { nodes { title latestReadChapter { chapterNumber lastReadAt }
trackRecords { nodes { trackerId remoteId title lastChapterRead } } } } } }`
from `<SUWAYOMI_URL>/api/graphql`; the AniList record is tracker id 2. Covers,
titles and media for suggestions come from one batched AniList query.
- **Library suggestions.** A Reading series with an AniList id and no Library
item is listed at `GET /api/suggestions` (editor, admin, agent) and on the
admin Overview, with one button that creates the Library item (title,
medium from AniList, `anilistId`, `state: now`, published) through
`POST /api/changes`, and a Dismiss (`POST /api/suggestions/<key>/dismiss`,
editor or admin; kept in the `suggestion_dismissals` table). The list itself
is the job's cache entry `_suggestions`; a series leaves it as soon as a
Library item carries its AniList id.
- **Kavita** exchanges `KAVITA_API_KEY` at `POST /api/Plugin/authenticate` for
a token, reads `GET /api/Series/<id>`, `GET /api/Series/metadata?seriesId=`,
`POST /api/Series/all-v2` (only when a note needs an AniList match; one
metadata request per series, a quarter second apart) and
`POST /api/want-to-read/v2`. AniList matching applies to book, light novel
and webnovel notes only, so a manga note is Suwayomi's.
**Store stock (Bambuddy, Homebox)** never writes notes. A job stores what is
in stock on every product line that has an option in a group it feeds
(`filamentStock` for Bambuddy's group, Filament by default;
`materialStock` for Homebox's), as `[{ group, options: [{ name, hex?, inStock }] }]`.
The site lays it under the note (`optionGroups` in
`services/site/src/lib/store.ts`, which the Store pages and the order check
both use): an option's own `inStock` and `hex` always win; an option that
leaves `inStock` out (it is optional) takes the synced value, and is out of
stock when the source does not have it; in-stock colors the note does not
list are added after the note's own. With nothing synced, options show
exactly as the note says (a missing `inStock` counts as in stock).
- **Bambuddy** reads `GET /api/v1/inventory/spools` (`BAMBUDDY_SPOOLS_PATH`)
with an API key made in Bambuddy's Settings, sent in the `X-API-Key` header.
Field names were checked against Greg's Bambuddy (2026-10-12) and are all in
`mapBambuddySpools` in `sync/jobs/bambuddy.ts`. Grams left is `label_weight`
minus `weight_used`; archived spools are skipped. A spool with no
`color_name` (RFID spools) is named by its nearest plain color (Black,
Blue...); setting a color name in Bambuddy overrides that. A color is in
stock when some spool of it has at least `BAMBUDDY_MIN_GRAMS` (default 50)
left.
- **Homebox** logs in at `POST /api/v1/users/login`, looks up the label and
location ids, and lists `GET /api/v1/items?labels=<id>` (or
`locations=<id>`). Each `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` entry is
`name` or `name=Group` (`leather` feeds Leather). An item is in stock when
its quantity is above zero; a `#rrggbb` in its description is its swatch
color. A name that matches no label or location fails the run.
| Variable | |
| --- | --- |
| `SUWAYOMI_URL` | Enables `suwayomi`, like `http://suwayomi:4567`. |
| `SUWAYOMI_USER`, `SUWAYOMI_PASS` | Its own read-only Suwayomi account. |
| `SUWAYOMI_AUTH` | `ui_login` (default with a user), `basic`, or `none`. |
| `KAVITA_URL`, `KAVITA_API_KEY` | Enable `kavita`, like `http://kavita:5000`; the key of a Kavita user that can see the libraries. |
| `BAMBUDDY_URL` | Enables `bambuddy`. |
| `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`). |
| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | The API key from Bambuddy's Settings, sent in `X-API-Key` (or in the named header). |
| `BAMBUDDY_GROUP`, `BAMBUDDY_MATERIALS`, `BAMBUDDY_MIN_GRAMS` | Option group (Filament), materials to include (like `PLA,PETG`; all when empty), grams that count as in stock (50). |
| `HOMEBOX_URL`, `HOMEBOX_USER`, `HOMEBOX_PASS` | Enable `homebox`, with a read-only Homebox user. |
| `HOMEBOX_LABELS`, `HOMEBOX_LOCATIONS` | Which items count, `name` or `name=Group`, comma separated. Labels default to `leather,paper`. |
Each also takes `SYNC_<JOB>_MINUTES` and `SYNC_<JOB>_HEALTHCHECK_URL` like any
job (`SYNC_SUWAYOMI_HEALTHCHECK_URL`, and so on).
### How they run
- **Enabled only when configured.** The public sources run when named in
`SYNC_JOBS`. A job that needs a URL or a login is enabled by those
variables alone.
- **Scheduler.** Each job runs once shortly after start (20 seconds apart,
so they do not all start together), then every interval. A job never runs
twice at once; a run past its timeout (10 minutes unless the job says
otherwise) is aborted and reported as failed.
- **Reporting.** Every run lands in `sync_runs` (`/api/syncs`, the admin
Overview). With `SYNC_<JOB>_HEALTHCHECK_URL` set, the job pings
`<url>/start`, then `<url>` or `<url>/fail` with the message, as the nightly
backup does.
- **The cache.** Table `sync_cache` in `content.db`, keyed by job and note
path, holding the values as JSON and when they were fetched. A job replaces
a note's values only with values it fetched successfully, so a failed run
or a failed item keeps the last good values. Values for notes that leave a
job's scope (a deck loses its `archidektId`) are dropped. The cache can be
rebuilt by running the jobs again.
### What the site gets
`/api/notes?full=true` and `/api/note` give every note a `synced` object:
each job's values for that note merged together (jobs do not share keys).
The site lays `synced` under the note's `data`, so **a value set in the note
always wins** (a note's own `cover` beats a synced one), and a note with
nothing synced renders exactly as before.
### Writing a job
A job is a `SyncJob` ([`sync/types.ts`](../services/content/src/sync/types.ts)),
added to `enabledJobs` in [`sync/index.ts`](../services/content/src/sync/index.ts)
behind the settings it needs (read in [`sync/settings.ts`](../services/content/src/sync/settings.ts)
from the environment, and documented above):
```ts
interface SyncJob {
name: string; // "bambuddy": sync_runs, the cache and SYNC_BAMBUDDY_* use it
everyMinutes: number;
timeoutMs?: number; // default 10 minutes
healthcheckUrl?: string;
run(ctx: SyncContext): Promise<{ message?: string; counts?: Record<string, number>; ok?: boolean }>;
}
```
`run` gets a `SyncContext` and nothing else:
| | |
| --- | --- |
| `ctx.notes(type)` | Live notes of a type, read only. |
| `ctx.cached(path)`, `ctx.set(path, values)` | This job's cache for a note. Call `set` only with values fetched successfully. |
| `ctx.retain(paths)` | Drop this job's values for notes outside `paths`. |
| `ctx.apply(change)` | Propose a note change as `sync` (below). |
| `ctx.fetch(url, init)` | `fetch` with the sync User-Agent, the run's abort signal and a 30 second timeout (`timeoutMs` to change). |
| `ctx.sleep(ms)`, `ctx.signal`, `ctx.now()`, `ctx.log(msg)` | Polite pauses, cancellation, the clock, logging. |
Throw to fail the whole run; return `ok: false` when some items failed. A run
that throws changes nothing already cached.
**Read-only sources** (Bambuddy filament, Homebox materials) store what they
read with `ctx.set` on the note it belongs to and add the keys to the site's
readers; they never write notes.
**Jobs that change notes** (Suwayomi and Kavita progress for PersonalReader)
call `ctx.apply({ op: "update", path, fields })`, the same
`ContentService.apply` every client uses, with author `sync`, so the note
gets `updatedBy: sync`. A sync is held to every agent rule and can do nothing
an agent cannot:
- it cannot delete, move or rename a note, or change its type;
- a change to a note the sync did not write (Greg's, an agent's) is held for
review unless the note has `agentEdits: true`;
- added email addresses or phone numbers, anything that looks like a secret,
and large text loss are held;
- it may publish only statuses and Library items; anything else is held;
- more than 10 sync changes in 10 minutes are held (`batch`), so a job that
updates many notes should change only what actually differs.
The outcome says `accepted`, `held` or `rejected`; count those in the run's
`counts`. A note with a change waiting for review takes no further change
until Greg decides it, which `apply` answers as `rejected` with `waiting`.
## Form submissions
The site's Inquiries and Store order forms are emailed to Greg and saved here
as well (`services/content/src/submissions.ts`), so a lost email never loses a
request. Each copy keeps its kind (inquiry or order), type (inquiry type or
product line), name, email, the answers in form order, the attachment if any,
when it came in, how its email went (`pending`, `sent`, `failed`, `skipped`
when SMTP is not set up) and whether Greg has handled it.
- The site files copies with an `intake` token. That token can do nothing
else: every other route, `/mcp` included, answers 403, and it can never read
a copy back, not even the one it just filed. It reports the email result
once, with the receipt it got when filing.
- Only the `admin` token lists, reads, downloads and marks copies handled
(the admin page's Inbox).
- Attachments are kept under `DATA_DIR/submissions` with a name the service
makes up (id, random part, a known extension), up to `SUBMISSION_MAX_MB`.
Allowed: png, jpg, jpeg, gif, webp, avif, heic, pdf, txt, csv, dek, dec. They
are only ever served as downloads.
- The rows live in `content.db`, so the `backup.dump=sqlite` label covers
them; back up `DATA_DIR/submissions` with the volume too.
+135
View File
@@ -0,0 +1,135 @@
# Deploying on Cosmos
The stack is three containers on `homelab_network`, none with a host port:
| Container | Image target | Port | Reached as | Who can reach it |
| --- | --- | --- | --- | --- |
| `gk-content` | `content` | 4310 | `content.gregkilmer.com` (API and `/mcp`), `http://gk-content:4310` on homelab_network | Tailnet only |
| `gk-admin` | `admin` | 4320 | `admin.gregkilmer.com` | Tailnet only |
| `gk-site` | `site` | 4321 | `beta.gregkilmer.com` until cutover, then `gregkilmer.com` | Tailnet only until cutover, then public through Cloudflare |
The old site keeps serving gregkilmer.com until phase 8 (cutover).
Files in the repo:
- `Dockerfile`: one target per service, plus `check` (typecheck and every test). Node is pinned to `node:22.20.0-alpine`.
- `deploy/compose.yaml`: the stack. Containers run as the vault's owner, with a read-only root filesystem, no capabilities and `no-new-privileges`.
- `deploy/deploy.sh`: builds `check`, then the three images tagged with the commit, and restarts the stack. If the new containers aren't healthy within two minutes, it puts the last good tag back. Run it again on the same commit and it does nothing (`FORCE=1` rebuilds).
- `deploy/rollback.sh`: switches to the previous tag. Run it again to roll forward.
- `deploy/pull-and-deploy.sh`, with `deploy/gk-deploy.service` and `gk-deploy.timer`: every 5 minutes, Cosmos fetches master from GitHub into its own clone of the repo and runs `deploy.sh` when master has moved. A commit that failed to deploy isn't retried until master moves again.
## On Cosmos
Everything lives in `/home/greg/homelab/gregkilmer`:
```
/home/greg/homelab/gregkilmer/
repo/ clone of github.com/Patiun/com.gregkilmer.www, for deploys only
compose.yaml copied from deploy/compose.yaml by every deploy
.env GK_TAG, GK_PREVIOUS_TAG, VAULT_SITE, PUID, PGID, DEPLOY_HEALTHCHECK_URL
content.env content service secrets and sync settings
admin.env ADMIN_TOKEN
site.env site secrets: tokens, Turnstile, SMTP
data/content/ content.db and submissions/ (backed up)
```
Kopia already covers `/home/greg`, so the `.env` files and `data/` are in the encrypted backups.
## First-time setup
1. **Clone and settings.** The repo is private, so give Cosmos read-only access with a deploy key:
```sh
ssh-keygen -t ed25519 -N "" -C cosmos-gregkilmer-deploy -f ~/.ssh/gregkilmer_deploy
```
Add `~/.ssh/gregkilmer_deploy.pub` on GitHub under the repo's Settings, then Deploy keys, with write access left off. Then:
```sh
mkdir -p /home/greg/homelab/gregkilmer/data/content
cd /home/greg/homelab/gregkilmer
GIT_SSH_COMMAND="ssh -i ~/.ssh/gregkilmer_deploy -o IdentitiesOnly=yes" \
git clone git@github.com:Patiun/com.gregkilmer.www.git repo
git -C repo config core.sshCommand "ssh -i ~/.ssh/gregkilmer_deploy -o IdentitiesOnly=yes"
cp repo/deploy/stack.env.example .env
cp repo/services/content/.env.example content.env
cp repo/deploy/admin.env.example admin.env
cp repo/services/site/.env.example site.env
chmod 600 .env *.env
```
In `.env`, check that `PUID` and `PGID` match `id -u greg` and `id -g greg`, so the content service can write notes back into the vault.
2. **Tokens.** Make one per role with `openssl rand -hex 24`:
- In `content.env`, set `CONTENT_TOKENS=reader:<r>,admin:<a>,intake:<i>,agent:<g>`.
- In `admin.env`, set `ADMIN_TOKEN=<a>`.
- In `site.env`, set `CONTENT_TOKEN=<r>` and `CONTENT_INTAKE_TOKEN=<i>`.
- Give agents `<g>`.
Leave `CONTENT_URL` out of `site.env`; compose sets it.
3. **The rest of the settings.** Fill in Healthchecks, the Discord webhook, Turnstile, SMTP and the sync logins. The list is in `/mnt/project-files/website/phase6/setup-on-cosmos.md` and in each `.env.example`.
4. **First deploy.** Run it by hand once and watch it:
```sh
/home/greg/homelab/gregkilmer/repo/deploy/pull-and-deploy.sh
```
Then check:
- `docker compose -f /home/greg/homelab/gregkilmer/compose.yaml ps` shows three healthy containers.
- `docker logs gk-content` says `Content service on 0.0.0.0:4310: N notes`.
5. **Deploy timer.** This makes every later merge to master deploy on its own:
```sh
sudo cp /home/greg/homelab/gregkilmer/repo/deploy/gk-deploy.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now gk-deploy.timer
```
- The service runs as `greg`, who must be in the `docker` group.
- Make a Healthchecks check with a 5-minute period, and set its URL as `DEPLOY_HEALTHCHECK_URL` in `.env`. It turns red when GitHub can't be reached or a commit fails to deploy.
- Logs: `journalctl -u gk-deploy`.
6. **Nginx Proxy Manager.** Add a proxy host for each hostname. Force SSL on each. For `content`, add `proxy_buffering off;` under Advanced, because MCP streams its responses.
| Host | Forward to | Access list |
| --- | --- | --- |
| `admin.gregkilmer.com` | `http://gk-admin:4320` | Tailnet only |
| `content.gregkilmer.com` | `http://gk-content:4310` | Tailnet only |
| `beta.gregkilmer.com` | `http://gk-site:4321` | Tailnet only |
- DNS: an A record for each pointing at 100.99.111.16 (TTL 300), the same as other tailnet hosts.
- Certificates: Let's Encrypt can't reach a tailnet-only host over HTTP, so use NPM's DNS challenge with your DNS provider. This is the same open item as the `*.patiun.com` certificate.
- Agents already on homelab_network can skip NPM and use `http://gk-content:4310/mcp`.
7. **Turnstile.** Add `beta.gregkilmer.com` and `gregkilmer.com` to the widget's hostnames.
8. **Healthchecks.** Create these checks:
- one for the content service's full scan (`HEALTHCHECK_URL`), every 5 minutes;
- one per sync job you turn on (Archidekt, Suwayomi, Bambuddy and Homebox hourly; BGG and Kavita daily; covers every 15 minutes).
9. **Inventory note.** Copy `vault-kit/24.01_PersonalWebsite/GregKilmer.com stack.md` into the vault's `24.01_PersonalWebsite` folder, so `cosmos-inventory` stops listing the containers as undocumented.
## Backups
- `gk-content` carries `backup.dump=sqlite`. `cosmos-dump` finds `content.db` in its `/data` mount and copies it with `VACUUM INTO` before Kopia runs.
- `content.db` holds note history, the review queue, saved inquiries and orders, and the sync cache.
- The vault mount has no SQLite files, so nothing else is dumped.
- Form attachments are plain files in `data/content/submissions/`, which Kopia picks up with the rest of `/home/greg`.
- The plan called for a cache folder in `exclusions.conf`. None is needed: the sync cache and search index live inside `content.db` and are small.
- `gk-admin` and `gk-site` keep no data.
## Cutover (phase 8)
1. In NPM, point `gregkilmer.com` and `www.gregkilmer.com` at `http://gk-site:4321` with no access list, and retire the old `greg-profile-site` container.
2. Turn on Cloudflare's proxy for both records with SSL mode Full (strict). Cloudflare doesn't cache HTML by default, which is what the site needs: pages change within a minute of a note changing.
3. The site already trusts `X-Forwarded-Host` and `X-Forwarded-Proto` for gregkilmer.com and its subdomains (`security.allowedDomains` in `services/site/astro.config.mjs`), so form posts pass Astro's origin check behind the proxy. It reads the visitor's address from `CF-Connecting-IP` for the form rate limits.
## Day to day
- **Deploy:** merge to master on GitHub. Cosmos picks it up within 5 minutes. To deploy straight away, run `sudo systemctl start gk-deploy`.
- **Retry a failed commit:** `FORCE=1 /home/greg/homelab/gregkilmer/repo/deploy/pull-and-deploy.sh`.
- **Roll back:** `/home/greg/homelab/gregkilmer/repo/deploy/rollback.sh`, or set `GK_TAG` in `.env` to any image tag still on the host and run `docker compose up -d`.
- **Logs:** `docker logs gk-content`. Sync runs also appear on the admin page's Overview.
- **Restart one service:** `docker compose -f /home/greg/homelab/gregkilmer/compose.yaml restart gk-site`.
+162
View File
@@ -0,0 +1,162 @@
# 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
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=<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.
+17
View File
@@ -0,0 +1,17 @@
# Keep the Docker build context lean.
node_modules
dist
.astro
.git
.gitignore
.env
.env.*
!.env.example
*.log
npm-debug.log*
.DS_Store
.vscode
.idea
docs
*.md
!README.md
+30
View File
@@ -0,0 +1,30 @@
# build output
dist/
.output/
# dependencies
node_modules/
# generated types
.astro/
# environment
.env
.env.*
!.env.example
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# macOS / editor
.DS_Store
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
# misc
*.local
+53
View File
@@ -0,0 +1,53 @@
# =============================================================================
# Dockerfile — Greg Kilmer Profile Site
# =============================================================================
# Multi-stage build producing a minimal Node server image for the Cosmos homelab.
#
# Stage 1 (builder): install all deps, run astro build -> dist/server/entry.mjs
# Stage 2 (runtime): copy only dist + prod node_modules, run the Node server
#
# The @astrojs/node standalone adapter emits a self-contained server, so the
# runtime image needs only node_modules + dist. Build once, run anywhere.
# =============================================================================
# ---------- Stage 1: build ----------
FROM node:22.12.0-alpine AS builder
WORKDIR /app
# Install deps. Copy manifests first to leverage Docker layer caching.
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund --legacy-peer-deps
# Copy source and build. SITE_URL can be overridden at build time.
COPY . .
ARG SITE_URL=https://gregkilmer.com
ENV SITE_URL=$SITE_URL
RUN npm run build
# Prune to production dependencies for the runtime stage.
RUN npm prune --omit=dev
# ---------- Stage 2: runtime ----------
FROM node:20-alpine AS runtime
WORKDIR /app
# Run as a non-root user for defense in depth.
RUN addgroup -S app && adduser -S app -G app
# Copy production node_modules and the built server + client assets.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
# Astro Node standalone server reads HOST/PORT from env at runtime.
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
USER app
# Healthcheck: the server responds 200 on /.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1
CMD ["node", "./dist/server/entry.mjs"]
+7
View File
@@ -0,0 +1,7 @@
# Legacy site
The GregKilmer.com site as it was before the 2026 rebuild, kept for reference.
It still runs the live site until cutover, built from this folder.
Do not add features here. Its data was migrated into vault notes by
`tools/migrate`; new content goes into the vault.
+156
View File
@@ -0,0 +1,156 @@
# Greg Kilmer — Profile & Portfolio Site
A responsive, content-driven personal site for Greg Kilmer, built as **two wings
of the same house**: a polished professional resume/portfolio on one side, and a
warm, personality-driven interests hub on the other.
Built with **Astro 7 + React Islands + TypeScript**, served by a **Node server**
with API routes, ready to deploy as a container on a homelab Cosmos system.
---
## Highlights
- **Astro 7** static-first core with React Islands for interactivity (mobile
menu, theme toggle, animated hero background, contact form).
- **Node backend** (`@astrojs/node`, standalone) with a typed `/api/contact`
endpoint (SMTP via nodemailer + webhook fallback, Zod validation, rate limit).
- **Section-aware theming**: the Professional wing reads dark/polished, the
Interests wing reads warm/inviting, plus a global light/dark toggle.
- **Rich motion**: animated mesh-gradient hero with an optional canvas particle
field, IntersectionObserver scroll reveals, View Transitions page swaps,
scroll-triggered nav blur. All `prefers-reduced-motion` aware.
- **Content Collections** (typed): add projects and experience by dropping
markdown/MDX files.
- **mdi icons everywhere** (no emojis, per the spec), inlined at build time from
`@iconify-json/mdi` for zero runtime cost.
- **Self-hosted variable fonts** (Inter, Plus Jakarta Sans, JetBrains Mono).
- **Accessible**: semantic landmarks, skip link, keyboard-operable menu,
focus-visible rings, ARIA on interactive bits, WCAG-AA contrast targets.
- **Deploy artifacts**: multi-stage `Dockerfile` + `docker-compose.yml`.
---
## Quickstart
```bash
# 1. Install dependencies (Node 20+)
npm install
# 2. Start the dev server with hot reload
npm run dev
# -> http://localhost:4321
# 3. Production build (runs astro check + build)
npm run build
# 4. Run the built Node server
npm run serve
# -> http://localhost:4321
```
Environment is optional for local dev. To enable contact-form delivery, copy
`.env.example` to `.env` and fill in SMTP or webhook vars (see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)).
---
## Project structure
```
src/
├── components/
│ ├── hero/ AnimatedBackground.tsx (gradient + canvas particles)
│ ├── motion/ Reveal.astro (IntersectionObserver scroll reveals)
│ ├── nav/ NavBar.astro, MobileMenu.tsx, ThemeToggle.tsx
│ ├── cards/ InterestCard.astro
│ ├── professional/ Timeline, SkillGrid, ProjectCard, SectionNav, ContactForm
│ ├── layout/ Footer, SectionHeader, SplitTransition, InterestHero,
│ │ RelatedInterests, Placeholder
│ └── Icon.astro Inline mdi icon renderer (build-time)
├── content/
│ ├── projects/ Project collection entries (markdown/MDX)
│ └── experience/ Experience collection entries
├── layouts/
│ └── BaseLayout.astro <html> shell, head, fonts, theme init, nav, footer
├── pages/
│ ├── index.astro Home (hero + snapshot grid)
│ ├── about.astro About + split transition fork
│ ├── professional/index.astro Single long page: Summary, Experience,
│ │ Skills, Projects, Education, Contact
│ ├── interests/ Hub + 5 sub-pages
│ │ ├── index.astro
│ │ ├── ttrpgs / gaming / reading / making / pokemon .astro
│ ├── contact.astro Two-column connect page + contact form
│ └── api/contact.ts POST endpoint -> SMTP / webhook
├── content.config.ts Collection schemas (projects, experience)
├── lib/ site.ts (nav/socials), motion.ts, content helpers
├── styles/ global.css, themes.css, animations.css
└── scripts/theme-init.ts No-FOUC theme boot (inlined in <head>)
```
---
## The two wings (theming model)
Theming has two independent axes (see [docs/THEMING.md](docs/THEMING.md)):
1. **Global light/dark** on `<html data-theme>`, set before paint by an inline
script, persisted to `localStorage`, toggleable via the nav button.
2. **Section tone** via `data-surface` on `<body>`:
- `neutral` (Home/About/Contact) — balanced middle ground
- `professional` — dark slate, polished
- `interest` — warm cream/amber, inviting
Components only reference semantic CSS variables (`--surface`, `--text`,
`--accent`...), so changing either axis restyles everything automatically.
---
## Adding content
- **Projects / Experience:** drop a markdown or MDX file in
`src/content/projects/` or `src/content/experience/`. Schemas and field
reference are in [docs/CONTENT.md](docs/CONTENT.md).
- **Interest pages:** hand-built `.astro` files under `src/pages/interests/`,
each following the spec's template (Header, Overview, Highlights, Currently,
Related). Edit copy in place.
- **Placeholders:** every `[Greg to fill]` gap from the spec is a styled
`Placeholder` slot with a TODO badge so you can find and replace them.
---
## Deployment (Cosmos homelab)
The site runs as a Node server inside a container. Full walkthrough in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md):
```bash
docker compose up -d --build
# Then point your reverse proxy at port 4321 with TLS for gregkilmer.com.
```
---
## Scripts
| Script | Description |
| ----------------- | ----------------------------------------------------- |
| `npm run dev` | Dev server with hot reload |
| `npm run build` | Type-check (`astro check`) then build the Node server |
| `npm run serve` | Run the built server (`dist/server/entry.mjs`) |
| `npm run preview` | Preview the build locally |
| `npm run check` | Type-check only |
---
## Tech stack
Astro 7, React 19, TypeScript 6, Tailwind CSS v4, Framer Motion, nodemailer,
Zod, `@iconify-json/mdi`, Fontsource variable fonts, `@astrojs/node` standalone.
---
## License
Personal site for Greg Kilmer. All rights reserved.
+8470
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "greg-kilmer-profile",
"version": "1.0.0",
"type": "module",
"private": true,
"description": "Personal profile / portfolio website for Greg Kilmer. Astro + React Islands + Node server.",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"serve": "node ./dist/server/entry.mjs",
"astro": "astro",
"check": "astro check",
"typecheck": "astro check && tsc --noEmit"
},
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "^7.0.2",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.2",
"@fontsource-variable/cinzel": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/montserrat": "^5.3.0",
"@iconify-json/mdi": "^1.2.3",
"@tailwindcss/vite": "^4.3.2",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^7.2.0",
"framer-motion": "^12.42.2",
"nodemailer": "^9.0.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"zod": "^4.4.3"
}
}

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 874 KiB

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 516 KiB

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Before

Width:  |  Height:  |  Size: 669 KiB

After

Width:  |  Height:  |  Size: 669 KiB

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Before

Width:  |  Height:  |  Size: 512 KiB

After

Width:  |  Height:  |  Size: 512 KiB

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 615 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Before

Width:  |  Height:  |  Size: 937 KiB

After

Width:  |  Height:  |  Size: 937 KiB

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 401 KiB

After

Width:  |  Height:  |  Size: 401 KiB

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 658 KiB

After

Width:  |  Height:  |  Size: 658 KiB

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 625 KiB

Before

Width:  |  Height:  |  Size: 874 KiB

After

Width:  |  Height:  |  Size: 874 KiB

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Before

Width:  |  Height:  |  Size: 665 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Some files were not shown because too many files have changed in this diff Show More