Files
shopdb-flask/plugins/guides/frontend/utils/proof.js
cproudlock 2c016de59e Guides answer the search box, and proofing catches what spellcheck cannot
A guide was invisible to global search. Core search has a hardcoded block per
domain rather than a hook, so a new plugin stays unreachable until someone adds
one - which is the same shape as the gap this test file already documents for
USB devices and printed parts: records with a list page, labels in the world,
and no way to find them from the search box.

Guides are searched by title, summary, tags AND body. The body matters more here
than elsewhere: people look for a procedure by something written halfway down it,
not by a title they never read. PUBLISHED ONLY - a draft is deliberately
invisible to readers, and search is exactly where that rule leaks if nobody
writes it down. The test proves it with a draft sharing a tag and a body word
with a published guide, so a missing filter would show up as a ride-along.

PROOFING catches what a spellchecker cannot, because none of these are spelling:
a smart quote in a command so it will not run, a checklist item with no text so
a step is silently missing, a heading left with nothing under it after a
reorganise, an image with no alt text, a TODO published by accident. Warnings,
never blocks - somebody publishing at 14:40 with one empty bullet should be
told, not stopped, and a procedure that exists imperfectly beats one nobody
wrote.

The smart-character issue reports once per block with a one-click fix, rather
than once per offending character: an opening and a closing quote are one
problem, and listing both makes the panel look like it is nagging.

The character table itself is written as escapes. The style gate forbids
non-ASCII in source, and a file whose job is detecting smart quotes cannot
contain any - which the gate caught, correctly.
2026-08-26 11:27:10 -04:00

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
}