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

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(' ');
}