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
This commit is contained in:
Greg Kilmer
2026-10-11 20:51:47 -04:00
committed by GitHub
655 changed files with 38270 additions and 4048 deletions
+11 -15
View File
@@ -1,17 +1,13 @@
# Keep the Docker build context lean. **/node_modules
node_modules **/dist
dist **/.astro
.astro /data
services/*/data
out
.git .git
.gitignore
.env .env
.env.* **/.env
!.env.example **/.env.*
*.log !**/.env.example
npm-debug.log* legacy/node_modules
.DS_Store legacy/dist
.vscode
.idea
docs
*.md
!README.md
+24
View File
@@ -0,0 +1,24 @@
# Gitea Actions on Cosmos: Gitea mirrors GitHub, and this builds, checks and
# restarts the GregKilmer.com stack on each new commit to master.
# It runs on a host-mode runner on Cosmos labelled "cosmos" (docs/deploy.md).
name: Deploy GregKilmer.com
on:
push:
branches: [master]
# A pull mirror's sync may not raise a push event; this catches new commits
# anyway. deploy.sh does nothing when the commit is already running.
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
concurrency:
group: gregkilmer-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: cosmos
steps:
- uses: actions/checkout@v4
- run: ./deploy/deploy.sh
+12 -14
View File
@@ -1,30 +1,28 @@
# build output
dist/
.output/
# dependencies # dependencies
node_modules/ node_modules/
# generated types # migration output (it goes into the vault, not the repo)
.astro/ out/
# generated from the schema
packages/schema/json/
# environment # environment
.env .env
.env.* .env.*
!.env.example !.env.example
# logs # logs and editor files
npm-debug.log* npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# macOS / editor
.DS_Store .DS_Store
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/settings.json !.vscode/settings.json
.idea/ .idea/
data/
# misc # Astro build output and generated types
*.local packages/ui/dist/
packages/ui/.astro/
services/site/dist/
services/site/.astro/
+78 -38
View File
@@ -1,53 +1,93 @@
# ============================================================================= # Images for the GregKilmer.com stack on Cosmos. One file, one target per service:
# 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 # docker build --target check . runs `npm run check` (typecheck and tests)
# Stage 2 (runtime): copy only dist + prod node_modules, run the Node server # 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 # The Gitea Actions workflow builds check first, so a red check stops a deploy.
# runtime image needs only node_modules + dist. Build once, run anywhere. # See docs/deploy.md.
# =============================================================================
# ---------- Stage 1: build ---------- # Pinned by version (Node 22.20, released 2025-09). Override with --build-arg to
FROM node:22.12.0-alpine AS builder # 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 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. FROM manifests AS deps
COPY package.json package-lock.json* ./ RUN npm ci --no-audit --no-fund
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. # ---------- typecheck and tests ----------
FROM deps AS check
COPY . . COPY . .
ARG SITE_URL=https://gregkilmer.com RUN npm run check
ENV SITE_URL=$SITE_URL
RUN npm run build
# Prune to production dependencies for the runtime stage. # ---------- the site's server build ----------
RUN npm prune --omit=dev FROM deps AS site-build
COPY packages packages
COPY services/site services/site
RUN npm run build -w @gk/site
# ---------- Stage 2: runtime ---------- # ---------- each service's production dependencies only ----------
FROM node:20-alpine AS runtime 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 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. # ---------- content service ----------
RUN addgroup -S app && adduser -S app -G app 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. # ---------- admin page ----------
COPY --from=builder /app/node_modules ./node_modules FROM runtime AS admin
COPY --from=builder /app/dist ./dist COPY --from=admin-deps /app/node_modules node_modules
COPY --from=builder /app/package.json ./package.json 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. # ---------- public site ----------
ENV HOST=0.0.0.0 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 ENV PORT=4321
EXPOSE 4321 EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
USER app CMD wget -qO- http://127.0.0.1:4321/ >/dev/null || exit 1
USER node
# Healthcheck: the server responds 200 on /. CMD ["node", "services/site/dist/server/entry.mjs"]
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"]
+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 The site, rebuilt around Greg's Obsidian vault. Notes in the vault's Site
of the same house**: a polished professional resume/portfolio on one side, and a folder are the content; a content service checks every change and keeps the
warm, personality-driven interests hub on the other. 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** ## What is here
with API routes, ready to deploy as a container on a homelab Cosmos system.
--- | 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`, `.gitea` | The Cosmos stack: images, compose file, deploy and rollback scripts, and the Gitea Actions workflow. 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 ## Commands
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`.
--- Node 22 or newer.
## Quickstart ```sh
```bash
# 1. Install dependencies (Node 20+)
npm install 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 # the content service, against a copy of the Site folder
npm run dev SITE_DIR=out/24.01_PersonalWebsite/Site DATA_DIR=data \
# -> http://localhost:4321 CONTENT_TOKENS="reader:<24+ chars>,admin:<24+ chars>" npm run content
# 3. Production build (runs astro check + build) # the admin page, in a second terminal
npm run build ADMIN_TOKEN=<the admin token> npm run admin # http://localhost:4320
# 4. Run the built Node server
npm run serve
# -> http://localhost:4321
``` ```
Environment is optional for local dev. To enable contact-form delivery, copy ## The old site
`.env.example` to `.env` and fill in SMTP or webhook vars (see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)).
--- The live site still runs from the old code until cutover. It builds the same
way as before, from inside `legacy/`:
## Project structure
```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 (the Gitea Actions deploy
# 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
+61
View File
@@ -0,0 +1,61 @@
#!/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
# Gitea Actions workflow 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"
if ! compose up -d --remove-orphans --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"
+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)"
+9
View File
@@ -0,0 +1,9 @@
# Stack settings for compose.yaml. On Cosmos: /home/greg/homelab/gregkilmer/.env
# The Gitea Actions deploy rewrites GK_TAG on every build; 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
+50
View File
@@ -0,0 +1,50 @@
# 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. |
| 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 page sends a content security
policy that allows no scripts at all; it works entirely with plain forms.
## 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.
+364
View File
@@ -0,0 +1,364 @@
# 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. |
| `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 (**unverified API**, below) | `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: unverified.** Its API is not documented anywhere this project
has, and no request has been made to one. The endpoint is
`BAMBUDDY_SPOOLS_PATH` (default `/api/v1/inventory/spools`, a guess), and all
knowledge of the response is in `mapBambuddySpools` in
`sync/jobs/bambuddy.ts`: it accepts a list (or one under `items`, `spools`,
`data`, `results`) and reads color name, hex, material and grams left under
common spellings. Check it against Greg's Bambuddy and fix that one function
before relying on it. A color is in stock when some spool of it has at least
`BAMBUDDY_MIN_GRAMS` (default 50) left, or does not say.
- **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`, unverified). |
| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | Sent as `Authorization: Bearer <key>`, or as the raw key 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.
+114
View File
@@ -0,0 +1,114 @@
# 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.
- `.gitea/workflows/deploy.yml`: runs `deploy.sh` on Cosmos for each new commit to master.
## On Cosmos
Everything lives in `/home/greg/homelab/gregkilmer`:
```
/home/greg/homelab/gregkilmer/
compose.yaml copied from deploy/compose.yaml by every deploy
.env GK_TAG, GK_PREVIOUS_TAG, VAULT_SITE, PUID, PGID
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. **Folder and settings.** Run these from a checkout of the repo:
```sh
mkdir -p /home/greg/homelab/gregkilmer/data/content
cd /home/greg/homelab/gregkilmer
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. **Gitea runner.** The workflow needs an `act_runner` on Cosmos with the label `cosmos` running in host mode (`cosmos:host`). It runs as a user in the `docker` group with write access to `/home/greg/homelab/gregkilmer`.
- A runner with the Docker socket is root-equivalent. Register it for this repository only, not for the whole instance.
5. **Gitea mirror.** Mirror `github.com/Patiun/com.gregkilmer.www` into Gitea as a pull mirror and turn Actions on for it.
- A pull mirror's sync may not raise a push event. The workflow therefore also runs every 15 minutes and deploys only when master has moved.
- Check once that a new commit reaches Cosmos. If the schedule doesn't fire on a mirror either, run the workflow by hand from the Actions tab.
6. **First deploy.** Run it from the Actions tab, or by hand from a checkout: `./deploy/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`.
7. **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`.
8. **Turnstile.** Add `beta.gregkilmer.com` and `gregkilmer.com` to the widget's hostnames.
9. **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).
10. **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; Gitea mirrors it and the workflow deploys it.
- **Roll back:** `GK_STACK_DIR=/home/greg/homelab/gregkilmer ./deploy/rollback.sh` from a checkout, 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`.
+150
View File
@@ -0,0 +1,150 @@
# 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), uses the brand TTFs in
`src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change.
The highlights row and Selected projects come from the `/resume` Page text note's
`stats` and `projects`. Greg's Resume Interview answers live in
`tools/migrate/src/seed.ts`.
## Where each page lives
| Page | File | Helpers |
| --- | --- | --- |
| Landing, About, Now | `pages/index.astro`, `about.astro`, `now.astro` | `lib/home.ts`, `components/home` |
| Resume and PDF | `pages/resume.astro`, `resume.pdf.ts` | `lib/resume.ts`, `lib/resume-pdf.ts` |
| Work, case studies | `pages/work/` | `lib/work.ts`, `components/work`, `components/blog/PostLayout` |
| Inquiries, sent, 404 | `pages/inquiries/`, `404.astro` | `lib/work.ts` |
| Library, Shelf | `pages/library/` | `lib/library.ts`, `components/library` |
| Workshop, crafts, Tabletop | `pages/workshop/`, `tabletop.astro` | `lib/world.ts`, `components/world` |
| Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` |
| Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` |
## Forms
The Inquiries form posts to `POST /api/inquiries` and the order form to
`POST /api/orders` (`src/pages/api/`). Both are plain multipart posts that
answer with a 303 redirect, so they work without JavaScript. The rules are
plain functions in `src/lib/forms.ts` (tested in `services/site/test`); the
network calls are in `src/lib/forms-server.ts`.
1. A filled honeypot field (`leave_blank`) gets the confirmation page and nothing else.
2. The answers are checked. Inquiries take only the chosen type's `job_*`,
`freelance_*` or `patiun_*` fields; others are ignored.
3. A per-address rate limit, then Cloudflare Turnstile when
`TURNSTILE_SECRET_KEY` is set (a missing or bad token, or Cloudflare being
unreachable, turns the post down).
4. Orders only: the line is checked against the live store notes, fetched
from the content service then and there. An order goes through only for a
line sold here, in a category with `ordersOpen: true`, with an open slot
(made to order) or stock, and with in-stock options. Otherwise it is
turned down, whatever was posted.
5. The copy is saved through the content service with `CONTENT_INTAKE_TOKEN`,
then emailed (SMTP via nodemailer) to `CONTACT_TO` or `ORDER_TO`, and the
email's result is recorded on the copy. A failed email still shows the
visitor the confirmation; the admin Inbox flags it. If saving fails the
email is still tried; only when neither works does the form show an error.
6. Success redirects to `/inquiries/sent` (cookie `gk-inquiry`) or
`/store/order/sent` (cookie `gk-order`): URL-encoded JSON, httpOnly,
SameSite Lax, ten minutes, secure in production. A turned-down post goes
back to the form with `?error=<code>` and a `gk-inquiry-draft` or
`gk-order-draft` cookie holding the reason, the marked fields and the
answers (an attachment has to be added again).
Settings are in `services/site/.env.example`: `CONTENT_INTAKE_TOKEN`,
`PUBLIC_TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, `SMTP_HOST`, `SMTP_PORT`,
`SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_ALLOW_SELFSIGNED`, `CONTACT_TO`,
`ORDER_TO`, `CONTACT_RATE_LIMIT`, `CONTACT_RATE_WINDOW_MIN`, `ORDER_RATE_LIMIT`,
`ORDER_RATE_WINDOW_MIN`, `FORM_MAX_ATTACHMENT_MB`. The Turnstile widget needs
JavaScript; with the secret set, a visitor without it cannot send a form.
+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

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

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