initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
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(' ');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue