107 lines
4.3 KiB
JavaScript
107 lines
4.3 KiB
JavaScript
/**
|
|
* 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, '"')
|
|
.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 <em> 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('<a href="' + escapeHtml(href) + '"' +
|
|
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
|
|
'>' + label + '</a>')
|
|
return '\u0000LINK' + (links.length - 1) + '\u0000'
|
|
})
|
|
|
|
// **bold** then *italic*, longest marker first so **x** is not read as
|
|
// *(*x*)*.
|
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>')
|
|
|
|
html = html.replace(/\n/g, '<br>')
|
|
|
|
html = html.replace(/\u0000LINK(\d+)\u0000/g, (match, index) => links[Number(index)])
|
|
|
|
html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) =>
|
|
'<code>' + codeSpans[Number(index)] + '</code>')
|
|
|
|
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')
|
|
}
|