diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte
index 526442d..db20e2c 100644
--- a/frontend/src/components/Home.svelte
+++ b/frontend/src/components/Home.svelte
@@ -12,12 +12,8 @@
/** When true, render only public+semi (the "/" landing). When false, show
* the full authenticated dashboard including the viewer's own private rows. */
publicOnly?: boolean;
- /** Monotonic counter from App. Changes (relative to the value at mount)
- * trigger a re-fetch. Lets the wordmark / "Min liste" buttons refresh
- * this view even when the user is already on /hjem or /. */
- reloadKey?: number;
}
- let { publicOnly = false, reloadKey = 0 }: Props = $props();
+ let { publicOnly = false }: Props = $props();
let activities: Activity[] = $state([]);
let loading = $state(true);
@@ -35,20 +31,8 @@
let showArchived = $state(false);
let showHidden = $state(false);
- // Track the reloadKey we've already reacted to so the $effect below
- // only triggers when the prop CHANGES — not on first paint, where
- // onMount already runs the initial load. load() doesn't touch reloadKey
- // so this can't self-fire.
- let lastSeenReloadKey = reloadKey;
onMount(() => load());
- $effect(() => {
- if (reloadKey !== lastSeenReloadKey) {
- lastSeenReloadKey = reloadKey;
- load();
- }
- });
-
async function load() {
loading = true;
try {
diff --git a/frontend/src/lib/markdown.ts b/frontend/src/lib/markdown.ts
deleted file mode 100644
index 4c6034f..0000000
--- a/frontend/src/lib/markdown.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * 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 110313f..b21de91 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -346,43 +346,6 @@ 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 b94d654..28bb896 100644
--- a/package.json
+++ b/package.json
@@ -14,18 +14,14 @@
"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
deleted file mode 100644
index 849ca32..0000000
--- a/tests/markdown.test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * 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('');
- expect(html).toContain('- en
');
- expect(html).toContain('- tre
');
- });
-
- 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,)');
- expect(html).not.toContain('data:text/html');
- });
-
- test('script tags in raw HTML stripped', () => {
- const html = renderMarkdown('Hei der');
- expect(html).not.toContain('