Files
hotdog-cms/docs/editor-roadmap.md

30 KiB
Raw Permalink Blame History

Editor roadmap

Draft, 2026-10-10; decisions taken the same day. The editor is the part of HotDog CMS that people who don't live in git will use. It should do what people actually use WordPress for, without what makes WordPress heavy and dangerous.

The rules it is built on

These come from John's decisions so far, and every milestone below has to keep them.

  1. The editor never lives with the sites. It is its own deployment, on its own host or on the editor's own machine, like inbuxa-admin. The public host serves files (plus the forms and search endpoint) and nothing else: no /wp-admin, no login page, no admin API. Taking over a web server gives an attacker no way to edit content. Taking over the editor gives no way into a web server, because the editor holds no deploy credentials.

  2. Git is the delivery path for every change, the visual editor included.

    • Saving is a commit, made as the person who saved.
    • A draft is a branch, and its preview is that branch's preview address.
    • Submitting for review opens a pull request on whichever platform the site lives on (Gitea, GitHub, GitLab, Forgejo, Bitbucket), or on plain git.
    • Publishing is that change reaching the branch the site builds from.
    • Undoing is a revert.

    There is no second, faster path to the live site.

  3. Operators decide how sites are published. The editor hands a commit to git. What happens next (CI, a deploy script, the pull agent, a container registry) is the operator's choice, on bare metal, a VPS or containers.

  4. One safety check, three ways in. Power users get a keyboard-driven, CLI-like environment. Novices get WYSIWYG. Everyone else gets Markdown with a live preview, and forms for structured parts. All three produce the same files and go through the same hotdog-cms check, preview and review. No mode skips a check, and no mode has a shortcut to the live site.

  5. Security and usability are never traded. If a feature seems to need that trade, the design is wrong.

  6. No data store until something truly needs one. Each milestone says whether it does.

Milestones

Each milestone is usable on its own. They're in build order: the in-between interface first, because the other two are built on its plumbing.

E0 · Foundations

Built 2026-10-10:

  • Server: hotdog-cms editor (Go), with the React + TypeScript interface embedded.

  • Sign-in: OAuth with PKCE for all five platforms; branches, reviews and permissions read from Gitea, Forgejo and GitHub.

  • Sessions: AES-GCM sealed cookies.

  • Workspaces: per-person checkouts made with the person's own token, then built and checked.

  • Interface: sites list, a site view (pages by collection, drafts, reviews, check results), and a page view with a live preview beside it.

  • Security: a strict CSP on the editor itself, a CSRF guard on anything that changes state, and page sources confined to content/.

  • Tests: a fake platform covers the whole sign-in and browsing flow.

  • Tried against the Coffey Labs Gitea with an OAuth app, "cl-cms editor (local test)", whose only redirect is loopback. John authorized it, and every view worked against the real repository. For the page view's preview pane, hotdog-cms preview -frame <editor origin> lets the editor, and only the editor, frame previews.

  • hotdog-cms editor: a separate server, by default listening on loopback, with its own strict CSP.

  • Sign-in through the git platform (OAuth with Gitea, GitHub, GitLab, Forgejo or Bitbucket). Edits are made with the signed-in person's own platform token, so the platform enforces who may do what. Every commit is attributed to them, and the editor stores no accounts. No data store: sessions are signed, short-lived cookies.

  • Open a site: browse its files, its pages by collection, and its open drafts and pull requests.

  • Embedded previews (hotdog-cms preview), with each branch's check results.

E1 · Write (the in-between interface)

Built 2026-10-10:

  • Page editor: common front matter as a form, or all of it as YAML (edits keep key order and comments); the body as Markdown; the page's check findings inline.

  • Live preview: rendered by the build engine as you type, served from its own origin (<token>.localhost:8191) so a site's scripts can't reach the editor's session.

  • Viewport picker (John): desktop, tablet and mobile, both while editing and in a full-window Present mode for demos; desktop scales to fit; the choice is remembered. Present opens in its own tab and follows the edit as it's typed.

  • Light, dark or automatic (John): a switch in the header, remembered per browser and applied before the page draws; automatic follows the device.

  • Resizable split (John): the divider between editor and preview drags, moves with the arrow keys, and resets on double-click; the width is remembered per browser.

  • Save commits as the person. The first save on the publishing branch starts a draft/<page> branch; nothing is ever written to the publishing branch. A save based on a commit the branch has moved past is refused.

  • New page starts its own draft.

  • Submit for review opens a pull request with the preview link and the check summary.

  • Publish merges it only if the draft passes the checks, and the platform still decides who may merge.

  • Scheduled publishing: publish_at, honored by the build, the pull agent (which rebuilds when a page comes due) and the CI templates (an hourly run).

  • Not yet: GitLab and Bitbucket reviews (E0's platform note), and media (E2).

  • Markdown with a live preview rendered by the same engine as the build, so what you see is exactly what publishes.

  • Front matter as a form, driven by what the site's layouts use (title, summary, date, tags with suggestions from existing terms, image and alt text). Raw front matter is one click away.

  • Save creates the draft branch (draft/<slug>) on first save; later saves add commits to it.

  • Check results appear inline, next to the field or line they're about. Errors block "submit", and the reason is shown in words.

  • Submit for review opens a pull request with a preview link (the commit status we already post). Publish merges it, for people the platform allows to merge.

  • Scheduled publishing, kept in git: front matter publish_at, honored by the build and the pull agent; previews show what will appear when. No data store.

Quick posts

Built 2026-10-10 (see quick-posts.md). John: "fast front page edits, alerts, top banner postings, quick to turn out", and good for calendars too.

  • Banner or alert: a line across the top of every page, or the home page only. It can be scheduled to go up and come down, in the site's time zone. It lives in data/banners.yaml, and the starter shows it.
  • News post, dated today; event, into a calendar (see events.md); front page, its sections, from the library.
  • Post now: one button commits, opens the review, runs the checks and publishes, when the platform lets that person merge. Otherwise the review waits for someone who can. A quick post is still a commit, still checked, still reviewed. There's no faster path to the live site.
  • Calendar pack bundled (hotdog-cms pack add calendar): event pages, an upcoming-events section, and an iCalendar feed people subscribe to (webcal), checked against ical.js and Python's icalendar.

Cloudflare (on hold)

On hold, John 2026-10-10.

John, 2026-10-10: manage DNS, certificates and domain registration, and deploy to Cloudflare (Workers with static assets, and Pages, which Cloudflare is folding into Workers) directly from the CMS. To be designed. The idea, and the rules it has to keep:

  • Deploying is a publish target. hotdog-cms publish gets a cloudflare target: Workers static assets or Pages, with our redirects and headers (CSP included) written in Cloudflare's formats. Like every target, it runs where the operator publishes from (CI, the pull agent, a laptop), and only after the checks pass. From the editor, "Publish" merges, and the operator's pipeline deploys. The editor holds no Cloudflare token: taking over the editor still gives no way into the site.

  • Previews: each draft branch as a Cloudflare preview deployment, as an alternative to hotdog-cms preview, linked from the review the same way.

  • DNS, certificates and domains: an operator's tool rather than an editor's, run with a token scoped to the zones it manages:

    • point a site's domain at its target;
    • check the certificate and the SSL mode (Full (strict));
    • Always Use HTTPS, HSTS, DNSSEC;
    • purge only the changed addresses after a publish.

    Changes are shown before they're applied, like a diff. Domain registration goes through Cloudflare Registrar, as far as its API allows; check that before promising it.

  • The editor's part: read-only status beside each site (domain resolves here, certificate valid until, last deploy). Two of check's rules lean on Cloudflare: Email Address Obfuscation (cloudflare-email) and cache rules. They could read the zone's real settings instead of assuming.

  • Not Cloudflare-only: the same shape should fit other DNS hosts and CDNs later; Cloudflare first because we run on it.

  • Possible data-store point: none expected. Tokens live in the operator's environment or CI secrets, as deploy credentials do today.

Mail servers: inbuxa and others (on hold)

On hold, John 2026-10-10.

John, 2026-10-10: plug straight into inbuxa, or a similar mail server, to set up email for forms and the rest, securely. Today the forms endpoint sends through any SMTP server, with a password in its environment. The idea:

  • Connect once, least privilege. The operator connects the endpoint to their mail server. What it holds is a credential that can only send, only from the site's own address ([email protected]), only to the form's recipients, at a limited rate. It never reads a mailbox.

    • On inbuxa: an OAuth client (inbuxa only accepts registered clients) sending through JMAP. Where inbuxa lacks a send-only scope, that's an inbuxa feature to add (and a divergence-log entry).
    • Elsewhere: SMTP submission over TLS, and JMAP where the server speaks it.
  • The credential lives with the endpoint, in the operator's environment, like every other secret: never in the editor, never in a repository.

  • Setup helper:

    • create the sending address or identity;
    • check the domain's mail records (SPF, DKIM, DMARC), showing what's missing rather than changing DNS on its own;
    • send a test message.

    Where the mail server manages DNS itself, as inbuxa does for DKIM, defer to it.

  • Submissions into the mail server: optionally, deliver each submission as a message filed into a mailbox (a Sieve rule files it into "Forms"). The mail server's spam filtering and search then do the work, and the E7 submissions viewer could read that mailbox with a read-only grant instead of the JSON-lines files. No data store: the mail server is the store.

  • Other mail functions, later: the "thanks, we got it" reply from the site's address, newsletter sign-up handed to the mail server's lists, and security.txt and privacy-page contacts checked against addresses that actually exist.

  • inbuxa first because it's ours; then generic JMAP and SMTP servers, and maybe sending services (Postmark, SES) through the same narrow interface.

Several accounts and repositories

Built 2026-10-10 (John: operators may have several accounts, on one git platform or several, with sites spread across them):

  • Several accounts at once: each account is its own sealed cookie, up to eight, because a Gitea token alone can be much of a cookie's 4 KB.
    • Signing in again adds an account; the same account again just refreshes.
    • The header menu signs out one account or all of them.
    • Still no data store: the browser holds the accounts.
  • A second account on the same platform: "Add an account" asks GitHub to let the person pick (prompt=select_account). Other platforms sign in whoever is signed in there, and the menu says so.
  • Sites from every account: each configured site is checked against every signed-in account on its platform (answers cached for a minute). The sites list shows which accounts can edit and which can only read.
  • Working as: when several accounts can open a site, the site view has a picker, remembered per browser and sent with every request about that site. The default is the first account that can write.
  • Separate per account: commits, checkouts and the live preview. When a platform stops taking one account's token, only that account is signed out.
  • Finding sites (built the same day, John: "let's try the auto finder"): with discover on for a platform, every repository a signed-in account can reach that holds a HotDog CMS site.yaml is offered beside the listed sites. It can be narrowed to some owners, and results are cached for ten minutes. It's off by default. See editor.md. Tried on the Coffey Labs Gitea with a throwaway repository (since deleted).

E2 · Media

Built 2026-10-10:

  • Re-encoding (internal/media):

    • uploads are decoded by Go's own decoders, never by C;
    • turned upright, then stripped of EXIF, location, comments and anything after the image data;
    • published at 480, 960 and 1600 px wide, each with a WebP copy when that's smaller (38 to 60% smaller on real photos). The WebP encoder is libwebp translated to pure Go and vendored in internal/media/webpenc: no system library, no cgo.
    • SVG is refused, and a file claiming to be enormous is refused before it's decoded.
  • Upload commits to the draft, as the person. From the publishing branch it starts the page's draft and carries unsaved text across.

  • Insert picture: from the button, or by dropping or pasting a picture onto the body. Upload or pick from the library. Alt text is required, or an explicit "decorative" choice. A library picture brings the alt text it was given before.

  • Pictures library: a page per site listing every picture, its sizes and weight, which files use it, and what's unused. Thumbnails come from the person's checkout, as sandboxed images only.

  • Responsive markup in the build: a Markdown image of a media/ picture becomes a <picture> with the WebP copies, srcset, width and height, and lazy loading (markdown.image_sizes sets sizes).

  • New checks: image-location (a JPEG carrying GPS, an error) and image-weight (over 500 KB, a warning).

  • Tried end to end on a throwaway repository on the Coffey Labs Gitea (since deleted).

  • Not yet: deleting unused pictures from the library, and object storage for sites with a lot of media.

  • Upload by drag and drop. Every image is re-encoded on the way in:

    • EXIF stripped (location included);
    • resized to sensible widths, with WebP and a fallback;
    • so a "picture" can't carry anything else.
  • An image can't be inserted without alt text, or an explicit "decorative" choice.

  • A media library, with what's used where and what's unused.

  • Storage: committed to the site's repository by default, as today. Possible data-store point: sites with a lot of large media may want object storage (S3-compatible) or Git LFS instead. Decide when a real site needs it.

E3 · Sections and look

Built 2026-10-10 (see themes.md):

  • Sections as cards: a page's sections are shown top to bottom. Add one from the site's library, drag or move it, duplicate or remove it, and edit its fields in a form; the live preview follows.
  • Forms from templates: a section declares its fields in a comment at the top of its template: labels, help, required, defaults, and twelve types including lists, groups, pictures, data files and collections. A template without one still gets a form, from the fields it uses, partials included. The cairnobs.org port's sections work unchanged. The starter's five sections are declared.
  • Edits keep the file as written: front matter changes keep key order, and every part that didn't change keeps its comments and style, even when sections move.
  • Look:
    • the theme lists in look.yaml what can change (colors with light and dark values, fonts, selects such as corners, a logo) and which pairs must stay readable;
    • the site's choices go in site.yaml (look:), and the build writes them as a fingerprinted stylesheet, so no inline styles;
    • the Look panel previews the home page as you pick and checks every pair as you go;
    • saving replaces only the look: block of site.yaml.
  • New check: look-contrast (an error under 4.5:1).
  • Found and fixed: the starter's own light-mode accent was 4.39:1 on its background, now 4.73:1.
  • Tried on a throwaway repository on the Coffey Labs Gitea (since deleted).

E4 · Power interface

Built 2026-10-10 (see editor.md):

  • Command palette (Ctrl+K): every action by name, from anywhere:

    • open any page by typing part of its title;
    • switch drafts, go to Files, Pictures or Look;
    • save, review, publish, theme, accounts.

    Views add their own commands while they're on screen. Ctrl+S saves in the page editor, the Look panel and the Files view.

  • Files: any text file in the site edited as raw text, with git's diff shown before every save, and a commit message. Files can be added and deleted, each has its own history, and a draft can be compared with the published branch. Saves are commits on drafts; .git and public/ are off limits. Commits can now delete files too, which also opens the way to deleting unused pictures.

  • **Console (Ctrl+):** a command line in the browser that speaks HotDog CMS and git (status, pages, edit, cat, new, check, drafts, branch, diff, log, review, publish, as, theme`). Every command is a call the interface makes too, with the laptop equivalent where there is one. It runs no shell commands.

  • Works on any git host: history and comparisons fetch only the depth they need into the shallow checkouts, so they work on plain git as well as the platforms.

  • Tried on a throwaway repository on the Coffey Labs Gitea (since deleted).

E5 · WYSIWYG interface

Built 2026-10-10 (see editor.md):

  • Visual editing of the page body, on ProseMirror's own Markdown support (Markdown in, Markdown out, no HTML in between), with a toolbar, the usual shortcuts and Markdown typing shortcuts. A Markdown/Visual switch is remembered per browser.
  • It never rewrites what nobody touched: each block remembers its exact source, and untouched blocks are saved character for character. Proven on all 135 jcoffey.dev articles: every one comes back identical, and 3,917 of their 3,918 blocks are editable visually. Editing a heading and adding a list in a real test gave a three-line diff.
  • Kept as written: tables, raw HTML, footnotes, task lists, {#id} attributes, strikethrough, link definitions, and anything whose round trip would change its meaning are read-only blocks, edited in the Markdown view. The toolbar can only produce Markdown.
  • Pictures dropped or pasted in go through Insert picture (re-encoded, alt text required).
  • Point and edit: a script added to live previews only (never to built sites) sends the clicked text to the editor, which puts the cursor there in either view. It listens only to the editor's window and posts only to the editor's origin.
  • Loaded on demand: the visual editor is a separate download, so the editor's first load didn't grow.
  • Found and fixed: Gitea's OAuth tokens expire after an hour, and the editor didn't renew them, so a long session quietly lost access (the sites list went empty). The editor now keeps the refresh token and renews ahead of expiry. Simultaneous requests share one renewal, and a refused renewal signs out only that account.
  • Tried on a throwaway repository on the Coffey Labs Gitea (since deleted).

E6 · Helpers

Built 2026-10-10:

  • Search and social: a search-result and link-preview panel on every page, from what the page actually renders (title and description lengths, missing picture or alt text, noindex).
  • The gotchas: every check finding now says how to fix it (Fix, shown in the editor and by hotdog-cms check -fix). A test fails if a new rule ships without a fix.
  • Redirects: a table over site.yaml's redirects:, validated (no duplicates, nothing that would hide a page). Moving a page renames it and redirects its old address in one commit, and collapses chains.
  • Social cards: cards: true draws a 1200×630 link-preview picture for every page without its own.
    • The title and summary, in the site's look, drawn in Go, so nothing needs installing.
    • The file name changes with the content, so previews refresh.
    • The starter turns cards on, which clears its social-image warnings. It also writes og:image:alt, and fediverse:creator from fediverse: in site.yaml.
  • Analytics wizard and consent (see privacy.md):
    • Plausible, Matomo or Google Analytics, loaded only after a visitor accepts a banner.
    • The banner is a region, not a wall. Decline comes first and looks the same as Accept, a "Cookie settings" link reopens it, declining deletes the counter's cookies, and Global Privacy Control counts as no.
    • Counting without asking needs gated: false in site.yaml, and check warns about it (analytics-ungated).
  • The privacy notice lists what the site uses (privacyFacts), from its own settings at every build, so it can't fall behind. The starter has a privacy page that uses it.
  • Search consoles: verification codes for Google, Bing and Yandex (paste the tag or the code), and the sitemap address to give each. IndexNow:
    • a key generated in the editor;
    • only changed pages pinged after each publish, per target, opt-in (indexnow: true, or the pull agent's -indexnow);
    • a failed ping never fails a publish.
  • Found and fixed on the way: x/net, x/text and x/sys releases under two weeks old were back to older ones.
  • Share links (decided by John, 2026-10-10): after publishing, or on any published page, the post text and buttons that open Mastodon, Bluesky and LinkedIn's own share pages with it filled in. No tokens: the person posts from their own account. Posting straight from the editor would need per-network tokens, which the editor doesn't hold; that stays out unless it's asked for.
  • Per-platform previews are one shared card for now.

E7 · Forms

Built 2026-10-10 (see forms.md):

  • Form builder over forms/*.yaml:

    • recipient, subject, reply-to (from the form's email fields), button, success page, mailto fallback, Turnstile and storage;
    • fields with label, name, kind, choices, placeholder, help, required and length, added, moved and removed;
    • new forms and deleting them.

    A live preview shows the form on the page that uses it, in the site's styling. Saves go to drafts, keep the file's opening comments and write only what's set.

  • Submissions viewer for forms with store: true:

    • the endpoint opens a separate listener (viewer:), never its public address, which stays write-only;
    • it refuses non-private addresses and needs a 32-character token;
    • the editor reads through it (submissions: per site) and shows submissions newest first, with paging, only to people who can edit the site, logging each read.
  • No data store: the JSON-lines files are still the record. Searching many submissions remains the data-store point noted below, and the mail server plan (submissions filed into a mailbox) is another way out.

  • Tried on a throwaway repository on the Coffey Labs Gitea (since deleted).

E8 · People and workflow

Built 2026-10-10 (see editor.md):

  • Roles from the platform: reader, writer or maintainer, from its permissions, shown in the site view. The editor stores none of it, and the platform still decides who may merge.
  • Review requests: submitting a draft can ask people to review, chosen from those the platform says can (Gitea's and Forgejo's reviewers, GitHub's collaborators). The platform notifies them.
  • History: the published branch's changes, newest first, from git. Undo makes a revert on a new draft, with merges reverted against their first parent, and says so when later changes overlap instead of guessing.
  • Passkeys and two-factor: provided by the platform's sign-in, nothing to build.
  • Fixed on the way: the site finder now looks again (at most every 30 seconds) when asked for a site it doesn't know, so a repository made a minute ago isn't invisible for ten.
  • Tried on a throwaway repository on the Coffey Labs Gitea (since deleted).

E9 · Importers

Built 2026-10-10 (see importing.md):

  • WordPress: a live site through its REST API (an Application Password for drafts and private posts, read from the environment and never stored), or an export file (WXR, including classic-editor content and [caption]).
  • Hugo (YAML or TOML front matter, aliases, page bundles with their pictures), Jekyll (dated posts, the site's permalink style worked out for redirects, redirect_from) and Ghost (its JSON export).
  • What every import does:
    • Markdown where it's faithful, HTML kept (format: html) where it isn't, with the reason in the report;
    • pictures re-encoded like uploads;
    • every old permalink becomes a redirect;
    • page hierarchies are kept;
    • shortcodes and template code are listed.
  • Pages that build: a WordPress import is built and checked in the tests with no errors.
  • Safety: pictures beside a Hugo or Jekyll page are read only from inside the folder being imported, so a post's file:// link can't reach other files. Nothing is overwritten without -overwrite.
  • Not done: Eleventy (its Markdown and front matter are close to Jekyll's; import jekyll handles simple sites).

E10 · Extensions without the plugin problem

Built 2026-10-10, as decided by John: extensions are files, not code.

  • Packs (see packs.md): sections, partials, layouts and stylesheets, from a folder or a git repository, installed as the site's own files with hotdog-cms pack add. They're reviewed and versioned like any change and recorded in site.yaml. A pack carries no JavaScript, no SVG, and no template with a script, an event handler or a javascript: link. The section library says which pack a section came from.
  • Operator steps: before and after commands on publish targets (see publishing.md). They're trusted, they live only in the operator's targets file, and in the editor only maintainers can change that file or CI pipelines.
  • Not built: a WebAssembly sandbox for third-party build steps. It comes back only if a real need turns up that packs and operator steps can't meet.

Also built 2026-10-10

  • Pagination in the starter: numbered page links, rel="prev" and rel="next", page numbers in titles (see themes.md).
  • The footer bar: copyright, coffeylabs.org, the version, and links to the source and the AGPL on every screen, signed in or not, plus the operator's own links. The endpoint answers /_hotdog/source (see editor.md).
  • The contributor agreement (CLA.md): a license to Coffey Labs, not an assignment, with a promise that contributions stay open source. Signed once by adding yourself to CONTRIBUTORS.md; tools/cla-check.sh checks a pull request.

Decisions (John, 2026-10-10)

Question Decided Why
How people sign in Through the git platform (OAuth with Gitea, GitHub, GitLab, Forgejo, Bitbucket); no local accounts No user store to secure; the platform's permissions and attribution come for free
The editor's front end React + TypeScript The stack inbuxa-admin and the webmail already use
The visual editor A Markdown-native ProseMirror editor Markdown in, Markdown out, with no lossy HTML step
Media storage In the site's repository, re-encoded; object storage as an option later No new service until a site needs one
Editor for hosting customers One editor per customer first; multi-tenant later Multi-tenant is where a real data store first becomes necessary

Where a data store comes in

Nothing up to E5 needs one: auth is delegated, drafts and schedules live in git, and sessions are signed cookies. The first real candidates are, in order:

  1. Multi-tenant hosting. Knowing which customer owns which sites, across many editors.
  2. Large media. Object storage rather than git, for sites with a lot of video or images.
  3. Searching form submissions. Past what reading files comfortably does.
  4. A shared rate limit across endpoints. From the forms work.

Each will be raised when a feature reaches it, not before.

What exists already

Everything the editor builds on is done and tested:

  • the build, with sections, collections, data files and forms;
  • previews per branch;
  • the platform layer (webhooks, commit statuses, CI);
  • publishing to any target;
  • the endpoint;
  • the check that gates every route.

The editor is a new front end over these, not a new system.

Live tests on GitHub (2026-10-10)

Sign-in (OAuth app, expiring tokens), the site finder, a page edit to a draft, a pull request with the check summary, publishing (merge), a quick post published in one step, history and undo all worked against a private GitHub repository. Fixed on the way:

  • the site finder skipped new repositories (GitHub reports size 0 for a while after the first push);
  • merged drafts are now deleted, as on Gitea;
  • commits use GitHub's numbered no-reply address, which stays tied to the account.

Not yet tried live: renewing an expired GitHub token (eight hours), and a second account with write but not admin access.

Next for GitHub: sign-in through a GitHub App instead of an OAuth app, so an editor can be limited to chosen repositories. OAuth apps can only ask for every repository the person can reach.