83 lines
3.1 KiB
JavaScript
83 lines
3.1 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, ''')
|
|
}
|
|
|
|
// 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 '<a href="' + escapeHtml(href) + '"' +
|
|
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
|
|
'>' + label + '</a>'
|
|
})
|
|
|
|
// **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(/\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')
|
|
}
|