amerc/src/DocsApp.jsx
artheru 66ccd54d21 docs: full-text search with snippets + peek a11y (0.93.0)
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
2026-06-10 14:08:19 +08:00

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/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>;
}