Files
shopdb-flask/plugins/guides/frontend/utils/proof.js

146 lines
5.7 KiB
JavaScript

/**
* 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
}