Files
schnittfest/AGENTS.md
T
2026-10-14 19:29:31 +02:00

765 lines
30 KiB
Markdown

# 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/**/<name>.html` | Go `html/template` for one element type (section, component, layout part). |
| **Admin config** | `admin/tpl/**/<name>.json` | Makes the template editable: label, editable fields, child content areas. |
| **Page** | `content/pages/**/<file>.json` | JSON with `path` (routes per language) and a tree of elements. |
| **Reference** ("ref") | `content/refs/<name>.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/.../<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 `<name>` 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 <string>` | Output trusted HTML (needed for `html` type fields). |
| `htmlAttr <string>` | 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 `<area>` declared in its admin config, render the
children **and** place a slot directly after them:
```gotemplate
{{ .CMS.Render .Args .Page (fmt "%s.<area>" .Content.Position) (index .Content.Content "<area>") }}
<slot name="{{ .Content.Position }}/<area>"></slot>
```
The `<area>` 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" -}}
<section class="text{{ with $background }} text--{{ . }}{{ end }}" data-shifu-element="{{ .Content.Position }}">
<div class="text__inner">
{{ with $headline }}<h2 class="text__headline">{{ . }}</h2>{{ end }}
{{ with $text }}<div class="text__body">{{ html . }}</div>{{ end }}
</div>
</section>
```
Container with child elements — `tpl/sections/cards.html`:
```gotemplate
{{- $headline := copy .Page .Content "headline" -}}
<section class="cards" data-shifu-element="{{ .Content.Position }}">
{{ with $headline }}<h2 class="cards__headline">{{ . }}</h2>{{ end }}
<div class="cards__grid">
{{ .CMS.Render .Args .Page (fmt "%s.items" .Content.Position) (index .Content.Content "items") }}
<slot name="{{ .Content.Position }}/items"></slot>
</div>
</section>
```
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" -}}
<article class="card" data-shifu-element="{{ .Content.Position }}">
{{ with $image }}<img class="card__image" src="{{ . }}" alt="{{ copy $.Page $.Content "image_alt" }}" loading="lazy" />{{ end }}
{{ with $title }}<h3 class="card__title">{{ . }}</h3>{{ end }}
{{ with $text }}<div class="card__text">{{ html . }}</div>{{ end }}
{{ with $href }}<a class="card__link" href="{{ . }}">{{ default (copy $.Page $.Content "link_label") "Read more" }}</a>{{ end }}
</article>
```
Multiple content areas (e.g. two columns) — `tpl/sections/columns.html`:
```gotemplate
<section class="columns" data-shifu-element="{{ .Content.Position }}">
<div class="columns__col">
{{ .CMS.Render .Args .Page (fmt "%s.left" .Content.Position) (index .Content.Content "left") }}
<slot name="{{ .Content.Position }}/left"></slot>
</div>
<div class="columns__col">
{{ .CMS.Render .Args .Page (fmt "%s.right" .Content.Position) (index .Content.Content "right") }}
<slot name="{{ .Content.Position }}/right"></slot>
</div>
</section>
```
Pitfalls:
* Inside `with`/`range`, `.` changes — use `$.Page` / `$.Content`.
* `{{ html $x }}` with a missing value prints the literal text `<nil>`. 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 `<head>` and `adminBody` before `</body>`. They output nothing for
regular visitors.
`tpl/layout/head.html`:
```gotemplate
{{- $title := copy .Page .Content "title" -}}
{{- $description := copy .Page .Content "meta_description" -}}
<!DOCTYPE html>
<html lang="{{ .Page.Language }}">
<head>
<base href="/" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ $title }}</title>
<meta name="description" content="{{ $description }}" />
<link rel="canonical" href="{{ .Page.CanonicalLink }}" />
{{- range $language, $path := .Page.Path }}
<link rel="alternate" hreflang="{{ $language }}" href="{{ hostname }}{{ $path }}" />
{{- end }}
<link rel="stylesheet" href="/static/main.css?v={{ .CMS.LastUpdate }}" />
<script defer src="/static/main.js?v={{ .CMS.LastUpdate }}"></script>
{{ adminHead .Page }}
</head>
<body>
```
`tpl/layout/end.html`:
```gotemplate
{{ adminBody .Page }}
</body>
</html>
```
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`
(`<head>` 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 */}}
<a class="button button--{{ default .Variant "primary" }}" href="{{ .Href }}">{{ .Label }}</a>
```
```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.<lang>`). |
| `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` | `<img src="{{ $x }}">` |
| `file` | media library picker, stores a path | `<a href="{{ $x }}">` |
| `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/<name>.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": "<div>Fast and reliable.</div>" },
"de": { "headline": "Wir bauen Dinge", "text": "<div>Schnell und zuverlässig.</div>" }
},
"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": "<div>…</div>" },
"de": { "title": "Design", "text": "<div>…</div>" }
},
"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 `<div>`,
`<br>`, `<strong>`, `<em>`, `<a>`, `<ul>/<ol>/<li>`, `<h1>`, `<blockquote>`,
`<pre>`. 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/<name>.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": "<name>" }`. 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
`<style>` blocks in templates.
`assets/scss/main.scss`:
```scss
@use "variables";
@use "base";
@use "layout";
// one partial per element type, same name as the template
@use "head";
@use "header";
@use "footer";
@use "hero";
@use "text";
@use "cards";
@use "card";
```
`assets/scss/_variables.scss`:
```scss
$color-text: #1d1d1f;
$color-accent: #0a64d6;
$max-width: 1120px;
$radius: 12px;
$breakpoint-md: 768px;
@mixin md {
@media (min-width: $breakpoint-md) { @content; }
}
```
`assets/scss/_card.scss`:
```scss
@use "variables" as *;
.card {
border-radius: $radius;
overflow: hidden;
&__image { display: block; width: 100%; height: auto; }
&__title { margin: 16px 0 8px; }
&__link { color: $color-accent; }
}
```
Conventions:
* Use the module system (`@use`/`@forward`), not deprecated `@import`.
* Scope styles to the element's root class (BEM: `.card`, `.card__title`,
`.card--dark`). Modifier names come from `select` option keys.
* Style rich-text output by element (`.text__body p, .text__body ul …`) since editors
can't add classes.
* Expose admin `color` fields as CSS custom properties
(`style="--accent: {{ $color }}"` + `color: var(--accent, #{$color-accent})`).
* Keep partials flat inside the Sass dir: Shifu only watches that directory itself
(not subfolders) for changes. Every subfolder is also a Sass load path, so names
must be unique.
* Fonts/images referenced from CSS go in `static/` and use absolute URLs
(`url("/static/fonts/inter.woff2")`).
* Requires the Dart Sass CLI (`sass`) on the machine running Shifu.
---
## 8. JavaScript (`assets/js/`)
Shifu bundles and minifies the JS entrypoint with esbuild on start and on save (ES
modules, TypeScript `.ts` also works). **Never** pre-bundle, never edit
`static/main.js`, never add a separate bundler. `import` between files in
`assets/js/` is fine. Avoid npm dependencies unless the user asks; if one is needed,
prefer copying a small ES-module library into `assets/js/` (e.g. `vendor_glide.js`).
Keep JS files flat in that directory: like Sass, only the directory itself is
watched for changes, not its subfolders.
Pattern: progressive enhancement, components bound by `data-component`, idempotent
initialisation that also handles elements the admin inserts or re-renders on the fly.
`assets/js/main.js`:
```js
import { initNav } from "./nav.js";
import { initSlider } from "./slider.js";
const components = {
nav: initNav,
slider: initSlider,
};
function init(root = document) {
root.querySelectorAll("[data-component]").forEach((el) => {
if (el.dataset.componentReady) {
return;
}
const fn = components[el.dataset.component];
if (fn) {
fn(el);
el.dataset.componentReady = "true";
}
});
}
document.addEventListener("DOMContentLoaded", () => {
init();
// the admin UI swaps element HTML in place after edits
new MutationObserver(() => init()).observe(document.body, { childList: true, subtree: true });
});
```
`assets/js/nav.js`:
```js
export function initNav(el) {
const toggle = el.querySelector(".header__toggle");
if (!toggle) {
return;
}
toggle.addEventListener("click", () => {
const open = el.classList.toggle("header--open");
toggle.setAttribute("aria-expanded", String(open));
});
}
```
Template side: `<header class="header" data-component="nav" data-shifu-element="{{ .Content.Position }}">`.
Rules:
* The site must be usable without JS; JS enhances.
* No inline `<script>` in element templates; no `onclick=` attributes.
* Pass configuration via `data-*` attributes rendered from `copy`/`data`.
* Don't rely on the DOM being static — the admin replaces element markup when
editors save. Never store state outside the element or bind listeners in a way
that breaks if an element is replaced.
* Load the bundle once, in `head.html`, with `defer`.
---
## 9. Workflows
### 9.1 Create a whole new site
1. Write `config.json`, `.secrets.env`, `.gitignore`
(`.secrets.env`, `shifu.db`, `static/main.css*`, `static/main.js*`).
2. Plan the element inventory from the design: layout parts (head, header, footer,
end), sections (hero, text, image, cards, columns, cta, faq, contact, …),
components (card, nav_item, faq_item, button…). Prefer a small set of flexible
sections with `select` options over many near-duplicates.
3. For each element type create: template, admin config, Sass partial (+ `@use` in
`main.scss`), and JS module (+ register in `main.js`) when interactive.
4. Create refs: `content/refs/head.json`, `header.json`, `footer.json`, `end.json`.
5. Create pages in `content/pages/`, each starting with `head` and ending with `end`,
with `copy` for every language in its `path`. Include a 404 page.
6. Put referenced images in `static/media/`.
7. Run `shifu run .`, open every page, check the log for errors.
### 9.2 Edit an existing site
1. Read `config.json`, list `tpl/`, `admin/tpl/`, `content/` and the Sass/JS
entrypoints first to learn the existing element inventory and conventions.
2. Reuse existing element types before creating new ones.
3. Edit content JSON surgically; keep keys you don't touch (`display_name`,
`analytics`, `header`, …). The admin writes compact single-line JSON — that's fine,
keep it valid; reformatting is allowed but not required.
4. Renaming a `copy`/`data` key or content area → update the template, the admin
config **and every page/ref JSON** that uses it (search all of `content/`).
5. Removing an element type → first remove all its uses from `content/`.
6. Adding a field → add it to template *and* admin config; existing content may lack
it, so templates must handle the missing value.
### 9.3 Add a new page
Create `content/pages/<slug>.json` (unique file name, not equal to any ref name),
copy the head/header/footer/end refs from an existing page, set `path` for all site
languages, override the head `title`/`meta_description`, and add nav entries in the
header ref if the page should be linked.
---
## 10. Verification checklist
Before finishing, confirm:
- [ ] Every element type has `tpl/**/<name>.html` and `admin/tpl/**/<name>.json` with the same unique base name (partials excepted).
- [ ] Every element template has one root element with `data-shifu-element="{{ .Content.Position }}"`.
- [ ] Every content area appears identically in the template (`fmt "%s.<area>"`, `index … "<area>"`, `<slot name=".../<area>">`), the admin config `content`, and the JSON.
- [ ] Every `copy`/`data` key read in a template is declared in the admin config with the right `type`.
- [ ] No arrays/objects in `data`; all `copy`/`data` values are strings.
- [ ] `html` values are wrapped in `{{ with }}`; no `<nil>` can appear.
- [ ] `head.html` contains `{{ adminHead .Page }}`, `end.html` contains `{{ adminBody .Page }}`, and the CSS/JS URLs match `sass.out`/`js.out`.
- [ ] Pages: root area named `content`, start with `head`, end with `end`, `copy` for all languages in `path`, paths unique and starting with `/`.
- [ ] Refs live in `content/refs/`, names don't collide with any other file name in `content/`.
- [ ] No hand-written files in `static/` for CSS/JS output; no extra build tooling.
- [ ] Every Sass partial is `@use`d by the entrypoint; every JS module is imported by the entrypoint.
- [ ] JSON files are valid (e.g. `python3 -m json.tool file.json`).
- [ ] `shifu run .` starts without "Error compiling sass", "Error compiling js", "Error rendering template" or "Error parsing website content file" in the log; no page shows `Reference "…" not found!` or `<nil>`.
- [ ] Logged in at `/admin`, each element can be selected, edited, and new children can be added to every content area.