Repository Public
1// Markdown rendered through marked, then sanitised by DOMPurify: the only place of the viewer where HTML derived from2// a file reaches the page. Scripts, handlers, styles, forms and embedded frames are removed; links only keep http(s)3// and mailto targets and open in a new tab without an opener; images that are not embedded (data:) are replaced by a4// placeholder, so that opening a document never makes the browser call a third party.5import DOMPurify from 'dompurify';6import { marked } from 'marked';7import { useMemo, useState } from 'react';8import { useT } from '../i18n.ts';9import type { PreviewSource } from '../types.ts';10import { IconButton } from '../ui.tsx';11import { Note, Tools, ToolToggle } from './shared.tsx';12import { TextBlock, TextGate, TruncatedNote } from './TextViews.tsx';1314const purify = DOMPurify(window); // a private instance: its hooks do not leak to other users of the library15const SAFE_LINK = /^(https?:|mailto:)/i;16const EMBEDDED_IMAGE = /^data:image\/(png|jpeg|gif|webp|avif);base64,/i;17purify.addHook('afterSanitizeAttributes', (node) => {18 const el = node as Element;19 if (el.tagName === 'A') {20 const href = el.getAttribute('href') ?? '';21 if (SAFE_LINK.test(href.trim())) { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer nofollow'); }22 else el.removeAttribute('href');23 } else if (el.tagName === 'INPUT') {24 if (el.getAttribute('type') === 'checkbox') el.setAttribute('disabled', ''); else el.remove();25 }26});27const FORBID_TAGS = ['style', 'form', 'button', 'select', 'textarea', 'iframe', 'frame', 'object', 'embed', 'video', 'audio', 'source', 'track', 'picture', 'svg', 'math', 'link', 'meta', 'base', 'dialog', 'template', 'slot', 'canvas', 'map', 'area'];28const FORBID_ATTR = ['style', 'srcset', 'sizes', 'class', 'id', 'name', 'background', 'poster', 'ping', 'action', 'formaction', 'autofocus', 'contenteditable', 'tabindex', 'is', 'slot', 'part'];2930export function renderMarkdown(text: string, blockedLabel: string): string {31 const html = marked.parse(text, { async: false, gfm: true, breaks: false });32 const clean = purify.sanitize(html, { USE_PROFILES: { html: true }, FORBID_TAGS, FORBID_ATTR, ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false, ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|data:image\/)/i, RETURN_DOM_FRAGMENT: true });33 const box = document.implementation.createHTMLDocument('').createElement('div');34 box.append(clean);35 // still in an inert document: images that are not embedded in the file become a placeholder before anything can load36 for (const img of box.querySelectorAll('img')) {37 if (EMBEDDED_IMAGE.test(img.getAttribute('src') ?? '')) continue;38 const ph = box.ownerDocument.createElement('span');39 const alt = img.getAttribute('alt');40 ph.className = 'spd-md-blocked'; ph.textContent = alt ? `${blockedLabel} — ${alt}` : blockedLabel;41 img.replaceWith(ph);42 }43 return box.innerHTML;44}4546function Rendered({ text }: { text: string }) {47 const t = useT();48 const label = t('preview.mdImageBlocked');49 const html = useMemo(() => renderMarkdown(text, label), [text, label]);50 const blocked = html.includes('spd-md-blocked');51 return (52 <>53 {blocked ? <Note testId="md-blocked-note">{t('preview.mdImagesNote')}</Note> : null}54 <div className="min-h-0 flex-1 overflow-auto bg-surface" tabIndex={0}>55 {/* the only dangerouslySetInnerHTML of the viewer: `html` is the DOMPurify output above */}56 <article className="spd-md mx-auto max-w-3xl px-5 py-6 sm:px-8 sm:py-8" data-testid="markdown" dangerouslySetInnerHTML={{ __html: html }} />57 </div>58 </>59 );60}6162export function MarkdownView({ source }: { source: PreviewSource }) {63 const t = useT();64 const [raw, setRaw] = useState(false);65 const [wrap, setWrap] = useState(true);66 return (67 <TextGate source={source}>68 {(s) => (69 <>70 <Tools>71 <ToolToggle active={!raw} onClick={() => setRaw(false)} testId="md-rendered">{t('preview.rendered')}</ToolToggle>72 <ToolToggle active={raw} onClick={() => setRaw(true)} testId="md-source">{t('preview.source')}</ToolToggle>73 {raw ? <IconButton icon="wrap" label={t('preview.wrap')} active={wrap} onClick={() => setWrap(!wrap)} /> : null}74 </Tools>75 <TruncatedNote state={s} />76 {raw ? <TextBlock text={s.text} wrap={wrap} /> : <Rendered text={s.text} />}77 </>78 )}79 </TextGate>80 );81}82