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: truereloads templates and content on every request (local only). For production setdev: false, setserver.hostto"",server.hostnameto the public origin (e.g.https://example.com, used for canonical links/sitemap), andsecure_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 thesassCLI: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:
- Have exactly one top-level HTML element carrying
data-shifu-element="{{ .Content.Position }}". - For each child content area
<area>declared in its admin config, render the children and place a slot directly after them:The{{ .CMS.Render .Args .Page (fmt "%s.<area>" .Content.Position) (index .Content.Content "<area>") }} <slot name="{{ .Content.Position }}/<area>"></slot><area>string must be identical in all three places (position suffix,indexkey, slot name) and in the admin config'scontentkeys. - Read every editable value through
copy(translatable text) orget(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
copyso it is editable and translatable. (Fallback labels viadefaultare 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.htmlandtpl/b/card.htmlwould 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
copyanddataare 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 everycopy/datakey the template uses. Don't store arrays/objects indata— 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", not3), otherwiseselectcomparisons fail. Use{{ int (get .Content "columns") }}when you need a number. - Copy is edited for the languages present in the page's
path. - Use
copyfor anything a translator would change (text, alt text, link URLs that differ by language, button labels); usedatafor design choices (variant, color, image, alignment, size). - Choose
selectover free text for design options and keep option keys stable — they are referenced by CSS modifier classes. - Prefixing a
labelwith_(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) orref(reference name), plus optionalcopy,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": [] }. pathkeys are language codes; the value is the exact route (no trailing slash, must start with/). Every language inpathshould havecopyfor every element; missing languages fall back toen.- 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_foundinconfig.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 — usestatic/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-emptycopy/dataon 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. nocontent/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 soshifu.dbexists; needs thesqlite3CLI):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 fromselectoption keys. - Style rich-text output by element (
.text__body p, .text__body ul …) since editors can't add classes. - Expose admin
colorfields 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; noonclick=attributes. - Pass configuration via
data-*attributes rendered fromcopy/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, withdefer.
9. Workflows
9.1 Create a whole new site
- Write
config.json,.secrets.env,.gitignore(.secrets.env,shifu.db,static/main.css*,static/main.js*). - 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
selectoptions over many near-duplicates. - For each element type create: template, admin config, Sass partial (+
@useinmain.scss), and JS module (+ register inmain.js) when interactive. - Create refs:
content/refs/head.json,header.json,footer.json,end.json. - Create pages in
content/pages/, each starting withheadand ending withend, withcopyfor every language in itspath. Include a 404 page. - Put referenced images in
static/media/. - Run
shifu run ., open every page, check the log for errors.
9.2 Edit an existing site
- Read
config.json, listtpl/,admin/tpl/,content/and the Sass/JS entrypoints first to learn the existing element inventory and conventions. - Reuse existing element types before creating new ones.
- 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. - Renaming a
copy/datakey or content area → update the template, the admin config and every page/ref JSON that uses it (search all ofcontent/). - Removing an element type → first remove all its uses from
content/. - 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>.htmlandadmin/tpl/**/<name>.jsonwith 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 configcontent, and the JSON. - Every
copy/datakey read in a template is declared in the admin config with the righttype. - No arrays/objects in
data; allcopy/datavalues are strings. htmlvalues are wrapped in{{ with }}; no<nil>can appear.head.htmlcontains{{ adminHead .Page }},end.htmlcontains{{ adminBody .Page }}, and the CSS/JS URLs matchsass.out/js.out.- Pages: root area named
content, start withhead, end withend,copyfor all languages inpath, paths unique and starting with/. - Refs live in
content/refs/, names don't collide with any other file name incontent/. - 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 showsReference "…" not found!or<nil>.- Logged in at
/admin, each element can be selected, edited, and new children can be added to every content area.