feat: wiki web UI — map of content, article view, revision history
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
eea6176ca9
commit
ea1554fd6e
Vendored
+11
-11
@@ -11,30 +11,30 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.C7Nk8VcW.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BX6BYLlQ.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.CVi31UVc.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B5B829i8.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BK7DUW2U.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CslSvznw.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C_dJMdcr.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Du3f5uIc.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B3RSY5nb.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.BMHPwGGu.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B7OLp0b1.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CzUB9ngU.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B5iOtbKp.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CtXjcZd5.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/17s2OMzH.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.5w0xBlSw.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_wis49l = {
|
||||
__sveltekit_ivyjz3 = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.C7Nk8VcW.js"),
|
||||
import("/_app/immutable/entry/app.BMHPwGGu.js")
|
||||
import("/_app/immutable/entry/start.CVi31UVc.js"),
|
||||
import("/_app/immutable/entry/app.5w0xBlSw.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -314,6 +314,23 @@ export const onboarding = {
|
||||
}
|
||||
};
|
||||
|
||||
// Wiki
|
||||
export const wiki = {
|
||||
list: (opts?: { q?: string; limit?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.q) qs.set('q', opts.q);
|
||||
if (opts?.limit) qs.set('limit', String(opts.limit));
|
||||
const query = qs.toString();
|
||||
return request<{ articles: any[] }>('GET', `/api/wiki/articles${query ? '?' + query : ''}`);
|
||||
},
|
||||
get: (slug: string) =>
|
||||
request<any>('GET', `/api/wiki/articles/${slug}`),
|
||||
getHistory: (slug: string) =>
|
||||
request<{ revisions: any[] }>('GET', `/api/wiki/articles/${slug}/history`),
|
||||
getMap: () =>
|
||||
request<any>('GET', `/api/wiki/map`)
|
||||
};
|
||||
|
||||
// Reactive Runs
|
||||
export const runs = {
|
||||
list: (params?: { agent?: string; status?: string; limit?: number; offset?: number }) => {
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
<!-- Search -->
|
||||
<a
|
||||
href="/conversations"
|
||||
class="sidebar-item mb-3 {isActive('/conversations') ? 'sidebar-item-active' : ''}"
|
||||
class="sidebar-item {isActive('/conversations') ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
@@ -141,6 +141,18 @@
|
||||
Search
|
||||
</a>
|
||||
|
||||
<!-- Wiki -->
|
||||
<a
|
||||
href="/wiki"
|
||||
class="sidebar-item mb-3 {isActive('/wiki') ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
Wiki
|
||||
</a>
|
||||
|
||||
<!-- Channels section -->
|
||||
<div class="mb-3">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<script lang="ts">
|
||||
import { wiki as wikiApi } from '$lib/api/client';
|
||||
|
||||
let mapData = $state<any>(null);
|
||||
let searchResults = $state<any[] | null>(null);
|
||||
let loadingData = $state(true);
|
||||
let searching = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let orphansExpanded = $state(false);
|
||||
|
||||
async function loadMap() {
|
||||
loadingData = true;
|
||||
try {
|
||||
mapData = await wikiApi.getMap();
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
loadMap();
|
||||
}
|
||||
});
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleSearchInput() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
const q = searchQuery.trim();
|
||||
if (!q) {
|
||||
searchResults = null;
|
||||
return;
|
||||
}
|
||||
searchTimeout = setTimeout(async () => {
|
||||
searching = true;
|
||||
try {
|
||||
const res = await wikiApi.list({ q, limit: 20 });
|
||||
searchResults = res.articles;
|
||||
} catch {
|
||||
searchResults = [];
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}d ago`;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h1 class="text-xl font-bold text-text-primary font-display">Wiki</h1>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="mb-5">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Search articles..."
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearchInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if searching}
|
||||
<div class="card p-5 mb-5">
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="skeleton h-10 w-full"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if searchResults !== null}
|
||||
<!-- Search results -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="text-sm font-semibold text-text-primary font-display">Search Results</h2>
|
||||
</div>
|
||||
{#if searchResults.length === 0}
|
||||
<div class="p-5 text-center text-text-secondary text-sm">
|
||||
No articles found for "{searchQuery}"
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-border">
|
||||
{#each searchResults as article}
|
||||
<a href="/wiki/{article.slug}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-sm text-text-primary">{article.title}</span>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span class="text-xs text-text-secondary">{formatTime(article.updated_at)}</span>
|
||||
{#if article.revision}
|
||||
<span class="text-xs text-text-secondary">rev {article.revision}</span>
|
||||
{/if}
|
||||
{#if article.word_count}
|
||||
<span class="text-xs text-text-secondary">{article.word_count} words</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if loadingData}
|
||||
<div class="card p-5 space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="skeleton h-12 w-full"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if mapData}
|
||||
<!-- Hub Articles -->
|
||||
{#if mapData.hubs?.length > 0}
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="text-sm font-semibold text-text-primary font-display">Hub Articles</h2>
|
||||
<p class="text-xs text-text-secondary mt-0.5">Most connected articles</p>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
{#each mapData.hubs as article}
|
||||
<a href="/wiki/{article.slug}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-sm text-text-primary">{article.title}</span>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span class="text-xs text-text-secondary">{formatTime(article.updated_at)}</span>
|
||||
{#if article.revision}
|
||||
<span class="text-xs text-text-secondary">rev {article.revision}</span>
|
||||
{/if}
|
||||
{#if article.word_count}
|
||||
<span class="text-xs text-text-secondary">{article.word_count} words</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
<span class="badge bg-accent-blue/20 text-accent-blue text-[10px]">
|
||||
{article.backlink_count} backlink{article.backlink_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- All Articles -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="text-sm font-semibold text-text-primary font-display">All Articles</h2>
|
||||
<p class="text-xs text-text-secondary mt-0.5">Sorted by last updated</p>
|
||||
</div>
|
||||
{#if !mapData.articles?.length}
|
||||
<div class="p-8 text-center text-text-secondary text-sm">
|
||||
No articles yet. Agents can create articles via MCP tools.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-border">
|
||||
{#each mapData.articles as article}
|
||||
<a href="/wiki/{article.slug}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-sm text-text-primary">{article.title}</span>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span class="text-xs text-text-secondary">{formatTime(article.updated_at)}</span>
|
||||
{#if article.revision}
|
||||
<span class="text-xs text-text-secondary">rev {article.revision}</span>
|
||||
{/if}
|
||||
{#if article.word_count}
|
||||
<span class="text-xs text-text-secondary">{article.word_count} words</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
{#if article.backlink_count > 0}
|
||||
<span class="badge bg-bg-tertiary text-text-secondary text-[10px]">
|
||||
{article.backlink_count} backlink{article.backlink_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Orphan Articles -->
|
||||
{#if mapData.orphans?.length > 0}
|
||||
<div class="card mb-5">
|
||||
<button
|
||||
class="w-full px-5 py-3 flex items-center justify-between border-b border-border hover:bg-bg-tertiary/30 transition-colors"
|
||||
onclick={() => (orphansExpanded = !orphansExpanded)}
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-text-primary font-display text-left">Orphan Articles</h2>
|
||||
<p class="text-xs text-text-secondary mt-0.5 text-left">No links in or out</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge bg-accent-yellow/20 text-accent-yellow text-[10px]">{mapData.orphans.length}</span>
|
||||
<svg class="w-4 h-4 text-text-secondary transition-transform {orphansExpanded ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{#if orphansExpanded}
|
||||
<div class="divide-y divide-border">
|
||||
{#each mapData.orphans as article}
|
||||
<a href="/wiki/{article.slug}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-sm text-text-primary">{article.title}</span>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span class="text-xs text-text-secondary">{formatTime(article.updated_at)}</span>
|
||||
{#if article.revision}
|
||||
<span class="text-xs text-text-secondary">rev {article.revision}</span>
|
||||
{/if}
|
||||
{#if article.word_count}
|
||||
<span class="text-xs text-text-secondary">{article.word_count} words</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Wanted Articles -->
|
||||
{#if mapData.wanted?.length > 0}
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="text-sm font-semibold text-text-primary font-display">Wanted Articles</h2>
|
||||
<p class="text-xs text-text-secondary mt-0.5">Referenced but not yet created</p>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
{#each mapData.wanted as wanted}
|
||||
<a href="/wiki/{wanted.slug}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-sm text-accent-red" style="text-decoration: underline dashed;">{wanted.slug}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
<span class="badge bg-accent-red/20 text-accent-red text-[10px]">
|
||||
{wanted.reference_count} reference{wanted.reference_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,465 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { wiki as wikiApi } from '$lib/api/client';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
|
||||
let article = $state<any>(null);
|
||||
let loadingData = $state(true);
|
||||
let notFound = $state(false);
|
||||
let backlinks = $state<any[]>([]);
|
||||
let outgoingLinks = $state<any[]>([]);
|
||||
let existingSlugs = $state<Set<string>>(new Set());
|
||||
|
||||
let slug = $derived($page.params.slug);
|
||||
|
||||
let _prevSlug = $state('');
|
||||
$effect(() => {
|
||||
if (slug !== _prevSlug) {
|
||||
_prevSlug = slug;
|
||||
loadArticle();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadArticle() {
|
||||
loadingData = true;
|
||||
notFound = false;
|
||||
article = null;
|
||||
backlinks = [];
|
||||
outgoingLinks = [];
|
||||
try {
|
||||
const [articleRes, mapRes] = await Promise.all([
|
||||
wikiApi.get(slug),
|
||||
wikiApi.getMap()
|
||||
]);
|
||||
article = articleRes;
|
||||
backlinks = articleRes.backlinks ?? [];
|
||||
outgoingLinks = articleRes.outgoing_links ?? [];
|
||||
|
||||
// Build set of all existing slugs for wiki link rendering
|
||||
const slugs = new Set<string>();
|
||||
if (mapRes.articles) {
|
||||
for (const a of mapRes.articles) {
|
||||
slugs.add(a.slug);
|
||||
}
|
||||
}
|
||||
if (mapRes.hubs) {
|
||||
for (const a of mapRes.hubs) {
|
||||
slugs.add(a.slug);
|
||||
}
|
||||
}
|
||||
if (mapRes.orphans) {
|
||||
for (const a of mapRes.orphans) {
|
||||
slugs.add(a.slug);
|
||||
}
|
||||
}
|
||||
existingSlugs = slugs;
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
notFound = true;
|
||||
// Still load map to show who references this slug
|
||||
try {
|
||||
const mapRes = await wikiApi.getMap();
|
||||
// Check wanted articles for references
|
||||
const wantedEntry = mapRes.wanted?.find((w: any) => w.slug === slug);
|
||||
if (wantedEntry) {
|
||||
backlinks = (wantedEntry.referenced_by ?? []).map((s: string) => ({ slug: s, title: s }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}d ago`;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
// --- Markdown rendering with wiki links ---
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function stripHtml(text: string): string {
|
||||
return text.replace(/<[^>]*>?/g, '');
|
||||
}
|
||||
|
||||
function processInline(text: string): string {
|
||||
let result = '';
|
||||
const codeRegex = /`([^`]+)`/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = codeRegex.exec(text)) !== null) {
|
||||
result += processInlineNonCode(text.slice(lastIndex, match.index));
|
||||
result += `<code class="wiki-inline-code">${escapeHtml(match[1])}</code>`;
|
||||
lastIndex = codeRegex.lastIndex;
|
||||
}
|
||||
result += processInlineNonCode(text.slice(lastIndex));
|
||||
return result;
|
||||
}
|
||||
|
||||
function processInlineNonCode(text: string): string {
|
||||
// Extract URLs before escaping
|
||||
const urlPlaceholders: string[] = [];
|
||||
let s = text.replace(
|
||||
/https?:\/\/[^\s<>()[\]"'`]+/g,
|
||||
(url) => {
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const trailing = url.slice(cleaned.length);
|
||||
const idx = urlPlaceholders.length;
|
||||
urlPlaceholders.push(
|
||||
`<a href="${escapeHtml(cleaned)}" target="_blank" rel="noopener" class="wiki-ext-link">${escapeHtml(cleaned)}</a>${escapeHtml(trailing)}`
|
||||
);
|
||||
return `\x01URL${idx}\x01`;
|
||||
}
|
||||
);
|
||||
|
||||
// Extract wiki links before escaping
|
||||
const wikiPlaceholders: string[] = [];
|
||||
s = s.replace(
|
||||
/\[\[([a-z0-9][a-z0-9-]*[a-z0-9])(?:\|([^\]]+))?\]\]/g,
|
||||
(_, linkSlug, text) => {
|
||||
const display = text || linkSlug;
|
||||
const exists = existingSlugs.has(linkSlug);
|
||||
const cls = exists ? 'wiki-link' : 'wiki-link wanted';
|
||||
const idx = wikiPlaceholders.length;
|
||||
wikiPlaceholders.push(`<a href="/wiki/${linkSlug}" class="${cls}">${escapeHtml(display)}</a>`);
|
||||
return `\x02WIKI${idx}\x02`;
|
||||
}
|
||||
);
|
||||
|
||||
s = escapeHtml(s);
|
||||
|
||||
// Bold
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
// Italic
|
||||
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
||||
|
||||
// Restore placeholders
|
||||
s = s.replace(/\x01URL(\d+)\x01/g, (_, idx) => urlPlaceholders[parseInt(idx)]);
|
||||
s = s.replace(/\x02WIKI(\d+)\x02/g, (_, idx) => wikiPlaceholders[parseInt(idx)]);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function renderMarkdown(raw: string): string {
|
||||
const sanitized = stripHtml(raw);
|
||||
const blocks: string[] = [];
|
||||
const codeBlockRegex = /```(?:\w*)\n?([\s\S]*?)```/g;
|
||||
let processed = sanitized.replace(codeBlockRegex, (_match, code) => {
|
||||
const idx = blocks.length;
|
||||
blocks.push(`<pre class="wiki-code-block"><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
|
||||
return `\x00BLOCK${idx}\x00`;
|
||||
});
|
||||
|
||||
const paragraphs = processed.split(/\n{2,}/);
|
||||
const htmlParts: string[] = [];
|
||||
|
||||
for (const para of paragraphs) {
|
||||
if (/^\x00BLOCK\d+\x00$/.test(para.trim())) {
|
||||
const idx = parseInt(para.trim().replace(/\x00BLOCK(\d+)\x00/, '$1'));
|
||||
htmlParts.push(blocks[idx]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = para.split('\n');
|
||||
|
||||
// Blockquote
|
||||
const isBlockquote = lines.every(l => /^>\s?/.test(l) || l.trim() === '');
|
||||
if (isBlockquote && lines.some(l => l.trim() !== '')) {
|
||||
const content = lines
|
||||
.filter(l => /^>\s?/.test(l))
|
||||
.map(l => processInline(l.replace(/^>\s?/, '')))
|
||||
.join('<br>');
|
||||
htmlParts.push(`<blockquote class="wiki-blockquote">${content}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lists
|
||||
const isUnordered = lines.every(l => /^\s*[-*]\s/.test(l) || l.trim() === '');
|
||||
const isOrdered = lines.every(l => /^\s*\d+\.\s/.test(l) || l.trim() === '');
|
||||
|
||||
if (isUnordered && lines.some(l => l.trim() !== '')) {
|
||||
const items = lines
|
||||
.filter(l => /^\s*[-*]\s/.test(l))
|
||||
.map(l => `<li>${processInline(l.replace(/^\s*[-*]\s+/, ''))}</li>`)
|
||||
.join('');
|
||||
htmlParts.push(`<ul>${items}</ul>`);
|
||||
} else if (isOrdered && lines.some(l => l.trim() !== '')) {
|
||||
const items = lines
|
||||
.filter(l => /^\s*\d+\.\s/.test(l))
|
||||
.map(l => `<li>${processInline(l.replace(/^\s*\d+\.\s+/, ''))}</li>`)
|
||||
.join('');
|
||||
htmlParts.push(`<ol>${items}</ol>`);
|
||||
} else {
|
||||
const headerMatch = para.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
htmlParts.push(`<h${level}>${processInline(headerMatch[2])}</h${level}>`);
|
||||
} else {
|
||||
const inlineHtml = lines
|
||||
.map(l => {
|
||||
if (/\x00BLOCK\d+\x00/.test(l)) {
|
||||
return l.replace(/\x00BLOCK(\d+)\x00/g, (_, idx) => blocks[parseInt(idx)]);
|
||||
}
|
||||
return processInline(l);
|
||||
})
|
||||
.join('<br>');
|
||||
htmlParts.push(`<p>${inlineHtml}</p>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return htmlParts.join('');
|
||||
}
|
||||
|
||||
let renderedBody = $derived(article?.body ? renderMarkdown(article.body) : '');
|
||||
</script>
|
||||
|
||||
{#if loadingData}
|
||||
<div class="p-5 max-w-5xl">
|
||||
<div class="space-y-4">
|
||||
<div class="skeleton h-8 w-1/3"></div>
|
||||
<div class="skeleton h-4 w-1/2"></div>
|
||||
<div class="skeleton h-64 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if notFound}
|
||||
<div class="p-5 max-w-5xl">
|
||||
<div class="card p-8 text-center">
|
||||
<div class="w-12 h-12 rounded-2xl bg-accent-red/10 flex items-center justify-center mx-auto mb-3">
|
||||
<svg class="w-6 h-6 text-accent-red" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-lg font-bold text-text-primary font-display mb-2">Article Not Found</h1>
|
||||
<p class="text-sm text-text-secondary mb-4">
|
||||
The article <span class="font-mono text-accent-red">{slug}</span> doesn't exist yet.
|
||||
</p>
|
||||
<p class="text-xs text-text-secondary mb-4">
|
||||
Agents can create this article using the <code class="font-mono text-xs px-1 py-0.5 bg-bg-tertiary rounded">wiki_write</code> MCP tool.
|
||||
</p>
|
||||
{#if backlinks.length > 0}
|
||||
<div class="mt-6 text-left max-w-md mx-auto">
|
||||
<h3 class="text-sm font-semibold text-text-primary font-display mb-2">Referenced by</h3>
|
||||
<div class="space-y-1">
|
||||
{#each backlinks as link}
|
||||
<a href="/wiki/{link.slug}" class="block px-3 py-2 rounded hover:bg-bg-tertiary/50 transition-colors text-sm text-accent-blue">
|
||||
{link.title || link.slug}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<a href="/wiki" class="text-sm text-accent-blue hover:underline">← Back to Wiki</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if article}
|
||||
<div class="p-5 max-w-5xl">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-4 flex items-center gap-2 text-xs text-text-secondary">
|
||||
<a href="/wiki" class="hover:text-text-primary transition-colors">Wiki</a>
|
||||
<span>/</span>
|
||||
<span class="text-text-primary">{article.title}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold text-text-primary font-display mb-3">{article.title}</h1>
|
||||
|
||||
<!-- Metadata bar -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5 text-xs text-text-secondary">
|
||||
<span>Updated {formatTime(article.updated_at)}</span>
|
||||
<span class="w-px h-3 bg-border"></span>
|
||||
<span>Rev {article.revision}</span>
|
||||
<span class="w-px h-3 bg-border"></span>
|
||||
<span>by {article.author}</span>
|
||||
{#if article.word_count}
|
||||
<span class="w-px h-3 bg-border"></span>
|
||||
<span>{article.word_count} words</span>
|
||||
{/if}
|
||||
<span class="w-px h-3 bg-border"></span>
|
||||
<a href="/wiki/{slug}/history" class="text-accent-blue hover:underline flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
History
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Article body -->
|
||||
<div class="card p-6">
|
||||
<div class="wiki-article">
|
||||
{@html renderedBody}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links section (below article on narrow screens) -->
|
||||
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{#if backlinks.length > 0}
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-text-primary font-display mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-accent-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
|
||||
</svg>
|
||||
Backlinks
|
||||
<span class="badge bg-bg-tertiary text-text-secondary text-[10px]">{backlinks.length}</span>
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
{#each backlinks as link}
|
||||
<a href="/wiki/{link.slug}" class="block px-2 py-1.5 rounded text-sm text-accent-blue hover:bg-bg-tertiary/50 transition-colors">
|
||||
{link.title || link.slug}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if outgoingLinks.length > 0}
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-text-primary font-display mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-accent-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Outgoing Links
|
||||
<span class="badge bg-bg-tertiary text-text-secondary text-[10px]">{outgoingLinks.length}</span>
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
{#each outgoingLinks as link}
|
||||
<a href="/wiki/{link.slug}" class="block px-2 py-1.5 rounded text-sm transition-colors {link.exists ? 'text-accent-blue hover:bg-bg-tertiary/50' : 'text-accent-red hover:bg-bg-tertiary/50'}" style="{link.exists ? '' : 'text-decoration: underline dashed;'}">
|
||||
{link.title || link.slug}
|
||||
{#if !link.exists}
|
||||
<span class="text-[10px] text-accent-red ml-1">(wanted)</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Wiki article typography */
|
||||
.wiki-article {
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.wiki-article :global(h1) {
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.wiki-article :global(h2) {
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.wiki-article :global(h3) {
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.wiki-article :global(p) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.wiki-article :global(ul),
|
||||
.wiki-article :global(ol) {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.wiki-article :global(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.wiki-article :global(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.wiki-article :global(li) {
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
.wiki-article :global(.wiki-inline-code) {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
.wiki-article :global(.wiki-code-block) {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8em;
|
||||
background: #0d1117;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
white-space: pre;
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.wiki-article :global(.wiki-code-block code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.wiki-article :global(.wiki-ext-link),
|
||||
.wiki-article :global(a:not(.wiki-link)) {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wiki-article :global(.wiki-ext-link:hover),
|
||||
.wiki-article :global(a:not(.wiki-link):hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.wiki-article :global(.wiki-link) {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.wiki-article :global(.wiki-link:hover) {
|
||||
color: #60a5fa;
|
||||
}
|
||||
.wiki-article :global(.wiki-link.wanted) {
|
||||
color: #ef4444;
|
||||
text-decoration: underline dashed;
|
||||
}
|
||||
.wiki-article :global(.wiki-link.wanted:hover) {
|
||||
color: #f87171;
|
||||
}
|
||||
.wiki-article :global(.wiki-blockquote) {
|
||||
border-left: 3px solid #3b82f6;
|
||||
padding-left: 1rem;
|
||||
color: #94a3b8;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.wiki-article :global(strong) {
|
||||
font-weight: 700;
|
||||
}
|
||||
.wiki-article :global(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { wiki as wikiApi } from '$lib/api/client';
|
||||
|
||||
let revisions = $state<any[]>([]);
|
||||
let articleTitle = $state('');
|
||||
let loadingData = $state(true);
|
||||
let expandedRevision = $state<number | null>(null);
|
||||
|
||||
let slug = $derived($page.params.slug);
|
||||
|
||||
let _prevSlug = $state('');
|
||||
$effect(() => {
|
||||
if (slug !== _prevSlug) {
|
||||
_prevSlug = slug;
|
||||
loadHistory();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadHistory() {
|
||||
loadingData = true;
|
||||
try {
|
||||
const [historyRes, articleRes] = await Promise.all([
|
||||
wikiApi.getHistory(slug),
|
||||
wikiApi.get(slug).catch(() => null)
|
||||
]);
|
||||
revisions = historyRes.revisions ?? [];
|
||||
articleTitle = articleRes?.title ?? slug;
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||||
' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}d ago`;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function wordCountDiff(current: number, previous: number | undefined): string {
|
||||
if (previous === undefined) return `+${current}`;
|
||||
const diff = current - previous;
|
||||
if (diff === 0) return '0';
|
||||
return diff > 0 ? `+${diff}` : String(diff);
|
||||
}
|
||||
|
||||
function wordCountDiffClass(current: number, previous: number | undefined): string {
|
||||
if (previous === undefined) return 'text-accent-green';
|
||||
const diff = current - previous;
|
||||
if (diff > 0) return 'text-accent-green';
|
||||
if (diff < 0) return 'text-accent-red';
|
||||
return 'text-text-secondary';
|
||||
}
|
||||
|
||||
function toggleRevision(rev: number) {
|
||||
expandedRevision = expandedRevision === rev ? null : rev;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-4 flex items-center gap-2 text-xs text-text-secondary">
|
||||
<a href="/wiki" class="hover:text-text-primary transition-colors">Wiki</a>
|
||||
<span>/</span>
|
||||
<a href="/wiki/{slug}" class="hover:text-text-primary transition-colors">{articleTitle || slug}</a>
|
||||
<span>/</span>
|
||||
<span class="text-text-primary">History</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-text-primary font-display">Revision History</h1>
|
||||
<p class="text-sm text-text-secondary mt-0.5">
|
||||
<a href="/wiki/{slug}" class="text-accent-blue hover:underline">{articleTitle || slug}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loadingData}
|
||||
<div class="card p-5 space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="skeleton h-12 w-full"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if revisions.length === 0}
|
||||
<div class="card p-8 text-center text-text-secondary text-sm">
|
||||
No revisions found.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card">
|
||||
<!-- Header row -->
|
||||
<div class="hidden sm:grid grid-cols-[60px_1fr_120px_80px_80px] gap-2 px-5 py-2 border-b border-border text-xs text-text-secondary font-medium">
|
||||
<span>Rev</span>
|
||||
<span>Author</span>
|
||||
<span>Date</span>
|
||||
<span class="text-right">Words</span>
|
||||
<span class="text-right">Change</span>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
{#each revisions as rev, i}
|
||||
{@const prevWordCount = i < revisions.length - 1 ? revisions[i + 1]?.word_count : undefined}
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-5 py-3 hover:bg-bg-tertiary/50 transition-colors text-left"
|
||||
onclick={() => toggleRevision(rev.revision)}
|
||||
>
|
||||
<div class="sm:grid sm:grid-cols-[60px_1fr_120px_80px_80px] sm:gap-2 sm:items-center">
|
||||
<span class="text-sm font-mono text-text-primary">#{rev.revision}</span>
|
||||
<span class="text-sm text-text-primary">{rev.author}</span>
|
||||
<span class="text-xs text-text-secondary" title={formatTimestamp(rev.created_at)}>{formatTime(rev.created_at)}</span>
|
||||
<span class="text-sm text-text-secondary text-right font-mono">{rev.word_count ?? '-'}</span>
|
||||
<span class="text-sm text-right font-mono {wordCountDiffClass(rev.word_count ?? 0, prevWordCount)}">
|
||||
{wordCountDiff(rev.word_count ?? 0, prevWordCount)}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Mobile layout -->
|
||||
<div class="sm:hidden flex flex-wrap items-center gap-2 mt-1">
|
||||
<span class="text-xs text-text-secondary">{rev.author}</span>
|
||||
<span class="text-xs text-text-secondary">{formatTime(rev.created_at)}</span>
|
||||
{#if rev.word_count}
|
||||
<span class="text-xs text-text-secondary">{rev.word_count} words</span>
|
||||
{/if}
|
||||
<span class="text-xs font-mono {wordCountDiffClass(rev.word_count ?? 0, prevWordCount)}">
|
||||
({wordCountDiff(rev.word_count ?? 0, prevWordCount)})
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{#if expandedRevision === rev.revision}
|
||||
<div class="px-5 pb-4">
|
||||
<div class="bg-bg-primary rounded-lg border border-border p-4 text-sm text-text-primary/90 font-mono whitespace-pre-wrap leading-relaxed max-h-96 overflow-y-auto">
|
||||
{rev.body ?? '(no body)'}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user