# 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
      `