From 60d045b55f68d26d78bc5024de254f77a1879483 Mon Sep 17 00:00:00 2001 From: artheru Date: Wed, 10 Jun 2026 12:22:10 +0800 Subject: [PATCH] docs: shareable URLs per document (0.87.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the docs site kept the selected document only in React state, so you couldn't bookmark, share, or refresh-into a specific doc, and browser back/forward did nothing — a real gap for a developer docs site people want to link into. - each doc now has a slug URL: docs.amerc.ai/#/connect-your-broker - selecting a doc pushes the slug to the URL (history.pushState) - loading a #/ URL lands directly on that doc; unknown/empty falls back to Quickstart as before - hashchange + popstate listeners make browser back/forward switch docs - the browser tab title now reflects the open doc ( · Docs · amerc) - verified live on docs.amerc.ai: click updates URL+view+title, deep-links land correctly, back/forward navigate between docs --- index.html | 2 +- package.json | 2 +- src/DocsApp.jsx | 29 +++++++++++++++++++++++++---- src/Scene2D.jsx | 2 +- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index c69f085..2565865 100644 --- a/index.html +++ b/index.html @@ -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.86.0-ogcard" /> + <meta name="version" content="0.87.0-docroute" /> <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" /> diff --git a/package.json b/package.json index 84fa396..66c0747 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "0.86.0-ogcard", + "version": "0.87.0-docroute", "private": true, "type": "module", "scripts": { diff --git a/src/DocsApp.jsx b/src/DocsApp.jsx index 810136b..62b8f25 100644 --- a/src/DocsApp.jsx +++ b/src/DocsApp.jsx @@ -22,6 +22,9 @@ function highlightCode(code) { 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'; + function DocsWorkspace({ canEdit }) { const [docs, setDocs] = useState([]); const [sel, setSel] = useState(null); @@ -35,15 +38,33 @@ function DocsWorkspace({ canEdit }) { const loadList = useCallback(async () => { try { const d = await api('/docs'); const list = d.docs || []; setDocs(list); - // land on a useful doc instead of an empty "select a document" - const def = list.find((x) => /quickstart/i.test(x.title)) || list.find((x) => x.folder === 'start here') || list[0]; + // 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); + 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 + const openDoc = useCallback((d) => { + setSel(d.id); + const slug = slugify(d.title); + if (window.location.hash !== `#/${slug}`) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#/${slug}`); + }, []); + useEffect(() => { + const onHash = () => { + const slug = window.location.hash.replace(/^#\/?/, ''); + const d = docs.find((x) => slugify(x.title) === slug); + if (d) setSel(d.id); + }; + 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); }).catch((e) => setErr(e.message)); + 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(() => { @@ -83,7 +104,7 @@ function DocsWorkspace({ canEdit }) { <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={() => setSel(d.id)}>{d.title}</button> + <button key={d.id} className={`docs-item${sel === d.id ? ' on' : ''}`} onClick={() => openDoc(d)}>{d.title}</button> ))} </div> ))} diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx index a723bfa..912e50b 100644 --- a/src/Scene2D.jsx +++ b/src/Scene2D.jsx @@ -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.86.0-ogcard'; +const RELEASE = '0.87.0-docroute'; const NAV = [ { id: 'home', label: 'Tavern' },