146 lines
5.7 KiB
JavaScript
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
|
|
}
|