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

118
tests/markdown.test.ts Normal file
View file

@ -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('<strong>fet</strong>');
expect(html).toContain('<em>kursiv</em>');
expect(html).toContain('strøket');
expect(html).toMatch(/<(del|s)>/);
});
test('lists', () => {
const html = renderMarkdown('- en\n- to\n- tre');
expect(html).toContain('<ul>');
expect(html).toContain('<li>en</li>');
expect(html).toContain('<li>tre</li>');
});
test('http/https links get external-link decoration', () => {
const html = renderMarkdown('[Klikk](https://example.com)');
expect(html).toContain('href="https://example.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer ugc"');
});
test('mailto links allowed', () => {
const html = renderMarkdown('[Skriv](mailto:test@example.org)');
expect(html).toContain('href="mailto:test@example.org"');
});
});
describe('renderMarkdown — sanitisation', () => {
test('javascript: URLs stripped', () => {
const html = renderMarkdown('[xss](javascript:alert(1))');
expect(html).not.toContain('javascript:');
expect(html).not.toContain('alert');
});
test('data: URLs stripped', () => {
const html = renderMarkdown('[xss](data:text/html,<script>alert(1)</script>)');
expect(html).not.toContain('data:text/html');
});
test('script tags in raw HTML stripped', () => {
const html = renderMarkdown('Hei <script>alert(1)</script> der');
expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
});
test('iframe stripped', () => {
const html = renderMarkdown('<iframe src="evil.com"></iframe>');
expect(html).not.toContain('<iframe');
});
test('on* event handlers stripped', () => {
const html = renderMarkdown('<a href="https://x.org" onclick="alert(1)">x</a>');
expect(html).not.toContain('onclick');
expect(html).not.toContain('alert(1)');
});
test('images stripped (privacy: external URLs leak viewer IP)', () => {
const html = renderMarkdown('![alt](https://tracker.example.org/pixel.png)');
expect(html).not.toContain('<img');
expect(html).not.toContain('tracker.example.org');
});
});
describe('renderMarkdown — heading downshift', () => {
test('# Heading → h3', () => {
const html = renderMarkdown('# Top');
expect(html).toContain('<h3>Top</h3>');
expect(html).not.toContain('<h1');
});
test('## Heading → h3', () => {
const html = renderMarkdown('## Sub');
expect(html).toContain('<h3>Sub</h3>');
expect(html).not.toContain('<h2');
});
test('### and below pass through', () => {
expect(renderMarkdown('### Sub')).toContain('<h3>Sub</h3>');
expect(renderMarkdown('#### Sub')).toContain('<h4>Sub</h4>');
});
});