From 1c95dbed00585bce1cbb3c28f434ebc5e4cfa492 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 21:15:31 +0200 Subject: [PATCH 1/2] feat(activity): Markdown rendering for descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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

/

. 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. --- bun.lock | 24 ++++ frontend/src/components/ActivityForm.svelte | 4 + frontend/src/components/ActivityRow.svelte | 5 +- frontend/src/lib/markdown.ts | 86 ++++++++++++++ frontend/src/styles.css | 37 ++++++ package.json | 4 + tests/markdown.test.ts | 118 ++++++++++++++++++++ 7 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/markdown.ts create mode 100644 tests/markdown.test.ts diff --git a/bun.lock b/bun.lock index 9a378db..4118771 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,18 @@ "": { "name": "vinterliste", "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", @@ -74,6 +78,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -144,6 +150,8 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/libsodium-wrappers": ["@types/libsodium-wrappers@0.7.14", "", {}, "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ=="], @@ -154,6 +162,10 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], @@ -172,6 +184,10 @@ "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + "dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], @@ -182,6 +198,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], + "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], @@ -196,6 +214,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -232,6 +252,10 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], diff --git a/frontend/src/components/ActivityForm.svelte b/frontend/src/components/ActivityForm.svelte index 986df24..a64d8d3 100644 --- a/frontend/src/components/ActivityForm.svelte +++ b/frontend/src/components/ActivityForm.svelte @@ -209,6 +209,10 @@ +

+ Du kan bruke Markdown — **fet**, *kursiv*, + [lenke](https://…), lister med -. +

Etiketter
diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index 34420f0..d481812 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -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}

{#if decrypted.description} -

{decrypted.description}

+
{@html renderMarkdown(decrypted.description)}
{/if} {#if decrypted.tags.length}
@@ -341,7 +342,7 @@ {/if} {#if activity.description} -

{activity.description}

+
{@html renderMarkdown(activity.description)}
{/if} {#if activity.tags.length}
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('