mirror of
https://github.com/Kugelschieber/schnittfest.git
synced 2026-11-12 09:02:01 +01:00
765 lines
30 KiB
Markdown
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.
|