Guides: procedures written and read inside ShopDB
This commit is contained in:
82
plugins/guides/frontend/utils/inline.js
Normal file
82
plugins/guides/frontend/utils/inline.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
Reference in New Issue
Block a user