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:
parent
95f989639d
commit
1c95dbed00
7 changed files with 276 additions and 2 deletions
118
tests/markdown.test.ts
Normal file
118
tests/markdown.test.ts
Normal 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('');
|
||||
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>');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue