import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { marked } from 'marked'; import ConsoleShell from './ConsoleShell.jsx'; import { api } from './api.js'; import { copyToast } from './toast.js'; marked.setOptions({ breaks: true }); // Minimal, safe syntax highlighter: strings, numbers, keywords only. // No comment rule on purpose — '//' inside URLs (https://) must never be eaten. const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); const HL_RE = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b\d+(?:\.\d+)?\b)|\b(const|let|var|function|return|while|do|done|if|then|else|fi|for|true|false|null|async|await|new|export|import|setInterval|setTimeout|fetch|sleep|curl|JSON|Math|console)\b/g; function highlightCode(code) { let out = '', last = 0, m; HL_RE.lastIndex = 0; while ((m = HL_RE.exec(code))) { out += esc(code.slice(last, m.index)); const cls = m[1] ? 'hl-str' : m[2] ? 'hl-num' : 'hl-kw'; out += `${esc(m[0])}`; last = m.index + m[0].length; } return out + esc(code.slice(last)); } // URL-safe slug from a doc title, for shareable #/ doc links. const slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'doc'; // A context snippet around a full-text match, markdown stripped, query ed. function makeSnippet(body, idx, query) { const start = Math.max(0, idx - 48); const end = Math.min(body.length, idx + query.length + 64); const raw = body.slice(start, end).replace(/[#>*`~_[\]]/g, '').replace(/\s+/g, ' ').trim(); const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig'); return (start > 0 ? '…' : '') + esc(raw).replace(re, '$1') + (end < body.length ? '…' : ''); } function DocsWorkspace({ canEdit }) { const [docs, setDocs] = useState([]); const [sel, setSel] = useState(null); const [doc, setDoc] = useState(null); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState({ title: '', folder: '', body: '' }); const [err, setErr] = useState(''); const [q, setQ] = useState(''); const [navOpen, setNavOpen] = useState(false); const [results, setResults] = useState(null); const renderRef = useRef(null); const pendingSection = useRef(null); const bodiesRef = useRef(null); const loadList = useCallback(async () => { try { const d = await api('/docs'); const list = d.docs || []; setDocs(list); // 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 + section URLs: #/ or #// const openDoc = useCallback((d) => { setSel(d.id); setNavOpen(false); 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 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); 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); document.title = `${d.doc.title} · Docs · amerc`; }).catch((e) => setErr(e.message)); }, [sel]); // 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}#`; }); 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]); // full-text search: fetch all doc bodies once, then match title + body, with snippets useEffect(() => { bodiesRef.current = null; }, [docs]); useEffect(() => { const query = q.trim(); if (!query) { setResults(null); return; } let live = true; (async () => { if (!bodiesRef.current) { const all = await Promise.all(docs.map((d) => api(`/docs/${d.id}`).then((r) => [d.id, r.doc.body || '']).catch(() => [d.id, '']))); if (!live) return; bodiesRef.current = Object.fromEntries(all); } const ql = query.toLowerCase(); const res = []; for (const d of docs) { const body = bodiesRef.current[d.id] || ''; const inMeta = (d.title + ' ' + (d.folder || '')).toLowerCase().includes(ql); const idx = body.toLowerCase().indexOf(ql); if (inMeta || idx >= 0) res.push({ ...d, snippet: idx >= 0 ? makeSnippet(body, idx, query) : '' }); } if (live) setResults(res); })(); return () => { live = false; }; }, [q, docs]); const create = async () => { const title = window.prompt('Document title', 'New document'); if (!title) return; try { const r = await api('/docs', { method: 'POST', body: { title, folder: '', body: `# ${title}\n\n` } }); await loadList(); setSel(r.id); setEditing(true); } catch (e) { setErr(e.message); } }; const save = async () => { try { await api(`/docs/${sel}`, { method: 'PATCH', body: draft }); await loadList(); setDoc({ ...doc, ...draft }); setEditing(false); } catch (e) { setErr(e.message); } }; const del = async () => { if (!window.confirm('Delete document?')) return; try { await api(`/docs/${sel}`, { method: 'DELETE' }); setSel(null); loadList(); } catch (e) { setErr(e.message); } }; const folders = {}; docs.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); }); return (
{err &&

{err}

} {!doc &&
Select or create a document.
} {doc && ( <>
{editing ? setDraft({ ...draft, title: e.target.value })} /> :

{doc.title}

}
{editing && setDraft({ ...draft, folder: e.target.value })} />} {canEdit && !editing && } {editing && } {editing && } {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 ?