deckle v1: full rebuild — Hugo CMS harness (complete spec) #31
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#31
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
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)
hugo server,hugobuilds). Git is the persistence and publish backbone: push tomain= deploy. Not SSG-agnostic, not a SaaS, no database.{{< hero … >}}), text between/inside them is first-class. No custom front-matter block format.editor/elements/*.yamlandeditor/pagetypes/*.yaml. Explicit declarations are the source of truth; inference is only a scaffolding aid.solid-tiptapas reference only).Functional spec
1. Content engine (Go,
internal/contentor equivalent){{<vs{{%, children), and inline/self-closing shortcode nodes. Nesting is supported (grid → columns → elements).parse(serialize(t)) == tandserialize(parse(canonical)) == canonicalalways. Parsing non-canonical-but-valid input then serializing yields canonical output with semantics preserved.{{</{{%are a serialize error (shortcodes can never be flattened into text).parse → serializebyte-identical), non-canonical input/golden pairs, and aFuzzParseRoundTripfuzz target. This suite is the foundation of the whole product — be thorough.2. Harness CLI and configuration
serve(the editor),init(scaffold a new site, §12), and a sidecar scaffolder (§3).serverequires an existing Hugo site, binds loopback only, supervises ahugo serverchild process (restart on crash, status surfaced to the SPA), and serves the embedded SPA + JSON API.harness.yamlat 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..harness/, gitignored), and uses that binary for preview, pre-flight, and everything else. CI uses the same pin (§12). Ifharness.yamlor the pin is absent, fall back tohugoon PATH with a visible warning.3. Sidecar schemas
editor/elements/<name>.yaml, one per shortcode. Shape:version:(schema version int, required),label,icon,group(structure|content|inline), body policy (none|markdown|rtewith optional per-field allowlist, §6),params(list of: name, type, label, default, required, options), andplacement(allowedIn: parent element names plus apagetoken for top level;allowedAt: page type names; absent/empty list = unrestricted on that axis).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.<name>" block — visible, selectable, not editable, round-trips byte-exact. No inferred fallback forms in v1.harness sidecar <name>): parseslayouts/shortcodes/<name>.htmlwith Go's template AST, emits a draft sidecar — params pre-listed from.Getcalls, body policy from.Innerusage, defaults recovered from| defaultidioms where present, every param typedtextwith a# TODO: set type/labelmarker. Developer edits and owns the result.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'stype); no declaration for a type = allow everything (permissive default).4. SPA shell, page tree, element list, forms
(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.weightthen title, drafts visually distinct, expansion/selection state survives refreshes.groupwith label + icon, offering only element types allowed there (placement rules ∧ page-type element allowlist). Insert with schema defaults, then open its form.wipsnapshots (§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
<slug>/index.md;_index.mdfor sections). Plain single-file.mdpages from existing sites are read and edited but never created.type, anddraft: true.aliases(existing aliases preserved, no duplicates). Renaming/moving a section aliases every descendant's old URL. Invariant: published URLs never 404.weightdeterministically; across sections it is a move + alias + placement. Tree order always matches Hugo's render order.draft; drafts remain visible in preview (the preview server builds drafts) — disabled means "not published", not "not previewable".6. Rich text bodies
rtein a sidecar renders a TipTap editor (via the owned Solid wrapper: mount/destroy lifecycle, editor state bridged into Solid reactivity, generic across fields).rtepolicy gainsallowedInline(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
imagefield 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 underassets/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.evil.svgrenamed.pngdoes not get through; a sniffed-type/extension mismatch is never stored under the misleading extension. Cap ~15 MB (one named constant), rejected before processing.8. Git backbone (save, publish, discard, undo, badges)
gitCLI is acceptable.wipref 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).pull --rebase(below) → pre-flight → stage exactly A's bundle → commit tomainwith 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.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.pull --rebaseresolve automatically — local wins insidecontent/, 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.main) state after explicit confirmation; other pages untouched; preview updates. Discarding a never-published page removes it (confirmation says so).9. Publish feedback (CI status)
10. Preview: click-to-select bridge and viewport toggle
ce/attrspartial emittingdata-ce-*attributes (page identity + element child-index path, matching the tree the API serves) only underhugo.IsServer. This partial is part of the element library's markup contract (§11), not a theme afterthought. A productionhugobuild contains zero markers and zero bridge script — covered by a test.postMessagewith origin validation on both ends (SPA accepts only the preview origin; bridge accepts commands only from its parent).11. Element library and theme
layouts/shortcodes/X.html+editor/elements/X.yaml, fully form-editable, placement metadata declared):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).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).button(href, label, variant),icon(name via class/data attribute).ce/attrson every block root. Acceptable-but-unstyled on a bare site.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 (weightorder,hiddenexcluded,menuTitleoverride), exactly one dropdown level, keyboard-accessible. Footer nav from a documented front-matter flag (imprint/privacy pattern). Ships thece/attrspartial and the bridge script inclusion behindhugo.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.12.
harness inithugo.tomlwired 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);.gitignorewith.harness/.harness init && harness serve= working, fully editable site, zero manual steps. Usable non-interactively (flags/defaults for title and language).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):
harness.yaml, Hugo pin/download, supervisor, loopback server, SPA shell + preview iframe + status (§2).(path, lang)identity, page-type sidecars, create/rename/move/reorder/toggle/delete, page settings form (§5).ce/attrsmarkers, theme, bridge, viewport toggle (§10, §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
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 ...(andnixpkgs#nodejsfor the SPA). Buildweb/distbeforego test ./...if the server embeds it.