Files
tools/docs/app/pages/[package]/[utility].vue
robonen 8adc2522c6 docs: site WIP, extractor type cleanup, tests preset; add broadcastedRef
Type the docs extractor's package.json parsing as unknown; comment the Vite
plugin version-skew cast; wire the tests preset; site/architecture WIP.
2026-06-15 16:55:22 +07:00

264 lines
14 KiB
Vue

<script setup lang="ts">import { demos } from '#docs/demos';
import { sections } from '#docs/sections';
const route = useRoute();
const { resolveEntry } = useDocs();
const packageSlug = computed(() => route.params.package as string);
const utilitySlug = computed(() => route.params.utility as string);
const entry = computed(() => resolveEntry(packageSlug.value, utilitySlug.value));
if (!entry.value) {
throw createError({
statusCode: 404,
message: `"${utilitySlug.value}" not found in package "${packageSlug.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()
.replaceAll(/[^a-z0-9]+/g, '-')
.replaceAll(/(^-|-$)/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}`;
}
// ── Page title & TOC ─────────────────────────────────────────────────────────
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;
});
useHead(() => ({
title: `${title.value}${pkg.value.name}`,
meta: [{
name: 'description',
content: entry.value?.kind === 'api' ? entry.value.item.description : pkg.value.description,
}],
}));
const toc = computed(() => {
const e = entry.value;
if (!e) return [];
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 }));
}
// api: derive from present sections
const i = e.item;
const items: Array<{ id: string; text: string; depth: number }> = [];
if (i.examples.length) items.push({ id: 'example', text: 'Example', depth: 2 });
if (i.hasDemo && demoComponent.value) items.push({ id: 'demo', text: 'Demo', depth: 2 });
if (i.signatures.length) items.push({ id: 'signature', text: 'Signature', depth: 2 });
if (i.typeParams.length) items.push({ id: 'type-parameters', text: 'Type Parameters', depth: 2 });
if (i.params.length) items.push({ id: 'parameters', text: 'Parameters', depth: 2 });
if (i.returns) items.push({ id: 'returns', text: 'Returns', depth: 2 });
if (i.properties.length) items.push({ id: 'properties', text: 'Properties', depth: 2 });
if (i.methods.length) items.push({ id: 'methods', text: 'Methods', depth: 2 });
if (i.relatedTypes?.length) items.push({ id: 'related-types', text: 'Related Types', depth: 2 });
return items;
});
const sectionTitle = 'comment-label mb-3';
</script>
<template>
<div v-if="entry" class="xl:grid xl:grid-cols-[minmax(0,1fr)_14rem] xl:gap-12">
<article class="min-w-0 max-w-3xl">
<!-- Breadcrumb -->
<nav class="flex items-center gap-1.5 font-mono text-[13px] text-fg-subtle mb-6">
<NuxtLink :to="`/${pkg.slug}`" class="hover:text-fg transition-colors">{{ pkg.name }}</NuxtLink>
<span>/</span>
<span class="text-fg">{{ title }}</span>
</nav>
<!-- API ITEM -->
<template v-if="entry.kind === 'api'">
<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="min-w-0 break-words text-[1.6rem] font-semibold 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="typeof entry.item.coverage === 'number' ? `tested · ${entry.item.coverage}%` : 'tested'"
variant="test"
:title="typeof entry.item.coverage === 'number' ? `${entry.item.coverage}% statement coverage` : undefined"
/>
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
</div>
<p v-if="entry.item.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.item.description" />
</p>
<div class="flex items-center gap-4 mt-4 text-sm">
<a :href="ghUrl(entry.item.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
Source
</a>
<a v-if="entry.item.hasTests" :href="ghUrl(entry.item.sourcePath).replace('index.ts', 'index.test.ts')" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /><polyline points="14 2 14 8 20 8" /><path d="m9 15 2 2 4-4" /></svg>
Tests
</a>
</div>
</header>
<section v-if="entry.item.examples.length" id="example" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">{{ entry.item.examples.length > 1 ? 'Examples' : 'Example' }}</h2>
<div class="space-y-3">
<DocsCode v-for="(ex, i) in entry.item.examples" :key="i" :code="ex" />
</div>
</section>
<section v-if="entry.item.hasDemo && demoComponent" id="demo" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Demo</h2>
<DocsDemo :component="demoComponent" :source-key="`${packageSlug}/${utilitySlug}`" />
</section>
<section v-if="entry.item.signatures.length" id="signature" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">{{ entry.item.signatures.length > 1 ? 'Signatures' : 'Signature' }}</h2>
<div class="space-y-2">
<DocsCode v-for="(sig, i) in entry.item.signatures" :key="i" :code="sig" />
</div>
</section>
<section v-if="entry.item.typeParams.length" id="type-parameters" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Type Parameters</h2>
<div class="space-y-1.5">
<div v-for="tp in entry.item.typeParams" :key="tp.name" class="flex items-baseline gap-2 text-sm flex-wrap">
<code class="font-mono font-medium text-accent-text">{{ tp.name }}</code>
<span v-if="tp.constraint" class="text-fg-subtle">extends <code class="font-mono text-xs">{{ tp.constraint }}</code></span>
<span v-if="tp.default" class="text-fg-subtle">= <code class="font-mono text-xs">{{ tp.default }}</code></span>
</div>
</div>
</section>
<section v-if="entry.item.params.length" id="parameters" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Parameters</h2>
<DocsParamsTable :params="entry.item.params" />
</section>
<section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Returns</h2>
<div class="flex items-baseline gap-2 text-sm flex-wrap" :class="entry.item.returns.properties?.length ? 'mb-3' : ''">
<code class="font-mono bg-bg-inset border border-border px-2 py-1 rounded text-xs wrap-break-word">{{ entry.item.returns.type }}</code>
<DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-fg-muted" />
</div>
<DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" />
</section>
<section v-if="entry.item.properties.length" id="properties" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Properties</h2>
<DocsPropsTable :properties="entry.item.properties" />
</section>
<section v-if="entry.item.methods.length" id="methods" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Methods</h2>
<DocsMethodsList :methods="entry.item.methods" />
</section>
<section v-if="entry.item.relatedTypes?.length" id="related-types" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Related Types</h2>
<div class="space-y-4">
<div v-for="rt in entry.item.relatedTypes" :key="rt.name" class="rounded-xl border border-border bg-bg-subtle p-4">
<div class="flex items-center gap-2 mb-2">
<DocsBadge :kind="rt.kind" size="sm" />
<h3 class="font-mono font-semibold text-sm text-fg">{{ rt.name }}</h3>
</div>
<p v-if="rt.description" class="text-sm text-fg-muted mb-3">
<DocsText :text="rt.description" />
</p>
<DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" />
<DocsPropsTable v-if="rt.properties.length" :properties="rt.properties" class="mt-3" />
</div>
</div>
</section>
</template>
<!-- COMPONENT -->
<template v-else-if="entry.kind === 'components'">
<header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge kind="component" size="md" />
<h1 class="font-display text-[1.7rem] font-bold tracking-tight text-fg">{{ entry.component.name }}</h1>
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
</div>
<p v-if="entry.component.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.component.description" />
</p>
<div class="flex items-center gap-4 mt-4 text-sm">
<a :href="ghUrl(entry.component.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
Source
</a>
</div>
</header>
<section v-if="entry.component.hasDemo && demoComponent" class="mb-10">
<h2 :class="sectionTitle">Demo</h2>
<DocsDemo :component="demoComponent" :source-key="`${packageSlug}/${utilitySlug}`" />
</section>
<DocsComponentAnatomy :component="entry.component" :package-name="pkg.name" />
</template>
<!-- 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 -->
<aside class="hidden xl:block">
<div class="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
<DocsToc :items="toc" />
</div>
</aside>
</div>
</template>