Images were a broken icon for every user. The stored URL was bound straight to <img src>, and a plain image request carries no Authorization header while this app keeps its JWT in one, so every fetch was a 401. They are now fetched through the API client, which also fixes the stored '/api/...' path under a subpath mount where the API is not at /api at all. A failed image says so in words rather than rendering as a broken icon, and the editor's own thumbnails go through the same path - an author needs to see the screenshot they pasted. Revision and image routes gated on their parent guide. Both were gated on guides.view alone, and revision ids are sequential, so being able to read one guide was enough to walk the ids and read every unpublished draft on the site - the opposite of what docs/GUIDES.md promises. An image in a PUBLISHED guide still serves to any reader; one that only appears in a draft does not. The guide list, the tag chips and search now match the PUBLISHED text rather than the working copy. Matching the working copy handed readers a hit on wording that is not in the page when they open it. The slug no longer follows the title. It was regenerated on every save that carried a title and the editor autosaves every three seconds, so fixing a typo in a heading silently moved the URL and broke exactly the pasted links the stored slug exists to protect. publish() now aborts when the pre-publish save failed. It ran regardless, so the author saw "Published version N" while readers got the previous revision. Link rendering: text is escaped before formatting, so a captured URL arrives carrying entities and was escaped a second time - every multi-parameter ServiceNow and SharePoint link opened with &amp; in it. Emphasis could also reach inside an href, and '//host' passed as an in-app link when the browser reads it as off-site. Tests are now HTTP-level. The draft-invisibility rule lived entirely in routes that no test executed, which is how three of them shipped serving drafts.
287 lines
11 KiB
Vue
287 lines
11 KiB
Vue
<template>
|
|
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
|
|
<template v-for="(block, index) in blocks" :key="block.id || index">
|
|
|
|
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
|
|
v-html="inline(block.text)"></h2>
|
|
|
|
<p v-else-if="block.type === 'paragraph'" class="gb-para"
|
|
v-html="inline(block.text)"></p>
|
|
|
|
<div v-else-if="block.type === 'step'" class="gb-step">
|
|
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
|
|
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
|
|
<div v-if="block.text" v-html="inline(block.text)"></div>
|
|
</div>
|
|
|
|
<!--
|
|
The reason this feature exists. Interactive ONLY when viewing: in the
|
|
editor a tick would be an edit, and an author clicking around their own
|
|
draft would quietly change what the next reader sees as "done".
|
|
-->
|
|
<ul v-else-if="block.type === 'checklist'" class="gb-checklist">
|
|
<li v-for="item in itemsOf(block)" :key="item.id" class="gb-check">
|
|
<label>
|
|
<input type="checkbox"
|
|
:checked="isTicked(item.id)"
|
|
:disabled="!interactive"
|
|
@change="toggle(item.id, $event.target.checked)">
|
|
<span :class="{ 'gb-done': interactive && isTicked(item.id) }"
|
|
v-html="inline(item.text)"></span>
|
|
</label>
|
|
</li>
|
|
</ul>
|
|
|
|
<ul v-else-if="block.type === 'bullets'" class="gb-list">
|
|
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
|
</ul>
|
|
|
|
<ol v-else-if="block.type === 'numbers'" class="gb-list">
|
|
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
|
</ol>
|
|
|
|
<div v-else-if="block.type === 'callout'"
|
|
class="gb-callout" :class="'gb-' + (block.variant || 'info')">
|
|
<strong v-if="block.title">{{ block.title }}</strong>
|
|
<div v-html="inline(block.text)"></div>
|
|
</div>
|
|
|
|
<div v-else-if="block.type === 'code'" class="gb-code">
|
|
<div class="gb-codehead">
|
|
<span class="gb-lang">{{ block.language || 'text' }}</span>
|
|
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
|
|
<!--
|
|
SELECTS the code; it does not write to the clipboard.
|
|
|
|
A page that programmatically copies a shell command is the exact
|
|
shape of a ClickFix attack - malware that plants a command in your
|
|
clipboard and talks you into pasting it into Run. uBlock Origin
|
|
blocks it, and it was right to: our button wrote PowerShell to the
|
|
clipboard on click.
|
|
|
|
Selecting the text and letting the person press Ctrl+C keeps the
|
|
convenience, moves the decision back to them, and gives security
|
|
tooling nothing to object to. It also selects the code ONLY - no
|
|
caption, no prompt, so nobody ends up pasting "PS C:\>" into a
|
|
terminal.
|
|
-->
|
|
<button type="button" class="gb-copy" @click="select(block)">
|
|
{{ selected === block.id ? 'Selected - press Ctrl+C' : 'Select' }}
|
|
</button>
|
|
</div>
|
|
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
|
|
</div>
|
|
|
|
<!--
|
|
The image is fetched by script, not by the browser.
|
|
|
|
An <img src> is a plain browser request: it carries no Authorization
|
|
header, and this app keeps its JWT in one. So a direct src against the
|
|
authenticated image route is a 401 and a broken-image icon for every
|
|
reader - which is exactly how this shipped. Fetching through the api
|
|
client sends the token, and it resolves against the client's baseURL, so
|
|
it also survives a subpath mount that a stored '/api/...' string does
|
|
not.
|
|
|
|
A failure says so in words. A guide whose screenshot is missing must not
|
|
look like a guide that never had one.
|
|
-->
|
|
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
|
<img v-if="imageState(block).src" :src="imageState(block).src"
|
|
:alt="block.alt || ''">
|
|
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
|
|
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
|
|
</p>
|
|
<p v-else class="gb-imageloading">Loading image...</p>
|
|
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
|
|
</figure>
|
|
|
|
<div v-else-if="block.type === 'table'" class="gb-tablewrap">
|
|
<table class="gb-table">
|
|
<thead v-if="block.header && block.header.length">
|
|
<tr><th v-for="(cell, c) in block.header" :key="c" v-html="inline(cell)"></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, r) in (block.rows || [])" :key="r">
|
|
<td v-for="(cell, c) in row" :key="c" v-html="inline(cell)"></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- An unknown type renders as nothing rather than as an error. A guide
|
|
written against a newer version must stay readable on an older one. -->
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, toRef } from 'vue'
|
|
import { renderInline } from '../utils/inline.js'
|
|
import { useGuideImages } from '../utils/images.js'
|
|
|
|
const props = defineProps({
|
|
blocks: { type: Array, default: () => [] },
|
|
// Live checkboxes belong to the viewer only.
|
|
interactive: { type: Boolean, default: false },
|
|
ticked: { type: Object, default: () => ({}) },
|
|
})
|
|
const emit = defineEmits(['toggle'])
|
|
|
|
const selected = ref('')
|
|
const codeNodes = {}
|
|
function registerCode (id, el) { if (el) codeNodes[id] = el }
|
|
|
|
// Images are fetched by script, not by <img src>; see utils/images.js.
|
|
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
|
|
|
|
function inline (text) {
|
|
return renderInline(text)
|
|
}
|
|
|
|
// Items may be plain strings (bullets) or objects with ids (checklists). One
|
|
// shape here so the template does not branch on it.
|
|
function itemsOf (block) {
|
|
return (block.items || []).map((item, index) =>
|
|
typeof item === 'string'
|
|
? { id: (block.id || 'b') + '-' + index, text: item }
|
|
: item)
|
|
}
|
|
|
|
function isTicked (itemid) {
|
|
return Boolean(props.ticked[itemid])
|
|
}
|
|
|
|
function toggle (itemid, checked) {
|
|
if (!props.interactive) return
|
|
emit('toggle', itemid, checked)
|
|
}
|
|
|
|
// Steps number themselves by position among steps, so inserting a paragraph
|
|
// between two of them does not renumber anything by hand.
|
|
const stepIndex = computed(() => {
|
|
const map = {}
|
|
let n = 0
|
|
props.blocks.forEach((block, index) => {
|
|
if (block.type === 'step') { n += 1; map[index] = n }
|
|
})
|
|
return map
|
|
})
|
|
function stepNumber (index) {
|
|
return stepIndex.value[index] || 1
|
|
}
|
|
|
|
function anchorFor (block) {
|
|
return 'g-' + (block.id || '')
|
|
}
|
|
|
|
function select (block) {
|
|
const node = codeNodes[block.id]
|
|
if (!node || !window.getSelection) return
|
|
const range = document.createRange()
|
|
range.selectNodeContents(node)
|
|
const selection = window.getSelection()
|
|
selection.removeAllRanges()
|
|
selection.addRange(range)
|
|
selected.value = block.id
|
|
setTimeout(() => { selected.value = '' }, 4000)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Every colour is a theme token. Nothing here is a literal, which is what makes
|
|
dark mode work with no dark-mode code. */
|
|
.gb-blocks { line-height: 1.65; }
|
|
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
|
|
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
|
|
|
|
.gb-step {
|
|
border-left: 3px solid var(--primary);
|
|
padding: 0.6rem 0 0.6rem 1rem;
|
|
margin: 0 0 1rem;
|
|
}
|
|
.gb-stepnum {
|
|
display: inline-block; font-size: 0.72rem; text-transform: uppercase;
|
|
letter-spacing: 0.06em; color: var(--primary); font-weight: 600;
|
|
}
|
|
.gb-step h3 { margin: 0.2rem 0 0.4rem; font-size: 1.05rem; }
|
|
|
|
.gb-checklist { list-style: none; padding: 0; margin: 0 0 1rem; }
|
|
.gb-check { margin: 0.35rem 0; }
|
|
.gb-check label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; }
|
|
.gb-interactive .gb-check label:hover { color: var(--primary); }
|
|
.gb-check input { margin-top: 0.28rem; flex: none; width: 1rem; height: 1rem; }
|
|
.gb-done { opacity: 0.55; text-decoration: line-through; }
|
|
|
|
.gb-list { margin: 0 0 1rem 1.2rem; }
|
|
.gb-list li { margin: 0.25rem 0; }
|
|
|
|
.gb-callout {
|
|
border-left: 4px solid var(--info, var(--primary));
|
|
background: var(--surface-2, rgba(127, 127, 127, 0.08));
|
|
padding: 0.75rem 1rem; margin: 0 0 1rem; border-radius: 4px;
|
|
}
|
|
.gb-callout strong { display: block; margin-bottom: 0.25rem; }
|
|
.gb-warning { border-left-color: var(--warning, #d9822b); }
|
|
.gb-success { border-left-color: var(--success, #2e9e5b); }
|
|
|
|
.gb-code { margin: 0 0 1rem; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
|
|
.gb-codehead {
|
|
display: flex; align-items: center; gap: 0.6rem;
|
|
padding: 0.3rem 0.6rem; background: var(--surface-2, rgba(127,127,127,0.08));
|
|
border-bottom: 1px solid var(--border); font-size: 0.75rem;
|
|
}
|
|
.gb-lang { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-light); }
|
|
.gb-caption { color: var(--text-light); }
|
|
.gb-copy {
|
|
margin-left: auto; font-size: 0.72rem; padding: 0.15rem 0.5rem;
|
|
border: 1px solid var(--border); background: transparent;
|
|
color: var(--text-light); border-radius: 3px; cursor: pointer;
|
|
}
|
|
.gb-copy:hover { border-color: var(--primary); color: var(--primary); }
|
|
.gb-code pre {
|
|
margin: 0; padding: 0.7rem 0.8rem; overflow-x: auto;
|
|
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.84rem;
|
|
/* WRAP rather than clip: these guides are full of long UNC paths, and a
|
|
line that scrolls out of sight is a command somebody copies half of. */
|
|
white-space: pre-wrap; word-break: break-word;
|
|
}
|
|
|
|
.gb-figure { margin: 0 0 1.2rem; }
|
|
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
|
|
.gb-imageloading, .gb-imagefailed {
|
|
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
|
|
border-radius: 4px; font-size: 0.9rem;
|
|
}
|
|
.gb-imageloading { color: var(--textmuted); }
|
|
.gb-imagefailed { color: var(--danger, #b3261e); }
|
|
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
|
|
|
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
|
|
.gb-table { border-collapse: collapse; width: 100%; }
|
|
.gb-table th, .gb-table td {
|
|
border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left;
|
|
vertical-align: top; font-size: 0.9rem;
|
|
}
|
|
.gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); }
|
|
|
|
@media print {
|
|
/* Paper has no hover, no clipboard and no theme. The light-mode token
|
|
overrides live in GuideView.vue, which can reach :root; this block is only
|
|
about what belongs on paper. */
|
|
.gb-copy { display: none; }
|
|
.gb-step, .gb-callout, .gb-code, .gb-figure, .gb-check { break-inside: avoid; }
|
|
|
|
/* Checkboxes print EMPTY on purpose, even where they are ticked on screen:
|
|
a printed guide is a worksheet somebody carries to the bay and ticks by
|
|
hand, and one person's progress is not part of the procedure. */
|
|
.gb-done { opacity: 1; text-decoration: none; }
|
|
.gb-check input { appearance: none; -webkit-appearance: none;
|
|
width: 0.85rem; height: 0.85rem; border: 1px solid #000;
|
|
border-radius: 2px; background: #fff; }
|
|
|
|
.gb-code pre { white-space: pre-wrap; }
|
|
.gb-stepnum { color: #000; }
|
|
}
|
|
</style>
|