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