Guides: procedures written and read inside ShopDB
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user