/** * 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, ''') } // The text was HTML-escaped before any pattern ran, so a captured URL arrives // carrying entities: a real query string like ?a=1&b=2 reaches here as // ?a=1&b=2. Escaping that again for the attribute produced &amp; and a // link that opened the wrong record - the ServiceNow and SharePoint links that // carry several parameters were all broken this way. Decode first, then let the // single escape on output put back exactly one level. function decodeEntities (text) { return String(text) .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') // last, so &lt; does not become < } // 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 // In-app link. NOT '//host', which looks like a path and is read by the // browser as protocol-relative - an off-site link wearing a local costume. if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed 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. // Finished links are stashed like code spans, so the emphasis passes below // cannot reach inside an href. Without that, a URL containing *characters* // has injected into the middle of the attribute and the link breaks. const links = [] html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => { const href = safeHref(decodeEntities(url)) if (!href) return label const external = /^https?:/i.test(href) links.push('' + label + '') return '\u0000LINK' + (links.length - 1) + '\u0000' }) // **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(/\u0000LINK(\d+)\u0000/g, (match, index) => links[Number(index)]) 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') }