+ No retention period is set, so nothing is ever deleted. Set + Audit Log Retention above and save to enable purging. +
++ {{ audit.expired.toLocaleString() }} entries are older + than {{ audit.retentiondays }} days and would be deleted. +
++ Nothing is older than {{ audit.retentiondays }} days. +
+ +' + codeSpans[Number(index)] + '')
+
+ return html
+}
+
+/** Plain text, for titles and anywhere markup would be noise. */
+export function stripInline (text) {
+ return String(text == null ? '' : text)
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1')
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/\*([^*]+)\*/g, '$1')
+}
diff --git a/plugins/guides/frontend/utils/inline.spec.js b/plugins/guides/frontend/utils/inline.spec.js
new file mode 100644
index 00000000..012eb916
--- /dev/null
+++ b/plugins/guides/frontend/utils/inline.spec.js
@@ -0,0 +1,50 @@
+/**
+ * Inline rendering: the security model and the link bugs it hid.
+ *
+ * Escape-then-format is the whole defence, and it has one awkward consequence
+ * that produced real broken links: by the time a URL is captured it is already
+ * escaped, so it has to be decoded before it can be escaped again exactly once.
+ */
+import { describe, it, expect } from 'vitest'
+import { renderInline } from './inline.js'
+
+describe('renderInline', () => {
+ it('escapes markup an author pasted', () => {
+ expect(renderInline('C:\\**temp**')
+ })
+})
diff --git a/plugins/guides/frontend/utils/proof.js b/plugins/guides/frontend/utils/proof.js
new file mode 100644
index 00000000..5212ea3f
--- /dev/null
+++ b/plugins/guides/frontend/utils/proof.js
@@ -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
+}
diff --git a/plugins/guides/frontend/utils/templates.js b/plugins/guides/frontend/utils/templates.js
new file mode 100644
index 00000000..1a166f81
--- /dev/null
+++ b/plugins/guides/frontend/utils/templates.js
@@ -0,0 +1,74 @@
+/**
+ * Starting points for a new guide.
+ *
+ * The blank page is the real barrier to somebody writing a procedure down. A
+ * skeleton with the right SHAPE - what you need first, the steps, how you know
+ * it worked, what to do when it does not - gets a half-decent guide out of
+ * somebody who would otherwise have written nothing.
+ *
+ * The shape is lifted from the KB articles the team already writes, so guides
+ * written here read like the ones written before them.
+ */
+
+function id (prefix, n) {
+ return prefix + n
+}
+
+export const templates = [
+ {
+ key: 'blank',
+ name: 'Blank',
+ description: 'Start with nothing',
+ blocks: () => [],
+ },
+ {
+ key: 'procedure',
+ name: 'Procedure',
+ description: 'Before you start, numbered steps, how to check it worked',
+ blocks: () => [
+ { id: id('t', 1), type: 'paragraph',
+ text: 'What this covers, and when somebody would need it.' },
+ { id: id('t', 2), type: 'callout', variant: 'warning', title: 'Before you start',
+ text: 'Anything that has to be true first, or that will bite halfway through.' },
+ { id: id('t', 3), type: 'heading', text: 'Steps' },
+ { id: id('t', 4), type: 'step', title: 'First step', text: '' },
+ { id: id('t', 5), type: 'step', title: 'Second step', text: '' },
+ { id: id('t', 6), type: 'heading', text: 'Check it worked' },
+ { id: id('t', 7), type: 'checklist', items: [
+ { id: id('ti', 1), text: '' },
+ { id: id('ti', 2), text: '' },
+ ] },
+ ],
+ },
+ {
+ key: 'troubleshooting',
+ name: 'Troubleshooting',
+ description: 'Symptom, likely causes, what to check first',
+ blocks: () => [
+ { id: id('t', 1), type: 'paragraph', text: 'The symptom, as somebody would describe it.' },
+ { id: id('t', 2), type: 'heading', text: 'Check these first' },
+ { id: id('t', 3), type: 'checklist', items: [
+ { id: id('ti', 1), text: '' },
+ { id: id('ti', 2), text: '' },
+ ] },
+ { id: id('t', 4), type: 'heading', text: 'Likely causes' },
+ { id: id('t', 5), type: 'table',
+ header: ['What you see', 'What it usually means'], rows: [['', '']] },
+ { id: id('t', 6), type: 'callout', variant: 'info',
+ text: 'The thing that looks like the cause and is not.' },
+ ],
+ },
+ {
+ key: 'checklist',
+ name: 'Checklist',
+ description: 'A list to work through, nothing else',
+ blocks: () => [
+ { id: id('t', 1), type: 'paragraph', text: 'When to use this list.' },
+ { id: id('t', 2), type: 'checklist', items: [
+ { id: id('ti', 1), text: '' },
+ { id: id('ti', 2), text: '' },
+ { id: id('ti', 3), text: '' },
+ ] },
+ ],
+ },
+]
diff --git a/plugins/guides/frontend/views/GuideEditor.vue b/plugins/guides/frontend/views/GuideEditor.vue
new file mode 100644
index 00000000..5d5dd462
--- /dev/null
+++ b/plugins/guides/frontend/views/GuideEditor.vue
@@ -0,0 +1,717 @@
+
+ + Image unavailable - it may have been removed +
+ + ++ Paste or drag an image anywhere to drop it in below the block you are working on. + Text supports **bold**, *italic*, `code` and [links](https://example.com) - + or use the buttons. +
+{{ guide.summary }}
+{{ guide.summary }}
+ +Nothing matches that.
+No guides yet.
+{{ guide.summary }}
+| Description: | -{{ article.shortdescription }} | -
|---|---|
| Topic: | -
- |
-
| URL: | -- - {{ article.linkurl }} - - | -
| Keywords: | -{{ article.keywords }} | -
| Clicks: | -{{ article.clicks }} | -
| Last Updated: | -{{ formatDate(article.lastupdated) }} | -
Article not found
-| Description: | +{{ article.shortdescription }} | +
|---|---|
| Topic: | +
+ |
+
| URL: | ++ + {{ article.linkurl }} + + | +
| Keywords: | +{{ article.keywords }} | +
| Clicks: | +{{ article.clicks }} | +
| Last Updated: | +{{ formatDate(article.lastupdated) }} | +
Article not found
+