feat(activity): Markdown rendering for descriptions

Activity descriptions now render through a small marked + DOMPurify
pipeline. Client-side only — the server keeps storing raw markdown
source, private descriptions stay inside the encrypted payload.

frontend/src/lib/markdown.ts exposes a single renderMarkdown(src)
helper. Allowlist: p / br / hr / strong / em / del / s / code / pre /
ul / ol / li / blockquote / a / h3–h6. URL scheme allowlist:
http(s) and mailto. Images are deliberately stripped — external
image URLs leak the viewer's IP to the linker's host. Raw HTML
pass-through is off. A DOMPurify afterSanitizeAttributes hook forces
target="_blank" rel="noopener noreferrer ugc" on every <a>, matching
the existing external-link pattern in PublicList. h1/h2 in the
source get downshifted to h3 via walkTokens so the description's
heading hierarchy doesn't collide with the SPA's own <h1>/<h2>.

Render sites: the two description spots in ActivityRow.svelte (one
for the decrypted private branch, one for non-private). New
.md class in styles.css gives the rendered block tight spacing,
discreet code/pre/blockquote treatment, accent-coloured links.

UX: a "Du kan bruke Markdown — **fet**, *kursiv*, [lenke](https://…),
lister med -" hint under the textarea in ActivityForm. No preview
pane to keep scope contained.

Tests: 14 cases in tests/markdown.test.ts covering the allowlist
(bold/italic/strike/lists/links/mailto), the sanitisation surface
(javascript: / data: / script / iframe / on* handlers / images),
and the h1/h2 → h3 downshift. happy-dom is registered locally in
beforeAll (and the markdown module dynamic-imported) rather than as
a project-wide preload — the latter overrides fetch/Request/Response
and breaks Bun-fetch-based API tests in other files.

Bundle impact: marked + dompurify add ~60KB to the SPA bundle.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 21:15:31 +02:00
commit 1c95dbed00
7 changed files with 276 additions and 2 deletions

View file

@ -14,14 +14,18 @@
"reset-password": "bun run server/reset-password.ts"
},
"dependencies": {
"dompurify": "^3.4.5",
"hono": "^4.6.0",
"libsodium-wrappers-sumo": "^0.7.15",
"marked": "^18.0.4",
"svelte-dnd-action": "^0.9.69"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tsconfig/svelte": "^5.0.4",
"@types/bun": "^1.1.0",
"@types/dompurify": "^3.2.0",
"@types/libsodium-wrappers-sumo": "^0.7.8",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",