deckle v1: full rebuild — Hugo CMS harness (complete spec) #31

Closed
opened 2026-07-13 02:36:00 +02:00 by dominik.polakovics · 0 comments

Summary

Build deckle v1 from scratch: a single Go binary that turns a plain Hugo site in a git repository into a safe CMS for a non-technical editor, with a SolidJS editing SPA, a lossless canonical content engine, sidecar-YAML-defined editing constraints, a shipped element library + modern theme, a git-backed save/publish workflow, and one-command site scaffolding.

This issue is the complete, authoritative spec. It replaces the retired v1 plan (#2–#17, closed as superseded by the clean-slate reset, #29 / PR #30). The repository is empty at start. The previous implementation exists in git history before PR #30 and may be consulted as reference, but where history and this spec disagree, this spec wins. Every design decision below was explicitly confirmed by the maintainer — do not relitigate them.

Product definition

  • Two audiences. The developer sets up the site, owns the theme and deployment, and defines exactly what is editable. The client editor (non-technical) edits content through forms, uploads images, and hits Publish — never seeing git, markdown, or Hugo. Where their needs conflict, the client editor wins; design so conflicts are rare.
  • The promise. The safety and simplicity of a locked-down CMS with the ops profile of a static site in git. The client cannot break the site; the developer keeps a vanilla Hugo repo that builds without this tool. No lock-in in either direction.
  • v1 runtime model: single-user, local-only. One person runs the binary on their own machine and edits at localhost. No authentication, no user management, no page locks, no CSRF machinery, no X-Remote-User. The server refuses to bind non-loopback addresses. Required discipline: the SPA talks to the harness exclusively through a clean HTTP JSON API, so a future proxied multi-user deployment is an ops change, not a redesign.

Architecture pillars (fixed decisions)

  1. Hugo-specific, git-backed. The tool deeply knows Hugo (bundles, front matter, shortcodes, hugo server, hugo builds). Git is the persistence and publish backbone: push to main = deploy. Not SSG-agnostic, not a SaaS, no database.
  2. Content = shortcodes in markdown. A page is ordinary Hugo markdown; each element is a shortcode ({{< hero … >}}), text between/inside them is first-class. No custom front-matter block format.
  3. The canonical round-trip engine is load-bearing. Every content mutation flows through a typed element tree and a byte-canonical serializer. Nothing anywhere in the codebase string-munges markdown. The engine is built first and everything else stands on it.
  4. Sidecar YAML is the constraint language. Developers declare what is editable via editor/elements/*.yaml and editor/pagetypes/*.yaml. Explicit declarations are the source of truth; inference is only a scaffolding aid.
  5. Editing paradigm: structured element list + generated forms + live preview. Side-by-side forms editing with click-to-select in the preview. No inline/in-preview editing in v1, and inline editing must not drive the architecture.
  6. Frontend: SolidJS + TypeScript SPA (Vite build, embedded into the Go binary). Rich text via TipTap core with a self-owned Solid wrapper (no React/Vue packages; treat community solid-tiptap as reference only).
  7. Server is the sole canonicalizer. The browser sends semantically correct data; the Go engine parses and re-serializes to canonical bytes on every save. The client never promises byte identity.

Functional spec

1. Content engine (Go, internal/content or equivalent)

  • Parse a Hugo content file (front matter in YAML/TOML/JSON + markdown body with shortcodes) into a typed tree: front matter (order-preserving for unmanaged keys), and a body node sequence of text nodes, block shortcode nodes (name, params, markup flag {{< vs {{%, children), and inline/self-closing shortcode nodes. Nesting is supported (grid → columns → elements).
  • Serialize the tree back to canonical bytes: deterministic param ordering/quoting, deterministic spacing, exactly one trailing newline. parse(serialize(t)) == t and serialize(parse(canonical)) == canonical always. Parsing non-canonical-but-valid input then serializing yields canonical output with semantics preserved.
  • Text nodes containing raw {{< / {{% are a serialize error (shortcodes can never be flattened into text).
  • Unknown/unparseable constructs must round-trip byte-exact rather than being destroyed; a file the parser cannot safely model is a read error, never a silent rewrite.
  • Test discipline: a corpus of canonical fixtures (parse → serialize byte-identical), non-canonical input/golden pairs, and a FuzzParseRoundTrip fuzz target. This suite is the foundation of the whole product — be thorough.

2. Harness CLI and configuration

  • Commands: serve (the editor), init (scaffold a new site, §12), and a sidecar scaffolder (§3).
  • serve requires an existing Hugo site, binds loopback only, supervises a hugo server child process (restart on crash, status surfaced to the SPA), and serves the embedded SPA + JSON API.
  • harness.yaml at the site root: site title, language list (exactly one entry in v1), pinned Hugo version, sensible defaults. Its Go type lives in a package importable without the server.
  • Hugo version pinning: the harness downloads the pinned Hugo release for the host platform on demand, verifies the artifact checksum, caches it under the harness's local state dir (.harness/, gitignored), and uses that binary for preview, pre-flight, and everything else. CI uses the same pin (§12). If harness.yaml or the pin is absent, fall back to hugo on PATH with a visible warning.

3. Sidecar schemas

  • Element sidecars at editor/elements/<name>.yaml, one per shortcode. Shape: version: (schema version int, required), label, icon, group (structure | content | inline), body policy (none | markdown | rte with optional per-field allowlist, §6), params (list of: name, type, label, default, required, options), and placement (allowedIn: parent element names plus a page token for top level; allowedAt: page type names; absent/empty list = unrestricted on that axis).
  • Param field types: text, textarea, number (int/float), bool, select (options), image, link, plus whatever small set the element library genuinely needs — keep the vocabulary minimal and documented.
  • Loading and validation: malformed sidecars produce actionable errors (file, field, reason) surfaced via API/UI; valid sidecars still load. Duplicate names are errors.
  • Unknown shortcodes (used on a page, no sidecar): rendered in the element list as an opaque "unknown element: <name>" block — visible, selectable, not editable, round-trips byte-exact. No inferred fallback forms in v1.
  • Sidecar scaffolder CLI (e.g. harness sidecar <name>): parses layouts/shortcodes/<name>.html with Go's template AST, emits a draft sidecar — params pre-listed from .Get calls, body policy from .Inner usage, defaults recovered from | default idioms where present, every param typed text with a # TODO: set type/label marker. Developer edits and owns the result.
  • Page-type sidecars at editor/pagetypes/<name>.yaml: version:, label, icon, children (page types creatable beneath), elements (element allowlist for pages of this type), fields (page-level front-matter fields, same field vocabulary as element params — title, SEO description are the typical case). A way to declare which types are allowed at the content root must exist. A page records its type in front matter (Hugo's type); no declaration for a type = allow everything (permissive default).

4. SPA shell, page tree, element list, forms

  • Layout: tree pane (site page tree, left), page pane (selected page's element list, center; the form mounts within/beside it), preview pane (right), plus a status pill for the supervised Hugo process.
  • Page identity is always the pair (path, lang) — never a bare path — threaded through every API endpoint and UI selection seam from day one, even though v1's UI manages a single language. A single server-side function resolves language → content root; handlers never join paths themselves.
  • Tree API/UI: sections and pages from the content directory, ordered by weight then title, drafts visually distinct, expansion/selection state survives refreshes.
  • Generated forms: selecting an element renders a form from its sidecar (labels, defaults, required, typed inputs). Server-side validation is authoritative; errors map inline to fields. Save sends values; the server validates, mutates the tree, canonically serializes, rewrites only that page's file; preview updates via LiveReload.
  • Element CRUD (all in the page pane, all server-validated, all through the engine):
    • Add: a picker at a chosen insertion position (top level between siblings, or inside a container), grouped by sidecar group with label + icon, offering only element types allowed there (placement rules ∧ page-type element allowlist). Insert with schema defaults, then open its form.
    • Delete: confirmation naming the element and warning about children; removes the whole subtree.
    • Drag & drop: reorder among siblings and move into/out of containers (including empty ones); invalid targets show a no-drop affordance and dropping there is a no-op; a drop is one mutation.
    • Positions are addressed by child-index paths into the parsed tree (nodes have no IDs); the client and server share the scheme. A rule-violating mutation request is rejected server-side with an actionable error and the file untouched, regardless of what the UI allowed.
  • Undo: built on the wip snapshots (§8) — no bespoke undo store. Page-scoped: "Undo" restores the selected page's state from the previous snapshot through the normal canonical save path. Single-step in v1; the UI is honest about that.

5. Page management

  • Every page the tool creates is a leaf bundle (<slug>/index.md; _index.md for sections). Plain single-file .md pages from existing sites are read and edited but never created.
  • Create: "New page/section" under a selected parent offers only the page types the parent's type (or the root declaration) allows. Asks for a title; slug auto-generated (lowercased, hyphenated, sibling-collision-safe), overridable. Writes the bundle with the type's front-matter defaults, the recorded type, and draft: true.
  • Rename/move: rewrites the bundle path (whole directory incl. resources) and appends the previous published URL to front-matter aliases (existing aliases preserved, no duplicates). Renaming/moving a section aliases every descendant's old URL. Invariant: published URLs never 404.
  • Reorder: drag & drop within a section rewrites sibling weight deterministically; across sections it is a move + alias + placement. Tree order always matches Hugo's render order.
  • Enable/disable: toggles draft; drafts remain visible in preview (the preview server builds drafts) — disabled means "not published", not "not previewable".
  • Delete: confirmation naming the page and its descendant count (mandatory for sections with children); removes the bundle/subtree. Recovery is git's job (§8), no trash.
  • Page settings: selecting the page itself (vs. an element) shows the schema-generated form for its page type's front-matter fields.
  • Write safety: every front-matter mutation preserves unmanaged keys and the body byte-for-byte; all operations confined to the content root; an existing target path is an error, never an overwrite; no half-moved state on failure.

6. Rich text bodies

  • Body policy rte in a sidecar renders a TipTap editor (via the owned Solid wrapper: mount/destroy lifecycle, editor state bridged into Solid reactivity, generic across fields).
  • Formatting allowlist, per field from the sidecar; default: bold, italic, links, bullet + numbered lists, headings h2–h4. Each construct grantable/withholdable per field. The allowlist controls the toolbar, keyboard shortcuts, input rules, and paste. h1 is never offered or accepted anywhere (reserved for the page title).
  • No raw HTML, ever. Raw HTML in source renders as visible literal text, never interpreted, never executed, never emitted. Constructs outside the field's allowlist in pre-existing content degrade to their plain-text reading rather than rendering or being silently dropped.
  • Paste from Word/Google Docs/web maps recognizable formatting to the allowlist and strips everything else (no styles, classes, spans, comments).
  • Links: external URL by hand, or internal page chosen from the site tree (stored as tree path). Editable and removable.
  • Round-trip: the editor loads from the element's markdown body and reports semantically-correct markdown back; the server canonicalizes (pillar 7). Loading and immediately saving an already-canonical body must produce a zero diff — verified by tests at the engine level.
  • Inline chips (in v1, sequenced after the core RTE is solid): the rte policy gains allowedInline (inline element names, e.g. button, icon; each must have a sidecar — a missing one is a schema-load error; absent list = no chips). Toolbar/slash insert offers exactly that list with sidecar label/icon. Chips are atomic non-editable inline nodes carrying name, markup flag, and typed params: cursor skips them as units, selection/delete/copy-paste treat them whole. Clicking a chip opens a popover form generated from its sidecar (same renderer as element forms; same validation). Round-trip is lossless for bodies mixing chips with marks — adjacent, nested in bold/links, back-to-back, at body boundaries — preserving param types and the markup flag. Pasting a chip into a field that doesn't allow it drops the chip, keeps surrounding text. Until chips ship, inline shortcodes in bodies appear as inert placeholders and must survive editing uncorrupted (they remain shortcode nodes — see the engine's raw-{{<-in-text rule).

7. Media

  • An image field opens a picker with Upload and Library tabs. Page-context uploads land in the page's leaf bundle; if the page is a legacy single-file .md, the first upload transparently converts it to a bundle (content preserved byte-for-byte) — never a dead end. Library uploads land under assets/media/ (folders supported); picking a library asset stores a stable reference distinguishable from a bundle-relative path, resolvable by the shipped shortcode templates, surviving canonical round-trip unchanged.
  • Library browsing: folders + thumbnails; thumbnails are generated/served by the harness and never enter the repo.
  • Gate at the door: allowed types jpg/png/webp/avif + PDF, decided by content sniffing (magic bytes/decode), never extension or declared MIME. SVG, video, executables rejected with clear messages; evil.svg renamed .png does not get through; a sniffed-type/extension mismatch is never stored under the misleading extension. Cap ~15 MB (one named constant), rejected before processing.
  • Processing before the repo: images downscaled to a documented max edge (~2560 px, named constant), EXIF orientation applied, re-encoded at sane quality. Original bytes/dimensions never enter the repo. PDFs stored unmodified (sniffed + capped). No upload byte is written inside the repo before sniff + cap + processing all pass.
  • Alt text required by default when an image is set; missing alt blocks save with an inline error on that field; per-field sidecar opt-out for decorative images; alt persists through the canonical serializer alongside the reference.
  • Delete (library or bundle asset): best-effort textual reference scan across content; warns and names referencing pages before confirmation; never blocks deletion.
  • Plain git, no LFS. Repo growth is a documented limitation ("brochure-scale sites"). Responsive rendering (srcset etc.) is the theme's job — one processed asset per upload.

8. Git backbone (save, publish, discard, undo, badges)

  • A dedicated internal git package encapsulates all repo operations; the server depends on its interface. Shelling out to git CLI is acceptable.
  • Save → wip safety ref. Every successful save, after writing the file, snapshots the entire working tree to a local wip ref without touching the user's index, HEAD, or branch. Never pushed. Empty diff vs. the previous snapshot is not an error. Kill the harness at any moment → no saved work lost; history recoverable from the ref. These snapshots also power Undo (§4).
  • Per-page Publish. Bundle resolution maps a page to its repo path set (bundle dir incl. resources, or the single file). Publish of page A: pull --rebase (below) → pre-flight → stage exactly A's bundle → commit to main with a message identifying the page → push. Page B's pending edits stay uncommitted throughout. Publish all commits every pending content change. An empty publish is reported as such, not committed.
  • Pre-flight gate (non-negotiable): before anything is committed, build the exact to-be-published tree (main + the bundle(s) being published — not the whole working tree; another page's half-finished draft can neither fail nor sneak into this publish) with the pinned Hugo, into a throwaway destination that does not disturb the preview server. Failure refuses the publish, commits and pushes nothing, and shows the Hugo error.
  • Rebase path policy: conflicts during pull --rebase resolve automatically — local wins inside content/, remote wins everywhere else (theme, layouts, config, assets). A developer pushing theme changes mid-session never blocks a publish. A rejected push surfaces the error; retrying works without duplicating commits.
  • Per-page Discard reverts a page's bundle to its last-published (main) state after explicit confirmation; other pages untouched; preview updates. Discarding a never-published page removes it (confirmation says so).
  • Badges: per-page Published (identical to main) / Modified (on main + local changes) / Draft (never on main), derived from diff state, shown in tree and page views, updated after save/publish/discard.
  • Draft-link warning: publishing a page that links to a still-draft page warns (would 404 live) and allows explicit override.
  • Failure UX: pre-flight failures, push rejections, and other git errors show the error plainly with "contact your developer" guidance. No self-service conflict resolution in v1.
  • Degradation: a site dir that is not a git repo (or lacks main/remote) does not crash the tool — saves still work; publish/discard/badges report a clear actionable error state.
  • Test against real temporary git repositories.

9. Publish feedback (CI status)

  • After a publish's push, the harness (never the browser) polls the forge's commit-status API for the pushed SHA and exposes a normalized state to the SPA: Publishing… (pending/first wait) → Live ✓ (terminal success) / Build failed (terminal failure, with a copyable link to the run).
  • v1 implements Forgejo/Gitea only, behind a forge-client abstraction (state + run URL) designed so GitHub is a later additive implementation, not a caller change. Forge kind + API base derived from the push remote URL, with a config override.
  • Token from environment or an out-of-repo secrets file; never read from the repo, never sent to the browser, never logged. No token → no polling and an honest static message: "handed to deployment, should be live in a few minutes." Any forge API failure degrades that publish's feedback to the same message — never blocks the publish, never spams.
  • Polling backs off, stops permanently on terminal state, and a commit with no CI activity degrades to the honest message after a bounded window.

10. Preview: click-to-select bridge and viewport toggle

  • Marker contract from day one: every block element template's root tag carries a ce/attrs partial emitting data-ce-* attributes (page identity + element child-index path, matching the tree the API serves) only under hugo.IsServer. This partial is part of the element library's markup contract (§11), not a theme afterthought. A production hugo build contains zero markers and zero bridge script — covered by a test.
  • A small bridge script rides in server-mode builds only: clicks on marked elements are reported to the parent SPA over postMessage with origin validation on both ends (SPA accepts only the preview origin; bridge accepts commands only from its parent).
  • Click selects the corresponding element in the element list and opens its form (same selection state as list-driven selection — one source of truth). Nested elements: innermost marked target wins, with an obvious way up to the container (breadcrumb/parent affordance). Unmarked regions do nothing editor-related; normal link navigation keeps working.
  • Reverse: selecting/hovering a form highlights the rendered element (outline), cleared on hover-end/selection change.
  • The mapping survives every LiveReload (identity from freshly emitted markers, never cached DOM; bridge re-establishes after each reload).
  • Select-and-highlight only — no editing gestures in the preview.
  • Viewport toggle: preview iframe switches between a representative mobile width and full desktop width; independent of the bridge.

11. Element library and theme

  • Library (each = layouts/shortcodes/X.html + editor/elements/X.yaml, fully form-editable, placement metadata declared):
    • Structure: grid (row + column shortcodes; column widths from a small fixed documented set of fractions emitted as classes), spacer (size step from a small scale, as a class).
    • Content: text, text-media (body + image + side param), image (figure/figcaption, alt, caption), gallery (nested per-image item shortcodes — field types stay scalar), hero (heading + body that may contain inline buttons), quote (blockquote + attribution), video-embed (click-to-load facade: zero third-party requests until explicit click; privacy-enhanced provider endpoints; self-contained tiny consent script), accordion (nested item shortcodes, details/summary-based), page-teasers (title/summary/link of a section's non-draft children — drafts never appear even though preview builds drafts), html (verbatim passthrough; hidden from the picker unless a developer flag exposes it; existing instances remain visible/editable), contact-form (markup + editor-editable labels; the submission endpoint/action URL is a developer-configured param — the tool takes no stance on the form backend).
    • Inline: button (href, label, variant), icon (name via class/data attribute).
  • Markup contract: semantic, theme-agnostic HTML; documented class vocabulary; classes only, no inline styles; no third-party assets; no JS except video-embed's consent loader; accessibility basics in the markup (alt, real headings, labelled interactive parts); ce/attrs on every block root. Acceptable-but-unstyled on a bare site.
  • Theme: self-contained under themes/, forkable and developer-owned. Modern-looking, clean, neutral; mobile-first; holds from 320 px up with no horizontal overflow; no JS framework (small vanilla JS only where necessary, e.g. mobile nav). Header nav derived from the page tree (weight order, hidden excluded, menuTitle override), exactly one dropdown level, keyboard-accessible. Footer nav from a documented front-matter flag (imprint/privacy pattern). Ships the ce/attrs partial and the bridge script inclusion behind hugo.IsServer. Layouts for the shipped page types (home, standard page, section list). A short "fork me" customization guide; no undocumented config. No Lighthouse-accessibility failures from theme markup. No human design-approval gate — the bar is "looks modern," self-assessed.
  • Round-trip corpus gains at least one canonical fixture per element (nesting for grid/accordion, inline chips in bodies). The sample/test site demonstrates every element.

12. harness init

  • In an empty dir (or fresh git repo with only VCS metadata): scaffolds minimal hugo.toml wired to the theme; content/<lang>/ with a starter home page; full copies of the theme and element library (developer-owned; deliberately no update/sync mechanism — document this); page-type sidecars; harness.yaml (title, single-language list, Hugo pin, defaults); CI workflows in both Forgejo and GitHub variants (checkout → build with the pinned Hugo → deploy step present but commented, with rsync/pages examples); .gitignore with .harness/.
  • harness init && harness serve = working, fully editable site, zero manual steps. Usable non-interactively (flags/defaults for title and language).
  • Never overwrites: any conflicting file → modify nothing, report the paths, exit nonzero.
  • Scaffold sources are embedded in the binary (single self-contained binary, works offline) and are the same files as the canonical theme/library — embedded by construction, not a hand-maintained second copy.
  • Everything scaffolded is commit-ready: no secrets, no absolute paths; every sidecar carries version:.

Build order

Sequential milestones; all tests green at every milestone; each milestone's surface is stable before the next builds on it (the retired plan died of concurrent edits to shared surfaces — this plan is deliberately serial):

  1. Engine — content parse/serialize + corpus + fuzz (§1).
  2. Harness core — CLI, harness.yaml, Hugo pin/download, supervisor, loopback server, SPA shell + preview iframe + status (§2).
  3. Schemas + forms + save — element sidecars, validation, scaffolder, one element editable end-to-end with canonical save + LiveReload (§3, §4 forms).
  4. Page tree + page management — tree pane, (path, lang) identity, page-type sidecars, create/rename/move/reorder/toggle/delete, page settings form (§5).
  5. Element CRUD — picker, placement enforcement, drag & drop, mutation API (§4).
  6. Element library + theme + click-to-select — all templates + sidecars, ce/attrs markers, theme, bridge, viewport toggle (§10, §11).
  7. RTE — wrapper, allowlist, sanitization, links; then chips last (§6).
  8. Media (§7).
  9. Git backbone — wip ref, undo-from-snapshots, publish/pre-flight/rebase policy, discard, badges (§8).
  10. Publish feedback — Forgejo polling + honest fallback (§9).
  11. harness init + final end-to-end pass (§12).

Adjust internal ordering only with a recorded reason; do not parallelize milestones against each other.

Global invariants

  • Every content write goes through the canonical engine; only the affected page's file is rewritten; unmanaged front matter and untouched nodes survive byte-for-byte.
  • The server re-validates everything; the UI is never the enforcement point.
  • All state-reading UI is API-driven (badges, schemas, tree, status) — no client-side derivation of server truths.
  • Loopback bind only; no secrets in the repo, in the browser, or in logs.
  • Tests accompany every milestone: engine corpus/fuzz, placement rules, page ops (temp site copies), git flows (temp repos), forge polling (fake servers), media sniffing/processing, RTE round-trip and sanitization, marker/bridge production-hygiene.

Out of scope for v1 (do not build)

Multi-user anything (locks, takeover, identity, CSRF, non-loopback binds), SaaS/tenancy, multi-language UI (identity carries lang; UI manages one), inline/in-preview editing, git LFS, GitHub status polling (abstraction ready, implementation later), GitLab, SVG or video uploads, image cropping/focal points, RTE tables/code blocks/images, multi-step undo/redo, trash/version-history UI, theme dark mode, scaffold update/sync mechanisms.

Development environment note

In the lab environment Go and Node are not on PATH; use nix shell nixpkgs#go --command go ... (and nixpkgs#nodejs for the SPA). Build web/dist before go test ./... if the server embeds it.

## Summary Build **deckle v1** from scratch: a single Go binary that turns a plain Hugo site in a git repository into a safe CMS for a non-technical editor, with a SolidJS editing SPA, a lossless canonical content engine, sidecar-YAML-defined editing constraints, a shipped element library + modern theme, a git-backed save/publish workflow, and one-command site scaffolding. This issue is the **complete, authoritative spec**. It replaces the retired v1 plan (#2–#17, closed as superseded by the clean-slate reset, #29 / PR #30). The repository is empty at start. The previous implementation exists in git history before PR #30 and **may be consulted as reference**, but where history and this spec disagree, **this spec wins**. Every design decision below was explicitly confirmed by the maintainer — do not relitigate them. ## Product definition - **Two audiences.** The **developer** sets up the site, owns the theme and deployment, and defines exactly what is editable. The **client editor** (non-technical) edits content through forms, uploads images, and hits Publish — never seeing git, markdown, or Hugo. Where their needs conflict, **the client editor wins**; design so conflicts are rare. - **The promise.** The safety and simplicity of a locked-down CMS with the ops profile of a static site in git. The client cannot break the site; the developer keeps a vanilla Hugo repo that builds without this tool. No lock-in in either direction. - **v1 runtime model: single-user, local-only.** One person runs the binary on their own machine and edits at localhost. No authentication, no user management, no page locks, no CSRF machinery, no `X-Remote-User`. The server refuses to bind non-loopback addresses. **Required discipline:** the SPA talks to the harness exclusively through a clean HTTP JSON API, so a future proxied multi-user deployment is an ops change, not a redesign. ## Architecture pillars (fixed decisions) 1. **Hugo-specific, git-backed.** The tool deeply knows Hugo (bundles, front matter, shortcodes, `hugo server`, `hugo` builds). Git is the persistence and publish backbone: push to `main` = deploy. Not SSG-agnostic, not a SaaS, no database. 2. **Content = shortcodes in markdown.** A page is ordinary Hugo markdown; each element is a shortcode (`{{< hero … >}}`), text between/inside them is first-class. No custom front-matter block format. 3. **The canonical round-trip engine is load-bearing.** Every content mutation flows through a typed element tree and a byte-canonical serializer. Nothing anywhere in the codebase string-munges markdown. The engine is built **first** and everything else stands on it. 4. **Sidecar YAML is the constraint language.** Developers declare what is editable via `editor/elements/*.yaml` and `editor/pagetypes/*.yaml`. Explicit declarations are the source of truth; inference is only a scaffolding aid. 5. **Editing paradigm: structured element list + generated forms + live preview.** Side-by-side forms editing with click-to-select in the preview. No inline/in-preview editing in v1, and inline editing must not drive the architecture. 6. **Frontend: SolidJS + TypeScript SPA** (Vite build, embedded into the Go binary). Rich text via **TipTap core with a self-owned Solid wrapper** (no React/Vue packages; treat community `solid-tiptap` as reference only). 7. **Server is the sole canonicalizer.** The browser sends semantically correct data; the Go engine parses and re-serializes to canonical bytes on every save. The client never promises byte identity. ## Functional spec ### 1. Content engine (Go, `internal/content` or equivalent) - Parse a Hugo content file (front matter in YAML/TOML/JSON + markdown body with shortcodes) into a typed tree: front matter (order-preserving for unmanaged keys), and a body node sequence of **text nodes**, **block shortcode nodes** (name, params, markup flag `{{<` vs `{{%`, children), and **inline/self-closing shortcode nodes**. Nesting is supported (grid → columns → elements). - Serialize the tree back to **canonical bytes**: deterministic param ordering/quoting, deterministic spacing, exactly one trailing newline. `parse(serialize(t)) == t` and `serialize(parse(canonical)) == canonical` always. Parsing non-canonical-but-valid input then serializing yields canonical output with semantics preserved. - Text nodes containing raw `{{<` / `{{%` are a serialize error (shortcodes can never be flattened into text). - Unknown/unparseable constructs must round-trip byte-exact rather than being destroyed; a file the parser cannot safely model is a read error, never a silent rewrite. - Test discipline: a corpus of canonical fixtures (`parse → serialize` byte-identical), non-canonical input/golden pairs, and a `FuzzParseRoundTrip` fuzz target. This suite is the foundation of the whole product — be thorough. ### 2. Harness CLI and configuration - Commands: `serve` (the editor), `init` (scaffold a new site, §12), and a sidecar scaffolder (§3). - `serve` requires an existing Hugo site, binds loopback only, supervises a `hugo server` child process (restart on crash, status surfaced to the SPA), and serves the embedded SPA + JSON API. - `harness.yaml` at the site root: site title, language list (exactly one entry in v1), **pinned Hugo version**, sensible defaults. Its Go type lives in a package importable without the server. - **Hugo version pinning:** the harness downloads the pinned Hugo release for the host platform on demand, verifies the artifact checksum, caches it under the harness's local state dir (`.harness/`, gitignored), and uses that binary for **preview, pre-flight, and everything else**. CI uses the same pin (§12). If `harness.yaml` or the pin is absent, fall back to `hugo` on PATH with a visible warning. ### 3. Sidecar schemas - **Element sidecars** at `editor/elements/<name>.yaml`, one per shortcode. Shape: `version:` (schema version int, required), `label`, `icon`, `group` (`structure` | `content` | `inline`), body policy (`none` | `markdown` | `rte` with optional per-field allowlist, §6), `params` (list of: name, type, label, default, required, options), and `placement` (`allowedIn`: parent element names plus a `page` token for top level; `allowedAt`: page type names; absent/empty list = unrestricted on that axis). - Param field types: `text`, `textarea`, `number` (int/float), `bool`, `select` (options), `image`, `link`, plus whatever small set the element library genuinely needs — keep the vocabulary minimal and documented. - Loading and validation: malformed sidecars produce **actionable errors** (file, field, reason) surfaced via API/UI; valid sidecars still load. Duplicate names are errors. - **Unknown shortcodes** (used on a page, no sidecar): rendered in the element list as an opaque "unknown element: `<name>`" block — visible, selectable, **not editable**, round-trips byte-exact. No inferred fallback forms in v1. - **Sidecar scaffolder CLI** (e.g. `harness sidecar <name>`): parses `layouts/shortcodes/<name>.html` with Go's template AST, emits a draft sidecar — params pre-listed from `.Get` calls, body policy from `.Inner` usage, defaults recovered from `| default` idioms where present, every param typed `text` with a `# TODO: set type/label` marker. Developer edits and owns the result. - **Page-type sidecars** at `editor/pagetypes/<name>.yaml`: `version:`, `label`, `icon`, `children` (page types creatable beneath), `elements` (element allowlist for pages of this type), `fields` (page-level front-matter fields, same field vocabulary as element params — title, SEO description are the typical case). A way to declare which types are allowed at the content root must exist. A page records its type in front matter (Hugo's `type`); no declaration for a type = allow everything (permissive default). ### 4. SPA shell, page tree, element list, forms - Layout: **tree pane** (site page tree, left), **page pane** (selected page's element list, center; the form mounts within/beside it), **preview pane** (right), plus a status pill for the supervised Hugo process. - Page identity is always the pair `(path, lang)` — never a bare path — threaded through every API endpoint and UI selection seam from day one, even though v1's UI manages a single language. A single server-side function resolves language → content root; handlers never join paths themselves. - Tree API/UI: sections and pages from the content directory, ordered by `weight` then title, drafts visually distinct, expansion/selection state survives refreshes. - **Generated forms:** selecting an element renders a form from its sidecar (labels, defaults, required, typed inputs). Server-side validation is authoritative; errors map inline to fields. Save sends values; the server validates, mutates the tree, canonically serializes, rewrites only that page's file; preview updates via LiveReload. - **Element CRUD** (all in the page pane, all server-validated, all through the engine): - *Add:* a picker at a chosen insertion position (top level between siblings, or inside a container), grouped by sidecar `group` with label + icon, offering **only** element types allowed there (placement rules ∧ page-type element allowlist). Insert with schema defaults, then open its form. - *Delete:* confirmation naming the element and warning about children; removes the whole subtree. - *Drag & drop:* reorder among siblings and move into/out of containers (including empty ones); invalid targets show a no-drop affordance and dropping there is a no-op; a drop is one mutation. - Positions are addressed by child-index paths into the parsed tree (nodes have no IDs); the client and server share the scheme. A rule-violating mutation request is rejected server-side with an actionable error and the file untouched, regardless of what the UI allowed. - **Undo:** built on the `wip` snapshots (§8) — no bespoke undo store. Page-scoped: "Undo" restores the selected page's state from the previous snapshot through the normal canonical save path. Single-step in v1; the UI is honest about that. ### 5. Page management - **Every page the tool creates is a leaf bundle** (`<slug>/index.md`; `_index.md` for sections). Plain single-file `.md` pages from existing sites are read and edited but never created. - *Create:* "New page/section" under a selected parent offers only the page types the parent's type (or the root declaration) allows. Asks for a title; slug auto-generated (lowercased, hyphenated, sibling-collision-safe), overridable. Writes the bundle with the type's front-matter defaults, the recorded `type`, and `draft: true`. - *Rename/move:* rewrites the bundle path (whole directory incl. resources) and appends the previous published URL to front-matter `aliases` (existing aliases preserved, no duplicates). Renaming/moving a **section** aliases every descendant's old URL. Invariant: **published URLs never 404**. - *Reorder:* drag & drop within a section rewrites sibling `weight` deterministically; across sections it is a move + alias + placement. Tree order always matches Hugo's render order. - *Enable/disable:* toggles `draft`; drafts remain visible in preview (the preview server builds drafts) — disabled means "not published", not "not previewable". - *Delete:* confirmation naming the page and its descendant count (mandatory for sections with children); removes the bundle/subtree. Recovery is git's job (§8), no trash. - *Page settings:* selecting the page itself (vs. an element) shows the schema-generated form for its page type's front-matter fields. - Write safety: every front-matter mutation preserves unmanaged keys and the body byte-for-byte; all operations confined to the content root; an existing target path is an error, never an overwrite; no half-moved state on failure. ### 6. Rich text bodies - Body policy `rte` in a sidecar renders a TipTap editor (via the owned Solid wrapper: mount/destroy lifecycle, editor state bridged into Solid reactivity, generic across fields). - **Formatting allowlist**, per field from the sidecar; default: bold, italic, links, bullet + numbered lists, headings h2–h4. Each construct grantable/withholdable per field. The allowlist controls the toolbar, keyboard shortcuts, input rules, **and paste**. **h1 is never offered or accepted anywhere** (reserved for the page title). - **No raw HTML, ever.** Raw HTML in source renders as visible literal text, never interpreted, never executed, never emitted. Constructs outside the field's allowlist in pre-existing content degrade to their plain-text reading rather than rendering or being silently dropped. - **Paste** from Word/Google Docs/web maps recognizable formatting to the allowlist and strips everything else (no styles, classes, spans, comments). - **Links:** external URL by hand, or internal page chosen from the site tree (stored as tree path). Editable and removable. - Round-trip: the editor loads from the element's markdown body and reports semantically-correct markdown back; the **server** canonicalizes (pillar 7). Loading and immediately saving an already-canonical body must produce a zero diff — verified by tests at the engine level. - **Inline chips (in v1, sequenced after the core RTE is solid):** the `rte` policy gains `allowedInline` (inline element names, e.g. `button`, `icon`; each must have a sidecar — a missing one is a schema-load error; absent list = no chips). Toolbar/slash insert offers exactly that list with sidecar label/icon. Chips are atomic non-editable inline nodes carrying name, markup flag, and typed params: cursor skips them as units, selection/delete/copy-paste treat them whole. Clicking a chip opens a popover form generated from its sidecar (same renderer as element forms; same validation). Round-trip is lossless for bodies mixing chips with marks — adjacent, nested in bold/links, back-to-back, at body boundaries — preserving param types and the markup flag. Pasting a chip into a field that doesn't allow it drops the chip, keeps surrounding text. Until chips ship, inline shortcodes in bodies appear as inert placeholders and must survive editing uncorrupted (they remain shortcode nodes — see the engine's raw-`{{<`-in-text rule). ### 7. Media - An `image` field opens a picker with **Upload** and **Library** tabs. Page-context uploads land in the page's leaf bundle; if the page is a legacy single-file `.md`, the first upload transparently converts it to a bundle (content preserved byte-for-byte) — never a dead end. Library uploads land under `assets/media/` (folders supported); picking a library asset stores a stable reference distinguishable from a bundle-relative path, resolvable by the shipped shortcode templates, surviving canonical round-trip unchanged. - Library browsing: folders + thumbnails; thumbnails are generated/served by the harness and never enter the repo. - **Gate at the door:** allowed types jpg/png/webp/avif + PDF, decided by **content sniffing** (magic bytes/decode), never extension or declared MIME. SVG, video, executables rejected with clear messages; `evil.svg` renamed `.png` does not get through; a sniffed-type/extension mismatch is never stored under the misleading extension. Cap ~15 MB (one named constant), rejected before processing. - **Processing before the repo:** images downscaled to a documented max edge (~2560 px, named constant), EXIF orientation applied, re-encoded at sane quality. Original bytes/dimensions never enter the repo. PDFs stored unmodified (sniffed + capped). No upload byte is written inside the repo before sniff + cap + processing all pass. - **Alt text** required by default when an image is set; missing alt blocks save with an inline error on that field; per-field sidecar opt-out for decorative images; alt persists through the canonical serializer alongside the reference. - **Delete** (library or bundle asset): best-effort textual reference scan across content; warns and names referencing pages before confirmation; never blocks deletion. - **Plain git, no LFS.** Repo growth is a documented limitation ("brochure-scale sites"). Responsive rendering (srcset etc.) is the theme's job — one processed asset per upload. ### 8. Git backbone (save, publish, discard, undo, badges) - A dedicated internal git package encapsulates all repo operations; the server depends on its interface. Shelling out to `git` CLI is acceptable. - **Save → wip safety ref.** Every successful save, after writing the file, snapshots the entire working tree to a local `wip` ref **without touching the user's index, HEAD, or branch**. Never pushed. Empty diff vs. the previous snapshot is not an error. Kill the harness at any moment → no saved work lost; history recoverable from the ref. These snapshots also power **Undo** (§4). - **Per-page Publish.** Bundle resolution maps a page to its repo path set (bundle dir incl. resources, or the single file). Publish of page A: `pull --rebase` (below) → **pre-flight** → stage exactly A's bundle → commit to `main` with a message identifying the page → push. Page B's pending edits stay uncommitted throughout. **Publish all** commits every pending content change. An empty publish is reported as such, not committed. - **Pre-flight gate (non-negotiable):** before anything is committed, build the exact to-be-published tree (`main` + the bundle(s) being published — **not** the whole working tree; another page's half-finished draft can neither fail nor sneak into this publish) with the pinned Hugo, into a throwaway destination that does not disturb the preview server. Failure refuses the publish, commits and pushes nothing, and shows the Hugo error. - **Rebase path policy:** conflicts during `pull --rebase` resolve automatically — local wins inside `content/`, remote wins everywhere else (theme, layouts, config, assets). A developer pushing theme changes mid-session never blocks a publish. A rejected push surfaces the error; retrying works without duplicating commits. - **Per-page Discard** reverts a page's bundle to its last-published (`main`) state after explicit confirmation; other pages untouched; preview updates. Discarding a never-published page removes it (confirmation says so). - **Badges:** per-page **Published** (identical to main) / **Modified** (on main + local changes) / **Draft** (never on main), derived from diff state, shown in tree and page views, updated after save/publish/discard. - **Draft-link warning:** publishing a page that links to a still-draft page warns (would 404 live) and allows explicit override. - **Failure UX:** pre-flight failures, push rejections, and other git errors show the error plainly with "contact your developer" guidance. No self-service conflict resolution in v1. - **Degradation:** a site dir that is not a git repo (or lacks main/remote) does not crash the tool — saves still work; publish/discard/badges report a clear actionable error state. - Test against real temporary git repositories. ### 9. Publish feedback (CI status) - After a publish's push, the **harness** (never the browser) polls the forge's commit-status API for the pushed SHA and exposes a normalized state to the SPA: **Publishing…** (pending/first wait) → **Live ✓** (terminal success) / **Build failed** (terminal failure, with a copyable link to the run). - **v1 implements Forgejo/Gitea only**, behind a forge-client abstraction (state + run URL) designed so GitHub is a later additive implementation, not a caller change. Forge kind + API base derived from the push remote URL, with a config override. - Token from environment or an out-of-repo secrets file; never read from the repo, never sent to the browser, never logged. **No token → no polling** and an honest static message: "handed to deployment, should be live in a few minutes." Any forge API failure degrades that publish's feedback to the same message — never blocks the publish, never spams. - Polling backs off, stops permanently on terminal state, and a commit with no CI activity degrades to the honest message after a bounded window. ### 10. Preview: click-to-select bridge and viewport toggle - **Marker contract from day one:** every block element template's root tag carries a `ce/attrs` partial emitting `data-ce-*` attributes (page identity + element child-index path, matching the tree the API serves) **only under `hugo.IsServer`**. This partial is part of the element library's markup contract (§11), not a theme afterthought. A production `hugo` build contains zero markers and zero bridge script — covered by a test. - A small bridge script rides in server-mode builds only: clicks on marked elements are reported to the parent SPA over `postMessage` with **origin validation on both ends** (SPA accepts only the preview origin; bridge accepts commands only from its parent). - Click selects the corresponding element in the element list and opens its form (same selection state as list-driven selection — one source of truth). Nested elements: innermost marked target wins, with an obvious way up to the container (breadcrumb/parent affordance). Unmarked regions do nothing editor-related; normal link navigation keeps working. - Reverse: selecting/hovering a form highlights the rendered element (outline), cleared on hover-end/selection change. - The mapping survives every LiveReload (identity from freshly emitted markers, never cached DOM; bridge re-establishes after each reload). - **Select-and-highlight only** — no editing gestures in the preview. - **Viewport toggle:** preview iframe switches between a representative mobile width and full desktop width; independent of the bridge. ### 11. Element library and theme - **Library** (each = `layouts/shortcodes/X.html` + `editor/elements/X.yaml`, fully form-editable, placement metadata declared): - Structure: `grid` (row + column shortcodes; column widths from a small fixed documented set of fractions emitted as classes), `spacer` (size step from a small scale, as a class). - Content: `text`, `text-media` (body + image + side param), `image` (figure/figcaption, alt, caption), `gallery` (nested per-image item shortcodes — field types stay scalar), `hero` (heading + body that may contain inline buttons), `quote` (blockquote + attribution), `video-embed` (**click-to-load facade: zero third-party requests until explicit click**; privacy-enhanced provider endpoints; self-contained tiny consent script), `accordion` (nested item shortcodes, `details`/`summary`-based), `page-teasers` (title/summary/link of a section's **non-draft** children — drafts never appear even though preview builds drafts), `html` (verbatim passthrough; **hidden from the picker** unless a developer flag exposes it; existing instances remain visible/editable), **`contact-form`** (markup + editor-editable labels; the submission endpoint/action URL is a developer-configured param — the tool takes no stance on the form backend). - Inline: `button` (href, label, variant), `icon` (name via class/data attribute). - **Markup contract:** semantic, theme-agnostic HTML; documented class vocabulary; **classes only, no inline styles**; no third-party assets; no JS except video-embed's consent loader; accessibility basics in the markup (alt, real headings, labelled interactive parts); `ce/attrs` on every block root. Acceptable-but-unstyled on a bare site. - **Theme:** self-contained under `themes/`, forkable and developer-owned. **Modern-looking**, clean, neutral; mobile-first; holds from 320 px up with no horizontal overflow; no JS framework (small vanilla JS only where necessary, e.g. mobile nav). Header nav derived from the page tree (`weight` order, `hidden` excluded, `menuTitle` override), exactly one dropdown level, keyboard-accessible. Footer nav from a documented front-matter flag (imprint/privacy pattern). Ships the `ce/attrs` partial and the bridge script inclusion behind `hugo.IsServer`. Layouts for the shipped page types (home, standard page, section list). A short "fork me" customization guide; no undocumented config. No Lighthouse-accessibility failures from theme markup. **No human design-approval gate** — the bar is "looks modern," self-assessed. - Round-trip corpus gains at least one canonical fixture per element (nesting for grid/accordion, inline chips in bodies). The sample/test site demonstrates every element. ### 12. `harness init` - In an empty dir (or fresh git repo with only VCS metadata): scaffolds minimal `hugo.toml` wired to the theme; `content/<lang>/` with a starter home page; full copies of the theme and element library (**developer-owned; deliberately no update/sync mechanism — document this**); page-type sidecars; `harness.yaml` (title, single-language list, Hugo pin, defaults); CI workflows in **both Forgejo and GitHub variants** (checkout → build with the pinned Hugo → deploy step present but commented, with rsync/pages examples); `.gitignore` with `.harness/`. - `harness init && harness serve` = working, fully editable site, zero manual steps. Usable non-interactively (flags/defaults for title and language). - **Never overwrites:** any conflicting file → modify nothing, report the paths, exit nonzero. - Scaffold sources are embedded in the binary (single self-contained binary, works offline) and are **the same files** as the canonical theme/library — embedded by construction, not a hand-maintained second copy. - Everything scaffolded is commit-ready: no secrets, no absolute paths; every sidecar carries `version:`. ## Build order Sequential milestones; **all tests green at every milestone**; each milestone's surface is stable before the next builds on it (the retired plan died of concurrent edits to shared surfaces — this plan is deliberately serial): 1. **Engine** — content parse/serialize + corpus + fuzz (§1). 2. **Harness core** — CLI, `harness.yaml`, Hugo pin/download, supervisor, loopback server, SPA shell + preview iframe + status (§2). 3. **Schemas + forms + save** — element sidecars, validation, scaffolder, one element editable end-to-end with canonical save + LiveReload (§3, §4 forms). 4. **Page tree + page management** — tree pane, `(path, lang)` identity, page-type sidecars, create/rename/move/reorder/toggle/delete, page settings form (§5). 5. **Element CRUD** — picker, placement enforcement, drag & drop, mutation API (§4). 6. **Element library + theme + click-to-select** — all templates + sidecars, `ce/attrs` markers, theme, bridge, viewport toggle (§10, §11). 7. **RTE** — wrapper, allowlist, sanitization, links; then **chips** last (§6). 8. **Media** (§7). 9. **Git backbone** — wip ref, undo-from-snapshots, publish/pre-flight/rebase policy, discard, badges (§8). 10. **Publish feedback** — Forgejo polling + honest fallback (§9). 11. **`harness init`** + final end-to-end pass (§12). Adjust internal ordering only with a recorded reason; do not parallelize milestones against each other. ## Global invariants - Every content write goes through the canonical engine; only the affected page's file is rewritten; unmanaged front matter and untouched nodes survive byte-for-byte. - The server re-validates everything; the UI is never the enforcement point. - All state-reading UI is API-driven (badges, schemas, tree, status) — no client-side derivation of server truths. - Loopback bind only; no secrets in the repo, in the browser, or in logs. - Tests accompany every milestone: engine corpus/fuzz, placement rules, page ops (temp site copies), git flows (temp repos), forge polling (fake servers), media sniffing/processing, RTE round-trip and sanitization, marker/bridge production-hygiene. ## Out of scope for v1 (do not build) Multi-user anything (locks, takeover, identity, CSRF, non-loopback binds), SaaS/tenancy, multi-language UI (identity carries `lang`; UI manages one), inline/in-preview editing, git LFS, GitHub status polling (abstraction ready, implementation later), GitLab, SVG or video uploads, image cropping/focal points, RTE tables/code blocks/images, multi-step undo/redo, trash/version-history UI, theme dark mode, scaffold update/sync mechanisms. ## Development environment note In the lab environment Go and Node are not on PATH; use `nix shell nixpkgs#go --command go ...` (and `nixpkgs#nodejs` for the SPA). Build `web/dist` before `go test ./...` if the server embeds it.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#31
No description provided.