Page Editing
The editor at GET /_admin/pages/{path} has three modes, switchable from the page header.
- Edit (default) renders one input per declared model field. Custom frontmatter the model does not declare is still surfaced so authors never lose a value silently.
- Source shows the raw YAML frontmatter and Markdown body in a single textarea — useful for spotting frontmatter the form pane does not surface, or for copy-pasting.
- Diff renders a read-only working-tree-vs-HEAD diff of the underlying content file. This is available on git-aware builds with a
.gitdirectory under the content tree.
All fields are disabled for the Publisher role.
Reaching the body with the keyboard
Edit mode renders one input per declared field, and every page inherits the core vocabulary — so on a page with a few tags the body field sits behind a couple of dozen controls. The editor’s first focusable element is a Skip to body editor link: invisible until it has focus, so the first Tab after the page loads reveals it and Enter jumps straight to the body. From there, Esc returns you to Save.
Body toolbar and editing shortcuts
In Edit mode the Markdown body field carries a toolbar and a set of authoring affordances. Everything is plain client-side behaviour over the textarea — the raw Markdown stays the source of truth, and with JavaScript disabled the field degrades to an ordinary textarea.
The toolbar buttons act on the current selection:
- H1 / H2 / H3 toggle the heading prefix on every selected line (applying it, or stripping it when every line already has it).
- Bold, Italic, and Code wrap the selection in
**,*, or`— and unwrap it when it is already wrapped. A multi-line Code selection becomes a fenced block. - Link replaces the selection with
[selection](url)and drops the caret into theurlslot. - Image opens the media picker and inserts an image reference.
- Quote toggles
>on every selected line.
Keyboard shortcuts (use Cmd on macOS, Ctrl elsewhere):
| Shortcut | Action |
|---|---|
| Cmd/Ctrl-B | Bold |
| Cmd/Ctrl-I | Italic |
| Cmd/Ctrl-K | Link |
| Cmd/Ctrl-1 / -2 / -3 | Heading 1 / 2 / 3 |
Additional affordances while typing:
- List and blockquote continuation — pressing Enter inside a
-,*,+,1., or>line starts the next item (ordered lists auto-increment, nested indentation is preserved). Pressing Enter on an empty item removes the marker and ends the list. - Tab / Shift-Tab indent and outdent the selected lines by two spaces; a Tab with no selection inserts two spaces for nested-list indentation. Because Tab is captured while the editor has focus, press Esc to leave the editor — focus lands on the Save button, which is where a Tab would have taken you.
- Auto-pairing — typing
*,_,`,[, or(while text is selected wraps the selection in the matching pair; typing the closing character steps over an auto-inserted one instead of duplicating it. - Smart paste — pasting an
http(s)URL while text is selected turns the selection into a[selection](url)link. All other pastes are native.
Every toolbar action and shortcut leaves the browser’s native undo intact (one Cmd/Ctrl-Z reverts it) and refreshes the live preview as you edit.
Syntax-highlighting editor
On desktop the body field is upgraded to a syntax-highlighting code editor (CodeMirror) layered on top of the same toolbar and shortcuts. It highlights Markdown structure — headings, emphasis, links, lists, blockquotes, and code spans — and highlights fenced code blocks in their own language (JavaScript/TypeScript, Rust, Python, HTML, CSS, JSON, YAML, and shell). The document stays byte-for-byte the Markdown you save; the highlighting is purely visual.
On top of the toolbar it adds:
- Find / replace — press Cmd/Ctrl-F to open an in-editor search panel.
- Soft line wrapping — long lines wrap by default instead of scrolling sideways.
- Granular undo / redo — a deeper, editor-native history (Cmd/Ctrl-Z / Cmd/Ctrl-Shift-Z) than the browser’s.
- Active-line highlight and bracket matching.
The toolbar buttons and the Cmd/Ctrl-B/I/K shortcuts operate on the highlighted editor exactly as they do on the plain field, and edits refresh the live preview as you type. The editor matches the admin light/dark theme and honours reduced-motion.
The editor is a progressive enhancement. With JavaScript disabled, on narrow/phone screens, or in the Publisher (read-only) role, the field falls back to the plain textarea with the toolbar described above — no content or functionality is lost.
Field validation
As you tab between inputs, each blur posts to POST /_admin/validate-field, which validates that single field without saving and returns the validation errors inline beneath the input (or clears a prior error). This lets authors fix problems before attempting a save.
Live preview
In Edit mode, the editor renders a live preview of the page from the current form values, giving visual feedback as you type.
The editor posts the form into the preview frame (POST /_admin/preview-document), so the preview is a page of its own, served with the site’s script policy. Theme scripts run in it; a <script> or an onclick= attribute in the draft is blocked, as it will be on the published page. The frame is sandboxed, so nothing in the preview can reach the admin. POST /_admin/preview-render returns the same render as JSON, for tools.
Save and conflict detection
The Save button posts the form to POST /_admin/pages/{path}. The save goes through an orchestrator that validates, conflict-checks, writes, and appends an audit-log entry.
Concurrent edits are detected via a hidden __mtime field. When the editor form is opened it records the on-disk file’s modification time (Unix seconds). On save, the server reads the file’s current mtime via std::fs::metadata() and compares it against the recorded value. A mismatch triggers a conflict outcome and no file is written — see Conflict resolution.
After a successful save, the fresh on-disk mtime is re-rendered into the hidden __mtime field so subsequent edits use the updated timestamp.
A save rewrites only the frontmatter fields you changed. Comments, blank lines, quoting style, and key order in the rest of the block are preserved exactly as you wrote them, and saving a page you have not changed writes nothing at all.
Frozen content guard
When your site uses versioned content, every released version is frozen so its pages keep documenting what that release actually shipped. Each version under versioning.roots carries a lifecycle state:
versioning: roots: /docs: versions: v0.19: label: "0.19" state: released # frozen: feature edits rejected, errata allowed v0.20-dev: label: "0.20 (Dev)" state: dev # the single writable bucket default: v0.19
state values are dev (the one writable bucket), released (frozen, correctable through the errata overrule), and archived (frozen, no edits at all). When state is omitted the last-declared version defaults to dev and the rest to released, so existing configs keep working. Exactly one version per root may be dev.
A page’s freeze state is read from the physical version folder it lives in, never the URL it is served at, so a page surfaced through version fallback is still edited where its file actually lives.
Opening a frozen page in the editor shows a banner and replaces the Save control:
- A released page offers an “Edit as errata” action. It reveals a required reason field; saving writes the page, records an
errata_updateaudit entry (who, when, reason, target version), and re-baselines that page’s hash in the frozen manifest soaccent validatestays green. - An archived page is read-only; even the errata overrule is refused until the version is moved back to
releasedin config.
The banner is advisory — the guard is enforced server-side. A plain save of a frozen page returns 403 Forbidden and writes nothing; an errata save of an archived page returns 403 too. The errata overrule is offered only to write-capable roles (the Publisher role cannot write at all).
Frozen content validation
Direct filesystem or CLI edits bypass the admin, so accent validate enforces the same freeze. At release-promotion time a per-root manifest (.frozen-manifest.toml, stored next to the version folders) records a content hash for every page in each frozen version:
# Write/refresh the baseline once the new dev bucket is cut: accent validate --write-manifest --config config.yaml # Normal runs (and CI) fail on any unrecorded change to a frozen page: accent validate --config config.yaml
validate recomputes the hashes and exits non-zero on any modification, unrecorded addition, or deletion of a frozen page. An errata correction made through the admin updates the manifest automatically, so a legitimate fix stays green while accidental drift fails the build.