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
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
86
frontend/src/lib/markdown.ts
Normal file
86
frontend/src/lib/markdown.ts
Normal 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.
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue