/** * Inline text rendering for guide blocks. * * A guide stores its text as a RESTRICTED Markdown subset - bold, italic, * inline code, links, line breaks - and nothing else. This turns that into * HTML. * * ESCAPE FIRST, THEN FORMAT. The order is the whole security model: every * angle bracket and ampersand becomes an entity before any pattern is applied, * so no markup an author pasted can survive into the DOM. There is no path here * that emits a tag the code below did not write itself. * * The restriction is not only about safety. Pasted rich text carries fonts and * hardcoded colours, which then fight the theme - black text on a dark * background, and nobody can say why. Emphasis is semantic; the stylesheet * decides how it looks. That is what makes dark mode work without an author * thinking about it, and why a guide written next year still matches one * written today. */ function escapeHtml (text) { return String(text == null ? '' : text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } // Only http(s) and mailto. A javascript: or data: URL in a link is the one way // an author could otherwise smuggle script past the escaping above. function safeHref (url) { const trimmed = String(url || '').trim() if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed if (trimmed.startsWith('/')) return trimmed // in-app link return null } export function renderInline (text) { let html = escapeHtml(text) // Inline code first, so formatting characters inside `code` stay literal - // a path like C:\**temp** should not turn half of itself bold. const codeSpans = [] html = html.replace(/`([^`]+)`/g, (match, code) => { codeSpans.push(code) return '\u0000CODE' + (codeSpans.length - 1) + '\u0000' }) // [label](url). The URL pattern allows ONE level of balanced parentheses so // real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case, // and a naive [^)]+ silently truncates it into a broken link. html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => { const href = safeHref(url) if (!href) return label const external = /^https?:/i.test(href) return '' + label + '' }) // **bold** then *italic*, longest marker first so **x** is not read as // *(*x*)*. html = html.replace(/\*\*([^*]+)\*\*/g, '$1') html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1$2') html = html.replace(/\n/g, '
') html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) => '' + codeSpans[Number(index)] + '') return html } /** Plain text, for titles and anywhere markup would be noise. */ export function stripInline (text) { return String(text == null ? '' : text) .replace(/`([^`]+)`/g, '$1') .replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') }