From 0e3c567c9755999898afbbd28eb0cb0bafa7b2ca Mon Sep 17 00:00:00 2001 From: artheru Date: Wed, 10 Jun 2026 12:50:31 +0800 Subject: [PATCH] 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 #// (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 --- index.html | 2 +- package.json | 2 +- src/DocsApp.jsx | 94 +++++++++++++++++++++++++++++++++++++------------ src/Scene2D.jsx | 2 +- src/styles.css | 11 ++++++ 5 files changed, 86 insertions(+), 25 deletions(-) diff --git a/index.html b/index.html index 2565865..cf11bd5 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,7 @@ @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } - + diff --git a/package.json b/package.json index 66c0747..a84df56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "0.87.0-docroute", + "version": "0.88.0-docsections", "private": true, "type": "module", "scripts": { diff --git a/src/DocsApp.jsx b/src/DocsApp.jsx index 62b8f25..af30626 100644 --- a/src/DocsApp.jsx +++ b/src/DocsApp.jsx @@ -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 #/ or #//; 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 #/, and follow browser back/forward + // shareable doc + section URLs: #/ or #// 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}#`; }); - }, [doc, editing]); + h = h.replace(/
]*)?>([\s\S]*?)<\/code><\/pre>/g, (m, code) => {
+      const rawCode = code.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
+      return `
${highlightCode(rawCode)}
`; + }); + 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 #//
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 }) {

updated by {doc.updated_by || '—'}

+ {!editing && ( + // always rendered in reading mode (hidden when too few sections) so it + // keeps a stable child slot — otherwise inserting it would shift the + //
index and remount it, wiping the heading/code decorations. + + )} {editing ?