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:
artheru 2026-06-10 12:50:31 +08:00
parent 60d045b55f
commit 0e3c567c97
5 changed files with 86 additions and 25 deletions

View File

@ -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" />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "0.87.0-docroute",
"version": "0.88.0-docsections",
"private": true,
"type": "module",
"scripts": {

View File

@ -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(/&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]);
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>

View File

@ -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' },

View File

@ -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; }