Files
shopdb-flask/plugins/guides/frontend/utils/inline.js
cproudlock 38d1e367fc Guides: write a procedure in ShopDB, not beside it
The knowledgebase plugin is a link registry - a description, a URL and some
keywords pointing at a document held somewhere else. That leaves the documents
themselves as HTML files on a share: no search, no history, no way to know
whether the copy pinned to a noticeboard is current, and no way for a technician
to tick off where they are.

A guide is stored as an ordered list of typed blocks rather than a blob of
HTML, because three of the four things this feature exists for depend on it. A
tickable step needs a stable id to be remembered; dark mode needs semantic
markup rather than pasted colours; and print needs to know where a step begins
so it does not split one across a page. Free HTML gives none of that and lets
pasted styling fight the theme.

Inline text is a restricted Markdown subset - bold, italic, inline code, links,
line breaks - escaped BEFORE formatting, so nothing an author pasted can reach
the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the
theme decides how it looks. That is what makes a guide written next year match
one written today, and dark mode work without an author thinking about it.

DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes
something writes an immutable revision; and a separate pointer says which
revision READERS get. Without that pointer, editing a live procedure changes it
under a technician who is halfway through following it. Publishing promotes an
existing revision rather than snapshotting, so a publish with unsaved edits
cannot ship a version nobody reviewed. Restoring writes a new revision with old
content, so the record of what people were told in between survives.

Saves dedup on a content hash covering title, summary, tags and blocks, so an
autosave that changed nothing writes no version - otherwise the history fills
with identical entries and hides the three edits that mattered.

Progress lives in the browser, not the database: shopfloor PCs share a login, so
per-user ticks would show one technician's progress to the next person on that
bay, which reads as "already done". Keyed by guide and version, because step 4
of version 3 is not step 4 of version 2.

Five permissions, and the split between edit and publish is the point: a
technician can write a procedure up while somebody else decides it is fit for
the floor. create-without-edit works too - an author may edit their own guide
until it is published, after which other people are following it.
2026-08-26 10:24:26 -04:00

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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// 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')
}