No description
  • Go 54.2%
  • TypeScript 40.9%
  • CSS 2.6%
  • HTML 1.9%
  • JavaScript 0.4%
Find a file
2026-07-19 19:52:15 +02:00
.forgejo/workflows docs: rewrite README for v1; pin repo CI Hugo; spec-align alt requirement 2026-07-13 06:55:49 +02:00
cmd/harness feat(cli): harness add <feature> — vendor embedded feature bundles onto a site 2026-07-17 21:57:41 +02:00
internal feat(news): categories field on news items + category filter on news-list 2026-07-19 19:23:21 +02:00
scaffold feat(news): categories field on news items + category filter on news-list 2026-07-19 19:23:21 +02:00
testdata Merge pull request 'feat: page-teasers section becomes a picked page reference' (#98) from afk/89 into main 2026-07-18 03:59:33 +02:00
web feat: add multiselect field type (multi-value curated options) 2026-07-19 10:35:56 +02:00
.gitignore feat: add testsite to gitignore 2026-07-16 00:43:48 +02:00
CLAUDE.md feat(init): scaffold an agent manual (AGENTS.md + CLAUDE.md) into new sites 2026-07-17 03:08:59 +02:00
go.mod feat(rte): rich text bodies — owned TipTap wrapper, allowlists, sanitization, chips 2026-07-13 06:43:39 +02:00
go.sum feat(media): sniffing gate, processing pipeline, refs, thumbnails, library 2026-07-13 05:20:12 +02:00
Makefile docs: rewrite README for v1; pin repo CI Hugo; spec-align alt requirement 2026-07-13 06:55:49 +02:00
README.md feat: add multiselect field type (multi-value curated options) 2026-07-19 10:35:56 +02:00

deckle

Deckle turns a plain Hugo site in git into a safe CMS for a non-technical editor. It ships as one Go binary — harness — that supervises hugo server for a live preview and serves an embedded editing UI, while a content engine and a set of per-element constraints keep the editor from ever producing markup the site cannot build.

It serves two audiences from one repository:

  • The developer owns a normal Hugo site: templates, a theme, config, and content files in git. Nothing about deckle is proprietary — the repository is vanilla Hugo, builds with stock hugo, and deploys however you like. Remove the harness and the site still builds.
  • The client editor gets a locked-down editing surface. They can only place the elements the developer declared, fill the fields the developer defined, and write rich text within a per-field allowlist. They never touch templates, never write raw HTML, and cannot leave the site in a state that fails to build: every publish is gated on a real Hugo build first.

There is no lock-in in either direction. The editing constraints live in plain YAML sidecar files next to the content; the content stays canonical Hugo markdown; and the whole editing session is ordinary git commits on main.

Quick start

make build                         # build ./harness (frontend + Go binary)

./harness init --site mysite       # scaffold a complete Hugo site: theme,
                                   #   element library, page types, CI workflows
./harness serve --site mysite      # supervise Hugo and open the editor

serve prints its address and binds loopback only — it is not reachable from other machines:

deckle harness listening on http://127.0.0.1:8090/

The four commands

harness init [--site <dir>] [--title <title>] [--lang <lang>]
harness add <feature> [--site <dir>]
harness serve --site <dir> [--addr 127.0.0.1:8090]
harness sidecar <name> [--site <dir>]
  • init scaffolds a new site from the embedded templates. Defaults: --site ., --title "My Site", --lang en. It is fully non-interactive and never overwrites — a target holding anything but .git is refused with the conflicting paths listed and nothing written (exit 1); usage errors exit 2.
  • add vendors an embedded feature bundle (e.g. harness add news) onto an existing site: it copies the bundle's sidecars and layout overrides under the site root — never overwriting an existing file, all-or-nothing — then applies the bundle's config edits (news adds buildFuture = true to hugo.toml). --site defaults to .; an unknown feature, a path conflict, or a target that is not a site exits 1.
  • serve supervises a Hugo dev server and serves the editor UI. --site is required; --addr defaults to 127.0.0.1:8090 and only loopback hosts (127.0.0.1, localhost, ::1) are accepted — a public interface is refused by design. Run it from inside the site with --site ..
  • sidecar scaffolds a draft element sidecar from an existing shortcode: it parses <site>/layouts/shortcodes/<name>.html and writes <site>/editor/elements/<name>.yaml, never overwriting one that exists.

How it works

Canonical content engine. internal/content is a lossless round-trip engine between Hugo content files and a typed element tree. Parse tokenizes with Hugo's own pageparser lexer and builds a Document; Serialize renders it in one canonical layout. serialize ∘ parse is the identity on canonical files and parse ∘ serialize is the identity on valid trees, so an edit rewrites only what changed. Front matter is edited surgically: YAML and TOML line by line (comments, key order, and unmanaged keys survive byte for byte), JSON with its key order and value tokens preserved, and every edit is verified by re-decoding rather than blindly rewritten. Shortcode params are named-only and typed the way Hugo types them ("123" is a string, bare 123 is an int).

Element and page-type sidecars — the constraint language. Editing is constrained by plain YAML files under <site>/editor/. An element sidecar (editor/elements/<name>.yaml) describes one editable shortcode: its label, icon, group (structure | content | inline), body policy, placement, and a list of typed params. A param's type is one of exactly ten field types — text, textarea, number, bool, select, multiselect, image, link, datetime, page — plus required, an optional default, and options (for select and multiselect). A page-type sidecar (editor/pagetypes/<name>.yaml) declares a kind of page: which page types are creatable beneath it (children), which elements it admits (elements), and its own front-matter fields (the same ten-type vocabulary — except multiselect, a string list stored as native YAML front matter, which is valid only in page-type fields, never in element params). Placement has two axes — allowedIn (parent element names, plus the token page for the top level) and allowedAt (page-type names) — and both are enforced server-side when an element is inserted or moved, intersected with the page type's elements allowlist.

A module shell, TYPO3-style. The backend is a module rail, a persistent page tree, and the active module's content. Modules are a registry (web/src/modules.tsx) — an id, a label, an icon, whether it wants the page tree, a component — and the first two are Page (the edit screen) and View (the live preview). Module, language, and selected page live in the URL (/page/en/blog/post.md), so a reload, a shared link, and the back button all reconstruct the same view; a navigation away from unsaved form edits is guarded (discard / save & continue / stay).

Generated forms, live preview, click-to-select. The SPA renders an editing form for each element straight from its sidecar schema — one widget per field type — so adding a param to a sidecar adds a field to the form with no code. The supervised hugo server renders the real site in the View module. Under hugo server (and only there) the theme tags every block element with data-ce-name/data-ce-page/data-ce-path and loads ce-bridge.js, which talks to the editor over an origin-validated postMessage handshake: clicking an element in the preview jumps to the Page module with that element selected, and saved edits rebuild the preview live. Production builds contain none of these markers, no bridge, and no editor CSS.

Branch-bundle page management. internal/pageops creates, renames, moves, reorders, drafts, and deletes pages. Every page it creates is a branch bundle (<slug>/_index.md), so any page can hold subpages; legacy leaf bundles (<slug>/index.md) and plain single-file pages from existing sites are fully supported by every operation except create, and are converted to branch bundles — URL-unchanged, no alias — the first time a child is created under or moved into them. Rename and move record the page's old published URL as a front-matter alias, so a published URL never 404s after the editor reorganizes the tree. Operations are confined to the content root, an existing target is always an error (never an overwrite), and a move whose post-write step fails rolls back to "nothing happened."

Rich text, never raw HTML. Body editing accepts markdown or a restricted rich-text (rte) policy. An rte field's allow list restricts formatting to a subset of {bold, italic, link, bulletList, orderedList, h2, h3, h4}; h1 is never valid because it is reserved for the page title. allowedInline names inline elements offered as chips. Raw HTML is rejected on every body save — for markdown and rich-text bodies alike — which is load-bearing, not cosmetic: the theme runs goldmark with unsafe = true, so nothing downstream sanitizes body markdown and the server's body validation is the raw-HTML defense.

Media gate. Every upload is admitted by content before a byte touches disk. The type is sniffed from magic bytes (never the filename or a declared MIME type); the allowlist is JPEG, PNG, WebP, AVIF, and PDF — SVG, video, and everything else are rejected with a message naming what was actually detected. A 15 MiB ceiling is enforced before any decoding. Raster images are downscaled so the longest edge is at most 2560px (never upscaled), re-encoded (JPEG quality 85 for opaque images, PNG when alpha is present), and stripped of all metadata — EXIF, GPS, colour profiles — so uploads cannot smuggle location data into a public repository. The stored file's extension is rewritten to match the sniffed type. PDFs are size-capped and stored byte for byte. Alt text is required by default on the shipped image elements — a missing alt blocks the save with an inline error; a developer opts a field out for decorative images by setting required: false on the alt param in the sidecar. Alt is always emitted on the <img>.

Git backbone. internal/gitrepo drives every repository operation by shelling out to git. After each content mutation the harness commits the whole working tree to a throwaway local ref, refs/deckle/wip (never pushed), without touching your index, HEAD, or branches — this is the single-step undo store. Publishing a page runs a non-negotiable pre-flight: it exports "main plus exactly this bundle" into a throwaway directory and builds it with the resolved (pinned) Hugo, production semantics, no drafts flag; if that build fails, nothing is committed or pushed. Publish then fetches remote main, reconciles a diverged local main by rebasing it in a temporary worktree (your checkout is never disturbed) with a local-wins-inside-content/, remote-wins-everywhere-else conflict policy, commits exactly the requested paths, and only advances refs after the push succeeds — a rejected push leaves the repo exactly as before and a retry is clean. Discard resets a page to its main state (a never-published page is removed). Per-page badges report published, modified, or draft (git-draft = absent from main). If the site is not a usable repository, saves still work and the git-backed endpoints degrade honestly rather than crashing.

Publish feedback. After a publish pushes to main, the harness — never the browser — polls the forge's commit-status API for the pushed SHA and normalizes CI state into a lifecycle the UI renders: Publishing… → Live, Build failed (with a run link), or an honest message when no CI activity appears. The API token is read from the DECKLE_FORGE_TOKEN environment variable, else from an out-of-repo file at <os.UserConfigDir>/deckle/forge-token; it is never read from the site repo, never logged, and never sent to the browser. With no token, no usable remote, or an unsupported forge, feedback falls back to a static, honest message instead of pretending.

Configuration reference

harness.yaml

A site may drop a harness.yaml at its root. It is optional — a missing file means all defaults and hugo from PATH. Decoding is lenient: unknown keys are tolerated so later versions can add settings.

title: My Site               # site title shown in the editor
languages: [en]              # exactly one entry in v1; defaults to [en]
defaultBackendLayout: one-col # fallback backend layout; must name a loaded layout
hugo:
  version: "0.163.3"          # exact three-component pin — no ranges, no "latest"
  # binary: /usr/local/bin/hugo   # optional explicit path; bypasses download

defaultBackendLayout is validated at startup: if it names a layout that editor/backendlayouts/ does not define, serve refuses to start rather than silently editing pages against a layout that does not exist. Omitting it is fine — pages then resolve to no layout and the page module falls back to a flat element list.

Hugo resolution follows a strict order, with no silent fallback once a pin or override is in effect (any failure — download error, checksum mismatch, missing binary — makes serve refuse to start rather than run a different Hugo):

  1. hugo.binary, if set, is used exactly as given (relative paths resolve against the site dir) and never downloads — the air-gapped escape hatch.
  2. Otherwise hugo.version, if set, is satisfied in the cheapest way that still honours the pin exactly:
    • If a hugo already on PATH is that exact version and the extended edition, it is used as-is — no download. This is what makes NixOS (and any distro that cannot exec generic Linux release binaries) work out of the box: install the pinned hugo however you like and the harness picks it up. It is not a fallback to a different Hugo — only an exact, extended match qualifies, so the pin's reproducibility guarantee holds.
    • Otherwise the extended-edition release for the current OS/arch is downloaded on first use, verified against the official sha256 checksums, and cached under the site's local state directory: <site>/.harness/hugo/<version>/<os>-<arch>/hugo (gitignored, reused across runs). If that binary turns out not to run on this machine (e.g. NixOS with no matching hugo installed), serve fails with an actionable error rather than a cryptic loader message. The thumbnail cache lives under <site>/.harness/thumbs.
  3. With no pin and no override, hugo from PATH is used, unchanged.

The active version and its provenance (pinned for a download, path-pinned for an installed hugo that satisfies the pin, binary, or path) are reported on GET /api/status and surface in the UI's status pill.

Element sidecar

One file per shortcode, editor/elements/<name>.yaml, where <name> is the shortcode name. Worked example (the scaffold's hero):

version: 1                    # required, integer >= 1
label: Hero                   # required
icon: hero                    # optional
group: content                # structure | content | inline (default content)
body:
  policy: rte                 # none | markdown | rte (default none)
  allowedInline: [button]     # rte-only: inline elements offered as chips
  # allow: [bold, italic, link, bulletList, orderedList, h2, h3, h4]
placement:
  allowedIn: [page]           # parent element names, plus "page" for top level
  # allowedAt: [post, page]   # page-type names
params:
  - name: heading
    type: text                # one of the ten field types (multiselect: page-type fields only)
    label: Heading
    required: true
    # default: ...            # YAML type must match the field type
    # options: [a, b]         # required for type select, forbidden otherwise

Body policy

body is either a scalar policy (body: rte) or a mapping. allow and allowedInline are legal only when policy: rte. allow restricts formatting to a subset of {bold, italic, link, bulletList, orderedList, h2, h3, h4} — an absent allow means the full set, an empty list means no formatting. h1 is never allowed. allowedInline names other loaded elements whose group is inline; each must exist or the sidecar fails to load.

Page-type sidecar

One file per type, editor/pagetypes/<name>.yaml. A page records its type in Hugo's type front-matter key, falling back to its top-level section name; a type with no sidecar is unconstrained. Worked example (the scaffold's page):

version: 1                    # required, must be exactly 1
label: Page                   # optional; defaults to the type name
icon: page                    # optional
# singleton: true             # optional; hides create handles for this type
children: [page, section]     # page types creatable beneath this one
# elements: [hero, notice]    # element allowlist for pages of this type
fields:                       # page-level front-matter fields (same vocabulary)
  - name: title
    type: text
    label: Title
    required: true
  - name: hidden
    type: bool
    label: Hide from header navigation
    default: false

children and elements distinguish three states, preserved to the wire: absent/null = unrestricted, [] = nothing permitted, non-empty = exactly those named. singleton is optional and purely presentational: the editor hides create-handles for a singleton type (the home page is the canonical one). The reserved keys type, draft, weight, aliases, backendLayout, and backendLayoutNextLevel may not be declared as fields; title may (and usually is).

Content-root authority

There is no reserved declaration file for the content root. What may be created directly at the root is governed by the root page's own type — the explicit type in content/_index.md — whose children list decides, with the same absent/empty semantics as any other parent. No root page, no explicit type, or no sidecar for that type leaves the root unconstrained. The scaffold ships a home type for its root page:

version: 1
singleton: true
children: [page, section]

(The earlier reserved _root.yaml mechanism has been removed; a leftover file by that name is ignored and reported as a page-type load error.)

Backend layout sidecar

One file per layout, editor/backendlayouts/<name>.yaml. A backend layout is a grid — rows of columns, every cell a named slot — and it is what the page module renders instead of a flat element list. Worked example (the scaffold's two-col):

version: 1                    # required, must be exactly 1
label: Two columns            # optional; defaults to the layout name
allowedAt: [page, section]    # optional; page types this layout may be chosen on
rows:
  - columns:
      - { name: main,    label: Main,    colspan: 3 }
      - { name: sidebar, label: Sidebar, colspan: 1, inherit: true }

A slot's elements allowlist and the layout's allowedAt follow the same three-state rule as everything else (absent/null = unrestricted, [] = nothing, non-empty = exactly those named). colspan and rowspan default to 1. Slot names must be unique across the whole layout.

The insert options offered in a slot are the intersection of four allowlists — the slot's elements, the element's allowedIn and allowedAt, and the page type's elements — enforced server-side, exactly as placement always has been.

Slots, layout resolution, and inheritance

A page persists a slot's content as a top-level slot wrapper shortcode in its body:

{{< slot name="main" >}}
{{< hero heading="Welcome" >}}…{{< /hero >}}
{{< /slot >}}

slot is reserved and harness-managed: it is never offered in the insert picker and cannot be inserted, deleted, or moved as an element. It round-trips through the content engine like any other shortcode, and the ordinal paths the preview bridge uses keep working straight through it.

Which layout applies to a page — its effective layout — resolves in one order, implemented once in Go for the editor and once in the theme for rendering:

  1. the page's own backendLayout front-matter key;
  2. else the nearest ancestor's backendLayoutNextLevel (which deliberately applies to descendants, not to the page that declares it);
  3. else defaultBackendLayout from harness.yaml;
  4. else none — a flat element list in the editor, plain .Content in the theme.

A slot that is empty or absent on a page inherits the nearest ancestor's content for that slot name. There is no opt-out: empty always means inherit. The page module shows inherited content greyed out and read-only, with a link to the ancestor that owns it.

The split worth internalizing: the backend layout decides what is editable on a page; the frontend template decides what renders. A slot may be declared — and edited — only on an ancestor's layout (a site-wide footer band, say) and still render on every descendant purely by inheritance, without ever appearing in the descendants' editing grid.

Switching a page's layout is non-destructive: slot wrappers that the new grid does not declare keep their content in the file and surface under Unused content in the page module, with move-to-slot and delete actions.

Scaffolding

harness init writes a complete, buildable site from the embedded scaffold: hugo.toml, harness.yaml, content/_index.md, the themes/deckle/ theme, the editor/elements/ library and editor/pagetypes/ declarations, a .gitignore, and both deploy workflows — .forgejo/workflows/deploy.yml and .github/workflows/deploy.yml. Four files (hugo.toml, harness.yaml, and the two deploy workflows) get @@TITLE@@, @@LANG@@, and @@HUGO_VERSION@@ substituted; the theme and every sidecar are copied verbatim. Init never overwrites and is fully non-interactive.

Both generated deploy workflows install the exact pinned Hugo (extended) and run hugo --minify, then leave the actual deploy commented out with two ready-made options — rsync-over-SSH and forge/GitHub Pages — for you to pick one, uncomment, and add the referenced secrets. No secrets or absolute paths are committed.

The theme is copied into themes/deckle and is yours to fork: restyle it, edit it in place, keep the small editor contracts intact (see the theme's own README.md). There is deliberately no update or sync mechanism — once scaffolded, the copy is yours and the harness never touches it again.

The sidecar scaffolder (harness sidecar <name>) removes the blank-page cost of authoring a sidecar for an existing shortcode: it parses layouts/shortcodes/<name>.html, recovers the params it reads (.Get "x"), its body policy (.Inner → markdown), and literal defaults, and emits a draft editor/elements/<name>.yaml studded with TODO comments for you to finish. It is a one-shot scaffold, not a sync, and never overwrites.

Development

The frontend lives in web/ (SolidJS + TypeScript, Vite) and is embedded into the Go binary via web/embed.go. Because of that embedding, web/dist must exist before go build or go test — build the frontend first.

Makefile targets:

Target Does
make web npm install + npm run build in web/
make build make web, then go build -o harness ./cmd/harness (default)
make test frontend tests (vitest), then go test ./...
make run make build, then serve testdata/site
make clean remove harness and web/dist

Toolchain (standard, no nix required):

  • Node 22+ — for the web/ frontend build and tests.
  • Go per go.mod (currently 1.26) — for the harness.
  • Hugo extended on PATH — required by the theme build test in internal/theme and handy for previewing the demo sites; pin 0.163.3 to match the theme and the scaffold.

For UI work, web/ has its own dev server (cd web && npm run dev) that proxies /api to a harness on :8090, so run ./harness serve --site testdata/site alongside it for live API data.

v1 limitations

Stated honestly — these are deliberate scope boundaries, not bugs:

  • Single-user, local-only. The harness binds loopback exclusively; there is no auth, no multi-user coordination, and operations assume one editor.
  • One language. harness.yaml accepts exactly one language; the content root is content/ itself, not content/<lang>/.
  • Plain git, no LFS. Media lives in the repository, sized for brochure-scale sites, not asset-heavy ones.
  • Named shortcode params only. Positional shortcode parameters (.Get 0) are rejected by the content engine and cannot be modeled as fields.
  • Org front matter is read-only. Any edit that would rewrite it fails with an actionable error; YAML, TOML, and JSON front matter are editable.
  • Single-step undo. Undo restores the previous WIP snapshot; there is no multi-step history.
  • SVG and video uploads are rejected. The media allowlist is JPEG, PNG, WebP, AVIF, and PDF only.
  • GitHub status polling is not implemented yet. Publish feedback detects and polls Forgejo/Gitea only; the client abstraction is in place for GitHub to be added as a new case without touching callers.
  • renderer.unsafe = true is required by the theme. Inline chips inside rich-text bodies are spliced into a shortcode's .Inner as raw HTML, so the theme needs goldmark's unsafe renderer. Consequently the server's body validation — rejecting raw HTML on every save — is the raw-HTML defense, not a UX nicety.

Repository layout

  • cmd/harness — the CLI entry point (init, add, serve, sidecar)
  • internal/ — content engine, sidecars, page ops, media, git backbone, forge client, Hugo resolver, site config, supervisor, and HTTP server
  • web/ — the SolidJS editing UI, embedded into the binary
  • scaffold/ — the theme, element library, page types, and init templates
  • testdata/site — a self-contained editing demo (its own sidecars + layouts)
  • testdata/library-site — a theme/render demo exercising every element
  • .forgejo/workflows — this repository's own CI