diff --git a/.gitignore b/.gitignore index d344ba6..82a0588 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ config.json +.secrets.env +shifu.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..66532b6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,764 @@ +# AGENTS.md — Building and editing websites with Shifu + +This file tells an AI agent how to create and maintain a website built with +[Shifu](https://github.com/emvi/shifu), a Git/file-based CMS that renders Go +`html/template` files from JSON content. Read it fully before generating or +changing anything. When the rules here conflict with habits from other +frameworks (React, Hugo, Next.js, Tailwind build pipelines, …), the rules here win. + +The goal of every change: **a site that renders correctly AND stays fully editable +in Shifu's admin UI** (on-page editor at `/admin`). Code that renders but cannot be +edited in the admin is considered broken. + +--- + +## 1. Mental model + +| Concept | Where it lives | What it is | +|---|---|---| +| **Template** | `tpl/**/.html` | Go `html/template` for one element type (section, component, layout part). | +| **Admin config** | `admin/tpl/**/.json` | Makes the template editable: label, editable fields, child content areas. | +| **Page** | `content/pages/**/.json` | JSON with `path` (routes per language) and a tree of elements. | +| **Reference** ("ref") | `content/refs/.json` | A standalone, reusable element (header, footer, head, …) shared by many pages. | +| **Sass** | `assets/scss/` | Compiled by Shifu (`sass` CLI) into `static/`. | +| **JS/TS** | `assets/js/` | Bundled + minified by Shifu (embedded esbuild) into `static/`. | +| **Static files** | `static/` | Served as-is under `/static/`. Admin uploads go to `static/media/`. | +| **Config** | `config.json` (+ `.secrets.env`) | Server, Sass/JS build, admin UI, etc. | + +Rendering: a request matches a page `path` → Shifu walks `content.content` (the +root list of elements) in order → each element renders `tpl/.../.html` with +`.CMS`, `.Args`, `.Page`, `.Content` → elements render their own children via +`.CMS.Render`. + +### The "one element type = matching files" rule + +Every editable element type `` consists of files that share the same base name: + +``` +tpl/sections/hero.html # required — markup +admin/tpl/sections/hero.json # required — makes it editable/addable in the admin +assets/scss/_hero.scss # optional — styles, scoped to .hero +assets/js/hero.js # optional — behaviour, bound via data-component="hero" +``` + +Names: lowercase `snake_case`, **globally unique across all subdirectories** (Shifu +identifies templates by file base name only, folders are just for humans). + +--- + +## 2. Project structure + +Use this layout for new sites (it matches `shifu init` and the admin UI's own paths): + +``` +. +├── config.json +├── .secrets.env # secrets, never commit (add to .gitignore) +├── .gitignore +├── admin/ +│ └── tpl/ +│ ├── layout/ # head.json, end.json, header.json, footer.json +│ ├── sections/ # hero.json, text.json, cards.json, … +│ └── components/ # card.json, button.json, nav_item.json, … +├── assets/ +│ ├── scss/ +│ │ ├── main.scss # Sass entrypoint (only file referenced in config) +│ │ ├── _variables.scss +│ │ ├── _base.scss +│ │ └── _hero.scss … # one partial per element type, FLAT (no subfolders) +│ └── js/ +│ ├── main.js # JS entrypoint +│ └── hero.js … # one module per interactive element type, FLAT +├── content/ +│ ├── pages/ # pages (admin creates pages here) +│ │ ├── home.json +│ │ └── not-found.json +│ └── refs/ # references (admin creates refs here) +│ ├── head.json +│ └── end.json +├── static/ # served under /static/ +│ ├── media/ # admin media library (uploads) +│ ├── main.css # GENERATED by Shifu — never write by hand +│ └── main.js # GENERATED by Shifu — never write by hand +└── tpl/ + ├── layout/ # head.html, end.html, header.html, footer.html + ├── sections/ + ├── components/ + └── partials/ # non-editable helpers used via {{ template }} +``` + +If you are editing an **existing** site, keep its structure, naming and file +locations (check `config.json` for the real `sass`/`js` dirs, entrypoints and output +paths) instead of migrating it to this layout. + +`shifu.db` (SQLite, created at runtime when the admin UI is enabled) stores admin +users, sessions and the list of references. Don't commit it. + +--- + +## 3. config.json + +Minimal configuration for local development: + +```json +{ + "dev": true, + "log_level": "info", + "server": { + "host": "localhost", + "port": 8080, + "hostname": "http://localhost:8080" + }, + "content": { + "provider": "fs", + "not_found": { + "en": "/not-found", + "de": "/de/nicht-gefunden" + } + }, + "sass": { + "dir": "assets/scss", + "entrypoint": "main.scss", + "out": "static/main.css", + "out_source_map": "static/main.css.map", + "watch": true + }, + "js": { + "dir": "assets/js", + "entrypoint": "main.js", + "out": "static/main.js", + "source_map": true, + "watch": true + }, + "ui": { + "path": "/admin", + "admin_password": "${ADMIN_PASSWORD}" + } +} +``` + +`.secrets.env` (one `KEY=value` per line, referenced as `${KEY}` in `config.json`): + +```env +ADMIN_PASSWORD=admin +``` + +Notes: + +* `dev: true` reloads templates and content on every request (local only). For + production set `dev: false`, set `server.host` to `""`, `server.hostname` to the + public origin (e.g. `https://example.com`, used for canonical links/sitemap), and + `secure_cookies: true`. +* Keys starting with `_` are treated as comments by convention (they are ignored). +* Optional sections (`api`, `remote`, `git`, `analytics`, `cors`) are documented in + the Shifu README — only add them when asked. +* Run with `shifu run .` (requires the `sass` CLI: `npm i -g sass`). JS needs no + Node.js, esbuild is built in. + +--- + +## 4. Templates (`tpl/`) + +Go `html/template` syntax plus Shifu functions and all +[Sprig](https://masterminds.github.io/sprig/) functions. + +### 4.1 Variables available in every template + +| Variable | Meaning | +|---|---| +| `.CMS` | CMS instance. `.CMS.Render`, `.CMS.LastUpdate` (cache-busting string). | +| `.Args` | `map[string]string` of route variables, e.g. `{{ index .Args "slug" }}`. | +| `.Page` | The page: `.Page.Language`, `.Page.Path` (map lang → path), `.Page.CanonicalLink`, `.Page.File`. | +| `.Content` | The element being rendered: `.Content.Position`, `.Content.Data`, `.Content.Copy`, `.Content.Content`. | + +### 4.2 Shifu template functions + +| Function | Use | +|---|---| +| `copy .Page .Content "key"` | Translated text for the page language; falls back to `en`. Returns `nil` if missing. | +| `get .Content "key"` | Language-independent value from `data`. Returns `nil` if missing. | +| `html ` | Output trusted HTML (needed for `html` type fields). | +| `htmlAttr ` | Output a trusted attribute. | +| `fmt "%s/%s" a b` | `fmt.Sprintf`. | +| `dict "k" v "k2" v2` | Build a map, e.g. to pass to partials. | +| `default a b c` | **Shifu's version**: returns the first non-nil argument (NOT Sprig's `default "fallback" .x`). | +| `int "12"`, `uint64`, `formatInt`, `formatFloat`, `formatDate`, `gtFloat`, `ltFloat`, `shuffle`, `year` | Helpers. | +| `markdown "static/x.md" .`, `markdownBlock "static/x.md" "block" .` | Render a Markdown file (path relative to project root). | +| `hostname`, `config` | `server.hostname`, full config. | +| `adminHead .Page`, `adminBody .Page`, `loggedIn .Page` | Admin UI hooks (see §4.4). | + +### 4.3 Element template contract (REQUIRED for admin editing) + +Every editable element template must: + +1. Have **exactly one top-level HTML element** carrying + `data-shifu-element="{{ .Content.Position }}"`. +2. For each child content area `` declared in its admin config, render the + children **and** place a slot directly after them: + ```gotemplate + {{ .CMS.Render .Args .Page (fmt "%s." .Content.Position) (index .Content.Content "") }} + + ``` + The `` string must be identical in all three places (position suffix, + `index` key, slot name) and in the admin config's `content` keys. +3. Read every editable value through `copy` (translatable text) or `get` (data), and + handle missing values — new elements added in the admin start **empty**. + +Canonical section template — `tpl/sections/text.html`: + +```gotemplate +{{- $headline := copy .Page .Content "headline" -}} +{{- $text := copy .Page .Content "text" -}} +{{- $background := get .Content "background" -}} + +
+
+ {{ with $headline }}

{{ . }}

{{ end }} + {{ with $text }}
{{ html . }}
{{ end }} +
+
+``` + +Container with child elements — `tpl/sections/cards.html`: + +```gotemplate +{{- $headline := copy .Page .Content "headline" -}} + +
+ {{ with $headline }}

{{ . }}

{{ end }} +
+ {{ .CMS.Render .Args .Page (fmt "%s.items" .Content.Position) (index .Content.Content "items") }} + +
+
+``` + +Child element — `tpl/components/card.html`: + +```gotemplate +{{- $title := copy .Page .Content "title" -}} +{{- $text := copy .Page .Content "text" -}} +{{- $href := copy .Page .Content "href" -}} +{{- $image := get .Content "image" -}} + + +``` + +Multiple content areas (e.g. two columns) — `tpl/sections/columns.html`: + +```gotemplate +
+
+ {{ .CMS.Render .Args .Page (fmt "%s.left" .Content.Position) (index .Content.Content "left") }} + +
+
+ {{ .CMS.Render .Args .Page (fmt "%s.right" .Content.Position) (index .Content.Content "right") }} + +
+
+``` + +Pitfalls: + +* Inside `with`/`range`, `.` changes — use `$.Page` / `$.Content`. +* `{{ html $x }}` with a missing value prints the literal text ``. Always wrap + it: `{{ with $x }}{{ html . }}{{ end }}`. +* Never hard-code visible text in element templates — put it in `copy` so it is + editable and translatable. (Fallback labels via `default` are fine.) +* Don't use Go's `{{ block }}`/`{{ define }}` inheritance for page layouts. Layout + in Shifu is composition: pages are lists of elements (`head`, `header`, sections, + `footer`, `end`). +* Template file base names must be unique across all of `tpl/`; `tpl/a/card.html` + and `tpl/b/card.html` would clash. + +### 4.4 Layout templates (head / end) + +Every page starts with a `head` element and ends with an `end` element (normally +used as references, see §6). They split the HTML document. The admin UI needs +`adminHead` inside `` and `adminBody` before ``. They output nothing for +regular visitors. + +`tpl/layout/head.html`: + +```gotemplate +{{- $title := copy .Page .Content "title" -}} +{{- $description := copy .Page .Content "meta_description" -}} + + + + + + + {{ $title }} + + + {{- range $language, $path := .Page.Path }} + + {{- end }} + + + {{ adminHead .Page }} + + +``` + +`tpl/layout/end.html`: + +```gotemplate +{{ adminBody .Page }} + + +``` + +The `/static/...` file names must match `sass.out` and `js.out` in `config.json`. + +The head is a *layout* element and is not wrapped in `data-shifu-element` +(`` content cannot be edited on-page; its fields are editable from the +admin's page tree). For per-page titles, override the head reference's copy on +each page (§6). + +### 4.5 Partials (non-editable helpers) + +Put reusable markup that is *not* an admin element in `tpl/partials/` and call it +by file name. Do **not** create an admin config for partials. + +```gotemplate +{{/* tpl/partials/button.html */}} +{{ .Label }} +``` + +```gotemplate +{{ with copy .Page .Content "cta_label" }} + {{ template "button.html" (dict "Label" . "Href" (copy $.Page $.Content "cta_href") "Variant" (get $.Content "cta_variant")) }} +{{ end }} +``` + +--- + +## 5. Admin configuration (`admin/tpl/`) + +One JSON file per element type, **same base name as the template**. Without it the +element renders but cannot be edited, and it cannot be added from the admin. + +```json +{ + "label": "Cards", + "layout": false, + "content": { + "items": { + "label": "Cards", + "tpl_filter": ["card"] + } + }, + "copy": { + "headline": { "label": "Headline" } + }, + "data": { + "background": { + "label": "Background", + "type": "select", + "options": { + "": "Default", + "light": "Light", + "dark": "Dark" + } + } + } +} +``` + +| Key | Meaning | +|---|---| +| `label` | Display name in the admin's element picker. | +| `layout` | `true` for structural elements (head, end, header, footer). Disables some actions; new elements are inserted *before* a trailing layout element. | +| `content` | Child content areas: key = area name used in the template, `label`, optional `tpl_filter` (list of template names allowed in that area; omit to allow all). | +| `copy` | Translatable fields (stored per language under `copy.`). | +| `data` | Language-independent fields (stored under `data`). | + +Field `type` (for `copy` and `data`): + +| Type | Editor | Template usage | +|---|---|---| +| *(omitted)* / `text` | single-line input | `{{ $x }}` | +| `html` | rich-text editor (Trix) | `{{ with $x }}{{ html . }}{{ end }}` | +| `img` | media library picker, stores a path like `/static/media/photo.jpg` | `` | +| `file` | media library picker, stores a path | `` | +| `boolean` | checkbox, stores `"on"` when checked, key removed when unchecked | `{{ if $x }}` | +| `select` | dropdown from `options` (`value: label`) | compare as string: `{{ if eq $x "dark" }}` | +| `color` | color picker, stores `#rrggbb` | `style="--accent: {{ $x }}"` | + +Rules derived from how the admin saves (important): + +* **When an editor saves an element, its `copy` and `data` are replaced by exactly + the configured fields.** Any key the template reads but the admin config does not + declare is deleted on the first save. ⇒ Declare *every* `copy`/`data` key the + template uses. Don't store arrays/objects in `data` — they cannot be edited and + are lost. Model repeatable things (cards, slides, nav items, FAQ entries, team + members) as **child elements** in a content area instead. +* All saved values are **strings**. Write data values as strings in JSON too + (`"columns": "3"`, not `3`), otherwise `select` comparisons fail. Use + `{{ int (get .Content "columns") }}` when you need a number. +* Copy is edited for the languages present in the page's `path`. +* Use `copy` for anything a translator would change (text, alt text, link URLs that + differ by language, button labels); use `data` for design choices (variant, color, + image, alignment, size). +* Choose `select` over free text for design options and keep option keys stable — + they are referenced by CSS modifier classes. +* Prefixing a `label` with `_` (e.g. `"_HTML Head"`) sorts it first in the picker; + the demo uses this for layout elements. + +Layout configs: + +```json +{ "label": "_HTML Head", "layout": true, "copy": { "title": { "label": "Title" }, "meta_description": { "label": "Meta Description" } } } +``` + +```json +{ "label": "_HTML End", "layout": true } +``` + +Admin config changes are picked up automatically for files directly inside +`admin/tpl/`; restart Shifu after adding files in its subdirectories. + +--- + +## 6. Content (`content/`) + +### 6.1 Pages — `content/pages/.json` + +A file with `path` and **no top-level `tpl`** is a page. + +```json +{ + "display_name": "Home", + "path": { + "en": "/", + "de": "/de" + }, + "sitemap": { "priority": "1.0" }, + "content": { + "content": [ + { + "ref": "head", + "copy": { + "en": { "title": "Acme — Home", "meta_description": "Acme builds things." }, + "de": { "title": "Acme — Start", "meta_description": "Acme baut Dinge." } + } + }, + { "ref": "header" }, + { + "tpl": "hero", + "copy": { + "en": { "headline": "We build things", "text": "
Fast and reliable.
" }, + "de": { "headline": "Wir bauen Dinge", "text": "
Schnell und zuverlässig.
" } + }, + "data": { "image": "/static/media/hero.jpg", "align": "center" } + }, + { + "tpl": "cards", + "copy": { "en": { "headline": "Services" }, "de": { "headline": "Leistungen" } }, + "content": { + "items": [ + { + "tpl": "card", + "copy": { + "en": { "title": "Design", "text": "
…
" }, + "de": { "title": "Design", "text": "
…
" } + }, + "content": {} + } + ] + } + }, + { "ref": "footer" }, + { "ref": "end" } + ] + } +} +``` + +Rules: + +* The page root must use a **single** content area named `content` + (`"content": { "content": [ … ] }`). Elements are rendered in array order. +* Each element has either `tpl` (template name, lowercase, no extension) **or** + `ref` (reference name), plus optional `copy`, `data`, `content`, `display_name`. +* Every content area declared in an element's admin config should exist in the JSON + (use `[]` when empty), e.g. `"content": { "items": [] }`. +* `path` keys are language codes; the value is the exact route (no trailing slash, + must start with `/`). Every language in `path` should have `copy` for every + element; missing languages fall back to `en`. +* Dynamic routes: always give the variable a regular expression, e.g. + `"/blog/{slug:[a-z0-9-]+}"` or `"/p/{id:[0-9]+}"` (a bare `{slug}` does not match + in the current version). Each `{…}` matches exactly one path segment; read it with + `{{ index .Args "slug" }}`. Longer paths are matched first. +* 404: create a page whose path matches `content.not_found` in `config.json` + (default `/404`). +* Optional page keys: `disable_cache`, `header` (HTTP headers map), `handler` (Go + custom handler, only if the backend defines it), `analytics` (Pirsch tags / A/B + experiments — see the Shifu README). +* Rich-text (`html`) values are HTML strings. The Trix editor produces `
`, + `
`, ``, ``, `
`, `
    /
      /
    1. `, `

      `, `
      `, + `
      `. Stick to these tags so content stays editable.
      +* Image paths should point to `static/media/…` (the admin media library), e.g.
      +  `/static/media/hero.jpg`, and the file must exist there.
      +* Media files go in `static/media/` only; don't put site assets (logos used by
      +  templates, icons, fonts) there — use `static/img/`, `static/fonts/` instead.
      +
      +### 6.2 References — `content/refs/.json`
      +
      +A content file with a **top-level `tpl`** is a reference (a standalone element).
      +Its name is the lowercase file name without `.json`.
      +
      +```json
      +{
      +    "tpl": "head",
      +    "copy": {
      +        "en": { "title": "Acme", "meta_description": "Acme builds things." },
      +        "de": { "title": "Acme", "meta_description": "Acme baut Dinge." }
      +    }
      +}
      +```
      +
      +```json
      +{
      +    "tpl": "header",
      +    "data": { "logo": "/static/media/logo.svg" },
      +    "copy": { "en": { "home_label": "Acme" }, "de": { "home_label": "Acme" } },
      +    "content": {
      +        "nav": [
      +            { "tpl": "nav_item", "copy": { "en": { "label": "About", "href": "/about" }, "de": { "label": "Über uns", "href": "/de/ueber-uns" } }, "content": {} }
      +        ]
      +    }
      +}
      +```
      +
      +* Use refs for anything shared across pages: `head`, `header`, `footer`, `end`,
      +  newsletter box, CTA banners.
      +* Pages use them with `{ "ref": "" }`. Non-empty `copy`/`data` on the page
      +  element override the ref's values for that page only (typical: page title).
      +* Ref names must be unique across **all** files in `content/` — never give a page
      +  file the same name as a ref (e.g. no `content/pages/header.json`).
      +* The admin's "add reference" picker lists refs from `shifu.db`, not from disk. Refs
      +  created by hand still render and are editable where used, but to make them
      +  insertable from the admin register them (with Shifu stopped, after it has run
      +  once so `shifu.db` exists; needs the `sqlite3` CLI):
      +  ```sh
      +  sqlite3 shifu.db "INSERT OR IGNORE INTO reference (name) VALUES ('head'), ('header'), ('footer'), ('end');"
      +  ```
      +
      +---
      +
      +## 7. Styles (`assets/scss/`)
      +
      +Shifu compiles the Sass entrypoint itself (`sass --style=compressed`) on start and
      +on every save. **Never** pre-compile, never write or edit `static/main.css` by hand,
      +never add PostCSS/Tailwind/Vite/webpack or npm build scripts, never inline large
      +`