Files
usmt-dash/web/docs.js
T
Johan Sandoval 5e8dea8027 Docs: clicking anywhere in an article's box opens it
Before, only the title text was clickable. The copy-link button keeps its
own action, and ctrl/cmd/shift-click on the box opens a new tab.
2026-10-07 18:50:18 +00:00

427 lines
25 KiB
JavaScript

/*
* USMT Dash - knowledge base upgrades
*
* - Every article has its own address: /docs/<title> (shareable; back/forward work).
* - The article list is searchable (titles and full text), shows how many articles there
* are, and is split into Applications and Troubleshooting & Guides. An article's group
* comes from a hidden first line, <!-- category: Applications -->, set from the editor;
* articles without one are grouped by a heuristic (named after an app = Applications).
* - PDF export lays the article out at page width with real page breaks, a title block
* and page numbers. Print uses the browser's own (vector) print-to-PDF.
* - GitHub-style alerts in blockquotes: > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION].
*/
(function () {
'use strict';
const CATS = ['Applications', 'Troubleshooting & Guides'];
const CAT_RE = /^\s*<!--\s*category:\s*([^>]*?)\s*-->[ \t]*\r?\n?/i;
const index = {}; // title -> { text, lower, category }
let query = '';
let indexing = null;
let firstLoad = true;
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const docs = () => (typeof availableDocs !== 'undefined' && Array.isArray(availableDocs)) ? availableDocs : [];
const permalink = t => '/docs/' + encodeURIComponent(t);
const currentTitle = () => (typeof currentDocTitle !== 'undefined' ? currentDocTitle : '');
function categoryOf(title, text) {
const m = String(text || '').match(CAT_RE);
if (m && m[1]) return m[1].trim();
// Heuristic: named after software we've seen on devices, or written as an install guide
try {
if (typeof softwareCache !== 'undefined') {
const t = title.toLowerCase();
for (const list of Object.values(softwareCache)) {
if (Array.isArray(list) && list.some(a => a && a.Name && getAppDisplayName(a.Name).toLowerCase() === t)) return CATS[0];
}
}
} catch (e) {}
if (/^#{1,3}\s*(install|installation|deploy)/im.test(text || '')) return CATS[0];
return CATS[1];
}
async function buildIndex() {
const titles = docs();
await Promise.all(titles.map(async t => {
try {
const r = await fetch(`/Documentation/${encodeURIComponent(t)}.md?t=${Date.now()}`);
const text = r.ok ? await r.text() : '';
index[t] = { text, lower: text.toLowerCase(), category: categoryOf(t, text) };
} catch (e) { index[t] = { text: '', lower: '', category: categoryOf(t, '') }; }
}));
Object.keys(index).forEach(t => { if (!titles.includes(t)) delete index[t]; });
}
function reindex() {
indexing = buildIndex().then(() => { indexing = null; renderSidebar(); });
return indexing;
}
// ---------------------------------------------------------------------------------
// Sidebar: search, count, categories
// ---------------------------------------------------------------------------------
const LINK_SVG = '<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M451.5 160C434.9 160 418.8 164.5 404.7 172.7C388.9 156.7 370.5 143.3 350.2 133.2C378.4 109.2 414.3 96 451.5 96C537.9 96 608 166 608 252.5C608 294 591.5 333.8 562.2 363.1L491.1 434.2C461.8 463.5 422 480 380.5 480C294.1 480 224 410 224 323.5C224 322 224 320.5 224.1 319C224.6 301.3 239.3 287.4 257 287.9C274.7 288.4 288.6 303.1 288.1 320.8C288.1 321.7 288.1 322.6 288.1 323.4C288.1 374.5 329.5 415.9 380.6 415.9C405.1 415.9 428.6 406.2 446 388.8L517.1 317.7C534.4 300.4 544.2 276.8 544.2 252.3C544.2 201.2 502.8 159.8 451.7 159.8zM307.2 237.3C305.3 236.5 303.4 235.4 301.7 234.2C289.1 227.7 274.7 224 259.6 224C235.1 224 211.6 233.7 194.2 251.1L123.1 322.2C105.8 339.5 96 363.1 96 387.6C96 438.7 137.4 480.1 188.5 480.1C205 480.1 221.1 475.7 235.2 467.5C251 483.5 269.4 496.9 289.8 507C261.6 530.9 225.8 544.2 188.5 544.2C102.1 544.2 32 474.2 32 387.7C32 346.2 48.5 306.4 77.8 277.1L148.9 206C178.2 176.7 218 160.2 259.5 160.2C346.1 160.2 416 230.8 416 317.1C416 318.4 416 319.7 416 321C415.6 338.7 400.9 352.6 383.2 352.2C365.5 351.8 351.6 337.1 352 319.4C352 318.6 352 317.9 352 317.1C352 283.4 334 253.8 307.2 237.5z"/></svg>';
function ensureSearchUi() {
const list = document.getElementById('docList');
if (!list || document.getElementById('docSearch')) return;
const box = document.createElement('div');
box.className = 'kb-search';
box.innerHTML = `
<input type="search" id="docSearch" placeholder="Search articles..." autocomplete="off" aria-label="Search articles">
<div class="kb-count" id="docCount" aria-live="polite"></div>`;
list.parentElement.insertBefore(box, list);
const input = box.querySelector('input');
input.addEventListener('input', () => { query = input.value.trim(); renderSidebar(); });
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { const first = list.querySelector('.kb-item a'); if (first) first.click(); }
if (e.key === 'Escape') { input.value = ''; query = ''; renderSidebar(); }
});
}
function snippet(text, q) {
const body = text.replace(CAT_RE, '').replace(/\[\[(\w+):([^\]|]+)(\|[^\]]*)?\]\]/g, '$2').replace(/[#>*_`]/g, '').replace(/\s+/g, ' ');
const i = body.toLowerCase().indexOf(q.toLowerCase());
if (i < 0) return '';
const start = Math.max(0, i - 40), end = Math.min(body.length, i + q.length + 60);
return (start ? '...' : '') + esc(body.slice(start, i)) + '<mark>' + esc(body.slice(i, i + q.length)) + '</mark>' + esc(body.slice(i + q.length, end)) + (end < body.length ? '...' : '');
}
function renderSidebar() {
ensureSearchUi();
const container = document.getElementById('docList');
if (!container) return;
const all = docs();
const count = document.getElementById('docCount');
if (!all.length) {
container.innerHTML = "<span class='kb-empty'>No articles yet. Use + Add to write the first one.</span>";
if (count) count.textContent = '0 articles';
return;
}
const q = query.toLowerCase();
const hits = all.map(t => {
const e = index[t];
const inTitle = !q || t.toLowerCase().includes(q);
const inText = q && e && e.lower.includes(q);
if (!inTitle && !inText) return null;
return { t, cat: e ? e.category : null, snip: (!inTitle && inText) ? snippet(e.text, query) : '' };
}).filter(Boolean);
if (count) {
count.textContent = q ? `${hits.length} of ${all.length} article${all.length === 1 ? '' : 's'} match` : `${all.length} article${all.length === 1 ? '' : 's'}`;
if (q && indexing) count.textContent += ' (searching text...)';
}
const groups = {};
hits.forEach(h => { const c = h.cat || 'Articles'; (groups[c] = groups[c] || []).push(h); });
const order = Object.keys(groups).sort((a, b) => {
const ia = CATS.indexOf(a), ib = CATS.indexOf(b);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
});
const active = currentTitle();
container.innerHTML = hits.length ? order.map(cat => `
<div class="kb-group">
<div class="kb-group-title">${esc(cat)} <span>${groups[cat].length}</span></div>
${groups[cat].map(h => `
<div class="kb-item${h.t === active ? ' active' : ''}">
<button type="button" class="kb-copy" data-copy-doc="${esc(h.t)}" title="Copy a Markdown link to this article">${LINK_SVG}</button>
<div class="kb-item-main">
<a href="${permalink(h.t)}" data-doc="${esc(h.t)}">${esc(h.t)}</a>
${h.snip ? `<div class="kb-snip">${h.snip}</div>` : ''}
</div>
</div>`).join('')}
</div>`).join('') : `<span class="kb-empty">No article mentions "${esc(query)}".</span>`;
}
// Clicks in the list: open in place (middle / ctrl-click still open a new tab)
document.addEventListener('click', e => {
const a = e.target.closest('#docList a[data-doc]');
if (a && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0) { e.preventDefault(); openDoc(a.dataset.doc); return; }
// Anywhere else in the highlighted box (except the copy-link button) opens it too
const item = !a && !e.target.closest('.kb-copy') && e.target.closest('#docList .kb-item');
const itemLink = item && item.querySelector('a[data-doc]');
if (itemLink && e.button === 0) {
if (e.ctrlKey || e.metaKey || e.shiftKey) window.open(itemLink.href, '_blank', 'noopener');
else openDoc(itemLink.dataset.doc);
return;
}
// Links between articles (/docs/Title or the older ?doc=Title) open in place
const link = e.target.closest('#docContent a[href]');
if (link && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0 && link.target !== '_blank') {
let target = null;
try {
const u = new URL(link.getAttribute('href'), document.baseURI);
if (u.origin === location.origin) {
const m = u.pathname.match(/^\/docs\/(.+)$/);
target = m ? decodeURIComponent(m[1]) : u.searchParams.get('doc');
}
} catch (err) {}
const doc = findDoc(target);
if (doc) { e.preventDefault(); openDoc(doc); return; }
}
const c = e.target.closest('#docList .kb-copy');
if (c) { e.preventDefault(); copyDocLink(c.dataset.copyDoc); }
});
// ---------------------------------------------------------------------------------
// Permalinks
// ---------------------------------------------------------------------------------
function titleFromPath() {
const m = location.pathname.match(/^\/docs\/(.+)$/);
if (!m) return null;
try { return decodeURIComponent(m[1]).replace(/\/+$/, ''); } catch (e) { return null; }
}
function findDoc(name) {
if (!name) return null;
const all = docs();
return all.find(d => d === name) || all.find(d => d.toLowerCase() === name.toLowerCase()) || null;
}
function setUrl(path, push) {
if (location.pathname + location.search === path) return;
try { history[push ? 'pushState' : 'replaceState']({ doc: path }, '', path); } catch (e) {}
}
function copyPageLink() {
const t = currentTitle();
if (!t) return;
fallbackCopyTextToClipboard(location.origin + permalink(t));
showToast('Page link copied to clipboard!');
}
function addToolbarButtons() {
const bar = document.getElementById('docToolbar');
if (!bar || document.getElementById('btn-copy-page')) return;
const pdf = document.getElementById('btn-download-pdf');
const mk = (id, title, html, fn) => {
const b = document.createElement('button');
b.className = 'nav-btn kb-tool'; b.id = id; b.title = title; b.type = 'button'; b.innerHTML = html; b.onclick = fn;
bar.insertBefore(b, pdf);
return b;
};
mk('btn-copy-page', 'Copy a link to this page', `${LINK_SVG}<span>Link</span>`, copyPageLink);
mk('btn-print-doc', 'Print, or save as PDF with your browser', '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 1h8v4H4zM2 6h12a1 1 0 0 1 1 1v5h-3v3H4v-3H1V7a1 1 0 0 1 1-1zm3 6v2h6v-2zm8-4a.8.8 0 1 0 0 1.6A.8.8 0 0 0 13 8z" fill="currentColor"/></svg><span>Print</span>', printDoc);
// Category picker (edit mode only)
const sel = document.createElement('select');
sel.id = 'docCategory'; sel.className = 'kb-cat-select'; sel.title = 'Which group this article is listed under';
sel.innerHTML = CATS.map(c => `<option>${esc(c)}</option>`).join('');
sel.style.display = 'none';
sel.onchange = () => {
const ed = document.getElementById('docEditor');
ed.value = `<!-- category: ${sel.value} -->\n` + ed.value.replace(CAT_RE, '');
};
bar.insertBefore(sel, document.getElementById('btn-save-doc'));
}
// ---------------------------------------------------------------------------------
// PDF + print
// ---------------------------------------------------------------------------------
const PRINT_CSS = `
.kb-print { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; color: #1f2328; background: #fff; font-size: 12.5px; line-height: 1.55; }
.kb-print * { box-sizing: border-box; }
.kb-print .kb-print-head { border-bottom: 2px solid #2a78d6; padding-bottom: 8px; margin-bottom: 14px; }
.kb-print .kb-print-head h1 { font-size: 22px; margin: 0; border: 0; padding: 0; color: #1f2328; }
.kb-print .kb-print-head div { color: #59636e; font-size: 11px; margin-top: 3px; }
.kb-print h1, .kb-print h2 { border-bottom: 1px solid #d1d9e0; padding-bottom: 4px; color: #1f2328; }
.kb-print h1 { font-size: 19px; } .kb-print h2 { font-size: 16px; } .kb-print h3 { font-size: 14px; }
.kb-print h1, .kb-print h2, .kb-print h3, .kb-print h4 { break-after: avoid; page-break-after: avoid; margin: 14px 0 8px; }
.kb-print p, .kb-print li { orphans: 3; widows: 3; }
.kb-print pre, .kb-print table, .kb-print blockquote, .kb-print img, .kb-print .kb-code { break-inside: avoid; page-break-inside: avoid; }
.kb-print a { color: #0969da; text-decoration: none; font-weight: 600; }
.kb-print code { font-family: Consolas, "Cascadia Mono", Menlo, monospace; font-size: 0.88em; background: #eff2f5; border: 1px solid #d1d9e0; border-radius: 4px; padding: 1px 5px; color: #1f2328; }
.kb-print pre { background: #f6f8fa !important; border: 1px solid #d1d9e0; border-radius: 6px; padding: 10px 12px !important; white-space: pre-wrap; word-break: break-word; margin: 8px 0 !important; }
.kb-print pre code, .kb-print pre code * { background: none !important; border: 0; padding: 0; color: #1f2328 !important; }
.kb-print .kb-code-head, .kb-print .copy-code-btn, .kb-print button { display: none !important; }
.kb-print table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 0.95em; }
.kb-print th, .kb-print td { border: 1px solid #d1d9e0; padding: 5px 8px; text-align: left; vertical-align: top; }
.kb-print th { background: #eef4fc !important; color: #1f2328 !important; font-weight: 700; }
.kb-print td { background: #fff !important; color: #1f2328 !important; }
.kb-print th, .kb-print td { white-space: normal !important; overflow: visible !important; text-overflow: clip !important; max-width: none !important; position: static !important; }
.kb-print p, .kb-print li, .kb-print h1, .kb-print h2, .kb-print h3, .kb-print h4, .kb-print strong, .kb-print em { color: #1f2328; }
.kb-print blockquote { display: block !important; margin: 8px 0; padding: 8px 12px; border-left: 4px solid #2a78d6; background: #f3f7fc; color: #1f2328; border-radius: 0 4px 4px 0; }
.kb-print blockquote p { display: block !important; margin: 0 0 6px; }
.kb-print blockquote p:last-child { margin-bottom: 0; }
.kb-print img { max-width: 100% !important; max-height: 420px; height: auto !important; display: block; margin: 8px 0; border: 1px solid #d1d9e0; border-radius: 4px; }
.kb-print .tag-badge { border: 1px solid currentColor; }
.kb-print mark { background: none; }`;
function printableClone() {
const content = document.getElementById('docContent');
const wrap = document.createElement('div');
wrap.className = 'kb-print';
const st = document.createElement('style'); st.textContent = PRINT_CSS; wrap.appendChild(st);
const head = document.createElement('div'); head.className = 'kb-print-head';
const date = document.getElementById('docEditDate');
const body = content.cloneNode(true);
// The article usually opens with its own H1; don't print the title twice
const firstH1 = body.querySelector('h1');
const dup = firstH1 && firstH1.textContent.trim().toLowerCase() === currentTitle().trim().toLowerCase();
head.innerHTML = `${dup ? '' : `<h1>${esc(currentTitle())}</h1>`}<div>${esc(date ? date.textContent : '')}${date ? ' &middot; ' : ''}${esc(location.origin + '/docs/' + currentTitle())}</div>`;
wrap.appendChild(head);
body.removeAttribute('id'); body.removeAttribute('style');
// Undo on-screen code block chrome (language bar + copy button container)
body.querySelectorAll('pre').forEach(pre => { pre.removeAttribute('style'); });
body.querySelectorAll('[style]').forEach(n => { if (n.tagName !== 'SPAN' || !n.classList.contains('tag-badge')) n.style.removeProperty('background'); });
wrap.appendChild(body);
return wrap;
}
function waitForImages(root) {
return Promise.all(Array.from(root.querySelectorAll('img')).map(img => img.complete ? null : new Promise(r => { img.onload = img.onerror = r; })));
}
async function downloadPdf() {
if (!currentTitle()) return;
const btn = document.getElementById('btn-download-pdf');
const label = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = label.replace('PDF', 'Exporting...');
// Lay the article out offscreen at the printable width of an A4/Letter page
// (images load while it's briefly attached and hidden, then html2pdf takes its own copy)
const host = document.createElement('div');
host.style.cssText = 'position: fixed; left: 0; top: 0; width: 688px; visibility: hidden; pointer-events: none;';
const page = printableClone();
page.style.width = '688px';
host.appendChild(page);
document.body.appendChild(host);
try {
await waitForImages(page);
host.removeChild(page);
const opt = {
margin: [14, 14, 16, 14],
filename: `${currentTitle()}.pdf`,
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true, logging: false, backgroundColor: '#ffffff', scrollX: 0, scrollY: 0 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: { mode: ['css', 'legacy'], avoid: ['pre', 'table', 'img', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'li', 'tr'] }
};
await html2pdf().set(opt).from(page).toPdf().get('pdf').then(pdf => {
const n = pdf.internal.getNumberOfPages();
const w = pdf.internal.pageSize.getWidth(), h = pdf.internal.pageSize.getHeight();
pdf.setFontSize(8); pdf.setTextColor(120);
for (let i = 1; i <= n; i++) {
pdf.setPage(i);
pdf.text(currentTitle(), 14, h - 7);
pdf.text(`Page ${i} of ${n}`, w - 14, h - 7, { align: 'right' });
}
}).save();
} catch (e) {
showToast('PDF export failed. Try Print > Save as PDF.');
} finally {
host.remove();
btn.disabled = false;
btn.innerHTML = label;
}
}
function printDoc() {
if (!currentTitle()) return;
const w = window.open('', '_blank');
if (!w) { showToast('Allow pop-ups to print this article.'); return; }
const page = printableClone();
w.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>${esc(currentTitle())}</title><base href="${esc(location.origin)}/">
<style>@page { margin: 16mm 14mm; } body { margin: 0; background: #fff; } .kb-print { max-width: 760px; margin: 0 auto; }</style></head><body></body></html>`);
w.document.body.appendChild(w.document.importNode(page, true));
w.document.close();
const go = () => { w.focus(); w.print(); };
Promise.all(Array.from(w.document.images).map(i => i.complete ? null : new Promise(r => { i.onload = i.onerror = r; }))).then(() => setTimeout(go, 150));
}
// ---------------------------------------------------------------------------------
// Rendering extras: GitHub-style alerts
// ---------------------------------------------------------------------------------
const ALERTS = { NOTE: 'Note', TIP: 'Tip', IMPORTANT: 'Important', WARNING: 'Warning', CAUTION: 'Caution' };
function decorate() {
// no trailing blank line at the end of a code block
document.querySelectorAll('#docContent pre code').forEach(code => {
if (code.dataset.kbTrim) return;
code.dataset.kbTrim = '1';
const last = code.lastChild;
if (last && last.nodeType === 3) last.nodeValue = last.nodeValue.replace(/\s+$/, '');
});
document.querySelectorAll('#docContent blockquote').forEach(bq => {
if (bq.dataset.kb) return;
bq.dataset.kb = '1';
const p = bq.querySelector('p');
if (!p) return;
const m = p.innerHTML.match(/^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(<br>)?\s*/i);
if (!m) return;
const kind = m[1].toUpperCase();
p.innerHTML = p.innerHTML.slice(m[0].length);
if (!p.innerHTML.trim()) p.remove();
bq.classList.add('kb-alert', 'kb-alert-' + kind.toLowerCase());
const t = document.createElement('div');
t.className = 'kb-alert-title';
t.textContent = ALERTS[kind];
bq.insertBefore(t, bq.firstChild);
});
}
// ---------------------------------------------------------------------------------
// Wiring
// ---------------------------------------------------------------------------------
function wrap(name, after, before) {
const orig = window[name];
if (typeof orig !== 'function') return;
window[name] = function () {
if (before) before.apply(this, arguments);
const r = orig.apply(this, arguments);
if (r && typeof r.then === 'function') return r.then(v => { after && after.apply(this, arguments); return v; });
if (after) after.apply(this, arguments);
return r;
};
}
document.addEventListener('DOMContentLoaded', () => {
window.renderDocsSidebar = renderSidebar;
window.downloadDocAsPDF = downloadPdf;
// Markdown link for use inside other articles
window.copyDocLink = function (docName) {
fallbackCopyTextToClipboard(`[${docName}](${permalink(docName)})`);
showToast('Markdown link copied: paste it into another article.');
};
addToolbarButtons();
wrap('loadDocsList', () => {
reindex();
if (firstLoad) {
firstLoad = false;
const t = titleFromPath();
if (location.pathname.startsWith('/docs')) {
const doc = findDoc(t);
if (doc) openDoc(doc);
else {
switchPage('docs');
if (t) {
const c = document.getElementById('docContent');
if (c) c.innerHTML = `<span style="color:#e74c3c; font-weight:bold;">There's no article called "${esc(t)}".</span> Pick one from the list, or search for it.`;
}
}
}
}
});
wrap('openDoc', () => {
renderSidebar(); decorate();
// Phones and tablets stack the list above the article: bring the article into view
if (window.matchMedia('(max-width: 1100px)').matches) {
const bar = document.getElementById('docToolbar');
if (bar) window.scrollTo({ top: window.scrollY + bar.getBoundingClientRect().top - (parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 60) - 10, behavior: 'smooth' });
}
}, name => { setUrl(permalink(name), true); });
wrap('formatCodeBlocks', decorate);
wrap('setDocMode', mode => {
const sel = document.getElementById('docCategory');
if (!sel) return;
sel.style.display = mode === 'edit' ? '' : 'none';
if (mode === 'edit') {
const ed = document.getElementById('docEditor');
const m = ed.value.match(CAT_RE);
sel.value = m && CATS.includes(m[1].trim()) ? m[1].trim() : categoryOf(currentTitle(), ed.value);
if (!m) ed.value = `<!-- category: ${sel.value} -->\n` + ed.value;
}
});
// Page routing (including /docs) lives in router.js; it hands /docs/<title> back here
window.KB = { open: name => { const d = findDoc(name); if (d) { openDoc(d); return true; } return false; } };
});
})();