import DOMPurify from 'dompurify' import { marked, type TokenizerAndRendererExtension } from 'marked' import { mathEngine } from './math.svelte' marked.setOptions({ gfm: true, breaks: true }) /* * Math the way GitHub writes it: `$x^2$` inline, `$$…$$` on lines of its own * or inline for display, and a ```math block. A `$` opening on a space or * closing before a digit is a price, not math — "$5 and $10" stays text — and * `\$` is a dollar sign as ever. */ const DISPLAY = /^\$\$((?:\\.|[^\\$])+?)\$\$/ const INLINE = /^\$(?!\s)((?:\\.|[^\\$\n])+?)(? src.match(/^\$\$/m)?.index, tokenizer(src) { const match = /^\$\$((?:\\.|[^\\$])+?)\$\$[^\S\n]*(?:\n+|$)/.exec(src) if (match) return { type: 'mathBlock', raw: match[0], text: match[1].trim() } }, renderer: (token) => tex(token.text, true), } const mathInline: TokenizerAndRendererExtension = { name: 'mathInline', level: 'inline', start: (src) => src.indexOf('$'), tokenizer(src) { const display = DISPLAY.exec(src) if (display) return { type: 'mathInline', raw: display[0], text: display[1].trim(), display: true } const inline = INLINE.exec(src) if (inline) return { type: 'mathInline', raw: inline[0], text: inline[1], display: false } }, renderer: (token) => tex(token.text, token.display), } marked.use({ extensions: [mathBlock, mathInline], renderer: { code(token) { return token.lang === 'math' ? tex(token.text, true) : false }, }, }) /** TeX as KaTeX sets it, or as its source while KaTeX is still loading. */ function tex(source: string, display: boolean): string { const katex = mathEngine() if (!katex) { const text = escapeHtml(source) return display ? `
${text}
` : `${text}` } // An error shows in place, in red, rather than throwing away the page. return katex.renderToString(source, { displayMode: display, throwOnError: false }) } /* * A link that opens another window leaves it no handle on this one. Only * `_blank` is kept as a target: a named one reuses whatever window has that * name, and browsers give `noopener` to `_blank` alone. */ DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (node.tagName !== 'A' || !node.hasAttribute('target')) return if (node.getAttribute('target') === '_blank') node.setAttribute('rel', 'noopener noreferrer') else node.removeAttribute('target') }) const SANITIZE = { // `semantics` and `annotation` carry KaTeX's MathML, for screen readers. ADD_TAGS: ['video', 'audio', 'source', 'semantics', 'annotation'], ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload', 'encoding'], } function escapeHtml(text: string): string { return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') } /** * Render entry markdown to sanitised HTML. When share credentials are given, * media URLs are rewritten to the public route — token and key both, since that * route demands the same pair the entry itself did — so the page works for a * reader who has no session, and a link to another document, an address inside * the private workspace that would lead the reader to its login page, is kept * as its text. */ export function renderMarkdown(source: string, share?: { token: string; key: string }): string { const md = share ? source.replaceAll( '/api/media/', `/api/share/${encodeURIComponent(share.token)}/${encodeURIComponent(share.key)}/media/`, ) : source const html = marked.parse(md, { async: false }) as string if (!share) return DOMPurify.sanitize(html, SANITIZE) // On the parsed page, not on its text: a pattern over HTML can end inside // an attribute and turn what was quoted there into markup. const page = DOMPurify.sanitize(html, { ...SANITIZE, RETURN_DOM_FRAGMENT: true }) for (const link of page.querySelectorAll('a[href^="/n/"]')) { const text = document.createElement('span') text.className = 'private-link' text.title = 'a page of the private workspace' text.append(...link.childNodes) link.replaceWith(text) } const holder = document.createElement('div') holder.append(page) return holder.innerHTML } /** The markdown snippet that embeds a freshly uploaded file. */ export function embedSnippet(file: { url: string; filename: string; mime: string }): string { const name = file.filename.replaceAll(']', '') if (file.mime.startsWith('image/')) return `![${name}](${file.url})` if (file.mime.startsWith('video/')) return `` if (file.mime.startsWith('audio/')) return `` return `[${name}](${file.url})` }