feat(docs): doc-sections system, crdt package, MCP server, and responsive fixes
Adds a hand-authored .vue doc-sections system (intro + guide pages per package, #docs/sections map, sidebar Guide group, client-side TOC), registers @robonen/crdt, renders demos client-only, base64-encodes the server-metadata virtual, plus the MCP docs endpoint and responsive/overflow fixes across pages and tables.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">import type { Component } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
component: Component;
|
||||
@@ -18,9 +19,20 @@ watch(showSource, async (show) => {
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-(--border) overflow-hidden">
|
||||
<!-- Live demo -->
|
||||
<div class="p-8 bg-(--bg-subtle) flex items-center justify-center min-h-32">
|
||||
<component :is="component" />
|
||||
<!-- Live demo — client-only: demos are interactive and use browser APIs,
|
||||
so they must not be instantiated during SSR/prerender. -->
|
||||
<div class="p-4 sm:p-8 bg-(--bg-subtle) flex items-center justify-center min-h-32">
|
||||
<ClientOnly>
|
||||
<component :is="component" />
|
||||
<template #fallback>
|
||||
<div class="flex items-center gap-2 text-sm text-(--fg-subtle)">
|
||||
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Loading demo…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Source toggle bar -->
|
||||
|
||||
@@ -6,7 +6,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="emits.length > 0" class="overflow-hidden rounded-xl border border-(--border)">
|
||||
<div v-if="emits.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-(--bg-subtle) text-left">
|
||||
|
||||
@@ -6,7 +6,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="params.length > 0" class="overflow-hidden rounded-xl border border-(--border)">
|
||||
<div v-if="params.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-(--bg-subtle) text-left">
|
||||
|
||||
@@ -8,7 +8,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="properties.length > 0" class="overflow-hidden rounded-xl border border-(--border)">
|
||||
<div v-if="properties.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-(--bg-subtle) text-left">
|
||||
|
||||
@@ -2,6 +2,7 @@ import metadata from '#docs/metadata';
|
||||
import type {
|
||||
CategoryMeta,
|
||||
ComponentMeta,
|
||||
DocSection,
|
||||
DocsMetadata,
|
||||
GuideSection,
|
||||
ItemMeta,
|
||||
@@ -13,7 +14,8 @@ import type {
|
||||
export type DocEntry
|
||||
= | { kind: 'api'; pkg: PackageMeta; category: CategoryMeta; item: ItemMeta }
|
||||
| { kind: 'components'; pkg: PackageMeta; component: ComponentMeta }
|
||||
| { kind: 'guide'; pkg: PackageMeta; section: GuideSection };
|
||||
| { kind: 'guide'; pkg: PackageMeta; section: GuideSection }
|
||||
| { kind: 'doc'; pkg: PackageMeta; section: DocSection };
|
||||
|
||||
export interface SearchResult {
|
||||
pkg: PackageMeta;
|
||||
@@ -62,11 +64,25 @@ export function useDocs() {
|
||||
return pkg.sections.length;
|
||||
}
|
||||
|
||||
/** The hand-authored intro section for a package, if any. */
|
||||
function getIntro(pkg: PackageMeta): DocSection | undefined {
|
||||
return pkg.docs.find(s => s.isIntro);
|
||||
}
|
||||
|
||||
/** Non-intro doc sections (the "Guide" list shown in the sidebar). */
|
||||
function getDocSections(pkg: PackageMeta): DocSection[] {
|
||||
return pkg.docs.filter(s => !s.isIntro);
|
||||
}
|
||||
|
||||
/** Resolve any `/:package/:slug` route to a normalised entry. */
|
||||
function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined {
|
||||
const pkg = getPackage(packageSlug);
|
||||
if (!pkg) return undefined;
|
||||
|
||||
// Hand-authored doc sections take precedence over auto-generated leaves.
|
||||
const docSection = pkg.docs.find(s => !s.isIntro && s.slug === slug);
|
||||
if (docSection) return { kind: 'doc', pkg, section: docSection };
|
||||
|
||||
if (pkg.kind === 'api') {
|
||||
for (const category of pkg.categories) {
|
||||
const item = category.items.find(i => i.slug === slug);
|
||||
@@ -139,6 +155,8 @@ export function useDocs() {
|
||||
countEntries,
|
||||
resolveEntry,
|
||||
firstEntrySlug,
|
||||
getIntro,
|
||||
getDocSections,
|
||||
search,
|
||||
getTotalItems,
|
||||
};
|
||||
|
||||
@@ -9,10 +9,10 @@ export interface Heading {
|
||||
export function slugHeading(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/`/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replaceAll('`', '')
|
||||
.replaceAll(/[^\w\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-');
|
||||
.replaceAll(/\s+/g, '-');
|
||||
}
|
||||
|
||||
/** Collect h2/h3 headings for the table of contents. */
|
||||
@@ -28,11 +28,13 @@ export function extractHeadings(markdown: string): Heading[] {
|
||||
}
|
||||
if (inFence) continue;
|
||||
|
||||
const m = line.match(/^(#{2,3})\s+(.+?)\s*#*$/);
|
||||
const m = line.match(/^(#{2,3})\s+(\S.*)$/);
|
||||
if (!m) continue;
|
||||
|
||||
const depth = m[1]!.length;
|
||||
const text = m[2]!.replace(/`/g, '').trim();
|
||||
// Strip an optional ATX closing run (a single space then trailing `#`s) without
|
||||
// a backtracking-prone pattern, then drop inline code ticks.
|
||||
const text = m[2]!.replace(/ #+ *$/, '').replaceAll('`', '').trim();
|
||||
let id = slugHeading(text);
|
||||
const count = seen.get(id) ?? 0;
|
||||
seen.set(id, count + 1);
|
||||
@@ -51,7 +53,7 @@ export function renderMarkdown(markdown: string): string {
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = function ({ tokens, depth }) {
|
||||
const inner = this.parser.parseInline(tokens);
|
||||
const plain = inner.replace(/<[^>]+>/g, '');
|
||||
const plain = inner.replaceAll(/<[^>]+>/g, '');
|
||||
let id = slugHeading(plain);
|
||||
const count = seen.get(id) ?? 0;
|
||||
seen.set(id, count + 1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">const { getGroupedPackages, getPackage } = useDocs();
|
||||
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections } = useDocs();
|
||||
const groups = getGroupedPackages();
|
||||
|
||||
const route = useRoute();
|
||||
@@ -91,6 +91,39 @@ watch(() => route.path, () => {
|
||||
|
||||
<!-- Expanded tree for the current package -->
|
||||
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1 mb-2 ml-2 pl-3 border-l border-(--border)">
|
||||
<!-- Hand-authored guide sections (intro + prose pages) -->
|
||||
<div v-if="currentPackage.docs.length" class="mb-2">
|
||||
<div class="text-[11px] font-medium text-(--fg-subtle) py-1 px-1">Guide</div>
|
||||
<ul>
|
||||
<li v-if="getIntro(currentPackage)">
|
||||
<NuxtLink
|
||||
:to="`/${pkg.slug}`"
|
||||
:class="[
|
||||
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
||||
route.path === `/${pkg.slug}`
|
||||
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
|
||||
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||
]"
|
||||
>
|
||||
Introduction
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li v-for="s in getDocSections(currentPackage)" :key="s.slug">
|
||||
<NuxtLink
|
||||
:to="`/${pkg.slug}/${s.slug}`"
|
||||
:class="[
|
||||
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
||||
isActive(pkg.slug, s.slug)
|
||||
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
|
||||
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||
]"
|
||||
>
|
||||
{{ s.title }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- api -->
|
||||
<template v-if="currentPackage.kind === 'api'">
|
||||
<div v-for="cat in currentPackage.categories" :key="cat.slug" class="mb-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">import { demos } from '#docs/demos';
|
||||
import { sections } from '#docs/sections';
|
||||
|
||||
const route = useRoute();
|
||||
const { resolveEntry } = useDocs();
|
||||
@@ -18,6 +19,35 @@ if (!entry.value) {
|
||||
const pkg = computed(() => entry.value!.pkg);
|
||||
|
||||
const demoComponent = computed(() => demos[`${packageSlug.value}/${utilitySlug.value}`] ?? null);
|
||||
const sectionComponent = computed(() => sections[`${packageSlug.value}/${utilitySlug.value}`] ?? null);
|
||||
|
||||
// ── Doc sections: client-side TOC built from the rendered headings ───────────
|
||||
const docRoot = ref<HTMLElement | null>(null);
|
||||
const docToc = ref<Array<{ id: string; text: string; depth: number }>>([]);
|
||||
|
||||
function buildDocToc() {
|
||||
const el = docRoot.value;
|
||||
if (!el) return;
|
||||
docToc.value = Array.from(el.querySelectorAll('h2, h3')).map((h) => {
|
||||
if (!h.id) {
|
||||
h.id = (h.textContent ?? '')
|
||||
.toLowerCase().trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
}
|
||||
return { id: h.id, text: h.textContent ?? '', depth: h.tagName === 'H2' ? 2 : 3 };
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = docRoot.value;
|
||||
if (!el) return;
|
||||
buildDocToc();
|
||||
// The section is an async component — rebuild once its content mounts.
|
||||
const observer = new MutationObserver(() => buildDocToc());
|
||||
observer.observe(el, { childList: true, subtree: true });
|
||||
onScopeDispose(() => observer.disconnect());
|
||||
});
|
||||
|
||||
function ghUrl(path: string) {
|
||||
return `https://github.com/robonen/tools/blob/master/${path}`;
|
||||
@@ -28,6 +58,7 @@ const title = computed(() => {
|
||||
const e = entry.value!;
|
||||
if (e.kind === 'api') return e.item.name;
|
||||
if (e.kind === 'components') return e.component.name;
|
||||
if (e.kind === 'doc') return e.section.title;
|
||||
return e.section.title;
|
||||
});
|
||||
|
||||
@@ -46,6 +77,9 @@ const toc = computed(() => {
|
||||
if (e.kind === 'guide') {
|
||||
return extractHeadings(e.section.markdown).map(h => ({ id: h.id, text: h.text, depth: h.depth }));
|
||||
}
|
||||
if (e.kind === 'doc') {
|
||||
return docToc.value;
|
||||
}
|
||||
if (e.kind === 'components') {
|
||||
return e.component.parts.map(p => ({ id: p.name.toLowerCase(), text: p.name, depth: 2 }));
|
||||
}
|
||||
@@ -82,7 +116,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
<header class="mb-8">
|
||||
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
||||
<DocsBadge :kind="entry.item.kind" size="md" />
|
||||
<h1 class="text-2xl font-bold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
|
||||
<h1 class="min-w-0 break-words text-2xl font-bold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
|
||||
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
|
||||
<DocsTag v-if="entry.item.hasTests" label="tested" variant="test" />
|
||||
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
|
||||
@@ -194,10 +228,17 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
<DocsComponentAnatomy :component="entry.component" :package-name="pkg.name" />
|
||||
</template>
|
||||
|
||||
<!-- ── GUIDE ──────────────────────────────────────────────────────── -->
|
||||
<template v-else>
|
||||
<!-- ── GUIDE (Markdown) ───────────────────────────────────────────── -->
|
||||
<template v-else-if="entry.kind === 'guide'">
|
||||
<DocsMarkdown :source="entry.section.markdown" />
|
||||
</template>
|
||||
|
||||
<!-- ── DOC SECTION (hand-authored .vue) ───────────────────────────── -->
|
||||
<template v-else-if="entry.kind === 'doc'">
|
||||
<div ref="docRoot" class="docs-section">
|
||||
<component :is="sectionComponent" />
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
|
||||
<!-- Right rail TOC -->
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">const route = useRoute();
|
||||
const { getPackage, countEntries } = useDocs();
|
||||
<script setup lang="ts">import { sections } from '#docs/sections';
|
||||
|
||||
const route = useRoute();
|
||||
const { getPackage, countEntries, getIntro } = useDocs();
|
||||
|
||||
const slug = computed(() => route.params.package as string);
|
||||
const pkg = computed(() => getPackage(slug.value));
|
||||
@@ -8,6 +10,10 @@ if (!pkg.value) {
|
||||
throw createError({ statusCode: 404, message: `Package "${slug.value}" not found` });
|
||||
}
|
||||
|
||||
// Hand-authored intro (docs/intro.vue) renders as the package hero when present.
|
||||
const intro = computed(() => getIntro(pkg.value!));
|
||||
const introComponent = computed(() => intro.value ? sections[`${slug.value}/introduction`] ?? null : null);
|
||||
|
||||
useHead({ title: `${pkg.value.name} — @robonen/tools` });
|
||||
|
||||
const kindLabel = computed(() => ({
|
||||
@@ -27,9 +33,14 @@ const otherSections = computed(() =>
|
||||
|
||||
<template>
|
||||
<div v-if="pkg" class="max-w-3xl">
|
||||
<!-- Header -->
|
||||
<header class="mb-8 pb-8 border-b border-(--border)">
|
||||
<div class="flex items-center gap-2.5 mb-2">
|
||||
<!-- Hand-authored intro hero (docs/intro.vue) -->
|
||||
<section v-if="introComponent" class="docs-section mb-12">
|
||||
<component :is="introComponent" />
|
||||
</section>
|
||||
|
||||
<!-- Auto header (shown only when there's no hand-authored intro) -->
|
||||
<header v-else class="mb-8 pb-8 border-b border-(--border)">
|
||||
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
||||
<h1 class="font-mono text-2xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1>
|
||||
<DocsTag :label="`v${pkg.version}`" variant="neutral" />
|
||||
</div>
|
||||
@@ -44,6 +55,11 @@ const otherSections = computed(() =>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- When an intro replaces the header, label the auto-generated reference -->
|
||||
<h2 v-if="introComponent && pkg.kind === 'api'" class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4 pt-2">
|
||||
API Reference
|
||||
</h2>
|
||||
|
||||
<!-- API: categories of items -->
|
||||
<template v-if="pkg.kind === 'api'">
|
||||
<section v-for="category in pkg.categories" :key="category.slug" class="mb-10">
|
||||
@@ -51,7 +67,7 @@ const otherSections = computed(() =>
|
||||
{{ category.name }}
|
||||
<span class="ml-1 text-(--fg-subtle) normal-case font-normal">· {{ category.items.length }}</span>
|
||||
</h2>
|
||||
<div class="grid gap-2">
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<NuxtLink
|
||||
v-for="item in category.items"
|
||||
:key="item.slug"
|
||||
@@ -81,7 +97,7 @@ const otherSections = computed(() =>
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
|
||||
All components <span class="normal-case font-normal">· {{ pkg.components.length }}</span>
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<NuxtLink
|
||||
v-for="c in pkg.components"
|
||||
:key="c.slug"
|
||||
@@ -113,7 +129,7 @@ const otherSections = computed(() =>
|
||||
<DocsMarkdown v-if="overview" :source="overview.markdown" />
|
||||
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">Sections</h2>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<NuxtLink
|
||||
v-for="s in otherSections"
|
||||
:key="s.slug"
|
||||
|
||||
@@ -39,7 +39,7 @@ useHead({ title: '@robonen/tools — Documentation' });
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
|
||||
{{ grp.label }}
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<NuxtLink
|
||||
v-for="pkg in grp.packages"
|
||||
:key="pkg.slug"
|
||||
|
||||
Reference in New Issue
Block a user