SPB Git

Repository Public

MarkdownView.tsx 4.4 KB 82 lines tsx

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
Files main
Menu

Theme