diff --git a/frontend/src/lib/markdown.ts b/frontend/src/lib/markdown.ts
new file mode 100644
index 0000000..4c6034f
--- /dev/null
+++ b/frontend/src/lib/markdown.ts
@@ -0,0 +1,86 @@
+/**
+ * Markdown rendering for activity descriptions.
+ *
+ * Pipeline: marked (CommonMark + GFM) → DOMPurify (strict allowlist).
+ *
+ * Why this shape:
+ * - Client-side only. Server stores raw markdown source — that's the
+ * canonical form. For private rows the source lives inside the
+ * encrypted payload; we decrypt then render here.
+ * - Allowlist over blocklist. The default DOMPurify config strips
+ * `javascript:` etc, but we tighten further: no images (external
+ * image URLs leak the viewer's IP), no raw HTML pass-through, only
+ * http/https/mailto in links.
+ * - All links get `target="_blank" rel="noopener noreferrer ugc"`
+ * via a DOMPurify hook. We treat description content as user-
+ * generated, untrusted text — the rel attributes match the
+ * existing external-link pattern in PublicList.svelte.
+ *
+ * The hook registration runs at module load; safe because DOMPurify
+ * hooks are global to the DOMPurify instance and idempotent.
+ */
+import { marked } from 'marked';
+import DOMPurify from 'dompurify';
+
+DOMPurify.addHook('afterSanitizeAttributes', (node) => {
+ if (node.nodeName === 'A') {
+ node.setAttribute('target', '_blank');
+ node.setAttribute('rel', 'noopener noreferrer ugc');
+ }
+});
+
+const ALLOWED_TAGS = [
+ 'p', 'br', 'hr',
+ 'strong', 'em', 'del', 's',
+ 'code', 'pre',
+ 'ul', 'ol', 'li',
+ 'blockquote',
+ 'a',
+ // Headings deliberately limited to h3-h6 — h1/h2 would compete with the
+ // SPA's own heading hierarchy. Markdown's `#` and `##` get downshifted by
+ // marked's `headerIds: false` config plus our renderer override below.
+ 'h3', 'h4', 'h5', 'h6',
+];
+const ALLOWED_ATTR = ['href', 'target', 'rel'];
+
+// Configure marked once. `breaks: true` makes a single newline render as
+// , which matches the existing pre-wrap behaviour users are used to.
+// `gfm: true` enables strikethrough + autolinks + tables, but tables are
+// stripped at the DOMPurify layer (not in the allowlist).
+marked.use({
+ gfm: true,
+ breaks: true,
+ // Downshift h1/h2 to h3 to avoid colliding with the SPA's
/
.
+ // h3..h6 pass through unchanged. walkTokens mutates the depth before
+ // the default heading renderer runs, which gets us the level shift
+ // without having to re-implement inline parsing in a custom renderer.
+ walkTokens(token) {
+ if (token.type === 'heading' && token.depth <= 2) {
+ token.depth = 3;
+ }
+ },
+});
+
+/**
+ * Render markdown source to sanitised HTML.
+ *
+ * Returns an empty string for empty/whitespace-only input so callers can
+ * cheaply check whether there's anything to display.
+ */
+export function renderMarkdown(src: string | null | undefined): string {
+ if (!src || !src.trim()) return '';
+ const html = marked.parse(src, { async: false }) as string;
+ return DOMPurify.sanitize(html, {
+ ALLOWED_TAGS,
+ ALLOWED_ATTR,
+ // Only http(s) and mailto. Default DOMPurify also strips javascript:
+ // but being explicit avoids surprises on protocol additions.
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
+ // KEEP_CONTENT defaults to true: text content of STRIPPED tags is
+ // preserved as plain text (so `` becomes the
+ // harmless string `alert(1)`). Script-type tag CONTENTS are still
+ // stripped via DOMPurify's internal FORBID_CONTENTS list. Setting
+ // KEEP_CONTENT:false was inexplicably wiping content of *allowed* tags
+ // too — confirmed via tests/_probe — so the default wins here.
+ });
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index b21de91..110313f 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -346,6 +346,43 @@ nav.top h1::after {
[role="listitem"] article.card button,
[role="listitem"] article.card a { cursor: pointer; }
+/* Markdown-rendered description blocks. Keep the rhythm tight so a list
+ doesn't bloat the activity card; cap link colour to --accent and pre/code
+ blocks to a discreet tinted background. The renderer enforces an
+ allowlist (see lib/markdown.ts) so we only need to style what we let
+ through. */
+.md p { margin: 0.25rem 0; }
+.md p:first-child { margin-top: 0; }
+.md p:last-child { margin-bottom: 0; }
+.md ul, .md ol { margin: 0.25rem 0 0.25rem 1.25rem; padding: 0; }
+.md li { margin: 0.1rem 0; }
+.md h3, .md h4, .md h5, .md h6 { margin: 0.5rem 0 0.25rem; font-size: 1rem; }
+.md a { color: var(--accent); }
+.md code {
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ background: rgba(127, 127, 127, 0.12);
+ padding: 0.05rem 0.3rem;
+ border-radius: 4px;
+ font-size: 0.9em;
+}
+.md pre {
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ background: rgba(127, 127, 127, 0.10);
+ padding: 0.5rem 0.7rem;
+ border-radius: var(--radius-sm);
+ overflow-x: auto;
+ font-size: 0.9em;
+ margin: 0.4rem 0;
+}
+.md pre code { background: transparent; padding: 0; }
+.md blockquote {
+ margin: 0.4rem 0;
+ padding-left: 0.7rem;
+ border-left: 3px solid var(--border);
+ color: var(--muted);
+}
+.md hr { border: 0; border-top: 1px solid var(--border); margin: 0.6rem 0; }
+
.error { color: var(--danger); margin-top: 0.5rem; }
footer {
diff --git a/package.json b/package.json
index 28bb896..b94d654 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts
new file mode 100644
index 0000000..849ca32
--- /dev/null
+++ b/tests/markdown.test.ts
@@ -0,0 +1,118 @@
+/**
+ * renderMarkdown — sanitisation contract.
+ *
+ * The function is the only path between user-typed markdown source and
+ * what the browser will run, so the allowlist matters. Tests cover:
+ * - what passes through (bold, italic, lists, links, headings 3+, code,
+ * blockquote, hr, strikethrough, line breaks)
+ * - what gets stripped (script, iframe, on* handlers, javascript: URLs,
+ * data: URLs, vbscript:, raw HTML tags not on the list)
+ * - link decoration: target=_blank rel=noopener noreferrer ugc
+ * - h1/h2 downshift to h3
+ */
+// DOMPurify needs `window` at module-load time. happy-dom registers
+// browser globals — but if loaded as a project-wide test preload it also
+// overrides fetch/Request/Response, breaking Bun-fetch-based API tests in
+// other files. Scope it here only: register happy-dom in beforeAll, then
+// dynamic-import the markdown module so it sees `window`.
+import { GlobalRegistrator } from '@happy-dom/global-registrator';
+import { beforeAll, describe, expect, test } from 'bun:test';
+
+type RenderFn = (src: string | null | undefined) => string;
+let renderMarkdown: RenderFn;
+
+beforeAll(async () => {
+ GlobalRegistrator.register();
+ ({ renderMarkdown } = await import('../frontend/src/lib/markdown'));
+});
+
+describe('renderMarkdown — allowlist', () => {
+ test('empty / whitespace → empty string', () => {
+ expect(renderMarkdown('')).toBe('');
+ expect(renderMarkdown(' \n ')).toBe('');
+ expect(renderMarkdown(null)).toBe('');
+ expect(renderMarkdown(undefined)).toBe('');
+ });
+
+ test('bold / italic / strikethrough pass through', () => {
+ const html = renderMarkdown('**fet** og *kursiv* og ~~strøket~~');
+ expect(html).toContain('fet');
+ expect(html).toContain('kursiv');
+ expect(html).toContain('strøket');
+ expect(html).toMatch(/<(del|s)>/);
+ });
+
+ test('lists', () => {
+ const html = renderMarkdown('- en\n- to\n- tre');
+ expect(html).toContain('