feat: rich-text element bodies — TipTap/Solid wrapper with markdown round-trip and configurable allowlist #24
No reviewers
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle!24
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "afk/7"
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?
Implements rich-text editing for element bodies: a headless SolidJS wrapper around
@tiptap/corethat 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 sidecarbody:key now accepts{type: rte, allow: [...]}beside the legacymarkdown/nonestrings. Allow tokens arebold, italic, link, bullet-list, ordered-list, h2, h3, h4with range shorthands (h2-h4); unknown tokens fail the load with an actionable error;h1is never valid (reserved for the page title). JSON always shipsbodyas{type, allow?}.internal/content:ParseFragment/SerializeFragmentround-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/bodyreturns one shortcode's canonical body markdown;POST /api/page/savegains an optionalbodyfield routed throughParseFragment(rejected forbody: none, promotes self-closing shortcodes to blocks when non-empty, omitted = body untouched);GET /api/pageslists content-relative.mdpaths 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:
noticeuses a restricted allowlist (bold, italic, link); the newproseelement 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:
data-pm-slice, letting an external page smuggle a live shortcode atom whose verbatimsrcreaches 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.[text]({{< ref path=... >}})) was lifted to a sentinel that markdown-it percent-encoded, destroying it on save.normalizeLink/validateLinknow 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/pagesfetch 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 build/test/vet ./...andgofmtall clean.tsc --noEmitclean,vite buildsucceeds.body: nonerejection and the pages endpoint verified.🤖 Generated with 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_01PJ5JiPQhRjzaY9bino8iXSA 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_01PJ5JiPQhRjzaY9bino8iXSPull request closed