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

@ -209,6 +209,10 @@
<label for="desc">Beskrivelse (valgfritt)</label>
<textarea id="desc" rows="4" bind:value={description}
placeholder="Detaljer, lenker, hva som helst"></textarea>
<p class="muted" style="font-size: 0.8rem; margin: 0.2rem 0 0;">
Du kan bruke Markdown — <code>**fet**</code>, <code>*kursiv*</code>,
<code>[lenke](https://…)</code>, lister med <code>-</code>.
</p>
<div id="tags-label" style="margin: 0.75rem 0 0.25rem; font-weight: 500;">Etiketter</div>
<div role="group" aria-labelledby="tags-label">

View file

@ -6,6 +6,7 @@
} from '../lib/crypto';
import { api } from '../lib/api';
import { onSpaLink } from '../lib/navigate';
import { renderMarkdown } from '../lib/markdown';
import { privateTagIndex } from '../lib/tagIndex';
import type { Activity } from '../../../shared/types';
@ -293,7 +294,7 @@
{/if}
</h3>
{#if decrypted.description}
<p style="white-space: pre-wrap; margin: 0.25rem 0;">{decrypted.description}</p>
<div class="md" style="margin: 0.25rem 0;">{@html renderMarkdown(decrypted.description)}</div>
{/if}
{#if decrypted.tags.length}
<div>
@ -341,7 +342,7 @@
{/if}
</h3>
{#if activity.description}
<p style="white-space: pre-wrap; margin: 0.25rem 0;">{activity.description}</p>
<div class="md" style="margin: 0.25rem 0;">{@html renderMarkdown(activity.description)}</div>
{/if}
{#if activity.tags.length}
<div>

View file

@ -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
// <br>, 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 <h1>/<h2>.
// 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 `<script>alert(1)</script>` 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.
});
}

View file

@ -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 {