Compare commits
32 Commits
v0.1.0
..
5077aeacfd
| Author | SHA1 | Date | |
|---|---|---|---|
| 5077aeacfd | |||
| 10a7d0a878 | |||
| 5442a8d888 | |||
| 9390a7cbc0 | |||
| a9fb5b3e85 | |||
| 151bed230a | |||
| 3f6167455d | |||
| 449ffcd62d | |||
| bd97720be1 | |||
| 5c3ab14ba3 | |||
| a945bb08cc | |||
| 10f206c756 | |||
| b1f49c63b1 | |||
| 7706afbdac | |||
| dcfd97e823 | |||
| 79f1c26426 | |||
| 754c6482be | |||
| f9a8fb1acd | |||
| e3b0cf60c5 | |||
| 60dfeecd7f | |||
| 0d0b8e4085 | |||
| a834451def | |||
| b1aec13eba | |||
| 963ad0df23 | |||
| 99ecdc2113 | |||
| 7025e95f89 | |||
| 1023882e19 | |||
| 4c3ff528db | |||
| 807528a613 | |||
| 32cf13a5cb | |||
| 3f646048a2 | |||
| a2f723ca2d |
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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=
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |