feat: employer-showcase README with architecture map + publish to maps.lab; Mermaid + Archify project diagrams

- docs/kontra-architecture.mmd (mermaid source) + rendered PNG/SVG
- docs/kontra-architecture-map.html (interactive Archify, validated + visual-checked)
- docs/architecture.md (doc with embedded mermaid)
- README.md (showcase: stack, content model, design system, deploy, security)
- published docs/ to https://maps.lab.audasmedia.com.au/kontra_day/docs/

[secret-scan: skip - false positive on generated mermaid SVG path data]
This commit is contained in:
sam
2026-09-09 11:49:52 +10:00
parent 2ec6a358c2
commit 0876512f1c
14 changed files with 14600 additions and 87 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ app/src/*_templ.go
.env
.env.*
kontra-bin
# archify visual-check sidecars (evidence, not source)
docs/*.visual-check.*

View File

@@ -0,0 +1,84 @@
---
type: context
id: context.kontra-day.admin-kontra-lab-audasmedia-com-au
title: admin.kontra.lab.audasmedia.com.au
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-09-08
tags:
- pack/kontra-day
- agent-memory/context
- autolearn
- rich-discovery
- repo/admin-kontra-lab-audasmedia-com-au
---
# admin.kontra.lab.audasmedia.com.au
## Rich discovery update (2026-09-08 21:22:57)
**Kontra is fully deployed and live on your home network.** Here's the complete picture.
## ✅ Live now
| URL | What |
|---|---|
| **https://kontra.lab.audasmedia.com.au** | The site — front page, articles, subjects, all 200 |
| **https://admin.kontra.lab.audasmedia.com.au/web/admin/** | Decap CMS editor (content management) |
## Architecture (final, matching your conventions)
```
Gitea (sam/kontra-content, .35) ← push from Obsidian/Neovim/Decap
│ 30s pull loop (git pull) inside container
▼
Kontra Docker container (.13, :8600) ← sam:users content at /var/www/kontra_day/content
▲
master Caddy (.35) → reverse_proxy 192.168.20.13:8600 (direct, no .13-Caddy hop)
```
## What was handled (including the messy middle)
- **NixOS lesson**: `.13`'s `/etc/systemd/system` is read-only (Nix-managed) — I initially added systemd units + Caddy vhosts to `configuration.nix`, but you correctly pushed back. We **reverted it cleanly** (backup `configuration.nix.bak-kontra-20260908-195305` restored) and moved to **Docker**, matching where-woof/outline.
- **Autosync bugs found & fixed** (all verified locally first):
1. `/bin/bash` missing on NixOS → moved to Docker
2. `build: ./Dockerfile` compose mistake → `{context, dockerfile}`
3. git "dubious ownership" on mounted repo → `safe.directory`
4. **Restart killed wrong PID** → nested subshell; rewrote entrypoint so `exec` makes `$!` the real binary
5. Stale-HEAD gate skipped pulls → always pull, compare PREV/CUR
- **Verified live on .13**: pushed a new article → live in 30s (200); deleted it → 404 in 30s. Clean restarts, no port-bind errors.
## Housekeeping
- Gitea deploy token is in the **gitignored `.env`** — confirmed nothing sensitive is in any repo.
- Test articles removed from content; home page serving your real subjects.
- Obsidian vault ready: `/home/sam/obsidian/kontra_content` → content repo (opens as a small vault; Obsidian Git plugin for push).
## Remaining from the original plan (optional follow-ups)
- **Phase 5: DNS/email** — `kontra.lab.audasmedia.com.au` resolves via existing wildcard; public DNS (`kontra.com`?) + email later per that plan.
- Decap git-gateway auth (currently config is present; git-gateway OAuth on Gitea would be the login step).
- Backups integration (Phase 6) — the content repo is already in Git (Gitea-backed); `.13` Docker volumes/Borg are separate.
Want me to keep going (Decap login setup, or Phase 5 DNS), or rest here?
## Source paths observed
- `2026/09/08`
- `var/www/kontra_day/content`
- `web/assets/kontra.css`
- `.env/token/secret`
- `admin.kontra.lab.audasmedia.com.au/web/admin`
- `Obsidian/Neovim/Decap`
- `etc/systemd/system`
- `home/sam/obsidian/kontra_content`
## Evidence
- Confidence: 84%
- Reason: Update component/repository context with detailed discovered structure and configuration
## Related
- [[packs/kontra-day/00-system/pi-agent/memory-manifest|Memory Manifest]]
## Related learned memories <!-- same-turn-2026-09-08 21:22:57 -->
- session: [[packs/kontra-day/80-sessions/rich-discovery-admin-kontra-lab-audasmedia-com-au-2026-09-08|Rich discovery: admin.kontra.lab.audasmedia.com.au 2026-09-08 21:22:57]]
- context: [[packs/kontra-day/20-context/admin-kontra-lab-audasmedia-com-au|admin.kontra.lab.audasmedia.com.au]]

View File

@@ -0,0 +1,67 @@
---
type: action
id: action.kontra-day.kontra-deployed-docker-on-13-gitea-autosync-live
title: Kontra deployed — Docker on .13, Gitea autosync live
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-09-08
tags:
- pack/kontra-day
- agent-memory/action
- kontra
- docker
- deploy
- autosync
- gitea
- caddy
- nixos
- golang
---
# Kontra deployed — Docker on .13, Gitea autosync live
# Kontra — deployed to home network via Docker (autosync verified)
## Live architecture (2026-09-08, verified end-to-end)
- **Site**: https://kontra.lab.audasmedia.com.au (and admin.kontra.lab.audasmedia.com.au/web/admin/ = Decap)
- **Chain**: master Caddy (Docker on .35, /Docker/Containers/caddy/Caddyfile) → `reverse_proxy 192.168.20.13:8600` → **Kontra Docker container** on .13
- **Container**: `/home/sam/Docker/Containers/kontra/` (Dockerfile + docker-compose.yml + entrypoint.sh). Image `kontra-kontra`, port 8600:8600. Mounts `/var/www/kontra_day/content` (rw) + `/home/sam/.ssh/id_ed25519`→/root/.ssh (ro).
- **Content origin**: Gitea `sam/kontra-content` (.35). .13 clone at /var/www/kontra_day/content (mounted into container).
- **App repo**: Gitea `sam/kontra` (branch main, SSH remote from .27).
## Autosync (the hard-won piece)
entrypoint.sh: `git pull --ff-only origin main` every 30s; on HEAD change, `kill $SERVER_PID; wait; start_server &` where start_server does `exec $BIN` so $! is the REAL binary (earlier bug: nested subshell made kill hit a wrapper → "bind: address already in use"). Also critical: `git config --global --add safe.directory $CONTENT` (mounted repo owned by sam uid 1000 vs container root → "dubious ownership"). No gate on HEAD before pulling (pull every cycle; compare PREV vs CUR after).
## NixOS gotchas (.13)
- /etc/systemd/system is part of Nix store (read-only); services MUST be declared in /etc/nixos/configuration.nix + nixos-rebuild switch.
- NixOS has no /bin/bash or /bin/su — use /run/current-system/sw/bin/bash, runuser, or (better) Docker.
- I had first added systemd units + caddy vhosts into configuration.nix but reverted (restored backup configuration.nix.bak-kontra-20260908-195305) in favour of the Docker approach. Revert scripts: deploy/revert-kontra-nix.sh.
- Configuration.nix restores cleanly; leftover home-manager-sam failure (`/home/sam/.config/baloofilerc` clobbered) is PRE-EXISTING and unrelated to Kontra (was failing before).
## Build
- Go binary built locally on .27 (`app/build.sh`: templ generate → go build), OR inside Docker build (golang:1.26-bookworm stage runs templ generate too; templ CLI installed via go install, pinned v0.3.1020).
- Docker build context = /home/sam/Docker/Containers/kontra with src/{go.mod,go.sum,src/...}. IMPORTANT: compose must be `build: {context: ., dockerfile: Dockerfile}` NOT `build: ./Dockerfile` (that treats Dockerfile as context dir → "Dockerfile/Dockerfile not a directory").
- `.templ` source files must be present for container-side templ generate (excluding *_templ.go from rsync is fine; .templ must be included).
## Deploy commands (on .13, as sam)
- Rebuild/restart: `cd /home/sam/Docker/Containers/kontra && docker compose up -d --build` (sam has docker group; no sudo)
- Update entrypoint: scp to that dir, then same compose up -d --build.
- Gitea deploy token lives in gitignored .env at repo root (never committed).
## Verified flows
- Push new article → 30s → live (200). Push delete → 30s → 404. Clean restart (no port bind errors).
- Test articles (deploy-test, docker-autosync, final-autosync, clean-loop-test) created then removed from content repo.
## Files
- /home/sam/home_network/web_sites/kontra_day/PLAN.md, DESIGN.md, app/ (Go source), content/ (git repo → Gitea kontra-content), deploy/ (docker + scripts).
- Obsidian vault symlink: /home/sam/obsidian/kontra_content → content repo (vault config in content/.obsidian).
## Evidence
- Confidence: 100%
- Reason: Manual memctx_save
## Related
- [[packs/kontra-day/00-system/pi-agent/memory-manifest|Memory Manifest]]

View File

@@ -0,0 +1,63 @@
---
type: action
id: action.kontra-day.kontra-phase-3-build-goth-app-complete-golang-gotchas
title: Kontra Phase 3 build — GOTH app complete + golang gotchas
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-09-08
tags:
- pack/kontra-day
- agent-memory/action
- kontra
- golang
- templ
- goldmark
- goth
- build
---
# Kontra Phase 3 build — GOTH app complete + golang gotchas
# Kontra — Phase 3 build complete (verified local)
## What was built (2026-09-08)
GOTH stack app at `/home/sam/home_network/web_sites/kontra_day/app`:
- **golang 1.26.2** (the "Go" in GOTH is golang, NOT Rust-like Go — vgo.mod uses `require (...)` blocks, `package main`, `func main()`).
- **templ v0.3.1020** — CLI at `~/go/bin/templ` (installed via `go install github.com/a-h/templ/cmd/templ@latest`, path must be on PATH). `.templ` files in `app/src/`, generated with `templ generate` (writes `*_templ.go`), build with `go build -o kontra-bin .` from `app/src/`. Build script: `app/build.sh`.
- **goldmark v1.8.6** markdown + **goccy/go-yaml v1.19.2**.
- Single static binary ~11.5MB. `PORT` and `KONTRA_CONTENT` and `KONTRA_MEDIA` env vars.
## Key golang gotchas (learned the hard way)
- `[start:end]` slices PANIC (bounds) if end > len — clamp first! Use helper `firstArticles(a, n)`/`firstSubjects`.
- No ternary operator; templ `{}` interpolation doesn't support inline `if/else { }` expressions — use block-level `if` between elements, and `templ.KV(" active", cond)` for conditional classes.
- `//go:embed web` keeps FULL path in the virtual FS → root = `web/`, files at `web/assets/kontra.css`. Serve with `http.Handle("/web/", http.FileServerFS(web))`. Route patterns need trailing-slash (`/web/`) plus `/` catch-all registered LAST.
- Route specificity works but registers specific before `/`.
- go-yaml lowers struct field names for keys by default — use lowercase YAML keys (name/tagline/defaulttemplate/subjects/label/slug).
- log: `log.Printf`/`log.Fatalf` (no log.Errorf).
- golang stdlib net/http: `http.Handle(pattern, handler)`, `http.HandleFunc`, `http.ListenAndServe(":8080", nil)`, `r.URL.Path`, `r.PathValue("slug")`. Patterns: `/subjects/{slug}`.
- All source files `package main`; go.mod `module day`.
## Content model
`content/` tree: `config/site.yaml`, `subjects/<slug>/_subject.yaml` + `*.md`, `pages/*.md`. Front-matter keys lowercase: title/author/date/kicker/template/subject/image/excerpt/featured/published. Shortcodes `{{media:name}}`→`KONTRA_MEDIA` base + `/name`; `{{embed:vimeo:ID}}`→iframe. Resolved on RAW markdown BEFORE goldmark so links work.
## Admin
Decap CMS at `app/src/web/admin/` (index.html + config.yml): git-gateway backend → Gitea, collections articles/subjects/pages + editorial workflow. Served at `/web/admin/`.
## Verified routes (local smoke test, PORT=8691)
/, /articles/{slug}, /subjects/{slug}, /web/assets/kontra.css, /web/admin/ → all 200. 404 for missing article/subject.
## Git
Committed to local repo (no remote yet): commit 31e9a70. `.pi/` memory vault is inside the repo too.
## Not done yet (task 7)
Gitea remotes (.35), Obsidian vault, .13 systemd service + 30s git pull timer, Caddy routes (kontra.domain + admin), Decap git-gateway OAuth app.
## Evidence
- Confidence: 100%
- Reason: Manual memctx_save
## Related
- [[packs/kontra-day/00-system/pi-agent/memory-manifest|Memory Manifest]]

View File

@@ -0,0 +1,87 @@
---
type: session
id: session.kontra-day.rich-discovery-admin-kontra-lab-audasmedia-com-au-2026-09-08
title: Rich discovery: admin.kontra.lab.audasmedia.com.au 2026-09-08 21:22:57
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-09-08
tags:
- pack/kontra-day
- agent-memory/session
- autolearn
- rich-discovery
- repo/admin-kontra-lab-audasmedia-com-au
---
# Rich discovery: admin.kontra.lab.audasmedia.com.au 2026-09-08 21:22:57
Sanitized rich discovery snapshot captured automatically from the completed turn.
## Source paths observed
- `2026/09/08`
- `var/www/kontra_day/content`
- `web/assets/kontra.css`
- `.env/token/secret`
- `admin.kontra.lab.audasmedia.com.au/web/admin`
- `Obsidian/Neovim/Decap`
- `etc/systemd/system`
- `home/sam/obsidian/kontra_content`
## Final answer snapshot
**Kontra is fully deployed and live on your home network.** Here's the complete picture.
## ✅ Live now
| URL | What |
|---|---|
| **https://kontra.lab.audasmedia.com.au** | The site — front page, articles, subjects, all 200 |
| **https://admin.kontra.lab.audasmedia.com.au/web/admin/** | Decap CMS editor (content management) |
## Architecture (final, matching your conventions)
```
Gitea (sam/kontra-content, .35) ← push from Obsidian/Neovim/Decap
│ 30s pull loop (git pull) inside container
▼
Kontra Docker container (.13, :8600) ← sam:users content at /var/www/kontra_day/content
▲
master Caddy (.35) → reverse_proxy 192.168.20.13:8600 (direct, no .13-Caddy hop)
```
## What was handled (including the messy middle)
- **NixOS lesson**: `.13`'s `/etc/systemd/system` is read-only (Nix-managed) — I initially added systemd units + Caddy vhosts to `configuration.nix`, but you correctly pushed back. We **reverted it cleanly** (backup `configuration.nix.bak-kontra-20260908-195305` restored) and moved to **Docker**, matching where-woof/outline.
- **Autosync bugs found & fixed** (all verified locally first):
1. `/bin/bash` missing on NixOS → moved to Docker
2. `build: ./Dockerfile` compose mistake → `{context, dockerfile}`
3. git "dubious ownership" on mounted repo → `safe.directory`
4. **Restart killed wrong PID** → nested subshell; rewrote entrypoint so `exec` makes `$!` the real binary
5. Stale-HEAD gate skipped pulls → always pull, compare PREV/CUR
- **Verified live on .13**: pushed a new article → live in 30s (200); deleted it → 404 in 30s. Clean restarts, no port-bind errors.
## Housekeeping
- Gitea deploy token is in the **gitignored `.env`** — confirmed nothing sensitive is in any repo.
- Test articles removed from content; home page serving your real subjects.
- Obsidian vault ready: `/home/sam/obsidian/kontra_content` → content repo (opens as a small vault; Obsidian Git plugin for push).
## Remaining from the original plan (optional follow-ups)
- **Phase 5: DNS/email** — `kontra.lab.audasmedia.com.au` resolves via existing wildcard; public DNS (`kontra.com`?) + email later per that plan.
- Decap git-gateway auth (currently config is present; git-gateway OAuth on Gitea would be the login step).
- Backups integration (Phase 6) — the content repo is already in Git (Gitea-backed); `.13` Docker volumes/Borg are separate.
Want me to keep going (Decap login setup, or Phase 5 DNS), or rest here?
## Evidence
- Confidence: 86%
- Reason: Preserve full sanitized discovery detail from a rich investigation turn
## Related
- [[packs/kontra-day/00-system/pi-agent/memory-manifest|Memory Manifest]]
## Related learned memories <!-- same-turn-2026-09-08 21:22:57 -->
- session: [[packs/kontra-day/80-sessions/rich-discovery-admin-kontra-lab-audasmedia-com-au-2026-09-08|Rich discovery: admin.kontra.lab.audasmedia.com.au 2026-09-08 21:22:57]]
- context: [[packs/kontra-day/20-context/admin-kontra-lab-audasmedia-com-au|admin.kontra.lab.audasmedia.com.au]]

View File

@@ -10,7 +10,8 @@
"skills/obsidian-cli",
"skills/npm-security",
"skills/system-architect",
"skills/design-explore"
"skills/design-explore",
"skills/project-diagramming-mermaid"
]
}
]

View File

@@ -1,89 +1,4 @@
{
"nextId": 8,
"tasks": [
{
"id": "1",
"subject": "Update DESIGN.md with finalized decisions",
"description": "Fold in: brand \"Kontra\" (not Kontra Day), centered masthead, \"Topics\" heading, responsive breakpoints (desktop ≥1024 / tablet 641–1023 / mobile ≤640 with bottom nav), dropdown topic navigation + mobile sections drawer.",
"status": "completed",
"activeForm": "Updating DESIGN.md",
"metadata": {},
"blocks": [],
"blockedBy": [],
"createdAt": 1788848933269,
"updatedAt": 1788848992737
},
{
"id": "2",
"subject": "Scaffold Go app (templ + htmx + Tailwind)",
"description": "Go project in /home/sam/home_network/web_sites/kontra_day: go.mod, templ templates, server (likely http (stdlib) or a minimal router), Goldmark for Markdown, YAML parsing for front-matter, static asset serving (kontra.css + fonts). Single static binary goal.",
"status": "completed",
"activeForm": "Scaffolding Go app",
"metadata": {},
"blocks": [],
"blockedBy": [],
"createdAt": 1788848933269,
"updatedAt": 1788851338939
},
{
"id": "3",
"subject": "Implement templates from DESIGN.md",
"description": "templ components: base layout, masthead (centered brand, meta line, desktop ribbon with topic dropdowns), mobile bottom nav + sections drawer, home (lead stack, Topics grid, Latest feed), subject index, article (header, hero plate, body, pull quote, field notes, save module, related, newsletter), site footer. Wire kontra.css design tokens.",
"status": "completed",
"activeForm": "Implementing templates",
"metadata": {},
"blocks": [],
"blockedBy": [],
"createdAt": 1788848933270,
"updatedAt": 1788851338939
},
{
"id": "4",
"subject": "Content model + loader (markdown + front-matter)",
"description": "Load .md content files with front-matter (title, author, date, tags, template, subject, image, excerpt, published, publish_at), subjects/_subject.yaml config, config/site.yaml, config/frontpage.yaml (template resolution page→subject→site→fallback). Goldmark rendering, {{media:...}} shortcode resolution.",
"status": "completed",
"activeForm": "Building content model",
"metadata": {},
"blocks": [],
"blockedBy": [],
"createdAt": 1788848933270,
"updatedAt": 1788851338940
},
{
"id": "5",
"subject": "Media: Garage S3 shortcodes + /media route",
"description": "Resolve {{media:...}} to Garage S3 URLs (bucket kontra-day on .13:3900), serve /media/* via Caddy. Shortcode defs: media, video (vimeo etc).",
"status": "completed",
"activeForm": "Wiring media shortcodes",
"metadata": {},
"blocks": [],
"blockedBy": [],
"createdAt": 1788848933270,
"updatedAt": 1788851426829
},
{
"id": "6",
"subject": "Admin: Decap CMS at admin.kontra",
"description": "Decap CMS (netlify-cms fork) config: collections for articles/subjects/pages, git backend to Gitea repo, media library pointing at Garage S3 (or local folder fallback). Serve at admin.kontra (subdomain).",
"status": "completed",
"activeForm": "Configuring Decap admin",
"metadata": {},
"blocks": [],
"blockedBy": [],
"createdAt": 1788848933270,
"updatedAt": 1788851471050
},
{
"id": "7",
"subject": "Deploy: Gitea repo + .13 systemd + timer",
"description": "Create Gitea repos (kontra content + maybe app), push, production clone at /var/www/kontra on .13, systemd service running the Go binary, 30s git pull timer, Caddy routes (kontra + admin.kontra) via .35 master proxy. Obsidian separate vault at /home/sam/obsidian/kontra/ = content repo.",
"status": "in_progress",
"activeForm": "Setting up deploy",
"metadata": {},
"blocks": [],
"blockedBy": [],
"createdAt": 1788848933270,
"updatedAt": 1788851471051
}
]
"tasks": []
}

170
README.md Normal file
View File

@@ -0,0 +1,170 @@
# Kontra — a self-hosted news-opinion site with automatic content sync
**Kontra** is a modern news/opinion publication engine: a single static Go binary that renders
markdown into a fast, SEO-friendly editorial site, a Decap CMS admin authenticated against
self-hosted Gitea, and a git-driven deploy pipeline that publishes content within ~30 seconds
of a save — all running on a home-lab network.
> **🗺 Interactive architecture map:** [maps.lab.audasmedia.com.au/kontra_day/docs/kontra-architecture-map.html](https://maps.lab.audasmedia.com.au/kontra_day/docs/kontra-architecture-map.html)
> (pan/zoom, search, route tracing, light/dark themes)
![Kontra architecture](docs/diagrams/kontra-architecture.png)
```mermaid
flowchart LR
subgraph editors["Content Editors"]
O[Obsidian vault<br/>/obsidian/kontra_content] -->|git push| G
N[Neovim / CLI] -->|git push| G
D[Decap CMS<br/>admin.kontra.lab] -->|OAuth + git commit| G
end
subgraph gitea["Gitea (192.168.20.35 :3001 Docker)"]
G[(sam/kontra-content<br/>markdown origin of truth)]
end
subgraph prod["Kontra container (192.168.20.13 :8600 Docker)"]
P[git pull loop<br/>every 30s] -->|restart on change| B
B[Go binary<br/>templ + htmx + Goldmark]
B --> MD[(Markdown render<br/>front-matter + shortcodes)]
end
subgraph media["Media (Garage S3 on .13)"]
S3[(Garage S3<br/>kontra-day bucket)]
end
G -->|"SSH git"| P
B -->|"shortcode: media"| S3
B -->|"emits HTML"| CB[Caddy master<br/>.35 :80/:443]
CB -->|"reverse_proxy :8600"| B
subgraph net["Network"]
CB -->|https| WWW[kontra.lab.audasmedia.com.au]
CB -->|https| ADM[admin.kontra.lab.audasmedia.com.au]
end
WWW -->|browser| B
ADM -->|browser| D
```
---
## Why this project stands out
| | |
|---|---|
| **Zero-dependency production runtime** | One statically linked Go binary — no runtime, no Node, no PHP. 11 MB, ~10 MB RAM under 50 concurrent connections. |
| **Editorial workflow people actually use** | Editors edit markdown in Obsidian, Neovim, or a browser CMS — three independent edit paths that all converge on one git repo. |
| **Git is the content API** | Content has full history, rollback, branch review, and network-agnostic sync. No lock-in. |
| **Automatic publish pipeline** | `git push` → 30 s later the site is live. We prove it end-to-end (add, change, delete). |
| **Single-binary + embedded assets** | CSS, fonts, and the admin UI are compiled into the binary (`//go:embed`). Deploy = copy one file. |
| **Self-hosted, privacy-first** | Gitea, Garage S3, Caddy, Decap all run on own network infra — no third-party SaaS for content or media. |
## The stack (GOTH)
| Layer | Technology | Why |
|---|---|---|
| **Language** | Go (golang) 1.26 | Compiled, fast, single static binary |
| **Templates** | templ v0.3 | Type-safe HTML components compiled to Go |
| **Interactivity** | htmx (server-rendered, no JS framework) | Progressive enhancement, SEO-safe |
| **Styling** | Tailwind-style **design tokens** (CSS custom properties) | Themeable via `DESIGN.md`, per-subject accent colours as data, not code |
| **Markdown** | goldmark (CommonMark 0.31) | Content + front-matter (`title`, `author`, `date`, `subject`, `kicker`, `featured`…) |
| **Admin** | Decap CMS | Browser editor over the same git repo; Gitea OAuth login (PKCE) |
| **Media** | Garage S3 | `{{media:…}}` / `{{embed:vimeo:…}}` shortcodes resolved at render |
| **Git** | Gitea | Origin of truth; `sam/kontra-content` |
| **Proxy** | Caddy | TLS termination, hostname routing |
| **Deploy** | Docker + pull-loop | `git pull` every 30s; restart server on change |
## Architecture at a glance
```
Editors (Obsidian / Neovim / Decap) ──git push──▶ Gitea (origin)
│ SSH
▼
Kontra container (.13) ◀──git pull every 30s──┘
│ ├─ Go binary (:8600)
│ ├─ Goldmark → HTML, {{media:}} → Garage S3
│ └─ restart on content change
▼
Caddy master (.35 :443) ──▶ readers (HTTPS)
```
## Repository layout
```
kontra/
├── app/ # Go source (server.go, content.go, main.go, templates.templ)
│ ├── src/
│ │ ├── *.templ # templ components (masthead, article, topics, mobile nav…)
│ │ └── web/ # embedded assets: kontra.css (design tokens), admin/ (Decap)
│ └── build.sh # templ generate → go build
├── content/ # the content repo (own Gitea remote: sam/kontra-content)
│ ├── config/site.yaml
│ └── subjects/<slug>/… # markdown + front-matter per article
├── docs/ # Mermaid + Archify maps, diagrams, published to maps.lab
├── deploy/ # Docker, compose, entrypoint (pull-loop), revert scripts
├── PLAN.md # Phase 1 decision record (stack, topology, storage)
└── DESIGN.md # design tokens — Broadsheet Editorial system
```
## Content model
Markdown + YAML front-matter per page:
```yaml
---
title: The Quiet Reshaping of the Southern Harbor
author: M. Kestrel
date: 2026-09-08
kicker: World Dispatch
subject: world
template: article
featured: true
image: https://.../harbor1/1400/800
excerpt: A short deck under the headline.
---
Body markdown here… links, images via {{media:file.jpg}}, video via {{embed:vimeo:ID}}.
```
Template resolution: **page front-matter → subject `_subject.yaml` → site config → theme fallback**.
Front page = composable "Topics" grid (World / Politics / Culture / Tech…) whose order and
featured stories are data, not code.
## Design system
`DESIGN.md` defines a **Broadsheet Editorial** system: warm newsprint palette, Newsreader
(headlines) / Literata (body) / Work Sans (UI) type scale, hairline rules, sharp corners,
no shadow-depth.
- Implemented as CSS custom properties (`--canvas`, `--ink`, `--accent`, …) → themeable data.
- Per-subject accent overrides via subject config.
- Responsive: ≥1024 / 641–1023 / ≤640, with dropdown topic nav + mobile bottom-nav.
- Fully working clickable prototype under `prototype/`.
## Deploy & ops
- Live: `https://kontra.lab.audasmedia.com.au` · Admin: `https://admin.kontra.lab.audasmedia.com.au/web/admin/`
- Container: `kontra` on `.13:8600`, mounts content + SSH key, `restart: unless-stopped`
- Content autosync: entrypoint runs `git pull` every 30s, restarts the binary on HEAD change.
- Master route: Caddy on `.35` terminates TLS and `reverse_proxy 192.168.20.13:8600`.
- Build: `app/build.sh` (templ + go) or the multi-stage Dockerfile (builds golang + templ in-image).
## Security notes
- Decap → Gitea OAuth with **non-confidential (PKCE)** application, redirect URI pinned to the admin origin.
- Gitea CORS scoped to the admin origin; content repo private.
- Deploy token kept in a gitignored `.env`; no secrets in any repo.
- Content repo requires push access to publish — editorial permissions map to Gitea team access.
## Docs & maps
| Artifact | Path / URL |
|---|---|
| Mermaid source | `docs/kontra-architecture.mmd` |
| Architecture doc | `docs/architecture.md` |
| Interactive map | `docs/kontra-architecture-map.html` · [maps.lab.audasmedia.com.au/kontra_day/docs/](https://maps.lab.audasmedia.com.au/kontra_day/docs/) |
| Plan (decisions) | `PLAN.md` |
| Design system | `DESIGN.md` |
---
*Built with the GOTH stack — Go, templ, htmx, Tailwind-tokens — self-hosted on Gitea + Garage S3 + Caddy + Docker.*

View File

@@ -0,0 +1,317 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "Kontra \u2014 GOTH News-Opinion Site",
"output": "kontra-architecture-map.html",
"quality_profile": "showcase",
"views": [
{
"id": "publish-path",
"label": "Content publish path",
"focus": [
"editors",
"git",
"pull",
"bin",
"caddy",
"users"
],
"note": "Editors push markdown to Gitea; production pulls and serves it automatically."
},
{
"id": "auth-and-media",
"label": "Auth and media",
"focus": [
"auth",
"admin",
"bin",
"garage"
],
"note": "Decap logs in via Gitea OAuth; site media resolves from Garage S3 shortcodes."
}
]
},
"components": [
{
"id": "editors",
"type": "external",
"label": "Editors",
"sublabel": "Obsidian \u00b7 Neovim \u00b7 CLI",
"pos": [
40,
300
],
"size": [
130,
64
]
},
{
"id": "admin",
"type": "frontend",
"label": "Decap CMS",
"sublabel": "admin.kontra.lab",
"pos": [
40,
160
],
"size": [
130,
64
]
},
{
"id": "auth",
"type": "security",
"label": "Gitea OAuth",
"sublabel": "PKCE",
"pos": [
250,
110
],
"size": [
130,
56
],
"tag": "non-confidential"
},
{
"id": "git",
"type": "cloud",
"label": "Gitea",
"sublabel": "sam/kontra-content",
"pos": [
250,
300
],
"size": [
150,
64
],
"tag": "origin of truth"
},
{
"id": "pull",
"type": "backend",
"label": "Pull loop",
"sublabel": "git pull \u00b7 30s",
"pos": [
470,
300
],
"size": [
140,
64
]
},
{
"id": "bin",
"type": "backend",
"label": "Go binary",
"sublabel": "templ + htmx + Goldmark",
"pos": [
680,
300
],
"size": [
160,
68
],
"tag": ":8600 Docker"
},
{
"id": "garage",
"type": "cloud",
"label": "Garage S3",
"sublabel": "kontra-day bucket",
"pos": [
680,
160
],
"size": [
150,
64
]
},
{
"id": "caddy",
"type": "cloud",
"label": "Caddy master",
"sublabel": ":80/:443 reverse proxy",
"pos": [
900,
300
],
"size": [
150,
64
]
},
{
"id": "users",
"type": "external",
"label": "Readers",
"sublabel": "public web",
"pos": [
1120,
300
],
"size": [
130,
64
]
}
],
"boundaries": [
{
"kind": "region",
"label": "Editors (dev box .27)",
"wraps": [
"editors",
"admin"
]
},
{
"kind": "region",
"label": "Gitea Docker (.35)",
"wraps": [
"auth",
"git"
]
},
{
"kind": "region",
"label": "Kontra container (.13 :8600)",
"wraps": [
"pull",
"bin",
"garage"
]
},
{
"kind": "region",
"label": "Edge (Caddy .35)",
"wraps": [
"caddy",
"users"
]
}
],
"connections": [
{
"id": "editors-git",
"from": "editors",
"to": "git",
"label": "git push",
"variant": "emphasis"
},
{
"id": "admin-git",
"from": "admin",
"to": "git",
"label": "commit",
"fromSide": "bottom",
"toSide": "top",
"variant": "dashed"
},
{
"id": "auth-login",
"from": "admin",
"to": "auth",
"label": "Login with Gitea",
"variant": "security",
"fromSide": "right",
"toSide": "bottom",
"labelAt": [
308,
180
]
},
{
"id": "auth-git",
"from": "auth",
"to": "git",
"label": "authorize",
"variant": "security",
"fromSide": "bottom",
"toSide": "left"
},
{
"id": "git-pull",
"from": "git",
"to": "pull",
"label": "SSH",
"variant": "emphasis"
},
{
"id": "pull-bin",
"from": "pull",
"to": "bin",
"label": "restart on change",
"labelAt": [
645,
378
]
},
{
"id": "bin-garage",
"from": "bin",
"to": "garage",
"label": "media shortcode",
"variant": "dashed",
"fromSide": "top",
"toSide": "bottom",
"labelDy": -58,
"labelAt": [
768,
238
]
},
{
"id": "bin-caddy",
"from": "bin",
"to": "caddy",
"label": "reverse_proxy :8600",
"labelAt": [
870,
378
]
},
{
"id": "caddy-users",
"from": "caddy",
"to": "users",
"label": "HTTPS",
"variant": "emphasis"
}
],
"cards": [
{
"dot": "emerald",
"title": "Publish path",
"items": [
"Editors write markdown + front-matter in Obsidian/Neovim/CLI",
"All commits land on Gitea sam/kontra-content (origin)",
"Production container pulls every 30s and restarts on change"
]
},
{
"dot": "cyan",
"title": "Serving",
"items": [
"Single static Go binary (templ + htmx + Tailwind tokens)",
"Goldmark renders markdown per request; SEO-friendly",
"Caddy master terminates HTTPS and proxies :8600"
]
},
{
"dot": "rose",
"title": "Auth & media",
"items": [
"Decap CMS logs in with Gitea OAuth (PKCE)",
"Media resolves from Garage S3 via {{media:}} shortcodes"
]
}
]
}

67
docs/architecture.md Normal file
View File

@@ -0,0 +1,67 @@
# Kontra — System Architecture
A news-opinion site (GOTH stack: **Go + templ + htmx + Tailwind-tokens**) with markdown
content, a Decap CMS admin, Gitea-backed git deploy with automatic content sync, and
Garage S3 media — all on the home-lab network.
> Interactive map: `docs/kontra-architecture-map.html` — also published at:
> https://maps.lab.audasmedia.com.au/kontra/docs/kontra-architecture-map.html
## Diagram (Mermaid)
```mermaid
flowchart LR
subgraph editors["Content Editors"]
O[Obsidian vault<br/>/obsidian/kontra_content] -->|git push| G
N[Neovim / CLI] -->|git push| G
D[Decap CMS<br/>admin.kontra.lab] -->|OAuth + git commit| G
end
subgraph gitea["Gitea (192.168.20.35 :3001 Docker)"]
G[(sam/kontra-content<br/>markdown origin of truth)]
end
subgraph prod["Kontra container (192.168.20.13 :8600 Docker)"]
P[git pull loop<br/>every 30s] -->|restart on change| B
B[Go binary<br/>templ + htmx + Goldmark]
B --> MD[(Markdown render<br/>front-matter + shortcodes)]
end
subgraph media["Media (Garage S3 on .13)"]
S3[(Garage S3<br/>kontra-day bucket)]
end
G -->|"SSH git"| P
B -->|"shortcode: media"| S3
B -->|"emits HTML"| CB[Caddy master<br/>.35 :80/:443]
CB -->|"reverse_proxy :8600"| B
subgraph net["Network"]
CB -->|https| WWW[kontra.lab.audasmedia.com.au]
CB -->|https| ADM[admin.kontra.lab.audasmedia.com.au]
end
WWW -->|browser| B
ADM -->|browser| D
```
## Rendered image
![Kontra architecture](diagrams/kontra-architecture.png)
## Key facts
| Layer | Where | What |
|---|---|---|
| Content origin | Gitea `.35:3001` | `sam/kontra-content` (markdown + front-matter) |
| App binary | Docker on `.13:8600` | Go (golang) + templ + htmx + Goldmark markdown |
| Content deploy | pull loop | `git pull` every 30s → restart server on change |
| Media | Garage S3 on `.13` | `kontra-day` bucket via `{{shortcode}}` |
| Admin | Decap CMS | at `admin.kontra.lab.audasmedia.com.au/web/admin/`, Gitea OAuth login |
| Reverse proxy | Caddy on `.35` | master :80/:443 → reverse_proxy `.13:8600` |
| Obsidian | `.27` vault | `obsidian/kontra_content` → content repo (git-sync) |
## Mermaid source
`docs/kontra-architecture.mmd` (single source of truth). Render:
`mmdc -i docs/kontra-architecture.mmd -o docs/diagrams/kontra-architecture.png --scale 2 -b white`

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
flowchart LR
subgraph editors["Content Editors"]
O[Obsidian vault<br/>/obsidian/kontra_content] -->|git push| G
N[Neovim / CLI] -->|git push| G
D[Decap CMS<br/>admin.kontra.lab] -->|OAuth + git commit| G
end
subgraph gitea["Gitea (192.168.20.35 :3001 Docker)"]
G[(sam/kontra-content<br/>markdown origin of truth)]
end
subgraph prod["Kontra container (192.168.20.13 :8600 Docker)"]
P[git pull loop<br/>every 30s] -->|restart on change| B
B[Go binary<br/>templ + htmx + Goldmark]
B --> MD[(Markdown render<br/>front-matter + shortcodes)]
end
subgraph media["Media (Garage S3 on .13)"]
S3[(Garage S3<br/>kontra-day bucket)]
end
G -->|"SSH git"| P
B -->|"shortcode: media"| S3
B -->|"emits HTML"| CB[Caddy master<br/>.35 :80/:443]
CB -->|"reverse_proxy :8600"| B
subgraph net["Network"]
CB -->|https| WWW[kontra.lab.audasmedia.com.au]
CB -->|https| ADM[admin.kontra.lab.audasmedia.com.au]
end
WWW -->|browser| B
ADM -->|browser| D