Files
2026-10-14 19:29:31 +02:00

30 KiB

AGENTS.md — Building and editing websites with Shifu

This file tells an AI agent how to create and maintain a website built with 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:

{
    "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):

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 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:
    {{ .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:

{{- $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:

{{- $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:

{{- $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:

<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:

{{- $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:

{{ 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.

{{/* tpl/partials/button.html */}}
<a class="button button--{{ default .Variant "primary" }}" href="{{ .Href }}">{{ .Label }}</a>
{{ 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.

{
    "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:

{ "label": "_HTML Head", "layout": true, "copy": { "title": { "label": "Title" }, "meta_description": { "label": "Meta Description" } } }
{ "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.

{
    "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.

{
    "tpl": "head",
    "copy": {
        "en": { "title": "Acme", "meta_description": "Acme builds things." },
        "de": { "title": "Acme", "meta_description": "Acme baut Dinge." }
    }
}
{
    "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):
    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:

@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:

$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:

@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:

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:

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 @used 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.