Rendering Excalidraw drawings

TL;DR

Excalidraw files already sync, store, and reach the page endpoint — the only
missing piece is rendering. This is a render-layer feature, not a greenfield
build. .excalidraw (and .canvas/.base) are in the sync whitelist
(internal/case/pushnotes/resolve.go:27-33), stored verbatim in
note_versions.content as raw files (internal/mdloader/loader.go:263-299),
get a URL that keeps the extension (/board.excalidraw), and hit the catch-all
page endpoint — where today they short-circuit to a "not supported yet"
placeholder (internal/case/rendernotepage/endpoint.go:96-103
views.html UnsupportedFile, line 533-534).

The plan: render the scene to a static SVG/PNG asset via an external render
service
(EXCALIDRAW_RENDER_URL). On note load we enqueue a background
render job
(mirroring internal/chartdata: enqueue-on-miss + debounced
reload), POST the scene JSON to the service, get back SVG/PNG, store it as a
note_assets asset on disk/S3
(NOT a SQLite blob), keyed by scene
content-hash
so identical scenes dedupe and unchanged scenes skip re-render. A
small bookkeeping table maps scene_hash → asset_id + last_error (mirrors
chart_data_cache, db/schema.sql:850). The page shows a placeholder until
the asset lands
, then live-pull (notebus/SSE) swaps it in.

Owner decision — no client-side fallback viewer. If there is nothing to
render with — EXCALIDRAW_RENDER_URL is unset, the render errored, or no asset
exists yet and no service is configured — the page just shows the existing
"not supported yet" placeholder
(the current stub at
endpoint.go:96-103views.html UnsupportedFile). No heavy @excalidraw
React bundle ships to readers, no fallback renderer. This makes Excalidraw
almost pure-backend: when a render exists the page emits a plain <img> /
inline SVG; the only frontend work is serving that asset (no JS widget).

A format wrinkle dominates the parser work: the common Obsidian
Excalidraw-plugin file is foo.excalidraw.md — scene JSON embedded in a
markdown wrapper. filepath.Ext("foo.excalidraw.md") == ".md", so trip2g treats
it as plain markdown today (renders the wrapper text, not a drawing). Only a
pure .excalidraw (JSON) file hits the raw-file path, and it has no parser.
The design targets both: extract the scene JSON out of the .excalidraw.md
wrapper, and parse the pure .excalidraw JSON.

This is effectively the first real consumer of the planned-but-unimplemented
external-HTTP render mechanism in template_processors.md
(buffered render → POST to an allowlisted host at cache-warm time). We align the
HTTP contract with it but ship a narrower, dedicated path first (no template
engine involvement, no Jet apply_processor).


What Excalidraw is, and how it'll be used here

Excalidraw is a hand-drawn-style whiteboard: boxes, arrows, freehand strokes,
text, embedded images. The
Obsidian Excalidraw plugin
stores a drawing as a scene: a JSON document with elements[] (shapes),
appState (theme, background), and files{} (embedded image blobs, base64).

On a published trip2g site a drawing is a figure: an author keeps an
architecture sketch or a flow diagram in their vault, links it from a note (or
publishes the file as its own page at /board.excalidraw), and a reader sees a
crisp static image — light or dark to match the site theme — with no editor
chrome and no multi-megabyte React bundle.

The two on-disk formats (critical)

File filepath.Ext Routed as Content
foo.excalidraw.md .md markdown (full pipeline) Markdown wrapper with scene JSON inside a code block / ## Drawing section, often compressed
foo.excalidraw .excalidraw raw file (isRawFile) Pure scene JSON

The .excalidraw.md form is what the Obsidian plugin produces by default, so
it's the common case — and the trap. Because the extension resolves to
.md, isRawFile (loader.go:263-266) returns false and the file runs the
entire markdown pipeline: goldmark parses it, the wrapper markdown becomes
HTML, and the reader sees a fenced code block full of JSON (or, if compressed, a
base64 blob), not a drawing.

The .excalidraw.md wrapper looks roughly like:

---
excalidraw-plugin: parsed
---

# Excalidraw Data
## Text Elements
...
## Drawing
```json
{"type":"excalidraw","version":2,"elements":[...],"appState":{...},"files":{...}}