8adc2522c6
Type the docs extractor's package.json parsing as unknown; comment the Vite plugin version-skew cast; wire the tests preset; site/architecture WIP.
298 lines
14 KiB
Vue
298 lines
14 KiB
Vue
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections, getComponentGroups } = useDocs();
|
||
const groups = getGroupedPackages();
|
||
|
||
const route = useRoute();
|
||
const isSidebarOpen = ref(false);
|
||
|
||
const currentPackageSlug = computed(() => {
|
||
const param = route.params.package;
|
||
return typeof param === 'string' ? param : undefined;
|
||
});
|
||
|
||
const currentPackage = computed(() =>
|
||
currentPackageSlug.value ? getPackage(currentPackageSlug.value) : undefined,
|
||
);
|
||
|
||
function isActive(pkgSlug: string, slug: string) {
|
||
return route.path === `/${pkgSlug}/${slug}`;
|
||
}
|
||
|
||
// ── Category tree: collapsed by default, filterable ───────────────────────
|
||
const navQuery = ref('');
|
||
const openCategories = ref(new Set<string>());
|
||
|
||
function toggleCategory(slug: string) {
|
||
if (openCategories.value.has(slug))
|
||
openCategories.value.delete(slug);
|
||
else
|
||
openCategories.value.add(slug);
|
||
|
||
openCategories.value = new Set(openCategories.value);
|
||
}
|
||
|
||
// Auto-open the category that contains the current page
|
||
watch([currentPackage, () => route.path], () => {
|
||
const pkg = currentPackage.value;
|
||
|
||
if (!pkg || pkg.kind !== 'api') return;
|
||
|
||
for (const category of pkg.categories) {
|
||
if (category.items.some(item => isActive(pkg.slug, item.slug)))
|
||
openCategories.value.add(category.slug);
|
||
}
|
||
|
||
openCategories.value = new Set(openCategories.value);
|
||
}, { immediate: true });
|
||
|
||
// Reset the filter when navigating to another package
|
||
watch(currentPackageSlug, () => {
|
||
navQuery.value = '';
|
||
});
|
||
|
||
const visibleCategories = computed(() => {
|
||
const pkg = currentPackage.value;
|
||
|
||
if (!pkg || pkg.kind !== 'api') return [];
|
||
|
||
const query = navQuery.value.trim().toLowerCase();
|
||
|
||
if (!query) return pkg.categories;
|
||
|
||
return pkg.categories
|
||
.map(category => ({
|
||
...category,
|
||
items: category.items.filter(item => item.name.toLowerCase().includes(query)),
|
||
}))
|
||
.filter(category => category.items.length > 0);
|
||
});
|
||
|
||
function isCategoryOpen(slug: string) {
|
||
// Filtering expands every matching category
|
||
return navQuery.value.trim() !== '' || openCategories.value.has(slug);
|
||
}
|
||
|
||
watch(() => route.path, () => {
|
||
isSidebarOpen.value = false;
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="min-h-screen">
|
||
<!-- Header -->
|
||
<header class="sticky top-0 z-50 border-b border-border backdrop-blur-md" style="background-color: var(--header-bg)">
|
||
<div class="mx-auto max-w-352 flex items-center gap-3 px-4 h-14 sm:px-6">
|
||
<button
|
||
type="button"
|
||
class="lg:hidden inline-flex items-center justify-center w-9 h-9 -ml-1.5 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset"
|
||
aria-label="Toggle navigation"
|
||
@click="isSidebarOpen = !isSidebarOpen"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
||
</svg>
|
||
</button>
|
||
|
||
<NuxtLink to="/" class="group flex items-center gap-2.5 mr-auto">
|
||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-accent text-accent-fg font-mono text-[13px] font-semibold leading-none select-none">
|
||
❯
|
||
</span>
|
||
<span class="hidden sm:flex items-baseline font-mono text-[13.5px] tracking-tight">
|
||
<span class="text-fg-subtle">~/</span><span class="text-fg font-medium">robonen</span><span class="text-fg-subtle">/</span><span class="text-accent-text font-medium">tools</span>
|
||
<span class="ml-1 inline-block w-1.75 h-3.75 translate-y-0.5 bg-accent opacity-0 group-hover:opacity-80 group-hover:animate-pulse" />
|
||
</span>
|
||
</NuxtLink>
|
||
|
||
<DocsSearch />
|
||
<DocsThemeToggle />
|
||
<a
|
||
href="https://github.com/robonen/tools"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset transition-colors"
|
||
aria-label="GitHub"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||
</svg>
|
||
</a>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="mx-auto max-w-352 w-full flex px-4 sm:px-6">
|
||
<!-- Sidebar -->
|
||
<aside
|
||
:class="[
|
||
'fixed inset-y-0 left-0 z-40 w-72 bg-bg border-r border-border pt-14 transform transition-transform lg:sticky lg:top-14 lg:z-auto lg:h-[calc(100vh-3.5rem)] lg:w-64 lg:shrink-0 lg:translate-x-0 lg:pt-0 lg:border-r-0 lg:bg-transparent',
|
||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||
]"
|
||
>
|
||
<nav class="h-full overflow-y-auto py-8 px-4 lg:pr-6 lg:pl-0 overscroll-contain">
|
||
<div v-for="grp in groups" :key="grp.group" class="mb-7">
|
||
<div class="comment-label mb-2 px-2">{{ grp.label.toLowerCase() }}</div>
|
||
<ul class="space-y-0.5">
|
||
<li v-for="pkg in grp.packages" :key="pkg.slug">
|
||
<NuxtLink
|
||
:to="`/${pkg.slug}`"
|
||
:class="[
|
||
'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors',
|
||
currentPackageSlug === pkg.slug
|
||
? 'text-fg font-medium bg-bg-inset'
|
||
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
|
||
]"
|
||
>
|
||
<span class="font-mono text-[13px]">{{ pkg.name.replace('@robonen/', '') }}</span>
|
||
<span class="text-[10px] font-mono text-fg-subtle">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span>
|
||
</NuxtLink>
|
||
|
||
<!-- Expanded tree for the current package -->
|
||
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1.5 mb-3 ml-2.5 pl-2.5 border-l border-border">
|
||
<!-- Quick filter — the tree below collapses to matches -->
|
||
<div v-if="currentPackage.kind === 'api'" class="relative mb-2 mt-1">
|
||
<span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-accent-text select-none">❯</span>
|
||
<input
|
||
v-model="navQuery"
|
||
type="text"
|
||
placeholder="filter…"
|
||
class="w-full h-7 pl-6 pr-2 font-mono text-[12px] rounded-md bg-bg-subtle border border-border text-fg placeholder:text-fg-subtle focus:outline-none focus:border-border-strong transition-colors"
|
||
>
|
||
</div>
|
||
|
||
<!-- Hand-authored guide sections (intro + prose pages) -->
|
||
<div v-if="currentPackage.docs.length && !navQuery" class="mb-2">
|
||
<div class="comment-label 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 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 font-medium'
|
||
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
|
||
]"
|
||
>
|
||
{{ s.title }}
|
||
</NuxtLink>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- api: collapsible categories -->
|
||
<template v-if="currentPackage.kind === 'api'">
|
||
<div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-fg-subtle">
|
||
no matches
|
||
</div>
|
||
|
||
<div v-for="cat in visibleCategories" :key="cat.slug" class="mb-0.5">
|
||
<button
|
||
type="button"
|
||
class="w-full flex items-center gap-1.5 py-1 px-1 rounded-md cursor-pointer group/cat"
|
||
@click="toggleCategory(cat.slug)"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24"
|
||
fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
|
||
:class="[
|
||
'shrink-0 text-fg-subtle transition-transform duration-150',
|
||
isCategoryOpen(cat.slug) ? 'rotate-90' : '',
|
||
]"
|
||
>
|
||
<polyline points="9 18 15 12 9 6" />
|
||
</svg>
|
||
<span class="comment-label group-hover/cat:text-fg-muted transition-colors">{{ cat.name.toLowerCase() }}</span>
|
||
<span class="ml-auto font-mono text-[10px] text-fg-subtle tabular-nums">{{ cat.items.length }}</span>
|
||
</button>
|
||
|
||
<ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5">
|
||
<li v-for="item in cat.items" :key="item.slug">
|
||
<NuxtLink
|
||
:to="`/${pkg.slug}/${item.slug}`"
|
||
:class="[
|
||
'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors',
|
||
isActive(pkg.slug, item.slug)
|
||
? 'text-accent-text font-medium'
|
||
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
|
||
]"
|
||
>
|
||
<span
|
||
:class="[
|
||
'shrink-0 text-[10px] select-none transition-opacity',
|
||
isActive(pkg.slug, item.slug) ? 'opacity-100 text-accent-text' : 'opacity-0',
|
||
]"
|
||
>❯</span>
|
||
<span class="truncate">{{ item.name }}</span>
|
||
</NuxtLink>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- components: grouped by functional category -->
|
||
<template v-else-if="currentPackage.kind === 'components'">
|
||
<div v-for="group in getComponentGroups(currentPackage)" :key="group.name" class="mb-2">
|
||
<div class="comment-label py-1 px-1">{{ group.name.toLowerCase() }}</div>
|
||
<ul>
|
||
<li v-for="c in group.components" :key="c.slug">
|
||
<NuxtLink
|
||
:to="`/${pkg.slug}/${c.slug}`"
|
||
:class="[
|
||
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
||
isActive(pkg.slug, c.slug)
|
||
? 'text-accent-text font-medium'
|
||
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
|
||
]"
|
||
>
|
||
{{ c.name }}
|
||
</NuxtLink>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- guide -->
|
||
<ul v-else>
|
||
<li v-for="s in currentPackage.sections" :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 font-medium'
|
||
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
|
||
]"
|
||
>
|
||
{{ s.title }}
|
||
</NuxtLink>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</nav>
|
||
</aside>
|
||
|
||
<!-- Mobile overlay -->
|
||
<div v-if="isSidebarOpen" class="fixed inset-0 z-30 bg-black/30 lg:hidden" @click="isSidebarOpen = false" />
|
||
|
||
<!-- Main content -->
|
||
<main class="flex-1 min-w-0 py-10 lg:pl-10">
|
||
<slot />
|
||
</main>
|
||
</div>
|
||
</div>
|
||
</template>
|