mirror of
https://github.com/Kugelschieber/schnittfest.git
synced 2026-11-12 09:02:01 +01:00
New design.
This commit is contained in:
@@ -0,0 +1,764 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user