initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:03:50 +02:00
commit 23816d00f7
71 changed files with 14497 additions and 0 deletions

15
frontend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
# Development image for the Vite dev server.
FROM node:22-alpine
ENV NODE_ENV=development
WORKDIR /app
# Install dependencies first so the layer caches across source edits. The
# compose bind-mount keeps node_modules in a named volume for the same reason.
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="go-doc — segmented documentation and audience feedback for users, operators and developers" />
<title>Go Doc: Application Documentation tool.</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1607
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
frontend/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "go-doc-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "go-doc documentation and audience feedback SPA",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"dompurify": "^3.4.16",
"lucide-react": "^1.51.0",
"marked": "^18.0.14",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-react": "^6.1.1",
"tailwindcss": "^4.3.3",
"vite": "^8.3.2"
}
}

170
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,170 @@
import { useCallback, useEffect, useState } from 'react';
import { ArrowLeft, BookOpen, Loader2 } from 'lucide-react';
import DocEditor from './components/DocEditor.jsx';
import DocIndex from './components/DocIndex.jsx';
import DocViewer from './components/DocViewer.jsx';
import RoleBadge from './components/RoleBadge.jsx';
import { ROLES, api } from './lib/api.js';
import { docHref, navigate, useRoute } from './lib/router.js';
import { mountAccountMenu } from './lib/account.js';
export default function App() {
const route = useRoute();
useEffect(() => {
mountAccountMenu();
}, []);
return (
<div className="min-h-full bg-ink-900">
<SiteHeader />
<main>
<RouteView key={`${route.name}:${route.slug ?? ''}`} route={route} />
</main>
</div>
);
}
function SiteHeader() {
return (
<header className="sticky top-0 z-20 border-b border-ink-700 bg-ink-900/85 backdrop-blur">
<div className="mx-auto flex max-w-[1400px] flex-wrap items-center gap-4 px-6 py-3">
<button
type="button"
onClick={() => navigate('/')}
className="flex items-center gap-2.5 transition hover:opacity-90"
>
<span className="grid h-8 w-8 place-items-center rounded-lg bg-sky-500 font-mono text-sm font-bold text-ink-950">
go
</span>
<span className="text-sm font-semibold tracking-tight text-white">go-doc</span>
</button>
<span className="hidden font-mono text-[11px] text-ink-500 sm:inline">
flat-file documentation & feedback
</span>
<nav className="ml-auto flex items-center gap-1.5">
<button
type="button"
onClick={() => navigate('/')}
className="ml-2 inline-flex items-center gap-1.5 rounded-lg border border-ink-700 px-3 py-1.5 text-xs font-medium text-ink-300 transition hover:bg-ink-800 hover:text-white"
>
<BookOpen size={13} aria-hidden="true" />
Index
</button>
<div data-account-menu className="ml-2" />
</nav>
</div>
</header>
);
}
function RouteView({ route }) {
switch (route.name) {
case 'index':
return <DocIndex />;
case 'view':
return <DocViewer slug={route.slug} />;
case 'edit':
return <EditorRoute slug={route.slug} />;
default:
return (
<div className="grid place-items-center py-32 text-center">
<p className="text-sm text-ink-400">
Nothing here. <RouteLink to="/">Return to the documentation index</RouteLink>.
</p>
</div>
);
}
}
function RouteLink({ to, children }) {
return (
<button type="button" onClick={() => navigate(to)} className="text-sky-400 underline">
{children}
</button>
);
}
/** Loads a document, then hands it to the hybrid WYSIWYG/Markdown editor. */
function EditorRoute({ slug }) {
const [doc, setDoc] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setDoc(await api.getDoc(slug));
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}, [slug]);
useEffect(() => {
load();
}, [load]);
async function handleSave({ slug: target, title, content }) {
setSaving(true);
try {
const saved = await api.saveDoc(target, content, title);
setDoc((current) => (current ? { ...current, ...saved, content } : current));
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="grid place-items-center py-32 text-sm text-ink-400">
<span className="inline-flex items-center gap-2">
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
Loading editor…
</span>
</div>
);
}
if (error || !doc) {
return (
<div className="grid place-items-center py-32 text-center">
<p className="text-sm text-ink-400">
Cannot edit “{slug}”: {error?.message ?? 'document not found'}.{' '}
<RouteLink to="/">Back to the index</RouteLink>.
</p>
</div>
);
}
return (
<div className="mx-auto flex h-[calc(100vh-3.25rem)] max-w-[1400px] flex-col gap-3 px-6 py-5">
<div className="flex items-center gap-2 text-xs text-ink-400">
<button
type="button"
onClick={() => navigate(docHref(slug))}
className="inline-flex items-center gap-1.5 transition hover:text-ink-200"
>
<ArrowLeft size={13} aria-hidden="true" />
Back to reader
</button>
</div>
<div className="min-h-0 flex-1">
<DocEditor
slug={doc.slug}
title={doc.title}
initialContent={doc.content}
onSave={handleSave}
saving={saving}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,148 @@
import { useMemo, useState } from 'react';
import { MessageSquarePlus, Send, Trash2 } from 'lucide-react';
import RoleBadge from './RoleBadge.jsx';
import { ROLES, api } from '../lib/api.js';
const EMPTY_AUTHOR = '';
/**
* CommentPanel lists the audience feedback attached to a document and lets any
* visitor post as User, Operator or Developer. The badge is rendered from the
* role the server stored, never from client input, so a Developer comment is
* always shown with the red 🔴 badge.
*/
export default function CommentPanel({ slug, comments, onRefresh }) {
const [filter, setFilter] = useState('all');
const [author, setAuthor] = useState(EMPTY_AUTHOR);
const [role, setRole] = useState(ROLES[0].role);
const [content, setContent] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const counts = useMemo(() => {
const tally = { all: comments.length };
for (const { role: r } of ROLES) tally[r] = 0;
for (const comment of comments) {
if (tally[comment.role] !== undefined) tally[comment.role] += 1;
}
return tally;
}, [comments]);
const visible = useMemo(
() => (filter === 'all' ? comments : comments.filter((c) => c.role === filter)),
[comments, filter],
);
async function submit(event) {
event.preventDefault();
if (!content.trim() || busy) return;
setBusy(true);
setError('');
try {
await api.createComment(slug, { author: author.trim(), role, content: content.trim() });
setContent('');
setNotice('Comment recorded');
window.setTimeout(() => setNotice(''), 2000);
await onRefresh?.();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function remove(id) {
try {
await api.deleteComment(slug, id);
await onRefresh?.();
} catch (err) {
setError(err.message);
}
}
return (
<section className="flex h-full flex-col overflow-hidden rounded-xl border border-ink-700 bg-ink-850">
<header className="flex flex-wrap items-center gap-2 border-b border-ink-700 bg-ink-800 px-4 py-3">
<h2 className="text-sm font-semibold text-white">Audience feedback</h2>
<span className="rounded-full bg-ink-700 px-2 py-0.5 font-mono text-[11px] text-ink-300">
{comments.length}
</span>
</header>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
{visible.length === 0 ? (
<p className="py-8 text-center text-sm text-ink-400">
No feedback yet. Be the first to leave a note for this audience.
</p>
) : (
visible.map((comment) => (
<article
key={comment.id}
className="group rounded-lg border border-ink-700 bg-ink-900/60 p-3 transition hover:border-ink-600"
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-ink-200">{comment.author}</span>
<time
dateTime={comment.timestamp}
className="ml-auto shrink-0 font-mono text-[11px] text-ink-500"
>
{new Date(comment.timestamp).toLocaleString()}
</time>
<button
type="button"
onClick={() => remove(comment.id)}
aria-label="Delete comment"
className="rounded p-1 text-ink-600 opacity-0 transition hover:text-red-400 focus:opacity-100 group-hover:opacity-100"
>
<Trash2 size={13} aria-hidden="true" />
</button>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-ink-300">
{comment.content.trim()}
</p>
</article>
))
)}
</div>
<form onSubmit={submit} className="border-t border-ink-700 bg-ink-800 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<MessageSquarePlus size={15} className="text-ink-400" aria-hidden="true" />
<input
value={author}
onChange={(event) => setAuthor(event.target.value)}
placeholder="Your name"
aria-label="Author"
className="w-32 rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 text-xs text-ink-200 outline-none transition focus:border-ink-600"
/>
</div>
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder={`Leave a note for the ${ROLES.find((r) => r.role === role).label.toLowerCase()} audience…`}
aria-label="Comment content"
rows={3}
className="mt-2 w-full resize-none rounded-md border border-ink-700 bg-ink-900 px-3 py-2 text-sm text-ink-200 outline-none transition focus:border-ink-600"
/>
<div className="mt-2 flex items-center justify-between">
<span className={`text-xs ${error ? 'text-red-400' : notice ? 'text-emerald-400' : 'text-ink-500'}`}>
{error || notice || 'Feedback is stored as JSON next to the document.'}
</span>
<button
type="submit"
disabled={busy || !content.trim()}
className="inline-flex items-center gap-2 rounded-md bg-ink-700 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-ink-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<Send size={13} aria-hidden="true" />
{busy ? 'Posting' : 'Post comment'}
</button>
</div>
</form>
</section>
);
}

View file

@ -0,0 +1,421 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bold,
Braces,
Code,
Eye,
Heading1,
Heading2,
Heading3,
Italic,
List,
ListOrdered,
Pilcrow,
Quote,
Redo2,
Save,
Strikethrough,
Undo2,
} from 'lucide-react';
import { countWords, htmlToMarkdown, markdownToHtml } from '../lib/markdown.js';
const MODES = {
VISUAL: 'visual',
SOURCE: 'source',
};
/**
* DocEditor is the hybrid writer for go-doc.
*
* Two modes share one source of truth -- the Markdown string held in state:
*
* Visual (WYSIWYG) a contentEditable surface pre-rendered from the Markdown.
* Structural edits (bold, italic, inline code, headings,
* lists, quotes, links) are applied to the DOM and the
* Markdown is regenerated on every input event.
*
* Source (Markdown) a plain textarea paired with a live preview pane. The
* preview re-renders as you type, so the Markdown stays
* readable while it is being written.
*
* The innerHTML of the visual surface is written only when the document or mode
* actually changes. React never re-renders it on each keystroke, which is what
* keeps the caret from jumping while you type.
*/
export default function DocEditor({ slug, initialContent = '', title: initialTitle = '', onSave, saving = false }) {
const [mode, setMode] = useState(MODES.VISUAL);
const [markdown, setMarkdown] = useState(initialContent);
const [title, setTitle] = useState(initialTitle);
const [dirty, setDirty] = useState(false);
const [status, setStatus] = useState('');
const [focusMode, setFocusMode] = useState(null);
const surfaceRef = useRef(null);
const injectedRef = useRef(null);
const docKeyRef = useRef(null);
const sourceRef = useRef(null);
const previewHtml = useMemo(() => markdownToHtml(markdown), [markdown]);
const stats = useMemo(
() => ({ words: countWords(markdown), chars: markdown.length, lines: markdown.split('\n').length }),
[markdown],
);
// A new document resets the editor and re-seeds both surfaces.
useEffect(() => {
setMarkdown(initialContent);
setTitle(initialTitle);
setDirty(false);
injectedRef.current = null;
docKeyRef.current = null;
}, [initialContent, initialTitle, slug]);
// Push Markdown into the visual surface whenever it diverges from what we last
// injected. Typing inside the surface produces a Markdown string equal to the
// injected one, so the caret survives untouched.
useEffect(() => {
if (mode !== MODES.VISUAL) return;
const surface = surfaceRef.current;
if (!surface) return;
if (docKeyRef.current !== slug && injectedRef.current !== markdown) {
surface.innerHTML = markdownToHtml(markdown);
injectedRef.current = markdown;
docKeyRef.current = slug;
return;
}
if (injectedRef.current !== markdown) {
surface.innerHTML = markdownToHtml(markdown);
injectedRef.current = markdown;
}
}, [mode, markdown, slug]);
const syncFromSurface = useCallback(() => {
const surface = surfaceRef.current;
if (!surface) return;
const next = htmlToMarkdown(surface.innerHTML);
injectedRef.current = next;
setMarkdown(next);
setDirty(true);
}, []);
const runCommand = useCallback(
(command, value = null) => {
const surface = surfaceRef.current;
if (!surface) return;
surface.focus();
document.execCommand(command, false, value);
syncFromSurface();
setFocusMode(MODES.VISUAL);
},
[syncFromSurface],
);
const toggleHeading = useCallback(
(level) => {
const surface = surfaceRef.current;
const current = document.queryCommandValue('formatBlock')?.toLowerCase();
runCommand('formatBlock', current === `h${level}` ? 'p' : `h${level}`);
},
[runCommand],
);
/**
* There is no execCommand for inline code, so the selection is wrapped with
* backticks through the Range API.
*/
const toggleInlineCode = useCallback(() => {
const surface = surfaceRef.current;
if (!surface) return;
surface.focus();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
runCommand('insertText', '`code`');
return;
}
const range = selection.getRangeAt(0);
const selected = selection.toString();
const wrapper = document.createElement('code');
wrapper.textContent = selected;
range.deleteContents();
range.insertNode(wrapper);
const after = document.createRange();
after.selectNodeContents(wrapper);
selection.removeAllRanges();
selection.addRange(after);
syncFromSurface();
setFocusMode(MODES.VISUAL);
}, [runCommand, syncFromSurface]);
const createLink = useCallback(() => {
const selection = window.getSelection();
const selected = selection?.toString() ?? '';
const href = window.prompt('Link target', 'https://');
if (!href) return;
if (!selected) {
runCommand('createLink', href);
return;
}
document.execCommand('createLink', false, href);
syncFromSurface();
}, [runCommand, syncFromSurface]);
const switchMode = useCallback(
(next) => {
if (next === mode) return;
if (next === MODES.SOURCE) {
// Freeze the current Markdown; the source textarea becomes the source
// of truth and the preview follows it live.
setMarkdown(sourceRef.current?.value ?? markdown);
} else {
injectedRef.current = null;
docKeyRef.current = null;
}
setMode(next);
setFocusMode(next);
},
[mode, markdown],
);
const handleSourceChange = useCallback((event) => {
setMarkdown(event.target.value);
setDirty(true);
}, []);
const handleSave = useCallback(async () => {
const content = mode === MODES.SOURCE ? (sourceRef.current?.value ?? markdown) : markdown;
setStatus('Saving…');
try {
await onSave?.({ slug, title, content });
setDirty(false);
setStatus(`Saved ${new Date().toLocaleTimeString()}`);
window.setTimeout(() => setStatus(''), 2600);
} catch (error) {
setStatus(`Save failed: ${error.message}`);
}
}, [markdown, mode, onSave, slug, title]);
// Ctrl/Cmd+S saves; Ctrl/Cmd+B and Ctrl/Cmd+I format inside the visual surface.
useEffect(() => {
const onKeyDown = (event) => {
const meta = event.metaKey || event.ctrlKey;
if (!meta) return;
if (event.key.toLowerCase() === 's') {
event.preventDefault();
handleSave();
return;
}
if (mode !== MODES.VISUAL) return;
if (event.key.toLowerCase() === 'b') {
event.preventDefault();
runCommand('bold');
} else if (event.key.toLowerCase() === 'i') {
event.preventDefault();
runCommand('italic');
} else if (event.key.toLowerCase() === 'k') {
event.preventDefault();
createLink();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [createLink, handleSave, mode, runCommand]);
const tools = [
{ label: 'Bold', icon: Bold, action: () => runCommand('bold'), group: 'inline' },
{ label: 'Italic', icon: Italic, action: () => runCommand('italic'), group: 'inline' },
{ label: 'Inline code', icon: Code, action: toggleInlineCode, group: 'inline' },
{ label: 'Strikethrough', icon: Strikethrough, action: () => runCommand('strikeThrough'), group: 'inline' },
{ label: 'Heading 1', icon: Heading1, action: () => toggleHeading(1), group: 'block' },
{ label: 'Heading 2', icon: Heading2, action: () => toggleHeading(2), group: 'block' },
{ label: 'Heading 3', icon: Heading3, action: () => toggleHeading(3), group: 'block' },
{ label: 'Bullet list', icon: List, action: () => runCommand('insertUnorderedList'), group: 'block' },
{ label: 'Numbered list', icon: ListOrdered, action: () => runCommand('insertOrderedList'), group: 'block' },
{ label: 'Quote', icon: Quote, action: () => runCommand('formatBlock', 'blockquote'), group: 'block' },
{ label: 'Paragraph', icon: Pilcrow, action: () => runCommand('formatBlock', 'p'), group: 'block' },
{ label: 'Code block', icon: Braces, action: () => runCommand('formatBlock', 'pre'), group: 'block' },
{ label: 'Link', icon: Eye, action: createLink, group: 'inline' },
];
return (
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-ink-700 bg-ink-850">
{/* Document identity + mode switch */}
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-ink-700 bg-ink-800 px-4 py-3">
<div className="min-w-0 flex-1">
<input
value={title}
onChange={(event) => {
setTitle(event.target.value);
setDirty(true);
}}
placeholder="Document title"
className="w-full truncate rounded-md border border-transparent bg-transparent px-2 py-1 text-lg font-semibold text-white outline-none transition focus:border-ink-600 focus:bg-ink-900"
/>
<p className="px-2 font-mono text-xs text-ink-400">data/docs/{slug}.md</p>
</div>
<div className="flex items-center gap-2">
<div
role="tablist"
aria-label="Editor mode"
className="flex rounded-lg border border-ink-700 bg-ink-900 p-1"
>
{[
{ id: MODES.VISUAL, label: 'Visual Edit (WYSIWYG)' },
{ id: MODES.SOURCE, label: 'Source Code (Markdown)' },
].map((option) => (
<button
key={option.id}
role="tab"
type="button"
aria-selected={mode === option.id}
onClick={() => switchMode(option.id)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition ${
mode === option.id
? 'bg-ink-700 text-white shadow-sm'
: 'text-ink-300 hover:bg-ink-800 hover:text-ink-200'
}`}
>
{option.label}
</button>
))}
</div>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-sky-500 px-3.5 py-2 text-xs font-semibold text-ink-950 transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:opacity-60"
>
<Save size={14} aria-hidden="true" />
{saving ? 'Saving' : 'Save'}
</button>
</div>
</header>
{/* Toolbar: visible in both modes, but only wired up for the visual surface */}
<div className="flex flex-wrap items-center gap-1 border-b border-ink-700 bg-ink-850 px-3 py-2">
{tools.map(({ label, icon: Icon, action, group }, index) => (
<button
key={label}
type="button"
title={label}
aria-label={label}
onClick={() => {
if (mode !== MODES.VISUAL) {
setStatus('Switch to Visual Edit to use formatting tools');
window.setTimeout(() => setStatus(''), 1800);
return;
}
action();
}}
className={`rounded-md p-2 text-ink-300 transition hover:bg-ink-700 hover:text-white disabled:opacity-40 ${
(group === 'block' && index === 4) || (group === 'inline' && index === tools.length - 1)
? 'ml-2 border-l border-ink-700 pl-2.5'
: ''
}`}
>
<Icon size={15} aria-hidden="true" />
</button>
))}
<div className="ml-auto flex items-center gap-1 text-ink-400">
<button
type="button"
onClick={() => runCommand('undo')}
aria-label="Undo"
title="Undo"
className="rounded-md p-2 transition hover:bg-ink-700 hover:text-white"
>
<Undo2 size={15} aria-hidden="true" />
</button>
<button
type="button"
onClick={() => runCommand('redo')}
aria-label="Redo"
title="Redo"
className="rounded-md p-2 transition hover:bg-ink-700 hover:text-white"
>
<Redo2 size={15} aria-hidden="true" />
</button>
</div>
</div>
{/* Editing surface */}
<div className="flex min-h-0 flex-1">
{mode === MODES.VISUAL ? (
<div className="relative min-h-0 w-full overflow-y-auto px-6 py-5">
<div
ref={surfaceRef}
className="md-body min-h-[60vh]"
contentEditable
suppressContentEditableWarning
spellCheck
role="textbox"
aria-multiline="true"
aria-label="Visual markdown editor"
data-placeholder="Start writing your documentation…"
onInput={syncFromSurface}
onBlur={() => setFocusMode(null)}
onFocus={() => setFocusMode(MODES.VISUAL)}
/>
</div>
) : (
<>
<textarea
ref={sourceRef}
defaultValue={markdown}
onChange={handleSourceChange}
spellCheck={false}
aria-label="Markdown source"
className="min-h-0 w-1/2 resize-none border-r border-ink-700 bg-ink-950 px-5 py-5 font-mono text-[13px] leading-6 text-ink-200 outline-none"
/>
<div className="min-h-0 w-1/2 overflow-y-auto bg-ink-850 px-6 py-5">
<p className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-ink-400">
Live preview
</p>
<div
className="md-body"
// Preview HTML is sanitised by markdownToHtml before it reaches here.
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
</div>
</>
)}
</div>
{/* Status bar */}
<footer className="flex items-center justify-between gap-4 border-t border-ink-700 bg-ink-800 px-4 py-2 text-xs text-ink-400">
<div className="flex items-center gap-3">
<span className={dirty ? 'text-amber-400' : 'text-emerald-400'}>
{dirty ? 'Unsaved changes' : 'In sync'}
</span>
{status ? <span className="text-ink-300">{status}</span> : null}
{focusMode ? (
<span className="hidden font-mono text-ink-500 sm:inline">
mode: {focusMode}
</span>
) : null}
</div>
<div className="flex items-center gap-4 font-mono">
<span>{stats.words} words</span>
<span>{stats.chars} chars</span>
<span>{stats.lines} lines</span>
<span className="hidden sm:inline">Ctrl+S to save</span>
</div>
</footer>
</div>
);
}
export { MODES };

View file

@ -0,0 +1,187 @@
import { useEffect, useMemo, useState } from 'react';
import { FilePlus2, FileText, MessageSquare, RefreshCw, Search } from 'lucide-react';
import RoleBadge from './RoleBadge.jsx';
import { ROLES, api } from '../lib/api.js';
import { slugify } from '../lib/slug.js';
import { docHref, editHref, navigate } from '../lib/router.js';
const STARTER = `# New Application Guide
Describe the application, then add a section for each audience:
## For Users
What a day-to-day user needs.
## For Operators
Deployment, health checks and rollback.
## For Developers
API contracts and integration notes.
`;
/** Index page: browse, search and create the flat-file Markdown documents. */
export default function DocIndex() {
const [docs, setDocs] = useState([]);
const [commentsByDoc, setCommentsByDoc] = useState({});
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [creating, setCreating] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [draft, setDraft] = useState(STARTER);
async function load() {
setLoading(true);
setError(null);
try {
const payload = await api.listDocsDetailed();
setDocs(payload.docs ?? []);
const tally = {};
for (const doc of payload.docs ?? []) tally[doc.slug] = doc.comments ?? 0;
setCommentsByDoc(tally);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return docs;
return docs.filter(
(doc) =>
doc.slug.toLowerCase().includes(needle) || doc.title.toLowerCase().includes(needle),
);
}, [docs, query]);
const previewSlug = useMemo(() => slugify(newTitle), [newTitle]);
async function create(event) {
event.preventDefault();
const slug = slugify(newTitle);
if (!slug) return;
setCreating(true);
try {
await api.saveDoc(slug, draft, newTitle.trim());
setNewTitle('');
setDraft(STARTER);
await load();
navigate(docHref(slug));
} catch (err) {
setError(err);
} finally {
setCreating(false);
}
}
const totalComments = docs.reduce((sum, doc) => sum + (doc.comments ?? 0), 0);
return (
<div className="mx-auto max-w-5xl px-6 py-10">
<header className="mb-8">
<h1 className="text-3xl font-semibold tracking-tight text-white">Documentation</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-400">
Flat-file Markdown served straight from <code className="font-mono text-ink-300">data/docs/</code>.
Every page carries guidance for three isolated audiences, each with its own feedback badge.
</p>
</header>
<div className="mb-6 flex flex-wrap items-center gap-3">
<div className="relative min-w-64 flex-1">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-500"
aria-hidden="true"
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search documents…"
aria-label="Search documents"
className="w-full rounded-lg border border-ink-700 bg-ink-850 py-2 pl-9 pr-3 text-sm text-ink-200 outline-none transition focus:border-ink-600"
/>
</div>
<button
type="button"
onClick={load}
className="inline-flex items-center gap-2 rounded-lg border border-ink-700 px-3 py-2 text-xs font-semibold text-ink-300 transition hover:bg-ink-800 hover:text-ink-100"
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} aria-hidden="true" />
Refresh
</button>
<span className="font-mono text-xs text-ink-500">
{docs.length} docs · {totalComments} comments
</span>
</div>
{error ? (
<p className="mb-6 rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error.message}
</p>
) : null}
{loading && docs.length === 0 ? (
<p className="py-16 text-center text-sm text-ink-400">Indexing data/docs…</p>
) : filtered.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-700 py-16 text-center text-sm text-ink-400">
No documents match “{query}”.
</p>
) : (
<ul className="grid gap-3 md:grid-cols-2">
{filtered.map((doc) => (
<li
key={doc.slug}
className="group rounded-xl border border-ink-700 bg-ink-850 p-4 transition hover:border-ink-600"
>
<button
type="button"
onClick={() => navigate(docHref(doc.slug))}
className="block w-full text-left"
>
<div className="flex items-start gap-3">
<FileText size={17} className="mt-0.5 shrink-0 text-ink-500" aria-hidden="true" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold text-ink-100 transition group-hover:text-white">
{doc.title}
</h2>
<p className="mt-0.5 truncate font-mono text-[11px] text-ink-500">{doc.slug}.md</p>
</div>
</div>
<div className="mt-3 flex items-center gap-3 text-[11px] text-ink-500">
<span>{doc.size} bytes</span>
<span>{new Date(doc.modified).toLocaleDateString()}</span>
<span className="ml-auto inline-flex items-center gap-1">
<MessageSquare size={11} aria-hidden="true" />
{commentsByDoc[doc.slug] ?? 0}
</span>
</div>
</button>
<div className="mt-3 flex gap-2 border-t border-ink-700 pt-3">
<button
type="button"
onClick={() => navigate(docHref(doc.slug))}
className="flex-1 rounded-md border border-ink-700 py-1.5 text-[11px] font-medium text-ink-300 transition hover:bg-ink-800 hover:text-white"
>
Read
</button>
<button
type="button"
onClick={() => navigate(editHref(doc.slug))}
className="flex-1 rounded-md border border-ink-700 py-1.5 text-[11px] font-medium text-ink-300 transition hover:bg-ink-800 hover:text-white"
>
Edit
</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,203 @@
import { useEffect, useMemo, useState } from 'react';
import {
AlertCircle,
ArrowLeft,
Clock,
FileText,
Link2,
MessageSquare,
Pencil,
Trash2,
} from 'lucide-react';
import CommentPanel from './CommentPanel.jsx';
import RoleBadge from './RoleBadge.jsx';
import { ROLES, api } from '../lib/api.js';
import { markdownToHtml } from '../lib/markdown.js';
import { markdownForRole, segmentByAudience } from '../lib/audience.js';
import { docHref, editHref, navigate, resolveDocLink } from '../lib/router.js';
/**
* DocViewer is the audience-segmented reader. One Markdown file is sliced into
* per-role sections, so switching the tab swaps the guidance without ever
* duplicating content across files.
*/
export default function DocViewer({ slug, onChanged }) {
const [doc, setDoc] = useState(null);
const [comments, setComments] = useState([]);
const [audience, setAudience] = useState('user');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
async function load() {
setLoading(true);
setError(null);
try {
const [docPayload, commentPayload] = await Promise.all([
api.getDoc(slug),
api.listComments(slug),
]);
setDoc(docPayload);
setComments(commentPayload.comments ?? []);
} catch (err) {
setError(err);
setDoc(null);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug]);
const segments = useMemo(() => (doc ? segmentByAudience(doc.content) : null), [doc]);
const bodyHtml = useMemo(
() => (doc ? markdownToHtml(markdownForRole(doc.content, audience)) : ''),
[audience, doc],
);
function handleLinkClick(event) {
const anchor = event.target.closest('a');
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!href) return;
const targetSlug = resolveDocLink(href, doc.slug);
if (targetSlug) {
event.preventDefault();
navigate(docHref(targetSlug));
}
}
if (loading) {
return (
<div className="grid place-items-center py-32 text-sm text-ink-400">
<span className="animate-pulse">Loading {slug}…</span>
</div>
);
}
if (error || !doc) {
return (
<div className="mx-auto max-w-2xl py-24 text-center">
<AlertCircle className="mx-auto text-red-400" size={32} aria-hidden="true" />
<h1 className="mt-4 text-lg font-semibold text-white">Document not available</h1>
<p className="mt-2 text-sm text-ink-400">
{error?.message ?? `No Markdown file is stored for “${slug}”.`}
</p>
<button
type="button"
onClick={() => navigate('/')}
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-ink-700 px-4 py-2 text-sm font-medium text-white transition hover:bg-ink-600"
>
<ArrowLeft size={15} aria-hidden="true" />
Back to index
</button>
</div>
);
}
async function remove() {
if (!window.confirm(`Permanently delete “${doc.slug}” and its feedback?`)) return;
try {
await api.deleteDoc(doc.slug);
onChanged?.();
navigate('/');
} catch (err) {
setError(err);
}
}
const activeMeta = ROLES.find((r) => r.role === audience);
return (
<div className="mx-auto grid max-w-[1400px] gap-6 px-6 py-8 xl:grid-cols-[minmax(0,1fr)_380px]">
<article className="min-w-0" onClick={handleLinkClick}>
<nav className="mb-5 flex items-center gap-2 text-xs text-ink-400">
<button
type="button"
onClick={() => navigate('/')}
className="inline-flex items-center gap-1 transition hover:text-ink-200"
>
<ArrowLeft size={13} aria-hidden="true" />
All documents
</button>
<span aria-hidden="true">/</span>
<span className="font-mono text-ink-300">{doc.slug}</span>
</nav>
<header className="mb-5 rounded-xl border border-ink-700 bg-ink-850 p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-tight text-white">{doc.title}</h1>
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-ink-400">
<span className="inline-flex items-center gap-1.5 font-mono">
<FileText size={13} aria-hidden="true" />
{doc.size} bytes
</span>
<span className="inline-flex items-center gap-1.5">
<Clock size={13} aria-hidden="true" />
{new Date(doc.modified).toLocaleString()}
</span>
<span className="inline-flex items-center gap-1.5">
<MessageSquare size={13} aria-hidden="true" />
{comments.length} comments
</span>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => navigate(editHref(doc.slug))}
className="inline-flex items-center gap-2 rounded-lg border border-ink-600 px-3 py-2 text-xs font-semibold text-ink-200 transition hover:bg-ink-800"
>
<Pencil size={14} aria-hidden="true" />
Edit
</button>
<button
type="button"
onClick={() => {
void navigator.clipboard?.writeText(`${window.location.origin}${docHref(doc.slug)}`);
}}
className="inline-flex items-center gap-2 rounded-lg border border-ink-600 px-3 py-2 text-xs font-semibold text-ink-200 transition hover:bg-ink-800"
>
<Link2 size={14} aria-hidden="true" />
Link
</button>
<button
type="button"
onClick={remove}
aria-label="Delete document"
className="inline-flex items-center gap-2 rounded-lg border border-ink-700 px-3 py-2 text-xs font-semibold text-ink-400 transition hover:border-red-500/40 hover:text-red-400"
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
</div>
{activeMeta ? (
<p className="mt-3 text-xs text-ink-400">
Showing the {activeMeta.label.toLowerCase()} view — {activeMeta.description.toLowerCase()}.
</p>
) : null}
</header>
<div
className="md-body rounded-xl border border-ink-700 bg-ink-850 px-7 py-6"
// Sanitised in markdownToHtml before it reaches the DOM.
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
</article>
<aside className="min-w-0 xl:sticky xl:top-6 xl:h-[calc(100vh-3rem)]">
<CommentPanel slug={doc.slug} comments={comments} onRefresh={load} />
</aside>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { roleMeta } from '../lib/api.js';
/** The colour-coded audience marker: User 🟢, Operator 🔵, Developer 🔴. */
export default function RoleBadge({ role, size = 'md', showLabel = true }) {
const meta = roleMeta(role);
const dimensions = size === 'sm' ? 'text-[11px] px-1.5 py-0.5 gap-1' : 'text-xs px-2 py-1 gap-1.5';
return (
<span
className={`inline-flex items-center rounded-full border font-medium ${dimensions}`}
style={{
color: meta.hex,
borderColor: `${meta.hex}55`,
backgroundColor: `${meta.hex}14`,
}}
title={meta.label}
>
<span aria-hidden="true" className="text-[13px] leading-none">
{meta.badge}
</span>
{showLabel ? <span>{meta.label}</span> : null}
<span className="sr-only">{meta.label}</span>
</span>
);
}

166
frontend/src/index.css Normal file
View file

@ -0,0 +1,166 @@
@import 'tailwindcss';
@theme {
--color-ink-950: #070a0e;
--color-ink-900: #0b0f14;
--color-ink-850: #10151c;
--color-ink-800: #151b24;
--color-ink-700: #1e2632;
--color-ink-600: #2b3543;
--color-ink-400: #6b7a90;
--color-ink-300: #9aa8bd;
--color-ink-200: #cbd5e1;
--color-user: #22c55e;
--color-operator: #3b82f6;
--color-developer: #ef4444;
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
}
@layer base {
html,
body,
#root {
height: 100%;
}
body {
@apply bg-ink-900 text-ink-200 font-sans antialiased;
}
::selection {
@apply bg-ink-600 text-ink-200;
}
/* Thin, unobtrusive scrollbars for the long markdown panes. */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-ink-600) transparent;
}
}
@layer components {
/* Rendered markdown surface, shared by the viewer, the editor preview and the
WYSIWYG surface so all three look identical. */
.md-body {
@apply text-[15px] leading-7 text-ink-200;
}
.md-body > * + * {
@apply mt-5;
}
.md-body h1 {
@apply mt-8 border-b border-ink-700 pb-2 text-2xl font-semibold tracking-tight text-white;
}
.md-body h2 {
@apply mt-8 border-b border-ink-800 pb-2 text-xl font-semibold tracking-tight text-white;
}
.md-body h3 {
@apply mt-6 text-lg font-semibold text-white;
}
.md-body h4,
.md-body h5,
.md-body h6 {
@apply mt-5 text-base font-semibold text-ink-200;
}
.md-body p {
@apply leading-7;
}
.md-body a {
@apply text-sky-400 underline decoration-sky-400/40 underline-offset-2 hover:decoration-sky-400;
}
.md-body ul {
@apply list-disc space-y-2 pl-6 marker:text-ink-400;
}
.md-body ol {
@apply list-decimal space-y-2 pl-6 marker:text-ink-400;
}
.md-body blockquote {
@apply border-l-2 border-ink-600 pl-4 text-ink-300 italic;
}
.md-body code {
@apply rounded border border-ink-700 bg-ink-850 px-1.5 py-0.5 font-mono text-[13px] text-emerald-300;
}
.md-body pre {
@apply overflow-x-auto rounded-lg border border-ink-700 bg-ink-950 p-4 text-[13px] leading-6;
}
.md-body pre code {
@apply border-0 bg-transparent p-0 text-ink-200;
}
.md-body hr {
@apply border-ink-700;
}
.md-body table {
@apply w-full border-collapse text-left text-sm;
}
.md-body th,
.md-body td {
@apply border border-ink-700 px-3 py-2;
}
.md-body th {
@apply bg-ink-850 font-semibold text-ink-200;
}
.md-body img {
@apply rounded-lg border border-ink-700;
}
/* The WYSIWYG surface is a live region, so it must not inherit the readonly
styling of the preview pane. */
.md-body[contenteditable='true'] {
@apply min-h-full outline-none;
}
.md-body[contenteditable='true']:empty::before {
content: attr(data-placeholder);
@apply text-ink-400;
}
/* Account menu component */
.account {
position: relative;
}
.account-trigger {
@apply flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-ink-200 transition hover:bg-ink-800;
}
.account-trigger:focus-visible {
@apply outline-none ring-2 ring-sky-500;
}
.account-avatar-slot {
@apply flex-shrink-0;
}
.account-caret {
@apply text-[10px] text-ink-400 transition;
}
.account[aria-expanded='true'] .account-caret {
@apply rotate-180;
}
.account-menu {
@apply absolute right-0 mt-2 min-w-[220px] rounded-lg border border-ink-700 bg-ink-850 py-2 shadow-lg z-50;
}
.account-identity {
@apply px-3 py-2 border-b border-ink-700;
}
.account-display-name {
@apply block text-sm font-medium text-white;
}
.account-email {
@apply block text-xs text-ink-400 truncate;
}
.account-tenant {
@apply block text-xs text-sky-400 truncate;
}
.account-menu a[role='menuitem'],
.account-menu button[role='menuitem'] {
@apply block w-full px-3 py-2 text-sm text-ink-200 transition hover:bg-ink-800 text-left;
}
.account-menu button[role='menuitem'] {
@apply bg-transparent border-0 cursor-pointer font-sans;
}
.account-note {
@apply px-3 py-2 text-xs text-ink-400 border-t border-ink-700;
}
.avatar {
@apply rounded-full bg-ink-700;
}
.avatar-empty {
@apply inline-flex items-center justify-center rounded-full bg-sky-500 text-ink-950 font-medium;
}
}

116
frontend/src/lib/account.js Normal file
View file

@ -0,0 +1,116 @@
export function initials(name) {
return String(name || '?')
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0].toUpperCase())
.join('') || '?';
}
const APOSTROPHE_ENTITY = '&' + '#39;';
export function escapeHTML(value) {
const s = String(value ?? '');
let out = '';
for (let i = 0; i < s.length; i++) {
const ch = s[i];
switch (ch) {
case '&': out += '&'; break;
case '<': out += '<'; break;
case '>': out += '>'; break;
case '"': out += '"'; break;
case '\x27': out += APOSTROPHE_ENTITY; break;
default: out += ch;
}
}
return out;
}
async function loadProfile() {
const response = await fetch('/api/auth/profile', {
credentials: 'same-origin',
headers: { accept: 'application/json' },
});
if (response.status === 401) {
return null;
}
if (!response.ok) return null;
return response.json();
}
function mount(host) {
if (!host || host.dataset.mounted === 'true') return;
host.dataset.mounted = 'true';
host.classList.add('account');
host.innerHTML = `
<button type="button" class="account-trigger" aria-haspopup="true" aria-expanded="false" hidden>
<span class="account-avatar-slot"></span>
<span class="account-name"></span>
<span class="account-caret" aria-hidden="true">▾</span>
</button>
<div class="account-menu" role="menu" hidden>
<div class="account-identity">
<strong class="account-display-name"></strong>
<span class="account-email"></span>
<span class="account-tenant" hidden></span>
</div>
<a class="account-profile" role="menuitem" href="/profile">Edit profile</a>
<form method="post" action="/auth/logout">
<button type="submit" role="menuitem">Log out</button>
</form>
<p class="account-note" hidden>Signing out here also ends your sessions in the applications that use this sign-in.</p>
</div>`;
const trigger = host.querySelector('.account-trigger');
const menu = host.querySelector('.account-menu');
const note = host.querySelector('.account-note');
const tenantEl = host.querySelector('.account-tenant');
const setOpen = (open) => {
menu.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
};
trigger.addEventListener('click', (event) => {
event.stopPropagation();
setOpen(menu.hidden);
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || menu.hidden) return;
setOpen(false);
trigger.focus();
});
document.addEventListener('click', () => setOpen(false), true);
loadProfile().then((profile) => {
if (!profile) {
return;
}
const name = profile.display_name || profile.email || '';
host.querySelector('.account-name').textContent = name;
host.querySelector('.account-display-name').textContent = name;
host.querySelector('.account-email').textContent = profile.email || '';
if (profile.tenant_slug) {
tenantEl.textContent = profile.tenant_slug;
tenantEl.hidden = false;
}
const slot = host.querySelector('.account-avatar-slot');
slot.innerHTML = profile.avatar_url
? `<img class="avatar" src="${escapeHTML(profile.avatar_url)}" alt="" width="24" height="24">`
: `<span class="avatar avatar-empty">${escapeHTML(initials(name))}</span>`;
trigger.hidden = false;
const usesHostedLogin = (profile.memberships || []).length > 0;
note.hidden = !usesHostedLogin;
});
}
export function mountAccountMenu() {
document.querySelectorAll('[data-account-menu]').forEach(mount);
}
if (typeof window !== 'undefined') {
window.mountAccountMenu = mountAccountMenu;
}

90
frontend/src/lib/api.js Normal file
View file

@ -0,0 +1,90 @@
const RAW_BASE = import.meta.env.VITE_API_BASE ?? '';
const DEFAULT_TIMEOUT = 15000;
/** API error carrying the HTTP status and the server's structured error code. */
export class ApiError extends Error {
constructor(message, status, code) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}
async function request(path, { method = 'GET', body, headers = {}, signal } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
if (signal) signal.addEventListener('abort', () => controller.abort(), { once: true });
try {
const response = await fetch(`${RAW_BASE}${path}`, {
method,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
if (response.status === 204) return null;
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) {
const code = payload?.error?.code;
const message = payload?.error?.message ?? `${response.status} ${response.statusText}`;
throw new ApiError(message, response.status, code);
}
return payload;
} catch (error) {
if (error instanceof ApiError) throw error;
if (error.name === 'AbortError') {
throw new ApiError('Request timed out or was cancelled', 0, 'timeout');
}
throw new ApiError(error.message || 'Network unreachable', 0, 'network');
} finally {
clearTimeout(timer);
}
}
export const api = {
health: () => request('/api/health'),
roles: () => request('/api/roles'),
listDocs: (query = '') =>
request(`/api/docs${query ? `?q=${encodeURIComponent(query)}` : ''}`),
listDocsDetailed: () => request('/api/docs?counts=1'),
getDoc: (slug) => request(`/api/docs/${encodeURIComponent(slug)}?format=json`),
saveDoc: (slug, content, title) => request(`/api/docs/${encodeURIComponent(slug)}`, {
method: 'POST',
body: { content, title },
}),
deleteDoc: (slug) => request(`/api/docs/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
listComments: (slug) => request(`/api/docs/${encodeURIComponent(slug)}/comments`),
createComment: (slug, comment) =>
request(`/api/docs/${encodeURIComponent(slug)}/comments`, { method: 'POST', body: comment }),
deleteComment: (slug, id) =>
request(`/api/docs/${encodeURIComponent(slug)}/comments/${encodeURIComponent(id)}`, {
method: 'DELETE',
}),
};
/** Audience segments mirrored from the Go engine; badges stay authoritative server-side. */
export const ROLES = [
{ role: 'user', badge: '🟢', label: 'User', hex: '#22c55e', description: 'Day-to-day workflow guidance' },
{ role: 'operator', badge: '🔵', label: 'Operator', hex: '#3b82f6', description: 'Deployment, health and rollback' },
{ role: 'developer', badge: '🔴', label: 'Developer', hex: '#ef4444', description: 'Implementation and API contracts' },
];
export function roleMeta(role) {
return ROLES.find((r) => r.role === role) ?? { role, badge: '⚪', label: role, hex: '#94a3b8' };
}

View file

@ -0,0 +1,85 @@
/**
* Audience segmentation.
*
* go-doc documents are plain Markdown, so a single file carries all three
* viewpoints at once. This module slices a document into a shared preamble, a
* shared remainder, and per-role sections, so the reader can show a User, an
* Operator or a Developer exactly the part that concerns them.
*
* Sections are delimited by level-2 headings. A section is classified by an
* explicit marker when present, otherwise by keywords in its heading; anything
* unclassified stays in `common` and is shown to every audience.
*/
export const AUDIENCES = ['user', 'operator', 'developer'];
const EXPLICIT = /<!--\s*godoc:(user|operator|developer)\s*-->/i;
const RULES = [
{ role: 'user', pattern: /\b(user|users|end[- ]?user|consumer|customer|day[- ]to[- ]day|using|usage|getting started|walkthrough|onboarding)\b/i },
{ role: 'operator', pattern: /\b(operator|operators|ops|operations|sre|deploy|deploying|deployment|release|runbook|infra|infrastructure|production|observability|monitoring|alerting|rollback|on[- ]?call|incident|backup|restore|scaling)\b/i },
{ role: 'developer', pattern: /\b(developer|developers|dev|api|sdk|code|schema|contract|integration|programming|internals|architecture|reference|endpoints|library|module)\b/i },
];
function classify(heading) {
const explicit = EXPLICIT.exec(heading);
if (explicit) return explicit[1].toLowerCase();
const normalized = heading.replace(/^\s*for\s+/i, '').replace(/^\s*the\s+/i, '');
for (const rule of RULES) {
if (rule.pattern.test(normalized)) return rule.role;
}
return null;
}
/**
* Split Markdown into `{ intro, common, user, operator, developer }`.
* Each value is the raw Markdown for that slice; `order` records the source
* order of every section so the reader can render an outline.
*/
export function segmentByAudience(markdown) {
const source = markdown ?? '';
const lines = source.split('\n');
const parts = { intro: source, common: '', user: '', operator: '', developer: '' };
const outline = [];
let currentRole = null;
let buffer = [];
let seenFirstSection = false;
const flush = () => {
const chunk = buffer.join('\n').trim();
buffer = [];
if (!chunk) return;
if (!seenFirstSection) {
parts.intro = [parts.intro.trim(), chunk].filter(Boolean).join('\n\n');
seenFirstSection = true;
return;
}
const target = currentRole ?? 'common';
parts[target] = [parts[target], chunk].filter(Boolean).join('\n\n');
};
for (const line of lines) {
if (/^##[ \t]+\S/.test(line) && !/^###/.test(line)) {
flush();
const heading = line.replace(/^##[ \t]+/, '').trim();
currentRole = classify(heading);
outline.push({ heading, role: currentRole ?? 'common', line: lines.indexOf(line) });
}
buffer.push(line);
}
flush();
parts.order = outline;
return parts;
}
/** Markdown assembled for one audience tab: preamble + shared + role sections. */
export function markdownForRole(markdown, role) {
const parts = segmentByAudience(markdown);
if (!role || !AUDIENCES.includes(role)) return markdown;
return [parts.intro, parts.common, parts[role]].filter((part) => part && part.trim()).join('\n\n');
}

View file

@ -0,0 +1,195 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
marked.setOptions({ gfm: true, breaks: true });
const PURIFY_CONFIG = {
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr', 'blockquote',
'ul', 'ol', 'li',
'strong', 'b', 'em', 'i', 'u', 's', 'del', 'mark',
'code', 'pre', 'kbd', 'samp',
'a', 'img',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
'span', 'div',
],
ALLOWED_ATTR: ['href', 'title', 'target', 'rel', 'src', 'alt', 'class', 'start'],
ALLOW_DATA_ATTR: false,
};
/**
* Render Markdown to sanitized HTML. Every rendered surface in the app goes
* through here so the viewer, the split preview and the WYSIWYG surface stay
* byte-for-byte consistent, and no untrusted markup can reach the DOM.
*/
export function markdownToHtml(markdown) {
if (!markdown) return '';
const raw = marked.parse(markdown, { async: false });
return DOMPurify.sanitize(raw, PURIFY_CONFIG);
}
/** Convert rendered HTML back into clean Markdown. */
export function htmlToMarkdown(html) {
if (!html) return '';
const template = document.createElement('template');
template.innerHTML = html;
return cleanMarkdown(serializeChildren(template.content));
}
function serializeChildren(node) {
let out = '';
for (const child of node.childNodes) out += serializeNode(child);
return out;
}
function serializeNode(node) {
switch (node.nodeType) {
case Node.TEXT_NODE:
return escapeText(node.nodeValue ?? '');
case Node.CODELEMENT_NODE:
return escapeText(serializeChildren(node));
case Node.COMMENT_NODE:
return '';
default:
break;
}
const tag = node.nodeName.toLowerCase();
const inner = serializeChildren(node);
switch (tag) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
return `${tag} ${inner.trim()}\n\n`;
case 'p':
case 'div':
return inner.trim() ? `${inner.trim()}\n\n` : '';
case 'br':
return ' \n';
case 'hr':
return '---\n\n';
case 'strong':
case 'b':
return wrap(inner.trim(), '**');
case 'em':
case 'i':
return wrap(inner.trim(), '*');
case 'u':
return wrap(inner.trim(), '<u>');
case 's':
case 'del':
return wrap(inner.trim(), '~~');
case 'mark':
return wrap(inner.trim(), '==');
case 'code': {
if (node.parentElement?.tagName?.toLowerCase() === 'pre') {
const language = readLanguage(node) ?? '';
return `\n\`\`\`${language}\n${node.textContent.replace(/\n$/, '')}\n\`\`\`\n\n`;
}
return wrap(inner.trim(), '`');
}
case 'pre':
return serializeChildren(node);
case 'a': {
const href = node.getAttribute('href') ?? '';
const label = inner.trim() || href;
return href ? `[${label}](${href})` : label;
}
case 'img': {
const src = node.getAttribute('src') ?? '';
const alt = node.getAttribute('alt') ?? '';
return `![${alt}](${src})`;
}
case 'blockquote': {
const quoted = inner.trim().split('\n').map((l) => (l ? `> ${l}` : '>')).join('\n');
return `${quoted}\n\n`;
}
case 'ul':
case 'ol':
return serializeList(node, tag === 'ol');
case 'li':
return `${inner.trim()}\n`;
case 'table':
return serializeTable(node);
default:
return inner;
}
}
function readLanguage(codeEl) {
const className = codeEl.getAttribute('class') ?? '';
const match = className.match(/language-([\w+-]+)/);
return match ? match[1] : '';
}
function serializeList(listEl, ordered, depth = 0) {
const indent = ' '.repeat(depth);
let out = '';
let index = 0;
for (const li of listEl.children) {
if (li.tagName?.toLowerCase() !== 'li') continue;
index += 1;
const marker = ordered ? `${index}. ` : '- ';
const own = serializeChildren(li)
.split('\n')
.filter((line) => line.trim() !== '')
.map((line, i) => (i === 0 ? `${indent}${marker}${line}` : `${indent} ${line}`))
.join('\n');
out += `${own}\n`;
}
return `${out}\n`;
}
function serializeTable(tableEl) {
const rows = Array.from(tableEl.rows ?? []);
if (rows.length === 0) return '';
const cells = (row) =>
Array.from(row.cells ?? []).map((cell) => serializeChildren(cell).replace(/\s+/g, ' ').trim());
const header = cells(rows[0]);
let out = `| ${header.join(' | ')} |\n`;
out += `| ${header.map(() => '---').join(' | ')} |\n`;
for (const row of rows.slice(1)) {
out += `| ${cells(row).join(' | ')} |\n`;
}
return `${out}\n`;
}
function wrap(value, token) {
if (!value) return '';
if (token === '`') return `\`${value}\``;
if (token === '<u>') return `<u>${value}</u>`;
return `${token}${value}${token}`;
}
/** Escape only what would otherwise change meaning in Markdown output. */
function escapeText(text) {
return text
.replace(/([\\`*_[\]])/g, '\\$1')
.replace(/\u00a0/g, ' ');
}
function cleanMarkdown(markdown) {
return markdown
.replace(/[ \t]+$/gm, '')
.replace(/\n{3,}/g, '\n\n')
.replace(/^\s+/, '')
.replace(/\s+$/, '')
.concat('\n');
}
/** Word and character counts for the editor status bar. */
export function countWords(markdown) {
const stripped = (markdown ?? '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*_`~[\]()|-]/g, ' ')
.trim();
if (!stripped) return 0;
return stripped.split(/\s+/).length;
}

114
frontend/src/lib/router.js Normal file
View file

@ -0,0 +1,114 @@
import { useCallback, useEffect, useState } from 'react';
const listeners = new Set();
function notify() {
for (const listener of listeners) listener();
}
/**
* Minimal History API router. go-doc has exactly three routes, so the app ships
* without react-router: `/` (index), `/docs/:slug` (reader) and `/docs/:slug/edit`.
*/
export function parseRoute(pathname) {
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/') return { name: 'index', slug: null, section: 'index' };
const segments = path.split('/').filter(Boolean);
if (segments[0] !== 'docs' || !segments[1]) return { name: 'notfound', slug: null, section: 'notfound' };
const slug = decodeURIComponent(segments[1]);
const section = segments[2] === 'edit' ? 'edit' : 'view';
return { name: section, slug, section };
}
export function navigate(to, { replace = false } = {}) {
if (to === window.location.pathname) return;
if (replace) window.history.replaceState({}, '', to);
else window.history.pushState({}, '', to);
notify();
window.scrollTo({ top: 0 });
}
export function useRoute() {
const [route, setRoute] = useState(() => parseRoute(window.location.pathname));
useEffect(() => {
const onChange = () => setRoute(parseRoute(window.location.pathname));
listeners.add(onChange);
window.addEventListener('popstate', onChange);
return () => {
listeners.delete(onChange);
window.removeEventListener('popstate', onChange);
};
}, []);
return route;
}
export function docHref(slug) {
return `/docs/${encodeURIComponent(slug)}`;
}
export function editHref(slug) {
return `/docs/${encodeURIComponent(slug)}/edit`;
}
export function useNavigate() {
return useCallback((to, options) => navigate(to, options), []);
}
/**
* Check if a link href points to another go-doc document and return the slug.
* Returns null if the link is external or not a document reference.
* Handles:
* - /docs/slug (absolute SPA route)
* - slug.md (relative markdown reference)
* - docs/slug.md (relative docs folder reference)
* - ./slug.md, ../slug.md
*/
export function resolveDocLink(href, currentSlug) {
if (!href) return null;
const url = new URL(href, 'http://localhost');
let path = url.pathname;
// Strip leading slash for consistent handling
if (path.startsWith('/')) path = path.slice(1);
// Absolute SPA route: /docs/slug
if (path.startsWith('docs/')) {
const slug = decodeURIComponent(path.slice(5));
if (slug && slug !== 'edit') return slug;
}
// Relative markdown file reference
if (path.endsWith('.md')) {
let file = path;
if (file.startsWith('./')) file = file.slice(2);
// Strip any docs/ prefix (including nested like docs/adr/)
file = file.replace(/^docs\/[^/]+\//, '').replace(/^docs\//, '');
const slug = file.replace(/\.md$/, '');
if (slug) {
// If we have a current slug, prefix with project name for subdocuments
// e.g., currentSlug="go-bananas", link="0001-embedded-sqlite" -> "go-bananas-0001-embedded-sqlite"
if (currentSlug) {
const projectPrefix = currentSlug.split('-').slice(0, -1).join('-') || currentSlug;
return `${projectPrefix}-${slug}`;
}
return slug;
}
}
// If current slug is provided, try to resolve relative to it
if (currentSlug && !path.startsWith('http')) {
const slug = path.replace(/\.md$/, '');
if (slug) {
const projectPrefix = currentSlug.split('-').slice(0, -1).join('-') || currentSlug;
return `${projectPrefix}-${slug}`;
}
}
return null;
}

54
frontend/src/lib/slug.js Normal file
View file

@ -0,0 +1,54 @@
/**
* Client-side mirror of storage.Slugify in the Go engine, used to preview the
* filename a new title will produce before the request is sent. The backend
* remains authoritative and re-normalises every slug it receives.
*/
const LATIN_FOLD = {
á: 'a', à: 'a', â: 'a', ä: 'a', ã: 'a', å: 'a', ā: 'a', ă: 'a', ą: 'a',
ç: 'c', ć: 'c', č: 'c', ĉ: 'c', ċ: 'c',
ď: 'd', đ: 'd',
é: 'e', è: 'e', ê: 'e', ë: 'e', ē: 'e', ė: 'e', ę: 'e', ě: 'e',
ğ: 'g', ĝ: 'g', ġ: 'g', ģ: 'g',
í: 'i', ì: 'i', î: 'i', ï: 'i', ī: 'i', į: 'i', ı: 'i',
ĵ: 'j', ķ: 'k', ł: 'l', ľ: 'l', ĺ: 'l',
ñ: 'n', ń: 'n', ň: 'n',
ó: 'o', ò: 'o', ô: 'o', ö: 'o', õ: 'o', ø: 'o', ō: 'o', ő: 'o',
ŕ: 'r', ř: 'r',
ś: 's', š: 's', ş: 's', ș: 's', ß: 's', þ: 't', ť: 't',
ú: 'u', ù: 'u', û: 'u', ü: 'u', ū: 'u', ů: 'u', ű: 'u',
ŵ: 'w', ý: 'y', ÿ: 'y', ŷ: 'y',
ź: 'z', ż: 'z', ž: 'z',
};
const MAX_SLUG_LEN = 120;
export function slugify(title) {
let out = '';
let pendingSeparator = true;
for (const raw of (title ?? '').toLowerCase()) {
const char = LATIN_FOLD[raw] ?? raw;
if (/[a-z0-9]/.test(char)) {
out += char;
pendingSeparator = false;
} else if (!pendingSeparator) {
out += '-';
pendingSeparator = true;
}
}
let slug = out.replace(/^-+|-+$/g, '');
if ([...slug].length > MAX_SLUG_LEN) {
slug = [...slug].slice(0, MAX_SLUG_LEN).join('').replace(/-+$/, '');
}
return slug;
}
/** Render a slug back into a human title: core-service-api -> Core Service Api. */
export function titleize(slug) {
return (slug ?? '')
.split(/[-_]/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}

11
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);

29
frontend/vite.config.js Normal file
View file

@ -0,0 +1,29 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// The dev server proxies /api to the Go backend so the SPA and the API share an
// origin during development. CORS stays enabled on the backend for anyone who
// prefers to call it cross-origin instead.
const BACKEND = process.env.GO_DOC_BACKEND ?? 'http://127.0.0.1:8080';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
strictPort: true,
host: '127.0.0.1',
proxy: {
'/api': {
target: BACKEND,
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: false,
chunkSizeWarningLimit: 900,
},
});