- Go 54.2%
- TypeScript 40.9%
- CSS 2.6%
- HTML 1.9%
- JavaScript 0.4%
|
All checks were successful
ci / build-and-test (push) Successful in 6m19s
|
||
|---|---|---|
| .forgejo/workflows | ||
| cmd/harness | ||
| internal | ||
| scaffold | ||
| testdata | ||
| web | ||
| .gitignore | ||
| CLAUDE.md | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
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>]
initscaffolds 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.gitis refused with the conflicting paths listed and nothing written (exit 1); usage errors exit 2.addvendors 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 addsbuildFuture = truetohugo.toml).--sitedefaults to.; an unknown feature, a path conflict, or a target that is not a site exits 1.servesupervises a Hugo dev server and serves the editor UI.--siteis required;--addrdefaults to127.0.0.1:8090and 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 ..sidecarscaffolds a draft element sidecar from an existing shortcode: it parses<site>/layouts/shortcodes/<name>.htmland 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):
hugo.binary, if set, is used exactly as given (relative paths resolve against the site dir) and never downloads — the air-gapped escape hatch.- Otherwise
hugo.version, if set, is satisfied in the cheapest way that still honours the pin exactly:- If a
hugoalready onPATHis 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 pinnedhugohowever 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 matchinghugoinstalled),servefails with an actionable error rather than a cryptic loader message. The thumbnail cache lives under<site>/.harness/thumbs.
- If a
- With no pin and no override,
hugofromPATHis 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:
- the page's own
backendLayoutfront-matter key; - else the nearest ancestor's
backendLayoutNextLevel(which deliberately applies to descendants, not to the page that declares it); - else
defaultBackendLayoutfromharness.yaml; - else none — a flat element list in the editor, plain
.Contentin 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 ininternal/themeand handy for previewing the demo sites; pin0.163.3to 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.yamlaccepts exactly one language; the content root iscontent/itself, notcontent/<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 = trueis required by the theme. Inline chips inside rich-text bodies are spliced into a shortcode's.Inneras 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 serverweb/— the SolidJS editing UI, embedded into the binaryscaffold/— the theme, element library, page types, and init templatestestdata/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