the docs search only matched titles/folders, so searching a term that lives in a doc's body (e.g. 'heartbeat') returned nothing — you had to already know which doc to open. - search now matches title AND body across all docs; results show the title plus a context snippet with the query highlighted (<mark>) - doc bodies are fetched once on first search and cached (invalidated when the doc list changes); in-flight searches cancel on a new query - clicking a result opens the doc (and updates the URL); clearing returns the folder tree - a11y: the homepage API peek used role=tablist/tab without tabpanels (an incomplete ARIA pattern) — switched to a labeled button group with aria-pressed - verified live: 'heartbeat' -> 3 body matches with highlighted snippets, click opens Connect-your-broker, clear restores the tree
251 lines
14 KiB
JavaScript
251 lines
14 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { marked } from 'marked';
|
|
import ConsoleShell from './ConsoleShell.jsx';
|
|
import { api } from './api.js';
|
|
import { copyToast } from './toast.js';
|
|
|
|
marked.setOptions({ breaks: true });
|
|
|
|
// Minimal, safe syntax highlighter: strings, numbers, keywords only.
|
|
// No comment rule on purpose — '//' inside URLs (https://) must never be eaten.
|
|
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
const HL_RE = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b\d+(?:\.\d+)?\b)|\b(const|let|var|function|return|while|do|done|if|then|else|fi|for|true|false|null|async|await|new|export|import|setInterval|setTimeout|fetch|sleep|curl|JSON|Math|console)\b/g;
|
|
function highlightCode(code) {
|
|
let out = '', last = 0, m;
|
|
HL_RE.lastIndex = 0;
|
|
while ((m = HL_RE.exec(code))) {
|
|
out += esc(code.slice(last, m.index));
|
|
const cls = m[1] ? 'hl-str' : m[2] ? 'hl-num' : 'hl-kw';
|
|
out += `<span class="${cls}">${esc(m[0])}</span>`;
|
|
last = m.index + m[0].length;
|
|
}
|
|
return out + esc(code.slice(last));
|
|
}
|
|
|
|
// URL-safe slug from a doc title, for shareable #/<slug> doc links.
|
|
const slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'doc';
|
|
|
|
// A context snippet around a full-text match, markdown stripped, query <mark>ed.
|
|
function makeSnippet(body, idx, query) {
|
|
const start = Math.max(0, idx - 48);
|
|
const end = Math.min(body.length, idx + query.length + 64);
|
|
const raw = body.slice(start, end).replace(/[#>*`~_[\]]/g, '').replace(/\s+/g, ' ').trim();
|
|
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig');
|
|
return (start > 0 ? '…' : '') + esc(raw).replace(re, '<mark>$1</mark>') + (end < body.length ? '…' : '');
|
|
}
|
|
|
|
function DocsWorkspace({ canEdit }) {
|
|
const [docs, setDocs] = useState([]);
|
|
const [sel, setSel] = useState(null);
|
|
const [doc, setDoc] = useState(null);
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState({ title: '', folder: '', body: '' });
|
|
const [err, setErr] = useState('');
|
|
const [q, setQ] = useState('');
|
|
const [navOpen, setNavOpen] = useState(false);
|
|
const [results, setResults] = useState(null);
|
|
const renderRef = useRef(null);
|
|
const pendingSection = useRef(null);
|
|
const bodiesRef = useRef(null);
|
|
|
|
const loadList = useCallback(async () => {
|
|
try {
|
|
const d = await api('/docs'); const list = d.docs || []; setDocs(list);
|
|
// hash is #/<doc-slug> or #/<doc-slug>/<section-slug>; land on the named doc
|
|
const raw = window.location.hash.replace(/^#\/?/, '');
|
|
const docSlug = raw.split('/')[0];
|
|
pendingSection.current = raw.split('/').slice(1).join('/') || null;
|
|
const fromHash = docSlug && list.find((x) => slugify(x.title) === docSlug);
|
|
const def = fromHash || list.find((x) => /quickstart/i.test(x.title)) || list.find((x) => x.folder === 'start here') || list[0];
|
|
if (def) setSel((cur) => cur ?? def.id);
|
|
} catch (e) { setErr(e.message); }
|
|
}, []);
|
|
useEffect(() => { loadList(); }, [loadList]);
|
|
// shareable doc + section URLs: #/<doc-slug> or #/<doc-slug>/<section-slug>
|
|
const openDoc = useCallback((d) => {
|
|
setSel(d.id);
|
|
setNavOpen(false);
|
|
pendingSection.current = null;
|
|
const slug = slugify(d.title);
|
|
if (window.location.hash !== `#/${slug}`) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#/${slug}`);
|
|
}, []);
|
|
const goSection = useCallback((id) => {
|
|
const el = renderRef.current?.querySelector(`#${CSS.escape(id)}`);
|
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
const docSlug = doc ? slugify(doc.title) : '';
|
|
if (docSlug) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#/${docSlug}/${id}`);
|
|
}, [doc]);
|
|
useEffect(() => {
|
|
const onHash = () => {
|
|
const raw = window.location.hash.replace(/^#\/?/, '');
|
|
const docSlug = raw.split('/')[0];
|
|
const section = raw.split('/').slice(1).join('/') || null;
|
|
const d = docs.find((x) => slugify(x.title) === docSlug);
|
|
if (!d) return;
|
|
pendingSection.current = section;
|
|
setSel(d.id);
|
|
if (section) { const el = renderRef.current?.querySelector(`#${CSS.escape(section)}`); if (el) el.scrollIntoView({ block: 'start' }); }
|
|
};
|
|
window.addEventListener('hashchange', onHash);
|
|
window.addEventListener('popstate', onHash);
|
|
return () => { window.removeEventListener('hashchange', onHash); window.removeEventListener('popstate', onHash); };
|
|
}, [docs]);
|
|
useEffect(() => {
|
|
if (sel == null) { setDoc(null); return; }
|
|
api(`/docs/${sel}`).then((d) => { setDoc(d.doc); setDraft({ title: d.doc.title, folder: d.doc.folder, body: d.doc.body }); setEditing(false); document.title = `${d.doc.title} · Docs · amerc`; }).catch((e) => setErr(e.message));
|
|
}, [sel]);
|
|
// Build the rendered HTML once per doc: parse markdown, then bake heading ids +
|
|
// shareable anchor links and code-block copy buttons directly into the string.
|
|
// Doing it at the string level (not post-render DOM mutation) means React can
|
|
// never wipe the decorations on a re-render. Also yields the on-this-page TOC.
|
|
const { docHtml, toc } = useMemo(() => {
|
|
if (!doc) return { docHtml: '', toc: [] };
|
|
let h = marked.parse(doc.body || '');
|
|
const entries = []; const used = {};
|
|
const dslug = slugify(doc.title);
|
|
h = h.replace(/<(h[23])>([\s\S]*?)<\/\1>/g, (m, tag, inner) => {
|
|
const text = inner.replace(/<[^>]+>/g, '').trim();
|
|
let sid = slugify(text);
|
|
if (used[sid]) { used[sid] += 1; sid = `${sid}-${used[sid]}`; } else used[sid] = 1;
|
|
entries.push({ id: sid, text, level: tag === 'h3' ? 3 : 2 });
|
|
return `<${tag} id="${sid}">${inner}<a class="docs-anchor" href="#/${dslug}/${sid}" title="Link to this section" aria-label="Link to section ${text.replace(/"/g, '')}">#</a></${tag}>`;
|
|
});
|
|
h = h.replace(/<pre><code(?:\s[^>]*)?>([\s\S]*?)<\/code><\/pre>/g, (m, code) => {
|
|
const rawCode = code.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
return `<div class="docs-code"><button type="button" class="docs-copy" data-copy>copy</button><pre><code>${highlightCode(rawCode)}</code></pre></div>`;
|
|
});
|
|
return { docHtml: h, toc: entries };
|
|
}, [doc]);
|
|
// copy buttons + anchor links inside the rendered HTML, via event delegation
|
|
const onDocClick = useCallback((e) => {
|
|
const copy = e.target.closest && e.target.closest('[data-copy]');
|
|
if (copy) { const pre = copy.parentElement.querySelector('pre'); if (pre) copyToast(pre.innerText, 'Copied'); return; }
|
|
const anc = e.target.closest && e.target.closest('.docs-anchor');
|
|
if (anc) { e.preventDefault(); goSection(anc.getAttribute('href').split('/').pop()); }
|
|
}, [goSection]);
|
|
// after a doc renders, honor a pending #/<doc>/<section> deep-link scroll
|
|
useEffect(() => {
|
|
if (!docHtml || !pendingSection.current) return;
|
|
const s = pendingSection.current; pendingSection.current = null;
|
|
// two frames so code highlighting / reflow settles before we measure the target
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
const el = renderRef.current?.querySelector(`#${CSS.escape(s)}`); if (el) el.scrollIntoView({ block: 'start' });
|
|
}));
|
|
}, [docHtml]);
|
|
// full-text search: fetch all doc bodies once, then match title + body, with snippets
|
|
useEffect(() => { bodiesRef.current = null; }, [docs]);
|
|
useEffect(() => {
|
|
const query = q.trim();
|
|
if (!query) { setResults(null); return; }
|
|
let live = true;
|
|
(async () => {
|
|
if (!bodiesRef.current) {
|
|
const all = await Promise.all(docs.map((d) => api(`/docs/${d.id}`).then((r) => [d.id, r.doc.body || '']).catch(() => [d.id, ''])));
|
|
if (!live) return;
|
|
bodiesRef.current = Object.fromEntries(all);
|
|
}
|
|
const ql = query.toLowerCase();
|
|
const res = [];
|
|
for (const d of docs) {
|
|
const body = bodiesRef.current[d.id] || '';
|
|
const inMeta = (d.title + ' ' + (d.folder || '')).toLowerCase().includes(ql);
|
|
const idx = body.toLowerCase().indexOf(ql);
|
|
if (inMeta || idx >= 0) res.push({ ...d, snippet: idx >= 0 ? makeSnippet(body, idx, query) : '' });
|
|
}
|
|
if (live) setResults(res);
|
|
})();
|
|
return () => { live = false; };
|
|
}, [q, docs]);
|
|
|
|
const create = async () => {
|
|
const title = window.prompt('Document title', 'New document'); if (!title) return;
|
|
try { const r = await api('/docs', { method: 'POST', body: { title, folder: '', body: `# ${title}\n\n` } }); await loadList(); setSel(r.id); setEditing(true); } catch (e) { setErr(e.message); }
|
|
};
|
|
const save = async () => { try { await api(`/docs/${sel}`, { method: 'PATCH', body: draft }); await loadList(); setDoc({ ...doc, ...draft }); setEditing(false); } catch (e) { setErr(e.message); } };
|
|
const del = async () => { if (!window.confirm('Delete document?')) return; try { await api(`/docs/${sel}`, { method: 'DELETE' }); setSel(null); loadList(); } catch (e) { setErr(e.message); } };
|
|
|
|
const folders = {};
|
|
docs.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); });
|
|
|
|
return (
|
|
<div className="docs">
|
|
<aside className="docs-side">
|
|
<button className="docs-nav-toggle" type="button" onClick={() => setNavOpen((v) => !v)} aria-expanded={navOpen}>
|
|
<span className="docs-nav-cur">{doc ? doc.title : 'Documents'}</span>
|
|
<span className="docs-nav-chev" aria-hidden="true">▾</span>
|
|
</button>
|
|
<div className={`docs-side-body${navOpen ? ' open' : ''}`}>
|
|
<div className="docs-side-top">
|
|
<input placeholder="search docs…" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
{canEdit && <button className="cs-btn cs-primary" onClick={create}>+ New</button>}
|
|
</div>
|
|
{q.trim() ? (
|
|
<div className="docs-results">
|
|
{results === null && <p className="cs-hint">Searching…</p>}
|
|
{results !== null && !results.length && <p className="cs-hint">No matches for “{q.trim()}”.</p>}
|
|
{results !== null && results.map((d) => (
|
|
<button key={d.id} className={`docs-result${sel === d.id ? ' on' : ''}`} onClick={() => openDoc(d)}>
|
|
<span className="docs-result-title">{d.title}</span>
|
|
{d.snippet && <span className="docs-result-snip" dangerouslySetInnerHTML={{ __html: d.snippet }} />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="docs-tree">
|
|
{Object.keys(folders).sort().map((f) => (
|
|
<div key={f} className="docs-folder">
|
|
<div className="docs-folder-name">{f || '📁 root'}</div>
|
|
{folders[f].map((d) => (
|
|
<button key={d.id} className={`docs-item${sel === d.id ? ' on' : ''}`} onClick={() => openDoc(d)}>{d.title}</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
{!docs.length && <p className="cs-hint">No documents yet. Create one — humans and agents (via API key) can both read & write here.</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
<section className="docs-main">
|
|
{err && <p className="cs-err">{err}</p>}
|
|
{!doc && <div className="docs-empty">Select or create a document.</div>}
|
|
{doc && (
|
|
<>
|
|
<div className="docs-head">
|
|
{editing
|
|
? <input className="docs-title-input" value={draft.title} onChange={(e) => setDraft({ ...draft, title: e.target.value })} />
|
|
: <h2>{doc.title}</h2>}
|
|
<div className="docs-actions">
|
|
{editing && <input className="docs-folder-input" placeholder="folder" value={draft.folder} onChange={(e) => setDraft({ ...draft, folder: e.target.value })} />}
|
|
{canEdit && !editing && <button className="cs-btn" onClick={() => setEditing(true)}>Edit</button>}
|
|
{editing && <button className="cs-btn cs-primary" onClick={save}>Save</button>}
|
|
{editing && <button className="cs-btn" onClick={() => { setEditing(false); setDraft({ title: doc.title, folder: doc.folder, body: doc.body }); }}>Cancel</button>}
|
|
{canEdit && <button className="cs-btn" onClick={del}>Delete</button>}
|
|
</div>
|
|
</div>
|
|
<p className="docs-byline">updated by {doc.updated_by || '—'}</p>
|
|
{!editing && (
|
|
// always rendered in reading mode (hidden when too few sections) so it
|
|
// keeps a stable child slot — otherwise inserting it would shift the
|
|
// <article> index and remount it, wiping the heading/code decorations.
|
|
<nav className="docs-toc" aria-label="On this page" hidden={toc.length < 3}>
|
|
<span className="docs-toc-h">On this page</span>
|
|
{toc.map((t) => (
|
|
<button key={t.id} type="button" className={`docs-toc-link lvl${t.level}`} onClick={() => goSection(t.id)}>{t.text}</button>
|
|
))}
|
|
</nav>
|
|
)}
|
|
{editing
|
|
? <textarea className="docs-editor" value={draft.body} onChange={(e) => setDraft({ ...draft, body: e.target.value })} />
|
|
: <article ref={renderRef} className="docs-render" onClick={onDocClick} dangerouslySetInnerHTML={{ __html: docHtml }} />}
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DocsApp() {
|
|
return <ConsoleShell title="docs" accent="#27d8ff" current="docs" publicView>{(auth) => <DocsWorkspace canEdit={!!auth.user} />}</ConsoleShell>;
|
|
}
|