- Go 56.8%
- TypeScript 39.1%
- CSS 2.4%
- HTML 1.6%
| .forgejo/workflows | ||
| cmd/harness | ||
| docs | ||
| 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),
# or download a release (below)
./harness version # "dev" for a local build, else the release tag
./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 the editor's address and, once Hugo answers, the preview's;
see Serving for its output and the other commands.
Releases
Without a checkout, take the harness from a release. CI cuts one on every
push to main that passes its build and tests, so the
latest release is
always main's head; there are no hand-made tags. A release's tag is its
version — v<YYYYMMDD>.<HHMMSS>-<sha7>, the commit's UTC time and short hash
(e.g. v20261011.143005-0c86c9a) — and is what that binary's harness version prints. Each release carries one bare executable and a checksums
file:
| Asset | Host |
|---|---|
harness-linux-amd64 |
Linux, x86-64 |
SHA256SUMS |
sha256sum format, one line per binary |
The binary is fully static (CGO_ENABLED=0 -tags nodynamic -trimpath;
nodynamic keeps the AVIF decoder's optional purego path from linking
dynamically). Linux x86-64 is the only release target — the host every
site's agent and deploy runs on — which keeps the release job to a single
build; other hosts build from source (go build ./cmd/harness, after
npm run build in web/). The repository is public: the
release API (https://git.cloonar.com/api/v1/repos/Cloonar/deckle/releases/latest,
whose tag_name is the latest tag) and the downloads
(https://git.cloonar.com/Cloonar/deckle/releases/download/<tag>/<asset>)
need no token.
Every scaffolded site documents how its agent gets and updates the harness:
the "Installing the harness" section of the site's AGENTS.md (from
scaffold/site/AGENTS.md) is a short shell procedure that compares
~/.deckle/harness/harness version with the latest tag and, only when they
differ, downloads the binary, verifies it against SHA256SUMS and moves it
into ~/.deckle/harness/harness. Sites never pin a
harness version — they follow the latest release — and nothing is linked onto
PATH; a harness already on PATH (a developer's own build) is used
instead.
Documentation
Every fact has one owning document; this README only points at them (docs/agents/docs-map.md says which owns what).
| Document | For |
|---|---|
| docs/harness.md | the CLI: every command and flag, exit codes, --json output, the receipt, harness.yaml |
| docs/api.md | the HTTP API the editor talks to: one section per route, coded errors, translations on the wire |
| docs/host-navigation.md | the versioned contract for a host that proxies the editor |
| docs/features.md | scaffolding and feature bundles: manifest, dependencies, install, remove, the shipped bundles |
| docs/architecture.md | how it works: content engine, sidecars, git backbone, branch mode, publish, multilingual sites |
| docs/adr/ | architecture decision records |
| scaffold/site/AGENTS.md | the site manual: sidecars, field types, body policies, placement, content grammar |
| scaffold/themes/deckle/README.md | the theme's template contract |
Commands
One line per command, as harness prints it (a test keeps the two equal);
docs/harness.md is the reference for every flag,
exit code and --json shape.
| Command | Does |
|---|---|
harness init |
scaffold a new deckle site into an empty directory |
harness features |
list the embedded feature bundles (and, with --site, their installed state) |
harness add |
vendor a feature bundle (embedded, e.g. news, or --dir) onto an existing site |
harness remove |
uninstall a feature bundle; refuses while it is in use unless --purge |
harness lang |
add a content language to the site, or remove one (refuses while it has translations unless --purge) |
harness reconcile |
rebase the draft branch onto main with the publish policy (host use; branch mode only) |
harness serve |
supervise a Hugo dev server and serve the harness UI |
harness sidecar |
scaffold a draft element sidecar from an element template |
harness check |
report sidecar, template and content problems without starting the editor |
harness migrate |
rewrite every sidecar and language file to the current version in canonical form |
harness version |
print the harness version (the release tag; "dev" for an unstamped build) |
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); unstamped, so harness version prints dev |
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.167.0to 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.
Editor language catalogs
The editor's interface strings live in web/src/i18n/, separate from
web/src/l10n.tsx, which is the content-language axis:
index.tsis the runtime:t(key, params), the SolidI18nProvider/useI18n, and formatters overIntl(dates, relative times, lists, sorting), which run under the formatting locale: the UI language with the region the request or browser named (en-GB), see "The editor's language" in docs/host-navigation.md.rich.tsxholdstRich(key, params, slots), for a sentence with an element inside it (a<code>file name, a<b>count, a link button): the sentence stays one key, and each{slot}placeholder renders as the element given for it. It is kept out ofindex.ts, which must stay importable without JSX.en.jsonandde.jsonare the shipped catalogs; there is one flat JSON file per language, and English is the reference every other one falls back to. Every catalog must carry every key the sources use, with the same placeholders. Keys are dotted and grouped by screen (tree.noPages,dialog.unsaved.title),common.*holds what screens share, andserver.<code>holds one entry per server error code.glossary.mdis the translator's glossary and register rule: impersonal phrasing, and the formal "Sie" where an address is unavoidable. Its Terms table holds the binding German terms; use them rather than a new rendering.servermessages.jsonis generated from the Go error registry; regenerate it withgo test ./internal/apimsg -run TestExport -update.
To add a string, add the key to en.json (and to every other catalog), then
call t("ns.key", {…}) with a literal key. Build a whole sentence as one
parameterized key, never by concatenating pieces, so word order can differ per
language. Placeholders are {name}; {name|q} quotes the value and
{name|list} joins a list. Plurals are key.one and key.other entries
chosen with Intl.PluralRules: do not append an s by hand. Site-authored
labels are data and never go through t().
Two vitest suites enforce this:
web/src/test/i18nParity.test.ts: every literalt()andtRich()key exists in every catalog (English and German alike); no catalog key is unused, and no catalog uses a placeholder English lacks; every code in the server registry has aserver.*entry whose English equals the registry's template; no hand-made plurals;t()andtRich()keys are string literals.web/src/test/i18nLeak.test.ts: scans the.tsxsources for JSX text nodes andtitle,placeholder,aria-labelandaltliterals that hold raw English, and names the file. HTML entities such as—are not counted as words. A short allowlist sits at the top of the test.
The test setup forces the English catalog, so component tests assert English.
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.
- Translations beside the page, no fallback. The content root is
content/itself, nevercontent/<lang>/: a translation is a file beside its source (_index.de.md), so a page missing in a language has no address there — there is no fallback to the default language, and a connected translation cannot be reconnected after it is disconnected. The editor's language UI is a separate change (see Multilingual sites). - 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 open page from the previous WIP snapshot (for the first change on a fresh clone, the baseline taken just before it); there is no multi-step history. It is offered only on a page the last change touched (either page of a cross-page move), and Discard all cannot be undone. Undo is per file: undoing a translation restores only that file, and undoing a source page restores its translations only when the last change propagated into them (and only those the same step changed; one edited by hand since keeps its edit and is repaired to its source's structure the next time it is opened).
- 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,features,add,remove,lang,serve,sidecar,check,reconcile,version)internal/— content engine, sidecars, page ops, media, git backbone, forge client, Hugo resolver, site config, supervisor, and HTTP server; the feature lifecycle lives ininternal/addcmd(manifests, vendoring,hugo.tomlandchildrenedits, receipts) andinternal/removecmd(what content a feature is used by, and the purge);internal/langcmdisharness lang(the language list, itshugo.tomltables, the address flip, the translation files);internal/checkcmdis theharness checkreportweb/— the SolidJS editing UI, embedded into the binaryscaffold/— the theme, element library, page types, and init templates, plus the feature bundles underscaffold/features/<name>/(categories,forms,news), each described by itsfeature.yamlmanifesttestdata/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, the release job that publishes the harness binaries on every push tomain(see Releases), and the daily govulncheck scan reported to CVE Watch