docs: shareable URLs per document (0.87.0)

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 #/<slug> 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 (<title> · Docs · amerc)
- verified live on docs.amerc.ai: click updates URL+view+title, deep-links
  land correctly, back/forward navigate between docs
This commit is contained in:
artheru 2026-06-10 12:22:10 +08:00
parent f0c8ea22cf
commit 60d045b55f
4 changed files with 28 additions and 7 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.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" />

View File

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

View File

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

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.86.0-ogcard';
const RELEASE = '0.87.0-docroute';
const NAV = [
{ id: 'home', label: 'Tavern' },