/** * Markdown rendering for the Md block. * Raw HTML is inert (escaped, never parsed). Links and images have SEPARATE * policies: links may be goto:/http(s)/mailto (external ones open a new tab, * never navigate the iframe); images are local-only - design/assets/ paths. */ import { Marked } from 'marked' const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`) /** Prose text: markup escaped, but an entity the author wrote (`©`, `A`) keeps its * meaning - marked's own rule; a raw `<` is still never a tag. */ const escapeText = (s: string) => s.replace(/&(?!#?\w+;)|[<>"']/g, (c) => `&#${c.charCodeAt(0)};`) /** D3: named color families for inline Md - the SAME families the diagrams use, so prose and * the diagram beside it speak one color language. Theme pairs (light/dark). Bound to classes, * never raw HTML (raw HTML stays inert). Used by the `:family[text]` extension + the frame CSS. */ export const FAMILIES: Record = { blue: { light: '#0088FF', dark: '#3B9DFF' }, orange: { light: '#F5820A', dark: '#FF9F33' }, purple: { light: '#B32BC8', dark: '#D34FE8' }, green: { light: '#1FA34A', dark: '#34C759' }, red: { light: '#E5342B', dark: '#FF453A' }, gray: { light: '#8B95A3', dark: '#7D8794' }, } const FAMILY_RE = new RegExp(`^:(${Object.keys(FAMILIES).join('|')})\\[([^\\]\\n]+)\\]`) /** Relative design/assets/ path -> served URL; null for anything else (fail closed). * Decodes BEFORE validating (a %2e%2e must not sneak past the ".." check) and * re-encodes per segment, so the validated path is the path the browser requests. */ export function assetUrl(src: string): string | null { let p = String(src ?? '').trim() try { p = decodeURIComponent(p) } catch { return null } if (p.includes('%')) return null // still encoded after one decode - refuse double-encoding games if (!p || p.includes('://') || p.includes(':') || p.startsWith('/') || p.startsWith('\\') || p.includes('\\')) return null if (p.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return null return `/design/assets/${p.split('/').map(encodeURIComponent).join('/')}` } const marked = new Marked({ gfm: true, renderer: { // raw HTML (block or inline) renders as its literal text - inert by construction. Comments // are the exception: markdown readers never show them, an unclosed one runs to the end (#15) html(token: any) { return escapeHtml(String(token.text ?? '').replace(/|$))/g, '')) }, // marked leaves the text INSIDE a raw block (