docs: on-this-page TOC + section deep links + heading anchors (0.88.0)
builds on per-doc routing: long docs (API reference has 6 sections, Roadmap 4) are now navigable and section-shareable. - each h2/h3 gets a slug id + a hover '#' anchor; URL form is #/<doc-slug>/<section-slug> (e.g. #/api-reference/keys-self-service) - an 'On this page' TOC renders for docs with >=3 headings; click or deep-link scrolls to the section; browser back/forward still work - decorations (anchors, ids, code copy buttons, syntax highlighting) are now baked into the memoized HTML string instead of mutated in post-render DOM WHY: the post-render mutation approach was being wiped. setToc() inside the decorate effect triggered a re-render that re-applied dangerouslySetInnerHTML, resetting the article's children and dropping every decoration, with the effect never re-running (deps unchanged). Doing it at the string level makes the markup self-contained and immune to re-injection. Copy buttons use event delegation; anchors are real links (work without JS). - verified live: 6/6 heading ids + anchors on API reference, TOC navigates, 3 copy buttons + highlighting on Connect-your-broker, deep-links land and scroll to the section, TOC hidden on short docs
This commit is contained in:
parent
60d045b55f
commit
0e3c567c97
@ -17,7 +17,7 @@
|
||||
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
||||
</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="version" content="0.87.0-docroute" />
|
||||
<meta name="version" content="0.88.0-docsections" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amerc-site",
|
||||
"version": "0.87.0-docroute",
|
||||
"version": "0.88.0-docsections",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import ConsoleShell from './ConsoleShell.jsx';
|
||||
import { api } from './api.js';
|
||||
@ -34,29 +34,44 @@ function DocsWorkspace({ canEdit }) {
|
||||
const [err, setErr] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const renderRef = useRef(null);
|
||||
const pendingSection = useRef(null);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
try {
|
||||
const d = await api('/docs'); const list = d.docs || []; setDocs(list);
|
||||
// land on the doc named in the URL hash, else a useful default
|
||||
const slug = window.location.hash.replace(/^#\/?/, '');
|
||||
const fromHash = slug && list.find((x) => slugify(x.title) === slug);
|
||||
// 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 URLs: select via #/<slug>, and follow browser back/forward
|
||||
// shareable doc + section URLs: #/<doc-slug> or #/<doc-slug>/<section-slug>
|
||||
const openDoc = useCallback((d) => {
|
||||
setSel(d.id);
|
||||
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 slug = window.location.hash.replace(/^#\/?/, '');
|
||||
const d = docs.find((x) => slugify(x.title) === slug);
|
||||
if (d) setSel(d.id);
|
||||
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);
|
||||
@ -66,20 +81,44 @@ function DocsWorkspace({ canEdit }) {
|
||||
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]);
|
||||
// decorate rendered code blocks with a hover copy button
|
||||
useEffect(() => {
|
||||
const el = renderRef.current;
|
||||
if (!el || editing) return;
|
||||
el.querySelectorAll('pre').forEach((pre) => {
|
||||
if (pre.parentElement?.classList.contains('docs-code')) return;
|
||||
const codeEl = pre.querySelector('code') || pre;
|
||||
codeEl.innerHTML = highlightCode(codeEl.textContent);
|
||||
const wrap = document.createElement('div'); wrap.className = 'docs-code';
|
||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'docs-copy'; btn.textContent = 'copy';
|
||||
btn.addEventListener('click', () => copyToast(pre.innerText, 'Copied'));
|
||||
pre.parentNode.insertBefore(wrap, pre); wrap.appendChild(btn); wrap.appendChild(pre);
|
||||
// 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}>`;
|
||||
});
|
||||
}, [doc, editing]);
|
||||
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]);
|
||||
|
||||
const create = async () => {
|
||||
const title = window.prompt('Document title', 'New document'); if (!title) return;
|
||||
@ -129,9 +168,20 @@ function DocsWorkspace({ canEdit }) {
|
||||
</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" dangerouslySetInnerHTML={{ __html: marked.parse(doc.body || '') }} />}
|
||||
: <article ref={renderRef} className="docs-render" onClick={onDocClick} dangerouslySetInnerHTML={{ __html: docHtml }} />}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -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 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.87.0-docroute';
|
||||
const RELEASE = '0.88.0-docsections';
|
||||
|
||||
const NAV = [
|
||||
{ id: 'home', label: 'Tavern' },
|
||||
|
||||
@ -1504,3 +1504,14 @@ button {
|
||||
.hf-an-body b { color: #7fe9ff; font-weight: 600; }
|
||||
.hf-an-body a { color: #46c8e0; }
|
||||
@media (max-width: 600px) { .hf-agentnative { flex-direction: column; text-align: center; } }
|
||||
|
||||
/* docs: on-this-page TOC + heading anchors (0.88) */
|
||||
.docs-toc { margin: 4px 0 24px; padding: 13px 16px; border: 1px solid #2a2140; border-radius: 12px; background: rgba(39, 216, 255, 0.03); display: flex; flex-direction: column; gap: 1px; }
|
||||
.docs-toc-h { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #6f5b8c; margin-bottom: 6px; }
|
||||
.docs-toc-link { text-align: left; background: none; border: none; color: #b9a7e0; font-size: 13.5px; line-height: 1.4; padding: 4px 8px; border-radius: 6px; cursor: pointer; transition: background 0.12s, color 0.12s; }
|
||||
.docs-toc-link:hover { background: rgba(39, 216, 255, 0.08); color: #fff; }
|
||||
.docs-toc-link.lvl3 { padding-left: 22px; font-size: 12.5px; color: #9a89bd; }
|
||||
.docs-render h2, .docs-render h3 { position: relative; scroll-margin-top: 28px; }
|
||||
.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-anchor:hover { opacity: 1; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user