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
This commit is contained in:
parent
ce9d2336f7
commit
66ccd54d21
@ -17,7 +17,7 @@
|
|||||||
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
||||||
</style>
|
</style>
|
||||||
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
|
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
|
||||||
<meta name="version" content="0.92.0-apipeek" />
|
<meta name="version" content="0.93.0-docsearch" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amerc-site",
|
"name": "amerc-site",
|
||||||
"version": "0.92.0-apipeek",
|
"version": "0.93.0-docsearch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -25,6 +25,16 @@ function highlightCode(code) {
|
|||||||
// URL-safe slug from a doc title, for shareable #/<slug> doc links.
|
// 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';
|
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 }) {
|
function DocsWorkspace({ canEdit }) {
|
||||||
const [docs, setDocs] = useState([]);
|
const [docs, setDocs] = useState([]);
|
||||||
const [sel, setSel] = useState(null);
|
const [sel, setSel] = useState(null);
|
||||||
@ -34,8 +44,10 @@ function DocsWorkspace({ canEdit }) {
|
|||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
|
const [results, setResults] = useState(null);
|
||||||
const renderRef = useRef(null);
|
const renderRef = useRef(null);
|
||||||
const pendingSection = useRef(null);
|
const pendingSection = useRef(null);
|
||||||
|
const bodiesRef = useRef(null);
|
||||||
|
|
||||||
const loadList = useCallback(async () => {
|
const loadList = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -121,6 +133,30 @@ function DocsWorkspace({ canEdit }) {
|
|||||||
const el = renderRef.current?.querySelector(`#${CSS.escape(s)}`); if (el) el.scrollIntoView({ block: 'start' });
|
const el = renderRef.current?.querySelector(`#${CSS.escape(s)}`); if (el) el.scrollIntoView({ block: 'start' });
|
||||||
}));
|
}));
|
||||||
}, [docHtml]);
|
}, [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 create = async () => {
|
||||||
const title = window.prompt('Document title', 'New document'); if (!title) return;
|
const title = window.prompt('Document title', 'New document'); if (!title) return;
|
||||||
@ -129,9 +165,8 @@ function DocsWorkspace({ canEdit }) {
|
|||||||
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 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 del = async () => { if (!window.confirm('Delete document?')) return; try { await api(`/docs/${sel}`, { method: 'DELETE' }); setSel(null); loadList(); } catch (e) { setErr(e.message); } };
|
||||||
|
|
||||||
const filtered = docs.filter((d) => (d.title + ' ' + (d.folder || '')).toLowerCase().includes(q.toLowerCase()));
|
|
||||||
const folders = {};
|
const folders = {};
|
||||||
filtered.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); });
|
docs.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="docs">
|
<div className="docs">
|
||||||
@ -145,17 +180,30 @@ function DocsWorkspace({ canEdit }) {
|
|||||||
<input placeholder="search docs…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input placeholder="search docs…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
{canEdit && <button className="cs-btn cs-primary" onClick={create}>+ New</button>}
|
{canEdit && <button className="cs-btn cs-primary" onClick={create}>+ New</button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="docs-tree">
|
{q.trim() ? (
|
||||||
{Object.keys(folders).sort().map((f) => (
|
<div className="docs-results">
|
||||||
<div key={f} className="docs-folder">
|
{results === null && <p className="cs-hint">Searching…</p>}
|
||||||
<div className="docs-folder-name">{f || '📁 root'}</div>
|
{results !== null && !results.length && <p className="cs-hint">No matches for “{q.trim()}”.</p>}
|
||||||
{folders[f].map((d) => (
|
{results !== null && results.map((d) => (
|
||||||
<button key={d.id} className={`docs-item${sel === d.id ? ' on' : ''}`} onClick={() => openDoc(d)}>{d.title}</button>
|
<button key={d.id} className={`docs-result${sel === d.id ? ' on' : ''}`} onClick={() => openDoc(d)}>
|
||||||
))}
|
<span className="docs-result-title">{d.title}</span>
|
||||||
</div>
|
{d.snippet && <span className="docs-result-snip" dangerouslySetInnerHTML={{ __html: d.snippet }} />}
|
||||||
))}
|
</button>
|
||||||
{!filtered.length && <p className="cs-hint">No documents yet. Create one — humans and agents (via API key) can both read & write here.</p>}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<section className="docs-main">
|
<section className="docs-main">
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { api } from './api.js';
|
|||||||
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
||||||
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
||||||
|
|
||||||
const RELEASE = '0.92.0-apipeek';
|
const RELEASE = '0.93.0-docsearch';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ id: 'home', label: 'Tavern' },
|
{ id: 'home', label: 'Tavern' },
|
||||||
@ -264,9 +264,9 @@ function ApiPeek() {
|
|||||||
const cur = PEEK.find((x) => x.id === ep);
|
const cur = PEEK.find((x) => x.id === ep);
|
||||||
return (
|
return (
|
||||||
<div className="hf-peek">
|
<div className="hf-peek">
|
||||||
<div className="hf-peek-tabs" role="tablist" aria-label="API endpoint">
|
<div className="hf-peek-tabs" role="group" aria-label="API endpoint">
|
||||||
{PEEK.map((e) => (
|
{PEEK.map((e) => (
|
||||||
<button key={e.id} type="button" role="tab" aria-selected={ep === e.id} className={`hf-peek-tab${ep === e.id ? ' on' : ''}`} onClick={() => setEp(e.id)}>{e.label}</button>
|
<button key={e.id} type="button" aria-pressed={ep === e.id} className={`hf-peek-tab${ep === e.id ? ' on' : ''}`} onClick={() => setEp(e.id)}>{e.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="hf-peek-term">
|
<div className="hf-peek-term">
|
||||||
|
|||||||
@ -1538,3 +1538,12 @@ button {
|
|||||||
.docs-anchor { opacity: 0; margin-left: 9px; padding: 0 2px; background: none; border: none; color: #46c8e0; cursor: pointer; font-size: 0.8em; font-weight: 600; text-decoration: none; vertical-align: middle; transition: opacity 0.12s; }
|
.docs-anchor { opacity: 0; margin-left: 9px; padding: 0 2px; background: none; border: none; color: #46c8e0; cursor: pointer; font-size: 0.8em; font-weight: 600; text-decoration: none; vertical-align: middle; transition: opacity 0.12s; }
|
||||||
.docs-render h2:hover .docs-anchor, .docs-render h3:hover .docs-anchor, .docs-anchor:focus-visible { opacity: 0.85; }
|
.docs-render h2:hover .docs-anchor, .docs-render h3:hover .docs-anchor, .docs-anchor:focus-visible { opacity: 0.85; }
|
||||||
.docs-anchor:hover { opacity: 1; }
|
.docs-anchor:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* docs full-text search results (0.93) */
|
||||||
|
.docs-results { padding: 8px; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.docs-result { display: flex; flex-direction: column; gap: 3px; text-align: left; background: none; border: none; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
|
||||||
|
.docs-result:hover { background: #131c2b; }
|
||||||
|
.docs-result.on { background: #1a2740; }
|
||||||
|
.docs-result-title { color: #eaf2ff; font-size: 13.5px; font-weight: 600; }
|
||||||
|
.docs-result-snip { color: #8595ab; font-size: 12px; line-height: 1.45; }
|
||||||
|
.docs-result-snip mark { background: rgba(39, 216, 255, 0.22); color: #d6f0f8; border-radius: 2px; padding: 0 1px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user