initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
1607
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal 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
170
frontend/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
frontend/src/components/CommentPanel.jsx
Normal file
148
frontend/src/components/CommentPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
421
frontend/src/components/DocEditor.jsx
Normal file
421
frontend/src/components/DocEditor.jsx
Normal 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 };
|
||||
187
frontend/src/components/DocIndex.jsx
Normal file
187
frontend/src/components/DocIndex.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
frontend/src/components/DocViewer.jsx
Normal file
203
frontend/src/components/DocViewer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/RoleBadge.jsx
Normal file
25
frontend/src/components/RoleBadge.jsx
Normal 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
166
frontend/src/index.css
Normal 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
116
frontend/src/lib/account.js
Normal 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
90
frontend/src/lib/api.js
Normal 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' };
|
||||
}
|
||||
85
frontend/src/lib/audience.js
Normal file
85
frontend/src/lib/audience.js
Normal 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');
|
||||
}
|
||||
195
frontend/src/lib/markdown.js
Normal file
195
frontend/src/lib/markdown.js
Normal 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 ``;
|
||||
}
|
||||
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
114
frontend/src/lib/router.js
Normal 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
54
frontend/src/lib/slug.js
Normal 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
11
frontend/src/main.jsx
Normal 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
29
frontend/vite.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue