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.
This commit is contained in:
2026-06-15 16:55:22 +07:00
parent be667df3d8
commit 8adc2522c6
32 changed files with 1740 additions and 295 deletions
+75
View File
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
const { data: home } = await useAsyncData(() => queryCollection('renovate').first());
useSeoMeta({
title: home.value?.title,
description: home.value?.description,
});
</script>
<template>
<ContentRenderer v-if="home" :value="home" />
<div v-else>Home not found</div>
</template>
+99 -34
View File
@@ -16,48 +16,113 @@
--radius-card: 0.5rem; --radius-card: 0.5rem;
} }
/* ── Semantic colour utilities ─────────────────────────────────────────────
Register the runtime theme tokens as Tailwind colours so templates use clean
utilities (`bg-bg`, `text-fg`, `border-border`, `ring-ring`, `bg-accent`…)
instead of the `bg-(--bg)` arbitrary-value escape hatch. `inline` makes each
utility emit `var(--token)` directly, so it stays switchable by the `.dark`
override below AND gains opacity modifiers (`bg-bg/80` → color-mix). The raw
`--token`s remain the single source of truth (consumed directly via `var()`
in the prose/identity CSS); these are thin aliases over them. */
@theme inline {
--color-bg: var(--bg);
--color-bg-subtle: var(--bg-subtle);
--color-bg-elevated: var(--bg-elevated);
--color-bg-inset: var(--bg-inset);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-fg-subtle: var(--fg-subtle);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-fg: var(--accent-fg);
--color-accent-subtle: var(--accent-subtle);
--color-accent-text: var(--accent-text);
--color-header-bg: var(--header-bg);
--color-ring: var(--ring);
}
/* ── Demo design-system shortcuts ──────────────────────────────────────────
The package demo.vue files share a small visual vocabulary: a width-capped
vertical shell, a code-comment eyebrow label, button/badge chrome, inputs,
and card surfaces. These were copy-pasted as identical Tailwind strings
across ~240 demos. Collapsed here into semantic utilities so the look is
tuned in one place. Each is the common CORE of its pattern — per-demo extras
(max-width, padding, disabled states, w-full, sizes) stay on the element, so
the rendered result is unchanged. */
@utility demo-stack {
@apply flex w-full flex-col gap-4;
}
@utility demo-label {
@apply text-xs font-medium uppercase tracking-wide text-fg-subtle;
}
@utility demo-card {
@apply rounded-xl border border-border bg-bg-elevated;
}
@utility demo-btn {
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-border bg-bg-elevated px-3 py-1.5 text-sm font-medium text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.98];
}
@utility demo-btn-primary {
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-transparent bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition hover:bg-accent-hover active:scale-[0.98];
}
@utility demo-badge {
@apply inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-inset px-2 py-0.5 text-xs font-medium text-fg-muted;
}
@utility demo-input {
@apply w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm text-fg transition placeholder:text-fg-subtle focus:border-accent focus:outline-none focus:ring-2 focus:ring-ring;
}
@utility demo-stat {
@apply font-mono font-bold tabular-nums text-fg;
}
/* ── Semantic design tokens — ink on warm paper, signal-orange instruments ── /* ── Semantic design tokens — ink on warm paper, signal-orange instruments ──
The site reads like a tool-maker's field manual: warm neutral surfaces, The site reads like a tool-maker's field manual: warm neutral surfaces,
hairline rules, international-orange accents, code-comment labels. */ hairline rules, international-orange accents, code-comment labels. */
:root { :root {
--bg: #faf8f3; /* Colours are OKLCH (perceptually uniform — even lightness steps, predictable
--bg-subtle: #f4f1e8; hue) and are exact equivalents of the original hand-tuned sRGB palette.
--bg-elevated: #fffdf8; Translucent tokens derive from their base via color-mix(), so they track
--bg-inset: #eeeadf; theme + accent retuning automatically instead of duplicating a literal. */
--border: #e5dfd0; --bg: oklch(0.9793 0.007 88.64);
--border-strong: #cfc6b1; --bg-subtle: oklch(0.958 0.0124 91.52);
--fg: #211e18; --bg-elevated: oklch(0.9942 0.0069 88.64);
--fg-muted: #5d574b; --bg-inset: oklch(0.9371 0.0153 90.24);
--fg-subtle: #93897a; --border: oklch(0.9043 0.0211 88.73);
--accent: #d9480f; --border-strong: oklch(0.8282 0.0303 87.56);
--accent-hover: #bf3f0d; --fg: oklch(0.2363 0.012 84.56);
--accent-fg: #fffdf8; --fg-muted: oklch(0.4588 0.0204 84.58);
--accent-subtle: #f7e7d8; --fg-subtle: oklch(0.6346 0.0249 78.12);
--accent-text: #c2410c; --accent: oklch(0.5999 0.1905 37.88);
--header-bg: rgba(250, 248, 243, 0.82); --accent-hover: oklch(0.5461 0.1724 37.96);
--ring: rgba(217, 72, 15, 0.35); --accent-fg: oklch(0.9942 0.0069 88.64);
--shadow-card: 0 1px 2px rgba(56, 44, 28, 0.05), 0 1px 3px rgba(56, 44, 28, 0.07); --accent-subtle: oklch(0.9367 0.0266 65.68);
--accent-text: oklch(0.5534 0.1739 38.4);
--header-bg: color-mix(in oklch, var(--bg) 82%, transparent);
--ring: color-mix(in oklch, var(--accent) 35%, transparent);
--shadow-card: 0 1px 2px oklch(0.302 0.0319 74.11 / 0.05), 0 1px 3px oklch(0.302 0.0319 74.11 / 0.07);
color-scheme: light; color-scheme: light;
} }
.dark { .dark {
--bg: #161310; --bg: oklch(0.1892 0.0077 67.33);
--bg-subtle: #1b1813; --bg-subtle: oklch(0.2107 0.0106 80.56);
--bg-elevated: #211d17; --bg-elevated: oklch(0.2332 0.0127 78);
--bg-inset: #2a251c; --bg-inset: oklch(0.267 0.0176 82.2);
--border: #322c22; --border: oklch(0.2964 0.0194 80.44);
--border-strong: #4a4231; --border-strong: oklch(0.3822 0.0294 85.68);
--fg: #ece7db; --fg: oklch(0.9286 0.0169 88);
--fg-muted: #b2a995; --fg-muted: oklch(0.7369 0.0298 86.66);
--fg-subtle: #7d7363; --fg-subtle: oklch(0.56 0.0269 79.61);
--accent: #ff7d33; --accent: oklch(0.7294 0.1789 46.57);
--accent-hover: #ff9a59; --accent-hover: oklch(0.7788 0.1452 51.83);
--accent-fg: #1d0e04; --accent-fg: oklch(0.1825 0.0328 56.53);
--accent-subtle: #3a2415; --accent-subtle: oklch(0.284 0.042 54.49);
--accent-text: #ff9c63; --accent-text: oklch(0.7835 0.139 49.63);
--header-bg: rgba(22, 19, 16, 0.82); /* --header-bg is not re-declared: the :root color-mix tracks --bg, which we
--ring: rgba(255, 125, 51, 0.4); override above. Only --ring needs a tweak (slightly stronger in dark). */
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5); --ring: color-mix(in oklch, var(--accent) 40%, transparent);
--shadow-card: 0 1px 2px oklch(0 0 0 / 0.4), 0 1px 3px oklch(0 0 0 / 0.5);
color-scheme: dark; color-scheme: dark;
} }
+2 -2
View File
@@ -22,8 +22,8 @@ const kindLabels: Record<string, string> = {
:class="[ :class="[
'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border', 'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border',
kind === 'component' kind === 'component'
? 'border-(--accent-subtle) bg-(--accent-subtle) text-(--accent-text)' ? 'border-accent-subtle bg-accent-subtle text-accent-text'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)', : 'border-border bg-bg-inset text-fg-muted',
size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs', size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs',
]" ]"
:title="kind" :title="kind"
+5 -5
View File
@@ -39,12 +39,12 @@ async function copy() {
</script> </script>
<template> <template>
<div class="group relative rounded-xl border border-(--border) bg-(--bg-subtle) overflow-hidden max-w-full"> <div class="group relative rounded-xl border border-border bg-bg-subtle overflow-hidden max-w-full">
<div v-if="!bare" class="flex items-center justify-between px-3 h-9 border-b border-(--border) bg-(--bg-subtle)"> <div v-if="!bare" class="flex items-center justify-between px-3 h-9 border-b border-border bg-bg-subtle">
<span class="text-[11px] font-mono uppercase tracking-wider text-(--fg-subtle)">{{ langLabel }}</span> <span class="text-[11px] font-mono uppercase tracking-wider text-fg-subtle">{{ langLabel }}</span>
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 text-[11px] font-medium text-(--fg-subtle) hover:text-(--fg) transition-colors cursor-pointer" class="inline-flex items-center gap-1 text-[11px] font-medium text-fg-subtle hover:text-fg transition-colors cursor-pointer"
@click="copy" @click="copy"
> >
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -59,7 +59,7 @@ async function copy() {
<button <button
v-else v-else
type="button" type="button"
class="absolute right-2 top-2 z-10 inline-flex items-center justify-center w-7 h-7 rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg-subtle) opacity-0 group-hover:opacity-100 hover:text-(--fg) transition-all cursor-pointer" class="absolute right-2 top-2 z-10 inline-flex items-center justify-center w-7 h-7 rounded-md bg-bg-elevated border border-border text-fg-subtle opacity-0 group-hover:opacity-100 hover:text-fg transition-all cursor-pointer"
title="Copy" title="Copy"
@click="copy" @click="copy"
> >
+8 -8
View File
@@ -43,10 +43,10 @@ const roleColor: Record<string, string> = {
<div class="space-y-10"> <div class="space-y-10">
<!-- Anatomy snippet --> <!-- Anatomy snippet -->
<section> <section>
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-3"> <h2 class="text-xs font-semibold uppercase tracking-wider text-fg-subtle mb-3">
Anatomy Anatomy
</h2> </h2>
<p class="text-sm text-(--fg-muted) mb-3"> <p class="text-sm text-fg-muted mb-3">
Import the parts and compose them. Each part forwards attributes to its underlying element. Import the parts and compose them. Each part forwards attributes to its underlying element.
</p> </p>
<DocsCode :code="anatomyCode" lang="vue" /> <DocsCode :code="anatomyCode" lang="vue" />
@@ -54,7 +54,7 @@ const roleColor: Record<string, string> = {
<!-- Parts --> <!-- Parts -->
<section> <section>
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4"> <h2 class="text-xs font-semibold uppercase tracking-wider text-fg-subtle mb-4">
API Reference API Reference
</h2> </h2>
<div class="space-y-8"> <div class="space-y-8">
@@ -65,18 +65,18 @@ const roleColor: Record<string, string> = {
class="scroll-mt-20" class="scroll-mt-20"
> >
<div class="flex items-center gap-2.5 mb-2"> <div class="flex items-center gap-2.5 mb-2">
<h3 class="font-mono text-base font-semibold text-(--fg)">{{ part.name }}</h3> <h3 class="font-mono text-base font-semibold text-fg">{{ part.name }}</h3>
<span <span
:class="[ :class="[
'text-[11px] px-2 py-0.5 rounded-full font-medium leading-none', 'text-[11px] px-2 py-0.5 rounded-full font-medium leading-none',
roleColor[part.role] ?? 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)', roleColor[part.role] ?? 'bg-bg-inset text-fg-muted border border-border',
]" ]"
> >
{{ part.role }} {{ part.role }}
</span> </span>
</div> </div>
<p v-if="part.description" class="text-sm text-(--fg-muted) mb-3 max-w-2xl"> <p v-if="part.description" class="text-sm text-fg-muted mb-3 max-w-2xl">
{{ part.description }} {{ part.description }}
</p> </p>
@@ -85,11 +85,11 @@ const roleColor: Record<string, string> = {
</div> </div>
<div v-if="part.emits.length > 0" class="mb-3"> <div v-if="part.emits.length > 0" class="mb-3">
<div class="text-[11px] font-semibold uppercase tracking-wider text-(--fg-subtle) mb-2">Emits</div> <div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Emits</div>
<DocsEmitsTable :emits="part.emits" /> <DocsEmitsTable :emits="part.emits" />
</div> </div>
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-(--fg-subtle) italic"> <p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic">
No props or events renders its element and forwards attributes. No props or events renders its element and forwards attributes.
</p> </p>
</div> </div>
+6 -6
View File
@@ -24,14 +24,14 @@ watch(showSource, async (show) => {
</script> </script>
<template> <template>
<div class="rounded-xl border border-(--border) overflow-hidden"> <div class="rounded-xl border border-border overflow-hidden">
<!-- Live demo client-only: demos are interactive and use browser APIs, <!-- Live demo client-only: demos are interactive and use browser APIs,
so they must not be instantiated during SSR/prerender. --> 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"> <div class="p-4 sm:p-8 bg-bg-subtle flex items-center justify-center min-h-32">
<ClientOnly> <ClientOnly>
<component :is="component" /> <component :is="component" />
<template #fallback> <template #fallback>
<div class="flex items-center gap-2 text-sm text-(--fg-subtle)"> <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"> <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" /> <path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg> </svg>
@@ -42,10 +42,10 @@ watch(showSource, async (show) => {
</div> </div>
<!-- Source toggle bar --> <!-- Source toggle bar -->
<div class="flex items-center border-t border-(--border) bg-(--bg-elevated)"> <div class="flex items-center border-t border-border bg-bg-elevated">
<button <button
type="button" type="button"
class="flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-(--fg-muted) hover:text-(--fg) transition-colors cursor-pointer" class="flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-fg-muted hover:text-fg transition-colors cursor-pointer"
@click="showSource = !showSource" @click="showSource = !showSource"
> >
<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"> <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">
@@ -62,7 +62,7 @@ watch(showSource, async (show) => {
</div> </div>
<!-- Source code --> <!-- Source code -->
<div v-if="showSource" class="border-t border-(--border) bg-(--bg-subtle)"> <div v-if="showSource" class="border-t border-border bg-bg-subtle">
<div class="overflow-x-auto text-[13px] [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!" v-html="highlighted" /> <div class="overflow-x-auto text-[13px] [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!" v-html="highlighted" />
</div> </div>
</div> </div>
+7 -7
View File
@@ -6,21 +6,21 @@ defineProps<{
</script> </script>
<template> <template>
<div v-if="emits.length > 0" class="overflow-x-auto 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"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-(--bg-subtle) text-left"> <tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Event</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Payload</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="e in emits" :key="e.name" class="border-t border-(--border) align-top"> <tr v-for="e in emits" :key="e.name" class="border-t border-border align-top">
<td class="py-2.5 px-4 whitespace-nowrap"> <td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ e.name }}</code> <code class="text-accent-text font-mono text-[13px] font-medium">{{ e.name }}</code>
</td> </td>
<td class="py-2.5 px-4"> <td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ e.payload }}</code> <code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
</td> </td>
</tr> </tr>
</tbody> </tbody>
+1 -1
View File
@@ -28,7 +28,7 @@ async function highlightCodeBlocks() {
try { try {
const out = await highlight(text, resolved); const out = await highlight(text, resolved);
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'not-prose rounded-xl border border-(--border) bg-(--bg-subtle) overflow-x-auto text-[13px] my-5 [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!'; wrapper.className = 'not-prose rounded-xl border border-border bg-bg-subtle overflow-x-auto text-[13px] my-5 [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!';
wrapper.innerHTML = out; wrapper.innerHTML = out;
pre.replaceWith(wrapper); pre.replaceWith(wrapper);
} }
+7 -7
View File
@@ -10,19 +10,19 @@ defineProps<{
<div <div
v-for="method in methods" v-for="method in methods"
:key="method.name" :key="method.name"
class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4" class="rounded-xl border border-border bg-bg-subtle p-4"
> >
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<code class="text-sm font-mono font-semibold text-(--fg)">{{ method.name }}</code> <code class="text-sm font-mono font-semibold text-fg">{{ method.name }}</code>
<span <span
v-if="method.visibility !== 'public'" v-if="method.visibility !== 'public'"
class="text-[10px] uppercase px-1.5 py-0.5 rounded bg-(--bg-inset) border border-(--border) text-(--fg-subtle)" class="text-[10px] uppercase px-1.5 py-0.5 rounded bg-bg-inset border border-border text-fg-subtle"
> >
{{ method.visibility }} {{ method.visibility }}
</span> </span>
</div> </div>
<p v-if="method.description" class="text-sm text-(--fg-muted) mb-3"> <p v-if="method.description" class="text-sm text-fg-muted mb-3">
<DocsText :text="method.description" /> <DocsText :text="method.description" />
</p> </p>
@@ -36,9 +36,9 @@ defineProps<{
<DocsParamsTable v-if="method.params.length > 0" :params="method.params" /> <DocsParamsTable v-if="method.params.length > 0" :params="method.params" />
<div v-if="method.returns" class="mt-2 text-sm"> <div v-if="method.returns" class="mt-2 text-sm">
<span class="text-(--fg-subtle)">Returns</span> <span class="text-fg-subtle">Returns</span>
<code class="ml-1.5 text-xs font-mono bg-(--bg-inset) border border-(--border) px-1.5 py-0.5 rounded">{{ method.returns.type }}</code> <code class="ml-1.5 text-xs font-mono bg-bg-inset border border-border px-1.5 py-0.5 rounded">{{ method.returns.type }}</code>
<DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-(--fg-muted)" /> <DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-fg-muted" />
</div> </div>
</div> </div>
</div> </div>
+12 -12
View File
@@ -6,33 +6,33 @@ defineProps<{
</script> </script>
<template> <template>
<div v-if="params.length > 0" class="overflow-x-auto 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"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-(--bg-subtle) text-left"> <tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Parameter</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Parameter</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Type</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider hidden sm:table-cell">Default</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Description</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="param in params" v-for="param in params"
:key="param.name" :key="param.name"
class="border-t border-(--border) align-top" class="border-t border-border align-top"
> >
<td class="py-2.5 px-4 whitespace-nowrap"> <td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ param.name }}</code><span v-if="param.optional" class="text-(--fg-subtle) text-xs">?</span> <code class="text-accent-text font-mono text-[13px] font-medium">{{ param.name }}</code><span v-if="param.optional" class="text-fg-subtle text-xs">?</span>
</td> </td>
<td class="py-2.5 px-4"> <td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ param.type }}</code> <code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ param.type }}</code>
</td> </td>
<td class="py-2.5 px-4 hidden sm:table-cell"> <td class="py-2.5 px-4 hidden sm:table-cell">
<code v-if="param.defaultValue" class="text-xs font-mono text-(--fg-muted)">{{ param.defaultValue }}</code> <code v-if="param.defaultValue" class="text-xs font-mono text-fg-muted">{{ param.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span> <span v-else class="text-fg-subtle"></span>
</td> </td>
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48"> <td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="param.description" :text="param.description" /> <DocsText v-if="param.description" :text="param.description" />
<span v-else></span> <span v-else></span>
</td> </td>
+13 -13
View File
@@ -8,34 +8,34 @@ defineProps<{
</script> </script>
<template> <template>
<div v-if="properties.length > 0" class="overflow-x-auto 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"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-(--bg-subtle) text-left"> <tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">{{ label ?? 'Property' }}</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">{{ label ?? 'Property' }}</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Type</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider hidden sm:table-cell">Default</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Description</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="prop in properties" v-for="prop in properties"
:key="prop.name" :key="prop.name"
class="border-t border-(--border) align-top" class="border-t border-border align-top"
> >
<td class="py-2.5 px-4 whitespace-nowrap"> <td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ prop.name }}</code><span v-if="prop.optional" class="text-(--fg-subtle) text-xs">?</span> <code class="text-accent-text font-mono text-[13px] font-medium">{{ prop.name }}</code><span v-if="prop.optional" class="text-fg-subtle text-xs">?</span>
<span v-if="prop.readonly" class="block text-[10px] text-(--fg-subtle) uppercase tracking-wide mt-0.5">readonly</span> <span v-if="prop.readonly" class="block text-[10px] text-fg-subtle uppercase tracking-wide mt-0.5">readonly</span>
</td> </td>
<td class="py-2.5 px-4"> <td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ prop.type }}</code> <code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ prop.type }}</code>
</td> </td>
<td class="py-2.5 px-4 hidden sm:table-cell"> <td class="py-2.5 px-4 hidden sm:table-cell">
<code v-if="prop.defaultValue" class="text-xs font-mono text-(--fg-muted)">{{ prop.defaultValue }}</code> <code v-if="prop.defaultValue" class="text-xs font-mono text-fg-muted">{{ prop.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span> <span v-else class="text-fg-subtle"></span>
</td> </td>
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48"> <td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="prop.description" :text="prop.description" /> <DocsText v-if="prop.description" :text="prop.description" />
<span v-else></span> <span v-else></span>
</td> </td>
+12 -12
View File
@@ -65,14 +65,14 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div> <div>
<button <button
type="button" type="button"
class="flex items-center gap-2 px-2.5 h-9 text-sm text-(--fg-subtle) bg-(--bg-subtle) border border-(--border) rounded-lg hover:border-(--border-strong) transition-colors w-9 sm:w-56 justify-center sm:justify-start cursor-pointer" class="flex items-center gap-2 px-2.5 h-9 text-sm text-fg-subtle bg-bg-subtle border border-border rounded-lg hover:border-border-strong transition-colors w-9 sm:w-56 justify-center sm:justify-start cursor-pointer"
@click="open" @click="open"
> >
<svg 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" class="shrink-0"> <svg 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" class="shrink-0">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /> <circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg> </svg>
<span class="hidden sm:inline flex-1 text-left font-mono text-[13px]">search</span> <span class="hidden sm:inline flex-1 text-left font-mono text-[13px]">search</span>
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-(--bg) border border-(--border) rounded text-(--fg-subtle)">K</kbd> <kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-bg border border-border rounded text-fg-subtle">K</kbd>
</button> </button>
<Teleport to="body"> <Teleport to="body">
@@ -84,21 +84,21 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm" @click="close" /> <div class="fixed inset-0 bg-black/40 backdrop-blur-sm" @click="close" />
<div class="fixed inset-x-0 top-[12vh] mx-auto max-w-xl px-4"> <div class="fixed inset-x-0 top-[12vh] mx-auto max-w-xl px-4">
<div class="bg-(--bg-elevated) rounded-xl border border-(--border) shadow-2xl overflow-hidden"> <div class="bg-bg-elevated rounded-xl border border-border shadow-2xl overflow-hidden">
<div class="flex items-center px-4 border-b border-(--border)"> <div class="flex items-center px-4 border-b border-border">
<span class="font-mono text-base text-(--accent-text) select-none shrink-0"></span> <span class="font-mono text-base text-accent-text select-none shrink-0"></span>
<input <input
v-model="query" v-model="query"
data-search-input data-search-input
type="text" type="text"
placeholder="search across all packages…" placeholder="search across all packages…"
class="w-full py-3.5 px-3 bg-transparent text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none font-mono text-[14px]" class="w-full py-3.5 px-3 bg-transparent text-fg placeholder:text-fg-subtle focus:outline-none font-mono text-[14px]"
> >
<kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-(--bg-inset) border border-(--border) rounded text-(--fg-subtle)">ESC</kbd> <kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-bg-inset border border-border rounded text-fg-subtle">ESC</kbd>
</div> </div>
<div class="max-h-[60vh] overflow-y-auto p-2"> <div class="max-h-[60vh] overflow-y-auto p-2">
<div v-if="query && results.length === 0" class="py-12 text-center text-sm text-(--fg-subtle)"> <div v-if="query && results.length === 0" class="py-12 text-center text-sm text-fg-subtle">
No results for "{{ query }}" No results for "{{ query }}"
</div> </div>
<ul v-else-if="results.length > 0" class="space-y-0.5"> <ul v-else-if="results.length > 0" class="space-y-0.5">
@@ -107,20 +107,20 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
:to="`/${r.pkg.slug}/${r.slug}`" :to="`/${r.pkg.slug}/${r.slug}`"
:class="[ :class="[
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors', 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
i === activeIndex ? 'bg-(--accent-subtle)' : 'hover:bg-(--bg-inset)', i === activeIndex ? 'bg-accent-subtle' : 'hover:bg-bg-inset',
]" ]"
@click="close" @click="close"
@mouseenter="activeIndex = i" @mouseenter="activeIndex = i"
> >
<DocsBadge :kind="r.badge" size="sm" /> <DocsBadge :kind="r.badge" size="sm" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-sm font-medium text-(--fg) truncate">{{ r.name }}</div> <div class="text-sm font-medium text-fg truncate">{{ r.name }}</div>
<div class="text-xs text-(--fg-subtle) truncate">{{ r.pkg.name }} · {{ r.description }}</div> <div class="text-xs text-fg-subtle truncate">{{ r.pkg.name }} · {{ r.description }}</div>
</div> </div>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
<div v-else class="py-12 text-center text-sm text-(--fg-subtle)"> <div v-else class="py-12 text-center text-sm text-fg-subtle">
Type to search functions, components &amp; guides Type to search functions, components &amp; guides
</div> </div>
</div> </div>
+3 -3
View File
@@ -4,10 +4,10 @@
}>(); }>();
const variantClasses: Record<string, string> = { const variantClasses: Record<string, string> = {
since: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)', since: 'bg-bg-inset text-fg-muted border border-border',
neutral: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)', neutral: 'bg-bg-inset text-fg-muted border border-border',
test: 'bg-emerald-50 text-emerald-800 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20', test: 'bg-emerald-50 text-emerald-800 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20',
demo: 'bg-(--accent-subtle) text-(--accent-text) border border-(--accent-subtle)', demo: 'bg-accent-subtle text-accent-text border border-accent-subtle',
wip: 'bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/20', wip: 'bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/20',
}; };
</script> </script>
+1 -1
View File
@@ -12,7 +12,7 @@ const label = computed(() => ({
type="button" type="button"
:title="`Theme: ${label} (click to change)`" :title="`Theme: ${label} (click to change)`"
:aria-label="`Theme: ${label}`" :aria-label="`Theme: ${label}`"
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 cursor-pointer" 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 cursor-pointer"
@click="cycle" @click="cycle"
> >
<ClientOnly> <ClientOnly>
+3 -3
View File
@@ -49,7 +49,7 @@ function go(id: string) {
<div class="comment-label mb-3"> <div class="comment-label mb-3">
on this page on this page
</div> </div>
<ul class="space-y-1 border-l border-(--border)"> <ul class="space-y-1 border-l border-border">
<li v-for="item in items" :key="item.id"> <li v-for="item in items" :key="item.id">
<a <a
:href="`#${item.id}`" :href="`#${item.id}`"
@@ -57,8 +57,8 @@ function go(id: string) {
'block py-1 -ml-px border-l-2 transition-colors', 'block py-1 -ml-px border-l-2 transition-colors',
item.depth === 3 ? 'pl-6' : 'pl-4', item.depth === 3 ? 'pl-6' : 'pl-4',
activeId === item.id activeId === item.id
? 'border-(--accent) text-(--accent-text) font-medium' ? 'border-accent text-accent-text font-medium'
: 'border-transparent text-(--fg-muted) hover:text-(--fg)', : 'border-transparent text-fg-muted hover:text-fg',
]" ]"
@click.prevent="go(item.id)" @click.prevent="go(item.id)"
> >
+46
View File
@@ -35,6 +35,28 @@ const GROUP_LABELS: Record<PackageGroup, string> = {
const GROUP_ORDER: PackageGroup[] = ['core', 'vue', 'configs', 'infra']; const GROUP_ORDER: PackageGroup[] = ['core', 'vue', 'configs', 'infra'];
/** Display order for component categories (unlisted categories sort last, AZ). */
const COMPONENT_CATEGORY_ORDER: string[] = [
'Forms',
'Selection',
'Color',
'Overlays',
'Menus',
'Disclosure',
'Navigation',
'Display',
'Feedback',
'Canvas & editors',
'Utilities',
'Other',
];
/** A category bucket of components, for grouped rendering. */
export interface ComponentGroup {
name: string;
components: ComponentMeta[];
}
export function useDocs() { export function useDocs() {
const data = metadata as unknown as DocsMetadata; const data = metadata as unknown as DocsMetadata;
@@ -74,6 +96,29 @@ export function useDocs() {
return pkg.docs.filter(s => !s.isIntro); return pkg.docs.filter(s => !s.isIntro);
} }
/**
* A `components`-kind package's components bucketed by `category`, ordered by
* {@link COMPONENT_CATEGORY_ORDER} (unlisted categories last, AZ), with the
* components inside each bucket kept in their incoming (alphabetical) order.
*/
function getComponentGroups(pkg: PackageMeta): ComponentGroup[] {
if (pkg.kind !== 'components') return [];
const buckets = new Map<string, ComponentMeta[]>();
for (const c of pkg.components) {
const cat = c.category || 'Other';
const list = buckets.get(cat);
if (list) list.push(c);
else buckets.set(cat, [c]);
}
const rank = (name: string) => {
const i = COMPONENT_CATEGORY_ORDER.indexOf(name);
return i === -1 ? COMPONENT_CATEGORY_ORDER.length : i;
};
return [...buckets.entries()]
.map(([name, components]) => ({ name, components }))
.sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
}
/** Resolve any `/:package/:slug` route to a normalised entry. */ /** Resolve any `/:package/:slug` route to a normalised entry. */
function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined { function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined {
const pkg = getPackage(packageSlug); const pkg = getPackage(packageSlug);
@@ -157,6 +202,7 @@ export function useDocs() {
firstEntrySlug, firstEntrySlug,
getIntro, getIntro,
getDocSections, getDocSections,
getComponentGroups,
search, search,
getTotalItems, getTotalItems,
}; };
+48 -43
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections } = useDocs(); <script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections, getComponentGroups } = useDocs();
const groups = getGroupedPackages(); const groups = getGroupedPackages();
const route = useRoute(); const route = useRoute();
@@ -79,11 +79,11 @@ watch(() => route.path, () => {
<template> <template>
<div class="min-h-screen"> <div class="min-h-screen">
<!-- Header --> <!-- Header -->
<header class="sticky top-0 z-50 border-b border-(--border) backdrop-blur-md" style="background-color: var(--header-bg)"> <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"> <div class="mx-auto max-w-352 flex items-center gap-3 px-4 h-14 sm:px-6">
<button <button
type="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)" 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" aria-label="Toggle navigation"
@click="isSidebarOpen = !isSidebarOpen" @click="isSidebarOpen = !isSidebarOpen"
> >
@@ -93,12 +93,12 @@ watch(() => route.path, () => {
</button> </button>
<NuxtLink to="/" class="group flex items-center gap-2.5 mr-auto"> <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 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>
<span class="hidden sm:flex items-baseline font-mono text-[13.5px] tracking-tight"> <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="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 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> </span>
</NuxtLink> </NuxtLink>
@@ -108,7 +108,7 @@ watch(() => route.path, () => {
href="https://github.com/robonen/tools" href="https://github.com/robonen/tools"
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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" aria-label="GitHub"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="currentColor">
@@ -122,7 +122,7 @@ watch(() => route.path, () => {
<!-- Sidebar --> <!-- Sidebar -->
<aside <aside
:class="[ :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', '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', isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
]" ]"
> >
@@ -136,24 +136,24 @@ watch(() => route.path, () => {
:class="[ :class="[
'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors', 'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors',
currentPackageSlug === pkg.slug currentPackageSlug === pkg.slug
? 'text-(--fg) font-medium bg-(--bg-inset)' ? 'text-fg font-medium bg-bg-inset'
: 'text-(--fg-muted) hover:text-(--fg) hover: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="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> <span class="text-[10px] font-mono text-fg-subtle">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span>
</NuxtLink> </NuxtLink>
<!-- Expanded tree for the current package --> <!-- 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)"> <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 --> <!-- Quick filter the tree below collapses to matches -->
<div v-if="currentPackage.kind === 'api'" class="relative mb-2 mt-1"> <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> <span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-accent-text select-none"></span>
<input <input
v-model="navQuery" v-model="navQuery"
type="text" type="text"
placeholder="filter…" 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" 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> </div>
@@ -167,8 +167,8 @@ watch(() => route.path, () => {
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
route.path === `/${pkg.slug}` route.path === `/${pkg.slug}`
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
Introduction Introduction
@@ -180,8 +180,8 @@ watch(() => route.path, () => {
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug) isActive(pkg.slug, s.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
{{ s.title }} {{ s.title }}
@@ -192,7 +192,7 @@ watch(() => route.path, () => {
<!-- api: collapsible categories --> <!-- api: collapsible categories -->
<template v-if="currentPackage.kind === 'api'"> <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)"> <div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-fg-subtle">
no matches no matches
</div> </div>
@@ -206,14 +206,14 @@ watch(() => route.path, () => {
xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24" 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" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
:class="[ :class="[
'shrink-0 text-(--fg-subtle) transition-transform duration-150', 'shrink-0 text-fg-subtle transition-transform duration-150',
isCategoryOpen(cat.slug) ? 'rotate-90' : '', isCategoryOpen(cat.slug) ? 'rotate-90' : '',
]" ]"
> >
<polyline points="9 18 15 12 9 6" /> <polyline points="9 18 15 12 9 6" />
</svg> </svg>
<span class="comment-label group-hover/cat:text-(--fg-muted) transition-colors">{{ cat.name.toLowerCase() }}</span> <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> <span class="ml-auto font-mono text-[10px] text-fg-subtle tabular-nums">{{ cat.items.length }}</span>
</button> </button>
<ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5"> <ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5">
@@ -223,14 +223,14 @@ watch(() => route.path, () => {
:class="[ :class="[
'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors', 'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors',
isActive(pkg.slug, item.slug) isActive(pkg.slug, item.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
<span <span
:class="[ :class="[
'shrink-0 text-[10px] select-none transition-opacity', 'shrink-0 text-[10px] select-none transition-opacity',
isActive(pkg.slug, item.slug) ? 'opacity-100 text-(--accent-text)' : 'opacity-0', isActive(pkg.slug, item.slug) ? 'opacity-100 text-accent-text' : 'opacity-0',
]" ]"
></span> ></span>
<span class="truncate">{{ item.name }}</span> <span class="truncate">{{ item.name }}</span>
@@ -240,22 +240,27 @@ watch(() => route.path, () => {
</div> </div>
</template> </template>
<!-- components --> <!-- components: grouped by functional category -->
<ul v-else-if="currentPackage.kind === 'components'"> <template v-else-if="currentPackage.kind === 'components'">
<li v-for="c in currentPackage.components" :key="c.slug"> <div v-for="group in getComponentGroups(currentPackage)" :key="group.name" class="mb-2">
<NuxtLink <div class="comment-label py-1 px-1">{{ group.name.toLowerCase() }}</div>
:to="`/${pkg.slug}/${c.slug}`" <ul>
:class="[ <li v-for="c in group.components" :key="c.slug">
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', <NuxtLink
isActive(pkg.slug, c.slug) :to="`/${pkg.slug}/${c.slug}`"
? 'text-(--accent-text) font-medium' :class="[
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
]" isActive(pkg.slug, c.slug)
> ? 'text-accent-text font-medium'
{{ c.name }} : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
</NuxtLink> ]"
</li> >
</ul> {{ c.name }}
</NuxtLink>
</li>
</ul>
</div>
</template>
<!-- guide --> <!-- guide -->
<ul v-else> <ul v-else>
@@ -265,8 +270,8 @@ watch(() => route.path, () => {
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug) isActive(pkg.slug, s.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
{{ s.title }} {{ s.title }}
+18 -18
View File
@@ -105,10 +105,10 @@ const sectionTitle = 'comment-label mb-3';
<div v-if="entry" class="xl:grid xl:grid-cols-[minmax(0,1fr)_14rem] xl:gap-12"> <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"> <article class="min-w-0 max-w-3xl">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="flex items-center gap-1.5 font-mono text-[13px] text-(--fg-subtle) mb-6"> <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> <NuxtLink :to="`/${pkg.slug}`" class="hover:text-fg transition-colors">{{ pkg.name }}</NuxtLink>
<span>/</span> <span>/</span>
<span class="text-(--fg)">{{ title }}</span> <span class="text-fg">{{ title }}</span>
</nav> </nav>
<!-- API ITEM --> <!-- API ITEM -->
@@ -116,7 +116,7 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8"> <header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap"> <div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge :kind="entry.item.kind" size="md" /> <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> <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.since" :label="`v${entry.item.since}`" variant="neutral" />
<DocsTag <DocsTag
v-if="entry.item.hasTests" v-if="entry.item.hasTests"
@@ -126,15 +126,15 @@ const sectionTitle = 'comment-label mb-3';
/> />
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" /> <DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
</div> </div>
<p v-if="entry.item.description" class="text-(--fg-muted) text-[15px] leading-relaxed"> <p v-if="entry.item.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.item.description" /> <DocsText :text="entry.item.description" />
</p> </p>
<div class="flex items-center gap-4 mt-4 text-sm"> <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"> <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> <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 Source
</a> </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"> <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> <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 Tests
</a> </a>
@@ -164,9 +164,9 @@ const sectionTitle = 'comment-label mb-3';
<h2 :class="sectionTitle">Type Parameters</h2> <h2 :class="sectionTitle">Type Parameters</h2>
<div class="space-y-1.5"> <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"> <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> <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.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> <span v-if="tp.default" class="text-fg-subtle">= <code class="font-mono text-xs">{{ tp.default }}</code></span>
</div> </div>
</div> </div>
</section> </section>
@@ -179,8 +179,8 @@ const sectionTitle = 'comment-label mb-3';
<section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20"> <section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Returns</h2> <h2 :class="sectionTitle">Returns</h2>
<div class="flex items-baseline gap-2 text-sm flex-wrap" :class="entry.item.returns.properties?.length ? 'mb-3' : ''"> <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> <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)" /> <DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-fg-muted" />
</div> </div>
<DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" /> <DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" />
</section> </section>
@@ -198,12 +198,12 @@ const sectionTitle = 'comment-label mb-3';
<section v-if="entry.item.relatedTypes?.length" id="related-types" class="mb-8 scroll-mt-20"> <section v-if="entry.item.relatedTypes?.length" id="related-types" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Related Types</h2> <h2 :class="sectionTitle">Related Types</h2>
<div class="space-y-4"> <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 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"> <div class="flex items-center gap-2 mb-2">
<DocsBadge :kind="rt.kind" size="sm" /> <DocsBadge :kind="rt.kind" size="sm" />
<h3 class="font-mono font-semibold text-sm text-(--fg)">{{ rt.name }}</h3> <h3 class="font-mono font-semibold text-sm text-fg">{{ rt.name }}</h3>
</div> </div>
<p v-if="rt.description" class="text-sm text-(--fg-muted) mb-3"> <p v-if="rt.description" class="text-sm text-fg-muted mb-3">
<DocsText :text="rt.description" /> <DocsText :text="rt.description" />
</p> </p>
<DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" /> <DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" />
@@ -218,14 +218,14 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8"> <header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap"> <div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge kind="component" size="md" /> <DocsBadge kind="component" size="md" />
<h1 class="font-display text-[1.7rem] font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1> <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" /> <DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
</div> </div>
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed"> <p v-if="entry.component.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.component.description" /> <DocsText :text="entry.component.description" />
</p> </p>
<div class="flex items-center gap-4 mt-4 text-sm"> <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"> <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> <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 Source
</a> </a>
+55 -27
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">import { sections } from '#docs/sections'; <script setup lang="ts">import { sections } from '#docs/sections';
const route = useRoute(); const route = useRoute();
const { getPackage, countEntries, getIntro } = useDocs(); const { getPackage, countEntries, getIntro, getComponentGroups } = useDocs();
const slug = computed(() => route.params.package as string); const slug = computed(() => route.params.package as string);
const pkg = computed(() => getPackage(slug.value)); const pkg = computed(() => getPackage(slug.value));
@@ -51,6 +51,15 @@ function scrollToCategory(catSlug: string) {
document.getElementById(`cat-${catSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.getElementById(`cat-${catSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
// ── Components: bucketed by functional category ───────────────────────────
const componentGroups = computed(() =>
pkg.value?.kind === 'components' ? getComponentGroups(pkg.value) : [],
);
function scrollToComponentGroup(name: string) {
document.getElementById(`cgrp-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// For guide packages, surface the overview section inline. // For guide packages, surface the overview section inline.
const overview = computed(() => const overview = computed(() =>
pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined, pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined,
@@ -68,13 +77,13 @@ const otherSections = computed(() =>
</section> </section>
<!-- Auto header (shown only when there's no hand-authored intro) --> <!-- Auto header (shown only when there's no hand-authored intro) -->
<header v-else class="mb-8 pb-8 border-b border-(--border)"> <header v-else class="mb-8 pb-8 border-b border-border">
<div class="comment-label mb-3">{{ kindLabel.toLowerCase() }} · {{ countEntries(pkg) }} entries</div> <div class="comment-label mb-3">{{ kindLabel.toLowerCase() }} · {{ countEntries(pkg) }} entries</div>
<div class="flex items-center gap-2.5 mb-2 flex-wrap"> <div class="flex items-center gap-2.5 mb-2 flex-wrap">
<h1 class="font-display text-3xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1> <h1 class="font-display text-3xl font-bold tracking-tight text-fg">{{ pkg.name }}</h1>
<DocsTag :label="`v${pkg.version}`" variant="neutral" /> <DocsTag :label="`v${pkg.version}`" variant="neutral" />
</div> </div>
<p class="text-(--fg-muted) text-[15px] leading-relaxed">{{ pkg.description }}</p> <p class="text-fg-muted text-[15px] leading-relaxed">{{ pkg.description }}</p>
<div class="mt-5"> <div class="mt-5">
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" /> <DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
</div> </div>
@@ -84,14 +93,14 @@ const otherSections = computed(() =>
<template v-if="pkg.kind === 'api'"> <template v-if="pkg.kind === 'api'">
<div class="sticky top-14 z-20 -mx-2 px-2 py-3 backdrop-blur-md" style="background-color: var(--header-bg)"> <div class="sticky top-14 z-20 -mx-2 px-2 py-3 backdrop-blur-md" style="background-color: var(--header-bg)">
<div class="relative mb-2.5"> <div class="relative mb-2.5">
<span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-(--accent-text) select-none"></span> <span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-accent-text select-none"></span>
<input <input
v-model="query" v-model="query"
type="text" type="text"
:placeholder="`filter ${countEntries(pkg)} entries…`" :placeholder="`filter ${countEntries(pkg)} entries…`"
class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--accent) transition-colors" class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-bg-elevated border border-border text-fg placeholder:text-fg-subtle focus:outline-none focus:border-accent transition-colors"
> >
<span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--fg-subtle) tabular-nums"> <span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-fg-subtle tabular-nums">
{{ filteredCount }} hits {{ filteredCount }} hits
</span> </span>
</div> </div>
@@ -101,17 +110,17 @@ const otherSections = computed(() =>
v-for="category in filteredCategories" v-for="category in filteredCategories"
:key="category.slug" :key="category.slug"
type="button" type="button"
class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-(--border) bg-(--bg-elevated) text-(--fg-muted) hover:border-(--accent) hover:text-(--accent-text) transition-colors cursor-pointer" class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-border bg-bg-elevated text-fg-muted hover:border-accent hover:text-accent-text transition-colors cursor-pointer"
@click="scrollToCategory(category.slug)" @click="scrollToCategory(category.slug)"
> >
{{ category.name.toLowerCase() }} {{ category.name.toLowerCase() }}
<span class="text-(--fg-subtle) tabular-nums">{{ category.items.length }}</span> <span class="text-fg-subtle tabular-nums">{{ category.items.length }}</span>
</button> </button>
</div> </div>
</div> </div>
<div v-if="query && filteredCategories.length === 0" class="py-16 text-center"> <div v-if="query && filteredCategories.length === 0" class="py-16 text-center">
<div class="font-mono text-sm text-(--fg-subtle)">// no matches for "{{ query }}"</div> <div class="font-mono text-sm text-fg-subtle">// no matches for "{{ query }}"</div>
</div> </div>
<section <section
@@ -128,48 +137,67 @@ const otherSections = computed(() =>
v-for="item in category.items" v-for="item in category.items"
:key="item.slug" :key="item.slug"
:to="`/${pkg.slug}/${item.slug}`" :to="`/${pkg.slug}/${item.slug}`"
class="group flex items-start gap-2.5 p-3 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all" class="group flex items-start gap-2.5 p-3 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all"
> >
<DocsBadge :kind="item.kind" size="sm" /> <DocsBadge :kind="item.kind" size="sm" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5 flex-wrap"> <div class="flex items-center gap-1.5 flex-wrap">
<span class="font-mono text-[13px] font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors truncate">{{ item.name }}</span> <span class="font-mono text-[13px] font-medium text-fg group-hover:text-accent-text transition-colors truncate">{{ item.name }}</span>
<DocsTag v-if="item.hasDemo" label="demo" variant="demo" /> <DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
</div> </div>
<p v-if="item.description" class="text-[12.5px] text-(--fg-subtle) mt-0.5 line-clamp-1">{{ item.description }}</p> <p v-if="item.description" class="text-[12.5px] text-fg-subtle mt-0.5 line-clamp-1">{{ item.description }}</p>
</div> </div>
</NuxtLink> </NuxtLink>
</div> </div>
</section> </section>
</template> </template>
<!-- Components: gallery --> <!-- Components: gallery grouped by functional category -->
<template v-else-if="pkg.kind === 'components'"> <template v-else-if="pkg.kind === 'components'">
<section> <!-- Category chips -->
<div class="mb-7 flex flex-wrap gap-1.5">
<button
v-for="group in componentGroups"
:key="group.name"
type="button"
class="font-mono text-[11px] px-2 py-1 rounded-md bg-bg-inset border border-border text-fg-muted hover:text-fg hover:border-border-strong transition-colors"
@click="scrollToComponentGroup(group.name)"
>
{{ group.name.toLowerCase() }}
<span class="text-fg-subtle tabular-nums">{{ group.components.length }}</span>
</button>
</div>
<section
v-for="group in componentGroups"
:id="`cgrp-${group.name}`"
:key="group.name"
class="mb-10 scroll-mt-24"
>
<h2 class="comment-label mb-4"> <h2 class="comment-label mb-4">
all components · {{ pkg.components.length }} {{ group.name.toLowerCase() }} · {{ group.components.length }}
</h2> </h2>
<div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2">
<NuxtLink <NuxtLink
v-for="c in pkg.components" v-for="c in group.components"
:key="c.slug" :key="c.slug"
:to="`/${pkg.slug}/${c.slug}`" :to="`/${pkg.slug}/${c.slug}`"
class="group block p-4 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all" class="group block p-4 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all"
> >
<div class="flex items-center justify-between gap-2 mb-1.5"> <div class="flex items-center justify-between gap-2 mb-1.5">
<span class="font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ c.name }}</span> <span class="font-semibold text-fg group-hover:text-accent-text transition-colors">{{ c.name }}</span>
<span class="font-mono text-[11px] text-(--fg-subtle) tabular-nums">{{ c.parts.length }} parts</span> <span class="font-mono text-[11px] text-fg-subtle tabular-nums">{{ c.parts.length }} parts</span>
</div> </div>
<p v-if="c.description" class="text-sm text-(--fg-subtle) line-clamp-2">{{ c.description }}</p> <p v-if="c.description" class="text-sm text-fg-subtle line-clamp-2">{{ c.description }}</p>
<div class="mt-3 flex flex-wrap gap-1"> <div class="mt-3 flex flex-wrap gap-1">
<span <span
v-for="part in c.parts.slice(0, 4)" v-for="part in c.parts.slice(0, 4)"
:key="part.name" :key="part.name"
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-(--bg-inset) border border-(--border) text-(--fg-subtle)" class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-bg-inset border border-border text-fg-subtle"
> >
{{ part.role }} {{ part.role }}
</span> </span>
<span v-if="c.parts.length > 4" class="text-[10px] font-mono text-(--fg-subtle) px-1">+{{ c.parts.length - 4 }}</span> <span v-if="c.parts.length > 4" class="text-[10px] font-mono text-fg-subtle px-1">+{{ c.parts.length - 4 }}</span>
</div> </div>
</NuxtLink> </NuxtLink>
</div> </div>
@@ -179,17 +207,17 @@ const otherSections = computed(() =>
<!-- Guide: overview markdown + section links --> <!-- Guide: overview markdown + section links -->
<template v-else> <template v-else>
<DocsMarkdown v-if="overview" :source="overview.markdown" /> <DocsMarkdown v-if="overview" :source="overview.markdown" />
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)"> <section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-border">
<h2 class="comment-label mb-4">sections</h2> <h2 class="comment-label mb-4">sections</h2>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<NuxtLink <NuxtLink
v-for="s in otherSections" v-for="s in otherSections"
:key="s.slug" :key="s.slug"
:to="`/${pkg.slug}/${s.slug}`" :to="`/${pkg.slug}/${s.slug}`"
class="group flex items-center justify-between gap-3 p-3.5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:bg-(--bg-subtle) transition-all" class="group flex items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:bg-bg-subtle transition-all"
> >
<span class="text-sm font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ s.title }}</span> <span class="text-sm font-medium text-fg group-hover:text-accent-text transition-colors">{{ s.title }}</span>
<span class="font-mono text-[11px] text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors"></span> <span class="font-mono text-[11px] text-fg-subtle group-hover:text-accent-text transition-colors"></span>
</NuxtLink> </NuxtLink>
</div> </div>
</section> </section>
+17 -17
View File
@@ -20,21 +20,21 @@ useHead({ title: '@robonen/tools — Documentation' });
<div class="comment-label mb-5">field manual · generated from source &amp; jsdoc</div> <div class="comment-label mb-5">field manual · generated from source &amp; jsdoc</div>
<h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-(--fg) mb-5 text-balance"> <h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-fg mb-5 text-balance">
Tools, documented<span class="text-(--accent)">.</span> Tools, documented<span class="text-accent">.</span>
</h1> </h1>
<p class="text-lg text-(--fg-muted) leading-relaxed max-w-2xl"> <p class="text-lg text-fg-muted leading-relaxed max-w-2xl">
A monorepo of TypeScript utilities, Vue composables, headless UI primitives A monorepo of TypeScript utilities, Vue composables, headless UI primitives
and shared tooling typed, tested and demoed in place. and shared tooling typed, tested and demoed in place.
</p> </p>
<div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-(--fg-subtle) border border-(--border) rounded-md bg-(--bg-elevated) px-3 py-2"> <div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-fg-subtle border border-border rounded-md bg-bg-elevated px-3 py-2">
<span class="text-(--accent-text)"></span> <span class="text-accent-text"></span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ packages.length }}</span> packages</span> <span><span class="text-fg font-medium tabular-nums">{{ packages.length }}</span> packages</span>
<span class="text-(--border-strong)">·</span> <span class="text-border-strong">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ totalItems }}</span> documented items</span> <span><span class="text-fg font-medium tabular-nums">{{ totalItems }}</span> documented items</span>
<span class="text-(--border-strong)">·</span> <span class="text-border-strong">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ groups.length }}</span> groups</span> <span><span class="text-fg font-medium tabular-nums">{{ groups.length }}</span> groups</span>
</div> </div>
</section> </section>
@@ -46,29 +46,29 @@ useHead({ title: '@robonen/tools — Documentation' });
v-for="pkg in grp.packages" v-for="pkg in grp.packages"
:key="pkg.slug" :key="pkg.slug"
:to="`/${pkg.slug}`" :to="`/${pkg.slug}`"
class="group relative block p-5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all overflow-hidden" class="group relative block p-5 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all overflow-hidden"
> >
<!-- Corner notch fills in on hover like an indicator lamp --> <!-- Corner notch fills in on hover like an indicator lamp -->
<span <span
class="absolute right-0 top-0 w-2 h-2 bg-(--accent) opacity-0 group-hover:opacity-100 transition-opacity" class="absolute right-0 top-0 w-2 h-2 bg-accent opacity-0 group-hover:opacity-100 transition-opacity"
style="clip-path: polygon(100% 0, 0 0, 100% 100%)" style="clip-path: polygon(100% 0, 0 0, 100% 100%)"
aria-hidden="true" aria-hidden="true"
/> />
<div class="flex items-start justify-between gap-3 mb-2"> <div class="flex items-start justify-between gap-3 mb-2">
<h3 class="font-mono text-sm font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors"> <h3 class="font-mono text-sm font-semibold text-fg group-hover:text-accent-text transition-colors">
{{ pkg.name }} {{ pkg.name }}
</h3> </h3>
<span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-(--border) bg-(--bg-subtle) text-(--fg-subtle) leading-none shrink-0"> <span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-border bg-bg-subtle text-fg-subtle leading-none shrink-0">
{{ kindLabels[pkg.kind] }} {{ kindLabels[pkg.kind] }}
</span> </span>
</div> </div>
<p class="text-sm text-(--fg-muted) leading-relaxed line-clamp-2"> <p class="text-sm text-fg-muted leading-relaxed line-clamp-2">
{{ pkg.description }} {{ pkg.description }}
</p> </p>
<div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-(--fg-subtle)"> <div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-fg-subtle">
<span>v{{ pkg.version }}</span> <span>v{{ pkg.version }}</span>
<span class="text-(--border-strong)">·</span> <span class="text-border-strong">·</span>
<span class="tabular-nums">{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span> <span class="tabular-nums">{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span>
</div> </div>
</NuxtLink> </NuxtLink>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import { defineCollection, defineContentConfig } from '@nuxt/content';
const repositories = [
'../configs/tsconfig',
'../core/stdlib',
'../core/platform',
'../infra/renovate',
'../web/vue',
];
export default defineContentConfig({
collections: repositories.reduce((acc, repo) => {
const name = repo.split('/').pop();
acc[name] = defineCollection({
source: {
include: `**/*.md`,
exclude: ['**/node_modules/**', '**/dist/**'],
cwd: repo,
},
type: 'page',
});
return acc;
}, {}),
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint'; import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, { export default compose(base, typescript, vue, imports, stylistic, {
name: 'docs/build-scripts', name: 'docs/build-scripts',
@@ -7,4 +7,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
/* Build-time tooling (doc extractor) logs progress to the console. */ /* Build-time tooling (doc extractor) logs progress to the console. */
'no-console': 'off', 'no-console': 'off',
}, },
}); }, tests);
+111 -65
View File
@@ -88,7 +88,7 @@ const PACKAGES: PackageConfig[] = [
{ path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' }, { path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' },
// ── vue ── // ── vue ──
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' }, { path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' },
{ path: 'vue/editor', slug: 'editor', kind: 'api', group: 'vue' }, { path: 'vue/writekit', slug: 'writekit', kind: 'api', group: 'vue' },
{ path: 'vue/primitives', slug: 'primitives', kind: 'components', group: 'vue' }, { path: 'vue/primitives', slug: 'primitives', kind: 'components', group: 'vue' },
// ── configs ── // ── configs ──
{ path: 'configs/eslint', slug: 'eslint', kind: 'guide', group: 'configs', guideSources: ['README.md', 'rules/*.md'] }, { path: 'configs/eslint', slug: 'eslint', kind: 'guide', group: 'configs', guideSources: ['README.md', 'rules/*.md'] },
@@ -98,6 +98,27 @@ const PACKAGES: PackageConfig[] = [
{ path: 'infra/renovate', slug: 'renovate', kind: 'guide', group: 'infra', guideSources: ['README.md'] }, { path: 'infra/renovate', slug: 'renovate', kind: 'guide', group: 'infra', guideSources: ['README.md'] },
]; ];
/**
* Display label for each category FOLDER under `src/`. Components now live at
* `src/<category>/<component>/`, so the folder is the source of truth for a
* component's category. Unlisted folders fall back to `toPascalCase(folder)`.
* The display order of categories lives in `useDocs` (`COMPONENT_CATEGORY_ORDER`).
*/
const CATEGORY_LABELS: Record<string, string> = {
forms: 'Forms',
selection: 'Selection',
color: 'Color',
overlays: 'Overlays',
menus: 'Menus',
disclosure: 'Disclosure',
navigation: 'Navigation',
display: 'Display',
feedback: 'Feedback',
canvas: 'Canvas & editors',
utilities: 'Utilities',
internal: 'Internal',
};
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
function toKebabCase(str: string): string { function toKebabCase(str: string): string {
@@ -716,14 +737,14 @@ function inferCategoryFromItem(item: ItemMeta): string {
} }
/** Resolve a package's export subpaths to source entry files. */ /** Resolve a package's export subpaths to source entry files. */
function resolveEntryPoints(pkgDir: string, exportsField: Record<string, any>): Array<{ subpath: string; filePath: string }> { function resolveEntryPoints(pkgDir: string, exportsField: Record<string, unknown>): Array<{ subpath: string; filePath: string }> {
const entryPoints: Array<{ subpath: string; filePath: string }> = []; const entryPoints: Array<{ subpath: string; filePath: string }> = [];
for (const [subpath, value] of Object.entries(exportsField)) { for (const [subpath, value] of Object.entries(exportsField)) {
if (typeof value !== 'object' || value === null) continue; if (typeof value !== 'object' || value === null) continue;
let entry: any = (value as Record<string, any>).import ?? (value as Record<string, any>).types; let entry: unknown = (value as Record<string, unknown>).import ?? (value as Record<string, unknown>).types;
if (typeof entry === 'object' && entry !== null) entry = entry.types || entry.default; if (typeof entry === 'object' && entry !== null) entry = (entry as Record<string, unknown>).types || (entry as Record<string, unknown>).default;
if (!entry || typeof entry !== 'string') continue; if (!entry || typeof entry !== 'string') continue;
// Wildcard exports (e.g. "./*") can't be resolved to a single file here. // Wildcard exports (e.g. "./*") can't be resolved to a single file here.
if (entry.includes('*')) continue; if (entry.includes('*')) continue;
@@ -942,75 +963,100 @@ function roleFromName(componentName: string, base: string): string {
return role || 'Root'; return role || 'Root';
} }
/**
* Build a single component group from its directory, or `null` when the dir is
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
* is the package subpath (e.g. `./forms/checkbox`).
*/
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
// A component group is any dir that ships at least one .vue file.
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
if (vueFiles.length === 0) return null;
const base = toPascalCase(slug);
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
// excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
// that aren't part of the public API. Fall back to all .vue (minus demo) only
// when the barrel exposes no parseable `export { default as X }`.
const order = readPartOrder(resolve(dir, 'index.ts'));
const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
const candidates = publicFiles.length > 0
? publicFiles
: vueFiles.filter(f => f !== 'demo.vue');
// Drop internal implementation/variant parts users never compose directly
// (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
const parts: ComponentPartMeta[] = [];
let groupDescription = '';
for (const file of orderedFiles) {
const sfc = readFileSync(resolve(dir, file), 'utf-8');
const plain = extractScriptBlock(sfc, false);
const setup = extractScriptBlock(sfc, true);
const { props, description } = extractPartProps(plain);
const name = file.replace(/\.vue$/, '');
const role = roleFromName(name, base);
if (role === 'Root' && description && !groupDescription) groupDescription = description;
// Merge in `defineModel` v-model props/emits (invisible to the interface/
// defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits)
if (!emits.some(e => e.name === me.name)) emits.push(me);
parts.push({ name, role, description, props, emits });
}
return {
name: base,
slug,
category,
description: groupDescription,
entryPoint,
parts,
hasDemo: existsSync(resolve(dir, 'demo.vue')),
demoSource: '', // loaded lazily client-side via #docs/demo-sources
sourcePath: relative(ROOT, dir),
};
}
function buildComponents(pkgDir: string): ComponentMeta[] { function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src'); const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return []; if (!existsSync(srcDir)) return [];
const components: ComponentMeta[] = []; const components: ComponentMeta[] = [];
for (const entry of readdirSync(srcDir, { withFileTypes: true })) { // Components live one level deep, in category folders: src/<category>/<component>/.
if (!entry.isDirectory()) continue; // The category folder IS the source of truth for the component's category.
const dir = resolve(srcDir, entry.name); for (const catEntry of readdirSync(srcDir, { withFileTypes: true })) {
if (!catEntry.isDirectory()) continue;
const catDir = resolve(srcDir, catEntry.name);
const label = CATEGORY_LABELS[catEntry.name];
// A component group is any dir that ships at least one .vue file. if (label) {
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue')); // A known category folder — each child dir is a component group.
if (vueFiles.length === 0) continue; for (const compEntry of readdirSync(catDir, { withFileTypes: true })) {
if (!compEntry.isDirectory()) continue;
const slug = entry.name; const c = buildComponentAt(
const base = toPascalCase(slug); resolve(catDir, compEntry.name),
compEntry.name,
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This label,
// excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …) `./${catEntry.name}/${compEntry.name}`,
// that aren't part of the public API. Fall back to all .vue (minus demo) only );
// when the barrel exposes no parseable `export { default as X }`. if (c) components.push(c);
const order = readPartOrder(resolve(dir, 'index.ts')); }
const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f)); }
const candidates = publicFiles.length > 0 else {
? publicFiles // Backward-compat: a flat component dir directly under src.
: vueFiles.filter(f => f !== 'demo.vue'); const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
// Drop internal implementation/variant parts users never compose directly if (c) components.push(c);
// (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
const parts: ComponentPartMeta[] = [];
let groupDescription = '';
for (const file of orderedFiles) {
const sfc = readFileSync(resolve(dir, file), 'utf-8');
const plain = extractScriptBlock(sfc, false);
const setup = extractScriptBlock(sfc, true);
const { props, description } = extractPartProps(plain);
const name = file.replace(/\.vue$/, '');
const role = roleFromName(name, base);
if (role === 'Root' && description && !groupDescription) groupDescription = description;
// Merge in `defineModel` v-model props/emits (invisible to the interface/
// defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits)
if (!emits.some(e => e.name === me.name)) emits.push(me);
parts.push({ name, role, description, props, emits });
} }
const entryPoint = `./${slug}`;
const demoPath = resolve(dir, 'demo.vue');
const hasDemo = existsSync(demoPath);
components.push({
name: base,
slug,
description: groupDescription,
entryPoint,
parts,
hasDemo,
demoSource: '', // loaded lazily client-side via #docs/demo-sources
sourcePath: relative(ROOT, dir),
});
} }
return components.sort((a, b) => a.name.localeCompare(b.name)); return components.sort((a, b) => a.name.localeCompare(b.name));
+8 -2
View File
@@ -44,7 +44,7 @@ export default defineNuxtModule({
'@robonen/fetch': 'core/fetch/src', '@robonen/fetch': 'core/fetch/src',
'@robonen/encoding': 'core/encoding/src', '@robonen/encoding': 'core/encoding/src',
'@robonen/crdt': 'core/crdt/src', '@robonen/crdt': 'core/crdt/src',
'@robonen/editor': 'vue/editor/src', '@robonen/writekit': 'vue/writekit/src',
'@robonen/primitives': 'vue/primitives/src', '@robonen/primitives': 'vue/primitives/src',
'@robonen/vue': vueSrc, '@robonen/vue': vueSrc,
}; };
@@ -58,7 +58,13 @@ export default defineNuxtModule({
// Primitive `as="template"` / Slot path), silently blanking every demo // Primitive `as="template"` / Slot path), silently blanking every demo
// that hits it. `import.meta.env.DEV` resolves correctly in dev & prod. // that hits it. `import.meta.env.DEV` resolves correctly in dev & prod.
config.define ??= {}; config.define ??= {};
(config.define as Record<string, unknown>).__DEV__ ??= 'import.meta.env.DEV'; // Inline a STATIC boolean, not `import.meta.env.DEV`: a define value is
// inserted verbatim and is NOT re-scanned for Vite's `import.meta.env`
// replacement, so in a prod build it shipped a literal `import.meta.env.DEV`
// into chunks where `import.meta.env` is undefined at runtime →
// "Cannot read properties of undefined (reading 'DEV')". A literal
// true/false has no runtime dependency and tree-shakes the dev branches.
(config.define as Record<string, unknown>).__DEV__ ??= JSON.stringify(nuxt.options.dev);
const existing = config.resolve?.alias; const existing = config.resolve?.alias;
const sourceAliases = [ const sourceAliases = [
+2
View File
@@ -115,6 +115,8 @@ export interface ComponentMeta {
name: string; name: string;
/** URL-friendly slug, e.g. "accordion" */ /** URL-friendly slug, e.g. "accordion" */
slug: string; slug: string;
/** Functional category for grouping in the docs, e.g. "Forms", "Overlays". */
category: string;
/** Short description (from README heading or first JSDoc) */ /** Short description (from README heading or first JSDoc) */
description: string; description: string;
/** Subpath export, e.g. "./accordion" */ /** Subpath export, e.g. "./accordion" */
+7 -7
View File
@@ -159,15 +159,15 @@ describe('getPackage / resolveEntry', () => {
describe('slug uniqueness & collisions', () => { describe('slug uniqueness & collisions', () => {
// A function and a co-located type/interface whose names differ only in case // A function and a co-located type/interface whose names differ only in case
// both slugify to the same value — the real extractor produces these in // both slugify to the same value — the real extractor produces these in
// @robonen/editor and @robonen/vue. // @robonen/writekit and @robonen/vue.
const colliding: DocsMetadata = { const colliding: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z', generatedAt: '2026-06-08T00:00:00.000Z',
packages: [ packages: [
{ {
name: '@robonen/editor', name: '@robonen/writekit',
version: '1.0.0', version: '1.0.0',
description: 'Editor', description: 'Writekit',
slug: 'editor', slug: 'writekit',
kind: 'api', kind: 'api',
group: 'vue', group: 'vue',
entryPoints: ['.'], entryPoints: ['.'],
@@ -197,12 +197,12 @@ describe('slug uniqueness & collisions', () => {
it('reaches both colliding symbols — function and interface — independently', () => { it('reaches both colliding symbols — function and interface — independently', () => {
const leaves = buildLeaves(colliding); const leaves = buildLeaves(colliding);
// Exact case-sensitive name disambiguates the function from the interface. // Exact case-sensitive name disambiguates the function from the interface.
const fn = resolveEntry(leaves, 'editor', 'position'); const fn = resolveEntry(leaves, 'writekit', 'position');
const iface = resolveEntry(leaves, 'editor', 'Position'); const iface = resolveEntry(leaves, 'writekit', 'Position');
expect(fn?.kind === 'api' && fn.item.kind).toBe('function'); expect(fn?.kind === 'api' && fn.item.kind).toBe('function');
expect(iface?.kind === 'api' && iface.item.kind).toBe('interface'); expect(iface?.kind === 'api' && iface.item.kind).toBe('interface');
// The disambiguated slug also resolves the interface directly. // The disambiguated slug also resolves the interface directly.
const bySlug = resolveEntry(leaves, 'editor', 'position-interface'); const bySlug = resolveEntry(leaves, 'writekit', 'position-interface');
expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface'); expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface');
}); });
+2
View File
@@ -20,6 +20,8 @@ export default defineNuxtConfig({
vite: { vite: {
plugins: [ plugins: [
// `as any`: @tailwindcss/vite and Nuxt resolve different `vite` versions, so
// their `Plugin` types are structurally identical but nominally incompatible.
tailwindcss() as any, tailwindcss() as any,
], ],
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
@@ -0,0 +1,27 @@
import { customRef, onScopeDispose } from 'vue';
export function broadcastedRef<T>(key: string, initialValue: T) {
const channel = new BroadcastChannel(key);
onScopeDispose(channel.close);
return customRef<T>((track, trigger) => {
channel.onmessage = (event) => {
track();
return event.data;
};
channel.postMessage(initialValue);
return {
get() {
return initialValue;
},
set(newValue: T) {
initialValue = newValue;
channel.postMessage(newValue);
trigger();
},
};
});
}