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')
|
||||
}
|
||||
145
plugins/guides/frontend/utils/proof.js
Normal file
145
plugins/guides/frontend/utils/proof.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Proofing: the mistakes that matter in a PROCEDURE, not a document.
|
||||
*
|
||||
* A spellchecker catches "teh". None of these are spelling, and every one has
|
||||
* been seen for real:
|
||||
*
|
||||
* - a smart quote in a command, so the command does not run
|
||||
* - a checklist item with no text, so a step is silently missing
|
||||
* - a heading with nothing under it, left when a draft was reorganised
|
||||
* - an image with no alt text, invisible to anyone who cannot see it
|
||||
* - a TODO that was never finished, published anyway
|
||||
*
|
||||
* Warnings, not blocks. A person publishing a guide at 14:40 with one empty
|
||||
* bullet should be told, not stopped - the tool does not know that the bullet is
|
||||
* deliberate, and a procedure that exists imperfectly beats one nobody wrote.
|
||||
*/
|
||||
|
||||
// Characters an editor or a word processor substitutes silently. In prose they
|
||||
// are harmless; in a command they are the difference between working and a
|
||||
// syntax error nobody can explain.
|
||||
const SMARTCHARS = [
|
||||
// Written as escapes, not literals: the repo's style gate forbids non-ASCII in
|
||||
// source, and a file whose job is detecting smart quotes cannot contain any.
|
||||
// The escapes are also unambiguous in a diff, where a curly quote and a
|
||||
// straight one look identical.
|
||||
['\u2018', "'"], ['\u2019', "'"], // single quotes
|
||||
['\u201C', '"'], ['\u201D', '"'], // double quotes
|
||||
['\u2013', '-'], ['\u2014', '-'], // en dash, em dash
|
||||
['\u00A0', ' '], // non-breaking space
|
||||
]
|
||||
|
||||
const UNFINISHED = /\b(TODO|TBD|FIXME|XXX|\?\?\?)\b/i
|
||||
|
||||
function issue (level, blockid, message, fix) {
|
||||
return { level, blockid, message, fix: fix || null }
|
||||
}
|
||||
|
||||
export function proof (guide, blocks) {
|
||||
const issues = []
|
||||
|
||||
if (!guide.title || !guide.title.trim()) {
|
||||
issues.push(issue('error', null, 'This guide has no title'))
|
||||
}
|
||||
if (!guide.summary || !guide.summary.trim()) {
|
||||
issues.push(issue('warn', null,
|
||||
'No summary. It is what people read in the list and in search results.'))
|
||||
}
|
||||
if (!guide.tags || !guide.tags.trim()) {
|
||||
issues.push(issue('warn', null, 'No tags, so this will be hard to find'))
|
||||
}
|
||||
if (!blocks.length) {
|
||||
issues.push(issue('error', null, 'This guide is empty'))
|
||||
return issues
|
||||
}
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
const where = 'block ' + (index + 1) + ' (' + block.type + ')'
|
||||
|
||||
if (block.type === 'code') {
|
||||
// The important one. An autocorrected quote in a command looks right and
|
||||
// does not run.
|
||||
//
|
||||
// Reported ONCE per block, however many offending characters it has: a
|
||||
// command with an opening and a closing smart quote is one problem with
|
||||
// one fix, and listing it twice makes the panel look like it is nagging.
|
||||
const found = SMARTCHARS.filter(([bad]) => (block.text || '').includes(bad))
|
||||
if (found.length) {
|
||||
issues.push(issue('error', block.id,
|
||||
where + ': contains ' + found.map(([, good]) => 'a curly or long ' +
|
||||
(good === "'" ? 'quote' : good === '"' ? 'double quote' :
|
||||
good === '-' ? 'dash' : 'space')).filter(
|
||||
(v, i, a) => a.indexOf(v) === i).join(' and ') +
|
||||
' where a command needs the plain character. Autocorrect does this, ' +
|
||||
'and the command will not run.',
|
||||
{ type: 'replaceAll', pairs: found }))
|
||||
}
|
||||
if (!(block.text || '').trim()) {
|
||||
issues.push(issue('error', block.id, where + ' is empty'))
|
||||
}
|
||||
} else if (['heading', 'paragraph', 'callout'].includes(block.type)) {
|
||||
if (!(block.text || '').trim() && !(block.title || '').trim()) {
|
||||
issues.push(issue('warn', block.id, where + ' is empty'))
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'step' && !(block.title || '').trim()
|
||||
&& !(block.text || '').trim()) {
|
||||
issues.push(issue('warn', block.id, where + ' has no title and no text'))
|
||||
}
|
||||
|
||||
if (['checklist', 'bullets', 'numbers'].includes(block.type)) {
|
||||
const empties = (block.items || []).filter(
|
||||
item => !String(typeof item === 'string' ? item : item.text || '').trim())
|
||||
if (empties.length) {
|
||||
issues.push(issue('warn', block.id,
|
||||
where + ': ' + empties.length + ' empty item' +
|
||||
(empties.length > 1 ? 's' : '') +
|
||||
'. An empty step reads as a step somebody forgot to write.'))
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'image' && !(block.alt || '').trim()) {
|
||||
issues.push(issue('warn', block.id,
|
||||
where + ': no alt text. Anyone who cannot see the image gets nothing.'))
|
||||
}
|
||||
|
||||
if (block.type === 'table' && !(block.rows || []).length) {
|
||||
issues.push(issue('warn', block.id, where + ' has a header but no rows'))
|
||||
}
|
||||
|
||||
const text = [block.text, block.title].filter(Boolean).join(' ')
|
||||
if (UNFINISHED.test(text)) {
|
||||
issues.push(issue('warn', block.id,
|
||||
where + ': still contains ' + text.match(UNFINISHED)[0]))
|
||||
}
|
||||
})
|
||||
|
||||
// A heading immediately followed by another heading usually means the section
|
||||
// under the first one was moved or never written.
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type !== 'heading') return
|
||||
const next = blocks[index + 1]
|
||||
if (!next || next.type === 'heading') {
|
||||
issues.push(issue('warn', block.id,
|
||||
'Heading "' + (block.text || '').slice(0, 40) + '" has nothing under it'))
|
||||
}
|
||||
})
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/** Apply a suggested fix in place, for the ones where the answer is obvious. */
|
||||
export function applyFix (block, fix) {
|
||||
if (!fix) return false
|
||||
const before = block.text || ''
|
||||
let after = before
|
||||
if (fix.type === 'replaceAll') {
|
||||
for (const [bad, good] of fix.pairs) after = after.split(bad).join(good)
|
||||
} else if (fix.type === 'replace') {
|
||||
after = after.split(fix.from).join(fix.to)
|
||||
}
|
||||
if (after === before) return false
|
||||
block.text = after
|
||||
return true
|
||||
}
|
||||
74
plugins/guides/frontend/utils/templates.js
Normal file
74
plugins/guides/frontend/utils/templates.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Starting points for a new guide.
|
||||
*
|
||||
* The blank page is the real barrier to somebody writing a procedure down. A
|
||||
* skeleton with the right SHAPE - what you need first, the steps, how you know
|
||||
* it worked, what to do when it does not - gets a half-decent guide out of
|
||||
* somebody who would otherwise have written nothing.
|
||||
*
|
||||
* The shape is lifted from the KB articles the team already writes, so guides
|
||||
* written here read like the ones written before them.
|
||||
*/
|
||||
|
||||
function id (prefix, n) {
|
||||
return prefix + n
|
||||
}
|
||||
|
||||
export const templates = [
|
||||
{
|
||||
key: 'blank',
|
||||
name: 'Blank',
|
||||
description: 'Start with nothing',
|
||||
blocks: () => [],
|
||||
},
|
||||
{
|
||||
key: 'procedure',
|
||||
name: 'Procedure',
|
||||
description: 'Before you start, numbered steps, how to check it worked',
|
||||
blocks: () => [
|
||||
{ id: id('t', 1), type: 'paragraph',
|
||||
text: 'What this covers, and when somebody would need it.' },
|
||||
{ id: id('t', 2), type: 'callout', variant: 'warning', title: 'Before you start',
|
||||
text: 'Anything that has to be true first, or that will bite halfway through.' },
|
||||
{ id: id('t', 3), type: 'heading', text: 'Steps' },
|
||||
{ id: id('t', 4), type: 'step', title: 'First step', text: '' },
|
||||
{ id: id('t', 5), type: 'step', title: 'Second step', text: '' },
|
||||
{ id: id('t', 6), type: 'heading', text: 'Check it worked' },
|
||||
{ id: id('t', 7), type: 'checklist', items: [
|
||||
{ id: id('ti', 1), text: '' },
|
||||
{ id: id('ti', 2), text: '' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'troubleshooting',
|
||||
name: 'Troubleshooting',
|
||||
description: 'Symptom, likely causes, what to check first',
|
||||
blocks: () => [
|
||||
{ id: id('t', 1), type: 'paragraph', text: 'The symptom, as somebody would describe it.' },
|
||||
{ id: id('t', 2), type: 'heading', text: 'Check these first' },
|
||||
{ id: id('t', 3), type: 'checklist', items: [
|
||||
{ id: id('ti', 1), text: '' },
|
||||
{ id: id('ti', 2), text: '' },
|
||||
] },
|
||||
{ id: id('t', 4), type: 'heading', text: 'Likely causes' },
|
||||
{ id: id('t', 5), type: 'table',
|
||||
header: ['What you see', 'What it usually means'], rows: [['', '']] },
|
||||
{ id: id('t', 6), type: 'callout', variant: 'info',
|
||||
text: 'The thing that looks like the cause and is not.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'checklist',
|
||||
name: 'Checklist',
|
||||
description: 'A list to work through, nothing else',
|
||||
blocks: () => [
|
||||
{ id: id('t', 1), type: 'paragraph', text: 'When to use this list.' },
|
||||
{ id: id('t', 2), type: 'checklist', items: [
|
||||
{ id: id('ti', 1), text: '' },
|
||||
{ id: id('ti', 2), text: '' },
|
||||
{ id: id('ti', 3), text: '' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user