Compare commits
22 Commits
684bbed645
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bcf604996 | |||
| 9b068bc48e | |||
| e578f1278f | |||
| ad445742bc | |||
| 6bf9f47780 | |||
| 70b88d7b02 | |||
| c5895e15ca | |||
| e220fa537e | |||
| 73513fb5e0 | |||
| e646be3434 | |||
| 12dac5abc1 | |||
| ba5500710c | |||
| 804b37a9a3 | |||
| 21bbe25ce2 | |||
| ee80b58352 | |||
| 644ee9f292 | |||
| 423901c80d | |||
| dd029c8819 | |||
| 5acddaa605 | |||
| df4a19933c | |||
| 5bec37a8a1 | |||
| f1fc99761f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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).
|
||||
|
||||
2
PLAN.md
2
PLAN.md
@@ -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. |
|
||||
|
||||
18
README.md
18
README.md
@@ -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)
|
||||
|
||||

|
||||
@@ -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.*
|
||||
@@ -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
|
||||
)
|
||||
|
||||
14
app/go.sum
14
app/go.sum
@@ -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=
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,3 +0,0 @@
|
||||
name: Culture
|
||||
template: article
|
||||
color: "#ad3222"
|
||||
@@ -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.
|
||||
@@ -1,3 +0,0 @@
|
||||
name: Politics
|
||||
template: article
|
||||
color: "#ad3222"
|
||||
@@ -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.
|
||||
@@ -1,3 +0,0 @@
|
||||
name: Tech
|
||||
template: article
|
||||
color: "#ad3222"
|
||||
@@ -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.
|
||||
@@ -1,3 +0,0 @@
|
||||
name: World
|
||||
template: article
|
||||
color: "#ad3222"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
{{embed:vimeo:76979871}}
|
||||
157
docs/KONTRA-GUIDE.md
Normal file
157
docs/KONTRA-GUIDE.md
Normal 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: ``
|
||||
- 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
110
docs/MEDIA-PLAN.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}}"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 |
@@ -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>• Decap CMS logs in with Gitea OAuth (PKCE)</li>
|
||||
<li>• Media resolves from Garage S3 via {{media:}} shortcodes</li>
|
||||
<li>• Media = git files beside articles; {{media:slug/file}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user