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.
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
|
||||
}
|
||||
@@ -44,6 +44,30 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Proofing: warnings, never a block. Somebody publishing at 14:40 with one
|
||||
empty bullet should be told, not stopped - the tool does not know the
|
||||
bullet is deliberate, and a procedure that exists imperfectly beats one
|
||||
nobody wrote.
|
||||
-->
|
||||
<div v-if="issues.length" class="ge-proof">
|
||||
<div class="ge-proofhead">
|
||||
<strong>{{ errorCount }} to fix, {{ issues.length - errorCount }} worth a look</strong>
|
||||
<button type="button" @click="showProof = !showProof">
|
||||
{{ showProof ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="showProof">
|
||||
<li v-for="(item, i) in issues" :key="i" :class="'ge-' + item.level">
|
||||
<button v-if="item.blockid" type="button" class="ge-jump"
|
||||
@click="jumpTo(item.blockid)">go</button>
|
||||
<span>{{ item.message }}</span>
|
||||
<button v-if="item.fix" type="button" class="ge-fix"
|
||||
@click="fix(item)">fix it</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
|
||||
<div class="ge-editor">
|
||||
<div v-for="(block, index) in blocks" :key="block.id"
|
||||
@@ -178,6 +202,7 @@ import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import GuideBlocks from '../components/GuideBlocks.vue'
|
||||
import { templates } from '../utils/templates.js'
|
||||
import { proof, applyFix } from '../utils/proof.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -197,6 +222,26 @@ const status = ref('')
|
||||
const showPreview = ref(true)
|
||||
const focused = ref('')
|
||||
const dirty = ref(false)
|
||||
const showProof = ref(true)
|
||||
|
||||
// Recomputed as you type. Cheap - it is a walk over a few dozen blocks - and
|
||||
// live feedback is the difference between proofing and a report nobody reads.
|
||||
const issues = computed(() => proof(guide, blocks.value))
|
||||
const errorCount = computed(() => issues.value.filter(i => i.level === 'error').length)
|
||||
|
||||
function jumpTo (blockid) {
|
||||
focused.value = blockid
|
||||
const node = textareas[blockid] || itemInputs[blockid]
|
||||
if (node?.scrollIntoView) {
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
node.focus?.()
|
||||
}
|
||||
}
|
||||
|
||||
function fix (item) {
|
||||
const block = blocks.value.find(b => b.id === item.blockid)
|
||||
if (block && applyFix(block, item.fix)) dirty.value = true
|
||||
}
|
||||
|
||||
const isNew = computed(() => !guide.guideid)
|
||||
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
|
||||
@@ -425,6 +470,12 @@ onBeforeRouteLeave(() => {
|
||||
})
|
||||
|
||||
async function publish () {
|
||||
if (errorCount.value) {
|
||||
const ok = window.confirm(
|
||||
errorCount.value + ' problem(s) would go out with this guide, including '
|
||||
+ 'anything flagged in a command. Publish anyway?')
|
||||
if (!ok) return
|
||||
}
|
||||
await save(true)
|
||||
try {
|
||||
const response = await api.post('/guides/' + guide.guideid + '/publish')
|
||||
@@ -608,6 +659,26 @@ onMounted(async () => {
|
||||
.ge-previewtitle { margin: 0 0 0.2rem; font-size: 1.4rem; }
|
||||
.ge-previewsummary { color: var(--text-light); margin: 0 0 1rem; }
|
||||
|
||||
.ge-proof {
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--warning, #d9822b);
|
||||
border-radius: 4px; padding: 0.5rem 0.7rem; margin-bottom: 1rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ge-proofhead { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ge-proofhead button {
|
||||
margin-left: auto; font-size: 0.72rem; background: none; border: none;
|
||||
color: var(--text-light); cursor: pointer; text-decoration: underline;
|
||||
}
|
||||
.ge-proof ul { margin: 0.4rem 0 0; padding: 0; list-style: none; }
|
||||
.ge-proof li { display: flex; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
|
||||
.ge-error span { color: var(--danger, #c0392b); }
|
||||
.ge-warn span { color: var(--text-light); }
|
||||
.ge-jump, .ge-fix {
|
||||
font-size: 0.68rem; padding: 0.05rem 0.35rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||
}
|
||||
.ge-fix { margin-left: auto; border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.ge-revisions { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
|
||||
.ge-rev { display: flex; gap: 0.6rem; align-items: center; font-size: 0.8rem; padding: 0.25rem 0; }
|
||||
.ge-revnum { font-weight: 600; }
|
||||
|
||||
Reference in New Issue
Block a user