feat: rich-text element bodies — TipTap/Solid wrapper with markdown round-trip and configurable allowlist #24

Closed
dominik.polakovics wants to merge 8 commits from afk/7 into main

Implements rich-text editing for element bodies: a headless SolidJS wrapper around @tiptap/core that round-trips an element's markdown body through the editor, enforces a developer-configurable per-field formatting allowlist from the sidecar, sanitizes paste, and never produces or executes raw HTML.

Closes #7

What shipped

Backend (Go)

  • internal/element: the sidecar body: key now accepts {type: rte, allow: [...]} beside the legacy markdown/none strings. Allow tokens are bold, italic, link, bullet-list, ordered-list, h2, h3, h4 with range shorthands (h2-h4); unknown tokens fail the load with an actionable error; h1 is never valid (reserved for the page title). JSON always ships body as {type, allow?}.
  • internal/content: ParseFragment / SerializeFragment round-trip a shortcode's body as body-only markdown, reusing the canonical block-body machinery and the engine's existing safety checks (no bare {{</{{% openers in text, etc.).
  • internal/server: GET /api/element/body returns one shortcode's canonical body markdown; POST /api/page/save gains an optional body field routed through ParseFragment (rejected for body: none, promotes self-closing shortcodes to blocks when non-empty, omitted = body untouched); GET /api/pages lists content-relative .md paths for the internal-link picker.

Frontend (SolidJS + TipTap)

  • web/src/rte: an allowlist-derived TipTap v3 extension set (h1 unrepresentable, unsafe link schemes refused at parse and command level) plus a pure markdown ⇄ ProseMirror conversion layer. Granted constructs become rich nodes; everything else — disallowed headings/lists, tables, code, raw HTML — degrades to its literal text reading in the editor rather than being dropped. Hugo shortcode spans ride through as inert atoms carrying their verbatim source, so bodies with shortcodes survive an edit uncorrupted.
  • web/src/components: rte-body elements render the editor in the element form with an allowlist-driven toolbar and a link dialog (external URLs and internal pages by tree path). The body saves through the existing element-save path.

Sample site: notice uses a restricted allowlist (bold, italic, link); the new prose element uses the default allowlist and its body exercises headings, lists, emphasis, links, an inline shortcode, and a literal raw-HTML line.

Security & correctness hardening

A multi-agent review surfaced (and this PR fixes) two issues beyond the initial build:

  • Stored-XSS via paste: pasted HTML was trusted whenever it contained the spoofable substring data-pm-slice, letting an external page smuggle a live shortcode atom whose verbatim src reaches the saved body. Pasted HTML is now never trusted to carry atoms — shortcode data-attributes are stripped unconditionally; atoms are only ever created from the trusted markdown load path.
  • Shortcode-in-URL data loss: a shortcode in a link/image URL ([text]({{< ref path=... >}})) was lifted to a sentinel that markdown-it percent-encoded, destroying it on save. normalizeLink/validateLink now leave sentinel-bearing URLs untouched, the shortcode source is restored into the href and emitted verbatim, and images degrade to literal text with the shortcode kept as an atom.

Also memoized the per-keystroke schema rebuild, gated the /api/pages fetch on the link grant, and de-duplicated the toolbar.

Server-side allowlist/CSRF enforcement is intentionally deferred: this issue assumes a trusted editor client, and identity/locks/CSRF are scoped to #13. The editor-level guarantees (paste sanitization, raw-HTML escaping, h1 unrepresentable) are enforced here.

Verification

  • Go: go build/test/vet ./... and gofmt all clean.
  • Web: 149 vitest tests pass, tsc --noEmit clean, vite build succeeds.
  • Live smoke test against the built harness: a body with an h2, bold, a shortcode-in-link, and a mid-text shortcode round-trips through the real save API preserving both shortcodes as tree nodes with no corruption; body: none rejection and the pages endpoint verified.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS

Implements rich-text editing for element bodies: a headless SolidJS wrapper around `@tiptap/core` that round-trips an element's markdown body through the editor, enforces a developer-configurable per-field formatting allowlist from the sidecar, sanitizes paste, and never produces or executes raw HTML. Closes #7 ## What shipped **Backend (Go)** - `internal/element`: the sidecar `body:` key now accepts `{type: rte, allow: [...]}` beside the legacy `markdown`/`none` strings. Allow tokens are `bold, italic, link, bullet-list, ordered-list, h2, h3, h4` with range shorthands (`h2-h4`); unknown tokens fail the load with an actionable error; `h1` is never valid (reserved for the page title). JSON always ships `body` as `{type, allow?}`. - `internal/content`: `ParseFragment` / `SerializeFragment` round-trip a shortcode's body as body-only markdown, reusing the canonical block-body machinery and the engine's existing safety checks (no bare `{{<`/`{{%` openers in text, etc.). - `internal/server`: `GET /api/element/body` returns one shortcode's canonical body markdown; `POST /api/page/save` gains an optional `body` field routed through `ParseFragment` (rejected for `body: none`, promotes self-closing shortcodes to blocks when non-empty, omitted = body untouched); `GET /api/pages` lists content-relative `.md` paths for the internal-link picker. **Frontend (SolidJS + TipTap)** - `web/src/rte`: an allowlist-derived TipTap v3 extension set (h1 unrepresentable, unsafe link schemes refused at parse and command level) plus a pure markdown ⇄ ProseMirror conversion layer. Granted constructs become rich nodes; everything else — disallowed headings/lists, tables, code, raw HTML — degrades to its literal text reading in the editor rather than being dropped. Hugo shortcode spans ride through as inert atoms carrying their verbatim source, so bodies with shortcodes survive an edit uncorrupted. - `web/src/components`: rte-body elements render the editor in the element form with an allowlist-driven toolbar and a link dialog (external URLs and internal pages by tree path). The body saves through the existing element-save path. **Sample site**: `notice` uses a restricted allowlist (bold, italic, link); the new `prose` element uses the default allowlist and its body exercises headings, lists, emphasis, links, an inline shortcode, and a literal raw-HTML line. ## Security & correctness hardening A multi-agent review surfaced (and this PR fixes) two issues beyond the initial build: - **Stored-XSS via paste**: pasted HTML was trusted whenever it contained the spoofable substring `data-pm-slice`, letting an external page smuggle a live shortcode atom whose verbatim `src` reaches the saved body. Pasted HTML is now never trusted to carry atoms — shortcode data-attributes are stripped unconditionally; atoms are only ever created from the trusted markdown load path. - **Shortcode-in-URL data loss**: a shortcode in a link/image URL (`[text]({{< ref path=... >}})`) was lifted to a sentinel that markdown-it percent-encoded, destroying it on save. `normalizeLink`/`validateLink` now leave sentinel-bearing URLs untouched, the shortcode source is restored into the href and emitted verbatim, and images degrade to literal text with the shortcode kept as an atom. Also memoized the per-keystroke schema rebuild, gated the `/api/pages` fetch on the link grant, and de-duplicated the toolbar. Server-side allowlist/CSRF enforcement is intentionally deferred: this issue assumes a trusted editor client, and identity/locks/CSRF are scoped to #13. The editor-level guarantees (paste sanitization, raw-HTML escaping, h1 unrepresentable) are enforced here. ## Verification - Go: `go build/test/vet ./...` and `gofmt` all clean. - Web: 149 vitest tests pass, `tsc --noEmit` clean, `vite build` succeeds. - Live smoke test against the built harness: a body with an h2, bold, a shortcode-in-link, and a mid-text shortcode round-trips through the real save API preserving both shortcodes as tree nodes with no corruption; `body: none` rejection and the pages endpoint verified. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS
A sidecar's body: key now accepts a mapping {type, allow} besides the
legacy markdown/none strings. Type rte carries a normalized allowlist
(bold, italic, link, bullet-list, ordered-list, h2-h4 with range
shorthands); unknown tokens fail the load with an actionable error and
h1 is never valid. JSON always ships body as {type, allow?}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS
ParseFragment/SerializeFragment round-trip a shortcode's body as
body-only markdown: fragments lex in body mode (a leading front matter
fence is literal text, via an empty prepended YAML block that consumes
the lexer's intro scan), results are normalized like a block body, and
serialization applies the same canonicalization and safety checks as
whole documents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS
GET /api/element/body returns one shortcode's canonical body markdown;
POST /api/page/save accepts an optional body field that replaces the
shortcode's body through ParseFragment (rejected for body:none
elements, promotes self-closing shortcodes to blocks when non-empty);
GET /api/pages lists content-relative .md paths for the internal-link
picker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS
notice becomes a restricted rte body (bold, italic, link); the new
prose element uses the default allowlist and its sample body exercises
headings, lists, emphasis, links, a mid-sentence inline shortcode, and
a literal raw-HTML line. goldmark unsafe rendering is enabled so the
nested shortcode's HTML output survives RenderString.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS
TipTap v3 extension sets derived from a field's formatting allowlist
(h1 unrepresentable, unsafe link schemes refused at parse and command
level) plus a pure markdown-to-ProseMirror conversion layer: granted
constructs become rich nodes, everything else degrades to its literal
text reading, raw HTML and shortcode openers in text are escaped on
output, and Hugo shortcode spans ride through as inert atoms carrying
their verbatim source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS
Elements whose sidecar declares an rte body render a TipTap editor in
place of nothing: a Solid wrapper builds the schema and toolbar from
the field's allowlist (no h1 anywhere), a link dialog covers external
URLs and internal pages by tree path, external paste strips smuggled
shortcode atoms, and the form saves the serialized body through the
existing element-save path — omitting it entirely if the body never
loaded, so a fetch failure cannot clobber content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS
A Hugo shortcode in a link destination or image URL (the common
[text]({{< relref ... >}}) pattern) was lifted to a private-use
sentinel that markdown-it then percent-encoded inside the URL, so the
post-parse restore missed it and the shortcode was destroyed on save.
Override normalizeLink/validateLink to leave sentinel-bearing URLs
untouched, restore the shortcode source into the link href, and emit
shortcode hrefs verbatim on serialize; images degrade to literal text
with the shortcode kept as an inert atom. Also memoize the ProseMirror
schema per allowlist (it was rebuilt every keystroke) and de-binary a
test file that carried a raw NUL byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS
fix(web): never trust pasted HTML to carry shortcode atoms
All checks were successful
ci / build-and-test (pull_request) Successful in 1m26s
7d1c9cf5a9
Pasted HTML was passed through unsanitized whenever it contained the
substring data-pm-slice, which any external page can spoof on the
clipboard — letting a span[data-shortcode] paste become a live atom
whose verbatim src (script and all) is emitted into the saved body.
Strip the shortcode data-attributes from every paste unconditionally;
atoms are only ever created from the trusted markdown load path. Also
skip the /api/pages fetch for rte bodies that don't grant links, and
fold the duplicated toolbar buttons into one data-driven list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJ5JiPQhRjzaY9bino8iXS
All checks were successful
ci / build-and-test (pull_request) Successful in 1m26s

Pull request closed

Sign in to join this conversation.
No reviewers
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!24
No description provided.