Compare commits

...

22 Commits

Author SHA1 Message Date
sam
8bcf604996 docs: link KONTRA-GUIDE.md from README 2026-09-10 16:22:56 +10:00
sam
9b068bc48e docs(guide): add KONTRA-GUIDE.md (editing/media/admin/ops); update archify+mermaid to git-files media (drop Garage) 2026-09-10 16:20:49 +10:00
sam
e578f1278f fix(loader): index.md article slug = folder name; skip subject-root index.md 2026-09-10 15:51:14 +10:00
sam
ad445742bc admin: match official Decap demo nested config — subfolders:false, depth:100, index_file:index 2026-09-10 15:48:38 +10:00
sam
6bf9f47780 admin: restore nested mode (with subject _index.md titles so tree shows named folders) 2026-09-10 15:34:43 +10:00
sam
70b88d7b02 admin: remove nested mode; flat folder: subjects lists all _index.md; subject indexes get title 2026-09-10 15:29:21 +10:00
sam
c5895e15ca media/loader: article slug from folder; _index.md loads as article; _ dirs ignored; slugs lowercased 2026-09-10 13:43:05 +10:00
sam
e220fa537e fix(admin): config.yml YAML — replace apostrophe-escaped label with clean double-quoted string (YAMLSemanticError) 2026-09-10 13:11:35 +10:00
sam
73513fb5e0 admin: Decap articles collection -> nested (depth 2) with meta.path for folder-per-article layout 2026-09-10 13:05:34 +10:00
sam
e646be3434 fix: body images constrained to container (max-width 100%); update decap config comment (media = git files, no Garage) 2026-09-10 12:43:07 +10:00
sam
12dac5abc1 tidy: content/ is its own repo (untrack from top); README media model = git files; no S3 2026-09-10 09:44:41 +10:00
sam
ba5500710c docs(DESIGN): add media model note (git files beside article; no S3); garage bucket+keys deleted 2026-09-10 09:43:52 +10:00
sam
804b37a9a3 docs: media model = git files beside articles (Garage S3 dropped); update README/PLAN/maps 2026-09-10 09:20:13 +10:00
sam
21bbe25ce2 media: resolve {{media:<slug>/<file>}} by searching article folders under subjects; traversal guarded 2026-09-10 09:17:32 +10:00
sam
ee80b58352 media: drop Garage S3/minio-go; images are git files beside articles; /media serves from content root (safe-path); loader recurses into article folders; remove admin upload page 2026-09-10 09:12:38 +10:00
sam
644ee9f292 fix(admin): upload stores object at bucket root so /media/{name} matches the redirected URL 2026-09-10 07:24:59 +10:00
sam
423901c80d feat(admin): /admin/media upload page — browser upload to Garage via minio-go, {{media:...}} snippet 2026-09-10 07:23:44 +10:00
sam
dd029c8819 fix: minio-go must sign with Garage region ('garage', not AWS us-east-1) 2026-09-10 07:18:37 +10:00
sam
5acddaa605 refactor(media): replace hand-rolled SigV4 with maintained minio-go SDK; add /admin/media/upload endpoint 2026-09-10 07:12:35 +10:00
sam
df4a19933c feat: /media proxy to Garage S3 via AWS SigV4 — images now serve publicly; remove debug 2026-09-09 18:25:34 +10:00
sam
5bec37a8a1 fix(sigv4): UTC strftime date format (%Y%m%d%H%M%S) 2026-09-09 17:32:21 +10:00
sam
f1fc99761f feat: /media route — app proxies Garage S3 bucket via AWS SigV4 (scoped read key) 2026-09-09 17:28:44 +10:00
32 changed files with 460 additions and 160 deletions

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@ kontra-bin
# archify visual-check sidecars (evidence, not source)
docs/*.visual-check.*
# content is its own git repo (sam/kontra-content) — not tracked here
content/

View File

@@ -164,6 +164,14 @@ Brand: **Kontra** (centered masthead). Home section heading: **Topics** (not "By
Reference implementation: `prototype/` (index.html, article.html, assets/kontra.css).
## Media (implementation note)
- Images are **git files beside their article**: `content/subjects/<s>/<slug>/<file>`.
Reference via `{{media:<slug>/<file>}}` (front-matter `image:` MUST be quoted —
`image: "{{media:slug/file.jpg}}"`). The app serves `/media/<path>` by searching
article folders (safe-path guarded). No S3/object store (Garage dropped 2026-09-10).
- Hero image: grayscale plate per §Hero plate; leave `image: ""` for text-only pieces.
## Token wiring (for the theme)
- Implement as CSS custom properties on `:root` (the design-token layer).

View File

@@ -13,7 +13,7 @@ A news-opinion website ("Kontra Day") with an admin panel. Pages are mainly text
| **Admin** | **Decap CMS** (at `admin.kontra.day`) | Free, open-source, browser-based UI that edits the same `.md` files directly and commits to git. No custom admin to build/maintain. |
| **Content source of truth** | **Git repo on Gitea (.35)** | History, rollback, multi-editor sync, off-site backup path. Matches your Gitea/Obsidian habits. |
| **Templates** | **templ components** chosen per page (front-matter `template:`) | See §6 — page-level → subject-level → site-level → theme fallback. |
| **Media** | **Garage S3 (on .13)** referenced via `{{media:...}}` shortcodes | Rename/migrate media without touching every article. |
| **Media** | **Git files beside each article** (`{{media:<slug>/<file>}}`) — Garage S3 dropped 2026-09-10 | Robust, versioned, backed up with content; no object-store auth/sync. |
| **Colours** | **CSS design tokens** (CSS custom properties), admin-editable values, not code | Per-subject accent colours as data, no template edits. |
| **Deploy** | **Git push → systemd timer `git pull` every 30s on .13** (no build step; Go renders on request) | Instant content updates; .27 can be offline. Timer chosen over webhook (fewer moving parts). |
| **Email phase (V)** | Revisit later — Go mail libs or hand off to network tooling | Defer until requirements are real. |

View File

@@ -6,6 +6,8 @@ self-hosted Gitea, and a git-driven deploy pipeline that publishes content withi
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)
>
> **📖 Editing, media, admin & ops guide:** [`docs/KONTRA-GUIDE.md`](docs/KONTRA-GUIDE.md)
> (pan/zoom, search, route tracing, light/dark themes)
![Kontra architecture](docs/diagrams/kontra-architecture.png)
@@ -28,12 +30,12 @@ flowchart LR
B --> MD[(Markdown render<br/>front-matter + shortcodes)]
end
subgraph media["Media (Garage S3 on .13)"]
S3[(Garage S3<br/>kontra-day bucket)]
subgraph media["Media (git files)"]
F[(Images beside articles<br/>in the content repo)]
end
G -->|"SSH git"| P
B -->|"shortcode: media"| S3
B -->|"record: media"| F
B -->|"emits HTML"| CB[Caddy master<br/>.35 :80/:443]
CB -->|"reverse_proxy :8600"| B
@@ -57,7 +59,7 @@ flowchart LR
| **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. |
| **Self-hosted, privacy-first** | Gitea + Caddy on own infra; media + content both live in git — no third-party SaaS, no object store to babysit. |
## The stack (GOTH)
@@ -68,8 +70,8 @@ flowchart LR
| **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 |
| **Admin** | Decap CMS + Obsidian | Both edit the same git repo; Obsidian for daily writing + media drop-in |
| **Media** | git files | Images live beside their article in the repo; `{{media:<slug>/<file>}}` 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 |
@@ -82,7 +84,7 @@ Editors (Obsidian / Neovim / Decap) ──git push──▶ Gitea (origin)
▼
Kontra container (.13) ◀──git pull every 30s──┘
│ ├─ Go binary (:8600)
│ ├─ Goldmark → HTML, {{media:}} → Garage S3
│ ├─ Goldmark → HTML, {{media:}} → repo media file
│ └─ restart on content change
▼
Caddy master (.35 :443) ──▶ readers (HTTPS)
@@ -167,4 +169,4 @@ no shadow-depth.
---
*Built with the GOTH stack — Go, templ, htmx, Tailwind-tokens — self-hosted on Gitea + Garage S3 + Caddy + Docker.*
*Built with the GOTH stack — Go, templ, htmx, Tailwind-tokens — content + media in git, self-hosted on Gitea + Caddy + Docker.*

View File

@@ -7,3 +7,12 @@ require (
github.com/goccy/go-yaml v1.19.2
github.com/yuin/goldmark v1.8.6
)
require (
github.com/go-ini/ini v1.67.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

View File

@@ -1,6 +1,20 @@
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/minio/minio-go v6.0.14+incompatible h1:fnV+GD28LeqdN6vT2XdGKW8Qe/IfjJDswNVuni6km9o=
github.com/minio/minio-go v6.0.14+incompatible/go.mod h1:7guKYtitv8dktvNUGrhzmNlA5wrAABTQXCoesZdFQO8=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=

View File

@@ -176,15 +176,38 @@ func (c *Content) loadSubject(slug string) (*Subject, error) {
return s, nil
}
// loadFilesUnder loads every *.md directly under dir (non-recursive; skips _*).
// loadFilesUnder loads every *.md under dir, recursing into subfolders
// (skips _prefixed dirs/files: config, templates). Supports both flat
// subjects/<s>/<slug>.md and folder-per-article subjects/<s>/<slug>/<slug>.md.
func (c *Content) loadFilesUnder(dir string, out *[]*Article) error {
return c.loadFilesUnderDepth(dir, out, 0)
}
// loadFilesUnderDepth: depth 0 = collection/subject root (skip index.md as it's the
// subject's own index), depth 1+ = article folders (index.md IS the article).
func (c *Content) loadFilesUnderDepth(dir string, out *[]*Article, depth int) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".md") || strings.HasPrefix(name, "_") {
if e.IsDir() {
// recurse one level (article folder) — skip hidden/_ folders (e.g. _attachments)
if !strings.HasPrefix(name, "_") {
c.loadFilesUnderDepth(path.Join(dir, name), out, depth + 1)
}
continue
}
if !strings.HasSuffix(name, ".md") {
continue
}
// skip _ files (config, templates) — but index.md/_index.md in a folder is the article
if strings.HasPrefix(name, "_") && name != "_index.md" {
continue
}
// at the subject/collection root (depth 0), index.md = the subject's own index — skip
if depth == 0 && (name == "index.md" || name == "_index.md") {
continue
}
if a, err := c.loadFile(path.Join(dir, name)); err == nil {
@@ -325,10 +348,17 @@ func parseFrontMatter(text string) (FrontMatter, string, bool) {
}
// slugOf derives a slug from a file path: dir/file.md -> file
// slugOf derives a slug from a file path: dir/index.md -> folder name (Decap nested),
// otherwise dir/file.md -> file base.
func slugOf(full string) string {
base := path.Base(full)
base = strings.TrimSuffix(base, ".md")
return base
// article files are now <folder>/index.md (Decap nested) — slug is the folder name.
if base == "_index" || base == "index" {
dir := path.Dir(full)
base = path.Base(dir)
}
return strings.ToLower(base)
}
// resolveShortcodes replaces {{media:name}} with base/name and
@@ -434,3 +464,36 @@ func firstSubjects(s []*Subject, n int) []*Subject {
}
return s
}
// resolveMediaPath maps a media reference like "chinese-chips/aboutme.jpeg" to an
// existing file under the content root. The reference is relative to the article
// folder, which sits under subjects/<subject>/<slug>/ — so search each subject's
// article folders for the first segment. Returns "" if not found.
func (c *Content) resolveMediaPath(root, name string) string {
// fast path: exact repo-relative path
if data, err := os.ReadFile(path.Join(root, name)); err == nil {
_ = data
return path.Join(root, name)
}
// search: first path element = folder under any subject
i := strings.IndexByte(name, '/')
if i < 0 {
return ""
}
folder := name[:i]
rest := name[i+1:]
subjDir := path.Join(root, "subjects")
if entries, err := os.ReadDir(subjDir); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
candidate := path.Join(subjDir, e.Name(), folder, rest)
if data2, err2 := os.ReadFile(candidate); err2 == nil {
_ = data2
return candidate
}
}
}
return ""
}

View File

@@ -39,6 +39,9 @@ func main() {
s := NewSite(c)
// Routes — register specific paths BEFORE the catch-all.
http.HandleFunc("/media/{path...}", func(w http.ResponseWriter, r *http.Request) {
s.media(w, r, r.PathValue("path"))
})
http.HandleFunc("/subjects/{slug}", func(w http.ResponseWriter, r *http.Request) {
s.subject(w, r, r.PathValue("slug"))
})

View File

@@ -3,7 +3,11 @@
package main
import (
"fmt"
"net/http"
"os"
"path"
"strings"
"github.com/a-h/templ"
)
@@ -13,6 +17,50 @@ type Site struct {
Content *Content
}
// media serves /media/{path...} by reading the file from the content repo root.
// Images are git-stored files beside their articles (e.g. subjects/world/<slug>/img.jpg)
// referenced as {{media:<path>}} — this maps path -> content root, safe-path guarded.
func (s *Site) media(w http.ResponseWriter, r *http.Request, name string) {
root := s.Content.Root
// guard: reject any path that escapes the content root (no .., no leading /)
if strings.Contains(name, "..") || strings.HasPrefix(name, "/") {
http.Error(w, "bad media path", http.StatusBadRequest)
return
}
full := s.Content.resolveMediaPath(root, name)
if full == "" {
http.Error(w, "media not found", http.StatusNotFound)
return
}
data, err := os.ReadFile(full)
if err != nil {
http.Error(w, "media not found", http.StatusNotFound)
return
}
ctype := mimeTypeFor(path.Ext(full))
if ctype != "" {
w.Header().Set("Content-Type", ctype)
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
_, _ = w.Write(data)
}
// mimeTypeFor returns a content type for common media extensions.
func mimeTypeFor(ext string) string {
switch ext {
case ".jpg", ".jpeg": return "image/jpeg"
case ".png": return "image/png"
case ".gif": return "image/gif"
case ".webp": return "image/webp"
case ".svg": return "image/svg+xml"
case ".avif": return "image/avif"
case ".mp4": return "video/mp4"
case ".webm": return "video/webm"
case ".mp3": return "audio/mpeg"
default: return ""
}
}
// home renders the front page.
func (s *Site) home(w http.ResponseWriter, r *http.Request) {
c := s.Content

View File

@@ -12,10 +12,11 @@ backend:
auth_endpoint: login/oauth/authorize
clear_credentials_before_refresh: false
# Media goes to Garage S3 via shortcode at render time; Decap just stores the
# object key inside the .md. If you instead want files committed to the repo,
# point media_folder at content/media and public_folder at /media.
media_folder: "" # no local uploads by default
# Media: images are git files beside their article (content/subjects/<s>/<slug>/).
# Reference via {{media:<slug>/<file>}}. The Decap media library is DISABLED so it
# cannot create orphan files in the repo — add images by dropping them into the
# Obsidian content folder instead.
media_folder: ""
public_folder: ""
# ------------------------------------------------------------------
@@ -28,7 +29,9 @@ collections:
folder: subjects
create: true
slug: "{{slug}}"
path: "{{slug}}/{{slug}}"
# nested mode — matches Decap's official demo (subfolders:false + index_file)
nested: { depth: 100, subfolders: false }
meta: { path: { widget: string, label: 'Path', index_file: 'index' } }
sortable_fields: [date, title]
fields:
- { name: title, label: Title, widget: string }
@@ -37,7 +40,7 @@ collections:
- { name: kicker, label: Kicker (section tag), widget: string, required: false }
- { name: template, label: Template, widget: string, default: article }
- { name: subject, label: Subject, widget: relation, collection: subjects, value_field: slug, search_fields: [name], display_fields: [name] }
- { name: image, label: Cover image, widget: image, required: false }
- { name: image, label: "Cover image; drop file in this article folder and use {{media:slug/file}}", widget: string, required: false }
- { name: excerpt, label: Excerpt / deck, widget: text, required: false }
- { name: tags, label: Tags, widget: list, allow_add: true, required: false }
- { name: featured, label: Featured on front page, widget: boolean, default: false }

View File

@@ -113,6 +113,10 @@ figure.plate img { display: block; width: 100%; filter: grayscale(1) contrast(1.
figure.gridplate { background: var(--surface); border: 1px solid var(--hairline); }
figure.gridplate img { display: block; width: 100%; aspect-ratio: 3/2; filter: grayscale(1) contrast(1.05); }
/* Body/raw markdown images: never exceed their container (fixes overflow past column). */
.article-body img, .content-column img, .article-body video { max-width: 100%; height: auto; }
.container img { max-width: 100%; height: auto; }
.pullquote { background: var(--surface); border-left: 4px solid var(--accent); padding: 24px; border-radius: 0; }
.pullquote .mark { font-family: var(--font-display); color: var(--accent); font-size: 38px; line-height: 1; opacity: 0.8; }

View File

@@ -1,25 +0,0 @@
name: Kontra
tagline: A daily broadsheet for the wide world. Independent • Printed in your browser.
defaulttemplate: article
subjects:
- label: World
slug: world
- label: Politics
slug: politics
- label: Culture
slug: culture
- label: Tech
slug: tech
- label: Business
slug: business
nav:
- label: World
slug: world
- label: Politics
slug: politics
- label: Culture
slug: culture
- label: Tech
slug: tech
- label: Business
slug: business

View File

@@ -1,8 +0,0 @@
---
title: About
author: Kontra
date: 2026-09-01
template: article
---
Kontra is an independent news-opinion publication. We believe in measured, deliberate journalism served in a typeface you can read.

View File

@@ -1,3 +0,0 @@
name: Culture
template: article
color: "#ad3222"

View File

@@ -1,13 +0,0 @@
---
title: A Micro-Press Keeps the Long Essay Alive at 16 Pages
author: S. Grey
date: 2026-09-05
kicker: Books
subject: culture
template: article
excerpt: Paper, price of ink, and why short-run still sells.
---
Paper, price of ink, and why short-run still sells. The micro-press prints a single essay a month, in an edition of four hundred, and has never once gone into a second printing.
Its publisher's theory is simple: scarcity is a feature, not a bug, when the writing is good.

View File

@@ -1,3 +0,0 @@
name: Politics
template: article
color: "#ad3222"

View File

@@ -1,13 +0,0 @@
---
title: The Two-Party Primary That Never Had a Crowd
author: P. Vela
date: 2026-09-07
kicker: Politics
subject: politics
template: article
excerpt: Voter lists up, enthusiasm down — how the ground shifted anyway.
---
Voter lists up, enthusiasm down — how the ground shifted anyway. The primaries drew record registrations but the smallest crowds in memory, and both outcomes are true at once.
Organizers blame the calendar; political scientists blame the weather; the poll workers blame their feet. Nobody is entirely wrong.

View File

@@ -1,3 +0,0 @@
name: Tech
template: article
color: "#ad3222"

View File

@@ -1,13 +0,0 @@
---
title: The Local AI Box That Runs Entirely on a Tennis Ball
author: J. Quin
date: 2026-09-04
kicker: Tech
subject: tech
template: article
excerpt: A one-RPi coop build, and what it does better than cloud.
---
A one-RPi coop build, and what it does better than cloud. In a converted radio shack, a cooperative runs a small language model on hardware the size of a paperback — deliberately, and on purpose.
It does less, but it does it privately, and the members own the whole stack. That, its operators argue, is the point.

View File

@@ -1,3 +0,0 @@
name: World
template: article
color: "#ad3222"

View File

@@ -1,13 +0,0 @@
---
title: The River Border Nobody Can Agree Where It Is
author: N. Harlow
date: 2026-09-06
kicker: World
subject: world
template: article
excerpt: Two towns, one floodplain, and a surveyor's error from 1893.
---
Two towns, one floodplain, and a surveyor's error from 1893 have kept a border in dispute for more than a century. Neither side will yield the mudflats, and both claim the weir as their own.
The original boundary stone was placed on a bank that has since migrated sixty yards downstream. Every spring, the river moves it again — and every summer, the argument resumes.

View File

@@ -1,27 +0,0 @@
---
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://picsum.photos/seed/harbor1/1400/800
excerpt: After decades of decline, the docklands cooperative is being rebuilt from the waterline up — and the tide of labour is turning with it.
---
Before the lighthouse beam cuts clean through the channel mist, the smell of damp pine and brine settles into the stones. For three centuries, the tidal basin has kept the same tempo: boats lurching over black water with diesel thrums, their gunwales caked in white rime, unloading timber and cold silver mackerel while the town above still sleeps behind shuttered granite facades.
> The ocean takes everything you give it and asks for another hour. If the tide leaves you high, you scrape hulls; if it surges, you pray the knots hold.
>
> — Marcel Gouëlou, 42 years shipwright & seine netting foreman
Yet the men and women working these slips are acutely aware of the narrowing wake behind them. Modern mechanized refrigeration terminals forty leagues south have hollowed out the regional cooperative. Here, where hand-mended nets dry on weathered oak bollards and weights are tallied in battered tin notebooks, maritime trade remains tactile, punishing, and inextricably tied to the low spring tides.
By noon, the diesel fumes thin out, replaced by steam whistling from the fishermen's canteen on Rue des Cordiers. Copper pots boil potatoes alongside salted hake. Here, the talk shifts from tonnage to fuel subsidies, offshore regulations, and the young people who prefer train commutes to the city over seventy-hour sea weeks.
Still, as evening pulls the waters outward into the English Channel, the moorings creak in unison. The old fleet may be diminishing in number, but those remaining harbour stones bear the polish of generations who knew how to listen to the tide long before the radar could tell them what was coming.
![Morning trawlers]({{media:harbor.jpg}})
{{embed:vimeo:76979871}}

157
docs/KONTRA-GUIDE.md Normal file
View File

@@ -0,0 +1,157 @@
# Kontra — Editing, Media, Admin & Ops
Single source of truth for working with the Kontra site. Covers: how content is
structured, how to edit it (Obsidian or Decap), how media works, how the admin is
configured, and how the whole thing runs + deploys.
**Live site:** https://kontra.lab.audasmedia.com.au
**Admin (Decap):** https://admin.kontra.lab.audasmedia.com.au/web/admin/
**Architecture map:** https://maps.lab.audasmedia.com.au/kontra_day/docs/kontra-architecture-map.html
```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 (.35 Docker)"]
G[(sam/kontra-content<br/>markdown + images = origin)]
end
subgraph prod["Kontra container (.13 :8600)"]
P[git pull loop<br/>every 30s] -->|restart on change| B
B[Go binary: templ + htmx + Goldmark]
end
subgraph media["Media (git files)"]
F[(Images beside articles<br/>in the content repo)]
end
G -->|"SSH git"| P
B -->|"{{media:...}} shortcode"| F
B -->|"HTML"| CB[Caddy master (.35 :443)]
CB --> WWW[browsers]
D -->|login| G
```
## 1. Content structure (the rules)
```
content/
├── config/site.yaml # site name, tagline, nav (lowercase keys)
├── subjects/
│ ├── world/
│ │ ├── index.md # SUBJECT index — Decap folder marker (not an article)
│ │ ├── _subject.yaml # subject config (name, template, color, ALL lowercase keys)
│ │ ├── chinese-chips/
│ │ │ ├── index.md # THE ARTICLE (file is always index.md inside its folder)
│ │ │ ├── hero.jpg # images live beside their article
│ │ │ └── figure-2.jpg
│ │ └── quantum-ai/…
│ └── tech/…
├── pages/ # standalone pages (about, 404…)
│ └── about.md
└── _templates/
└── article.md # Obsidian insert-template (⌘⇧T)
```
**Hard rules**
- Article file = **`index.md` inside its own folder** named exactly like the slug
(`subjects/<subject>/<slug>/index.md`). The folder name = the URL slug (lowercased).
- Filenames in a folder: **lowercase, no spaces** (`quantum-ai/`, not `Quantum-ai/`).
- Images go in the **same folder as the article** — never the repo root, never a shared folder.
- `_`-prefixed files are skipped by the site (`_subject.yaml`, `_templates/`, `.trash/`).
## 2. Editing content
### A. Obsidian (recommended daily editor)
Open **`/home/sam/obsidian/kontra_content`** (a symlink to the content repo) as a vault.
The **GitHub Sync** plugin auto-pushes on save (~15s).
- **New article:** insert the template via **⌘⇧T → "article"** — this substitutes `{{title}}`/`{{date}}`.
(If you hand-copy the template file, those variables stay literal and break front-matter.)
- **New subject:** create a folder under `subjects/` with an `index.md` (used as the subject index)
and `_subject.yaml`.
- **Publish/hide:** `published: true` → live; `published: false` → hidden everywhere (404 + removed from all lists). Omit = published.
- **Featured:** `featured: true` puts it in the hero lead slot.
### B. Decap (browser admin)
Login with **Gitea** → you get a nested tree: `Articles → world → chinese-chips …`.
Click an article to edit. Same file → same result. **No manual push needed** — Decap commits to Gitea, autosync publishes.
## 3. Media (images)
Images are **git files beside their article** — no S3/object store.
```
subjects/world/chinese-chips/
index.md
hero.jpg ← dragged into Obsidian (or placed in the folder)
```
**Referencing**
- Body: `![Alt]({{media:chinese-chips/hero.jpg}})`
- Front-matter hero: `image: "{{media:chinese-chips/hero.jpg}}"` ← **must be quoted**
- Rule: `{{media:<slug>/<exact-filename>}}` — slug lowercase, **exact filename incl. extension** (`.jpg` ≠ `.jpeg`), **no leading slash**, **no backticks** around a real image markdown line.
The app serves `/media/<path>` by mapping the slug→subject folder (safe-path guarded).
## 4. The admin (Decap) config
- Lives at **`app/src/web/admin/config.yml`** (embedded into the binary at build).
- **Nested collections** — this is how it lists the 2-level tree. Do NOT "simplify" it:
```yaml
collections:
- name: articles
label: Articles
folder: subjects
create: true
slug: "{{slug}}"
nested: { depth: 100, subfolders: false } # subfolders:false is REQUIRED
meta: { path: { widget: string, label: 'Path', index_file: 'index' } }
```
- **Decap gotchas (do not repeat):**
- `index_file: 'index'` (not `_index`), `subfolders: false` (not true)
- Labels in single quotes: NO apostrophes inside (breaks YAML — use double quotes)
- The media library is disabled (`media_folder: ""`) — images are git files, not uploaded through Decap.
- `subjects` + `pages` collections also exist. Auth is **Gitea OAuth** (app `decap-kontra`, PKCE).
## 5. Ops — how it runs
**Machines**
| Machine | IP | Role |
|---|---|---|
| Gitea (origin) | .35 :3001 | `sam/kontra-content` (content+images), `sam/kontra` (app) |
| Kontra container | .13 :8600 | Go binary; pulls content every 30s + restarts on change |
| Caddy master | .35 :443 | TLS; `kontra.lab…` + `admin.kontra…` → `.13:8600` |
| Dev (.27) | .27 | Obsidian vault + app source + build |
**Build** (after source changes, on .27)
```bash
cd app && ./build.sh # templ generate → go build → app/kontra-bin
```
The container builds inside Docker (golang stage) — update `config.yml`/assets then redeploy.
**Deploy to .13** (the container runs from `/home/sam/Docker/Containers/kontra/`)
```bash
# source (needed bits)
scp app/go.mod app/go.sum sam@192.168.20.13:/home/sam/Docker/Containers/kontra/src/
rsync -az --delete --exclude bin --exclude kontra-bin --exclude .git \
app/src/ sam@192.168.20.13:/home/sam/Docker/Containers/kontra/src/src/
ssh sam@192.168.20.13 'cd /home/sam/Docker/Containers/kontra && docker compose up -d --build'
```
> `.go.sum` must move with `go.mod`; and use `--delete` or stale files (like old `media.go`) break the build.
**Content autosync** — pushes to `sam/kontra-content` are pulled by the container every 30s; server restarts on change. No CI/CD needed.
**Secrets** — the Gitea deploy token lives in `.env` (repo root, **gitignored**, chmod 600). Never commit.
## 6. Backups
- Content + images are in the git repo → backed up by Gitea (mirrors + clients) and the production clone on `.13`.
- The app source lives in git on Gitea.
- House-wide offsite backup covers these hosts per the Backup Architecture plan.
## 7. For AI agents resuming this project
1. Read this file first.
2. The site is **live** and the pipeline works — prefer the smallest change + `rsync --delete` deploy + verify on `https://kontra.lab.audasmedia.com.au`.
3. Decap config: treat `config.yml` as sacred (nested `subfolders:false` + `index_file:index`).
4. Media = git files. `{{media:<slug>/<file>}}`, quoted in front-matter, exact filename.
5. Don't reintroduce S3/Garage for media (dropped 2026-09-10).

110
docs/MEDIA-PLAN.md Normal file
View File

@@ -0,0 +1,110 @@
# Plan — Robust media: images co-located with articles, git-as-source-of-truth
Status: **PROPOSED** — awaiting your review, then I implement exactly this.
## 0. Problem statement (why we're here)
The S3/Garage + Decap-media path produced a fragile system: a wrong-signing SDK default,
orphan attachments landing at repo root (aboutme.jpeg, webdevelopment.jpeg), a Decap media
library that writes into git instead of S3, and no single robust place for images. We are
**dropping Garage S3 for Kontra media entirely**. Images live as **files in the content
repo, co-located with their article** — one source of truth, versioned, backed up, and
served by the app.
## 1. Target layout (Option A — images with their article)
```
content/
└── subjects/
└── world/
├── _subject.yaml
├── chinese-chips/
│ ├── chinese-chips.md ← article (same filename → same URL /articles/chinese-chips)
│ ├── aboutme.jpeg ← images beside their article
│ └── hero.jpg
├── southern-harbor/
│ ├── southern-harbor.md
│ └── harbor.jpg
└── river-border/
└── river-border.md
```
- **Slug is unchanged** (from filename) — URLs `/articles/<slug>` stay identical.
- **Obsidian's native setting** ("attachments: same folder as current file") puts images
beside the article automatically — no config drift, no stray files at repo root.
- **Decap** reads/writes the same git tree, so it sees the same folders (its image widget
becomes a string `{{media:...}}`; no library files committed).
- **App** serves images at `/media/<path>` by reading the repo folder — a plain file server.
## 2. Why Option A over a top-level media/ folder
- Obsidian's default attachment behavior (same folder) needs **zero configuration** and
cannot orphan files — the exact failure mode that created `aboutme.jpeg` at root.
- Each article is a **self-contained folder** (text + images): move/copy/archive in one step.
- Collisions impossible — filenames only clash within the same article.
- Serving complexity is identical to a central media folder (both are static file serving).
## 3. Changes required (minimal, mechanical)
1. **Content repo**
- Migrate articles to folders: `subjects/<s>/<slug>/<slug>.md` (+ move their images in).
- Move stray images (`aboutme.jpeg`, `webdevelopment.jpeg`) into their article folder.
- Add `content/.gitignore`: nothing special (images are intentionally tracked now);
but add `.trash/` to gitignore so Obsidian's trash stops entering git.
- Add `/media`-relevant note in `_templates/article.md`.
2. **App loader** (`app/src/content.go`)
- Make article discovery **recurse one level** under each subject folder (currently
non-recursive: `*.md` at subject root, `_subject.yaml` skipped). New rule:
- `subjects/<s>/<slug>.md` (flat, existing) **and** `subjects/<s>/<slug>/*.md` (new).
- Still skip `_`-prefixed files/dirs (config, templates).
- Slug = filename without `.md`, unchanged.
3. **App media route** (`app/src/main.go` + handler)
- Replace the Garage proxy: `/media/{path...}` → serve the file from
`<content>/subjects/<s>/<slug>/<path>` or from the repo root `media/` if any.
Simplest robust: look up `<content>/<path>` in the content root (covers any location)
with a safe-path guard (no `..`), set Content-Type from extension.
- **Reference convention (DECIDED)**: full repo-relative path — `{{media:chinese-chips/aboutme.jpeg}}`.
The app maps `/media/{path...}` → file relative to content root (safe-path guarded).
- Remove `/admin/media` upload page + Garage upload endpoint + minio-go dependency
(or keep minio-go for future — recommend removing to keep binary lean).
4. **Front-matter/{{media:}}**
- `{{media:chinese-chips/aboutme.jpeg}}` → `/media/chinese-chips/aboutme.jpeg` →
the app serves the file from the content root (safe-path guarded). Works for images in
any folder (article folders, pages).
- Obsidian/Decap reference images by the same repo-relative path:
`{{media:<slug>/<filename>}}` for an image beside its article.
5. **Cleanup**
- Delete `real2.png` (+ `photonic-chips.png` leftover) from bucket — irrelevant now (S3 dropped).
- Remove the Garage bucket/key/kontra-day specific bits if unused elsewhere.
- Update `PLAN.md`, `DESIGN.md` (media section), `README.md`, `docs/OVERVIEW.md`
to the new model; remove S3/Decap-media claims.
6. **Backups**
- Media now lives in the **content git repo** → backed up wherever the repo is:
Gitea (.35) + production clone (.13) + the existing offsite/NAB backup chain.
- No new backup config needed.
## 4. Migration steps (in order)
1. Commit a branch: move the 5 known articles into folders (+ images), update `.gitignore`.
2. Loosen the loader (change #2), build, verify all existing URLs return 200.
3. Swap the media route (change #3), build, verify `{{media:...}}` images serve from the repo.
4. Push → autosync pulls to .13 → rebuild container with new binary.
5. Remove the Decap media-library bits + S3 env from compose (or keep env unused).
6. Fix the test article's references; verify Obsidian + Decap + site all agree.
7. Update the docs (PLAN/DESIGN/README/OVERVIEW) and delete stale bucket artifacts.
8. Full end-to-end: edit image in Obsidian → push → live in ~30s; delete image → 404s.
## 5. Risks / notes
- The loader change (recurse one level) is the only behavioral change to content discovery;
existing flat articles still work, so rollback is one revert.
- `/media/<path>` file serving must guard against path traversal (`..`) — handled in change #3.
- Obsidian trash: `.trash/` is already in the repo (Obsidian's own folder) — gitignoring it
stops it syncing to Gitea while keeping local trash.
- No secrets change; Gitea deploy token stays in `.env` (gitignored).
## 6. Out of scope (unless you ask)
- Reintroducing any S3/Garage for Kontra media.
- A browser-based media manager beyond the simple file page.
- Multi-editor auth beyond the existing Gitea/Obsidian workflow.

View File

@@ -45,11 +45,11 @@ flowchart LR
end
subgraph media["Media (Garage S3 on .13)"]
S3[(Garage S3<br/>kontra-day bucket)]
F[(Images beside articles<br/>in the content repo)]
end
G -->|"SSH git"| P
B -->|"shortcode: media"| S3
B -->|"shortcode: media"| F
B -->|"emits HTML"| CB[Caddy master<br/>.35 :80/:443]
CB -->|"reverse_proxy :8600"| B

View File

@@ -123,8 +123,8 @@
{
"id": "garage",
"type": "cloud",
"label": "Garage S3",
"sublabel": "kontra-day bucket",
"label": "Git media",
"sublabel": "images beside articles",
"pos": [
680,
160
@@ -310,7 +310,7 @@
"title": "Auth & media",
"items": [
"Decap CMS logs in with Gitea OAuth (PKCE)",
"Media resolves from Garage S3 via {{media:}} shortcodes"
"Media = git files beside articles; {{media:slug/file}}"
]
}
]

View File

@@ -28,11 +28,11 @@ flowchart LR
end
subgraph media["Media (Garage S3 on .13)"]
S3[(Garage S3<br/>kontra-day bucket)]
F[(Images beside articles<br/>in the content repo)]
end
G -->|"SSH git"| P
B -->|"shortcode: media"| S3
B -->|"shortcode: media"| F
B -->|"emits HTML"| CB[Caddy master<br/>.35 :80/:443]
CB -->|"reverse_proxy :8600"| B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -5007,15 +5007,15 @@
<text data-detail="fine" x="760" y="360" class="t-backend" font-size="7" text-anchor="middle">:8600 Docker</text>
</g>
<g id="node-garage" data-node-id="garage" data-node-label="Garage S3" tabindex="0" role="button" aria-label="Focus Garage S3, kontra-day bucket, Kontra container (.13 :8600)" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="kontra-day bucket" data-node-context="Kontra container (.13 :8600)">
<title>Garage S3 · kontra-day bucket · Kontra container (.13 :8600)</title>
<g id="node-garage" data-node-id="garage" data-node-label="Git media" tabindex="0" role="button" aria-label="Focus Git media, images beside articles, Kontra container (.13 :8600)" aria-pressed="false" data-node-kind="cloud" data-node-sublabel="images beside articles" data-node-context="Kontra container (.13 :8600)">
<title>Git media · images beside articles · Kontra container (.13 :8600)</title>
<rect x="680" y="160" width="150" height="64" rx="6" class="c-mask"/>
<rect x="680" y="160" width="150" height="64" rx="6" class="c-cloud" stroke-width="1.5"/>
<g aria-hidden="true" data-semantic-sigil="cloud" class="semantic-sigil s-cloud" transform="translate(686 166) scale(0.6875)">
<path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z"/>
</g>
<text data-detail-anchor x="755" y="190" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Garage S3</text>
<text data-detail="context" x="755" y="206" class="t-muted" font-size="9" text-anchor="middle">kontra-day bucket</text>
<text data-detail-anchor x="755" y="190" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">Git media</text>
<text data-detail="context" x="755" y="206" class="t-muted" font-size="9" text-anchor="middle">images beside articles</text>
</g>
<g id="node-caddy" data-node-id="caddy" data-node-label="Caddy master" tabindex="0" role="button" aria-label="Focus Caddy master, :80/:443 reverse proxy, Edge (Caddy .35)" aria-pressed="false" data-node-kind="cloud" data-node-sublabel=":80/:443 reverse proxy" data-node-context="Edge (Caddy .35)">
@@ -5317,7 +5317,7 @@
</div>
<ul>
<li>&bull; Decap CMS logs in with Gitea OAuth (PKCE)</li>
<li>&bull; Media resolves from Garage S3 via {{media:}} shortcodes</li>
<li>&bull; Media = git files beside articles; {{media:slug/file}}</li>
</ul>
</div>
</div>

View File

@@ -15,12 +15,12 @@ flowchart LR
B --> MD[(Markdown render<br/>front-matter + shortcodes)]
end
subgraph media["Media (Garage S3 on .13)"]
S3[(Garage S3<br/>kontra-day bucket)]
subgraph media["Media (git files)"]
F[(Images beside articles<br/>in the content repo)]
end
G -->|"SSH git"| P
B -->|"shortcode: media"| S3
B -->|"{{media:slug/file}}"| F
B -->|"emits HTML"| CB[Caddy master<br/>.35 :80/:443]
CB -->|"reverse_proxy :8600"| B