feat(docs): doc-sections system, crdt package, MCP server, and responsive fixes

Adds a hand-authored .vue doc-sections system (intro + guide pages per package, #docs/sections map, sidebar Guide group, client-side TOC), registers @robonen/crdt, renders demos client-only, base64-encodes the server-metadata virtual, plus the MCP docs endpoint and responsive/overflow fixes across pages and tables.
This commit is contained in:
2026-06-08 15:52:03 +07:00
parent def1db8b6c
commit 09433415b6
23 changed files with 1107 additions and 35 deletions
+232
View File
@@ -0,0 +1,232 @@
import { describe, expect, it } from 'vitest';
import type { DocsMetadata, ItemMeta } from '../extractor/types';
import { buildLeaves, countEntries, getPackage, groupPackages, resolveEntry, search } from './docs-index';
function item(partial: Partial<ItemMeta> & Pick<ItemMeta, 'name' | 'slug' | 'kind' | 'description'>): ItemMeta {
return {
since: '',
signatures: [],
params: [],
returns: null,
typeParams: [],
examples: [],
methods: [],
properties: [],
hasDemo: false,
demoSource: '',
hasTests: false,
relatedTypes: [],
sourcePath: '',
entryPoint: '.',
...partial,
};
}
const metadata: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z',
packages: [
{
name: '@robonen/stdlib',
version: '1.0.0',
description: 'Standard library utilities',
slug: 'stdlib',
kind: 'api',
group: 'core',
entryPoints: ['.'],
categories: [
{
name: 'Numbers',
slug: 'numbers',
items: [
item({ name: 'clamp', slug: 'clamp', kind: 'function', description: 'Clamp a number to a range' }),
item({ name: 'debounce', slug: 'debounce', kind: 'function', description: 'Debounce a function call' }),
],
},
],
components: [],
sections: [],
},
{
name: '@robonen/toolkit',
version: '2.0.0',
description: 'Vue composables',
slug: 'toolkit',
kind: 'components',
group: 'vue',
entryPoints: ['.'],
categories: [],
components: [
{
name: 'useClipboard',
slug: 'use-clipboard',
description: 'Reactive clipboard access',
entryPoint: './use-clipboard',
parts: [],
hasDemo: false,
demoSource: '',
sourcePath: '',
},
],
sections: [],
},
{
name: '@robonen/eslint',
version: '3.0.0',
description: 'Shared ESLint config',
slug: 'eslint',
kind: 'guide',
group: 'configs',
entryPoints: ['.'],
categories: [],
components: [],
sections: [
{ title: 'Imports preset', slug: 'imports', markdown: '# Imports\nSorts and dedupes imports.' },
],
},
],
};
describe('buildLeaves', () => {
it('flattens every leaf across all package kinds', () => {
const leaves = buildLeaves(metadata);
expect(leaves).toHaveLength(4);
expect(leaves.map(l => l.slug).sort()).toEqual(['clamp', 'debounce', 'imports', 'use-clipboard']);
});
it('tags each leaf with the right badge', () => {
const byName = new Map(buildLeaves(metadata).map(l => [l.name, l.badge]));
expect(byName.get('clamp')).toBe('function');
expect(byName.get('useClipboard')).toBe('component');
expect(byName.get('Imports preset')).toBe('guide');
});
});
describe('search', () => {
const leaves = buildLeaves(metadata);
it('returns empty for a blank query', () => {
expect(search(leaves, ' ')).toEqual([]);
});
it('ranks an exact name match first', () => {
const hits = search(leaves, 'clamp');
expect(hits[0]?.name).toBe('clamp');
expect(hits[0]?.packageSlug).toBe('stdlib');
});
it('matches on description body, not just names', () => {
const hits = search(leaves, 'clipboard');
expect(hits.map(h => h.name)).toContain('useClipboard');
});
it('applies AND semantics across tokens', () => {
expect(search(leaves, 'clamp clipboard')).toEqual([]);
});
it('honours the limit', () => {
expect(search(leaves, 'a', 1)).toHaveLength(1);
});
});
describe('getPackage / resolveEntry', () => {
const leaves = buildLeaves(metadata);
it('finds a package by slug, case-insensitively', () => {
expect(getPackage(metadata, 'STDLIB')?.name).toBe('@robonen/stdlib');
expect(getPackage(metadata, 'nope')).toBeUndefined();
});
it('resolves an api item by slug or exported name', () => {
expect(resolveEntry(leaves, 'stdlib', 'clamp')?.kind).toBe('api');
expect(resolveEntry(leaves, 'stdlib', 'Clamp')?.kind).toBe('api');
});
it('resolves a component by slug and by name', () => {
expect(resolveEntry(leaves, 'toolkit', 'use-clipboard')?.kind).toBe('components');
expect(resolveEntry(leaves, 'toolkit', 'useClipboard')?.kind).toBe('components');
});
it('resolves a guide section', () => {
const entry = resolveEntry(leaves, 'eslint', 'imports');
expect(entry?.kind).toBe('guide');
});
it('returns undefined for an unknown item', () => {
expect(resolveEntry(leaves, 'stdlib', 'missing')).toBeUndefined();
});
});
describe('slug uniqueness & collisions', () => {
// 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
// @robonen/editor and @robonen/vue.
const colliding: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z',
packages: [
{
name: '@robonen/editor',
version: '1.0.0',
description: 'Editor',
slug: 'editor',
kind: 'api',
group: 'vue',
entryPoints: ['.'],
categories: [
{
name: 'Model',
slug: 'model',
items: [
item({ name: 'position', slug: 'position', kind: 'function', description: 'Create a position' }),
item({ name: 'Position', slug: 'position', kind: 'interface', description: 'A position' }),
],
},
],
components: [],
sections: [],
},
],
};
it('disambiguates colliding slugs so every (package, slug) pair is unique', () => {
const leaves = buildLeaves(colliding);
const slugs = leaves.map(l => l.slug);
expect(slugs).toEqual(['position', 'position-interface']);
expect(new Set(slugs).size).toBe(slugs.length);
});
it('reaches both colliding symbols — function and interface — independently', () => {
const leaves = buildLeaves(colliding);
// Exact case-sensitive name disambiguates the function from the interface.
const fn = resolveEntry(leaves, 'editor', 'position');
const iface = resolveEntry(leaves, 'editor', 'Position');
expect(fn?.kind === 'api' && fn.item.kind).toBe('function');
expect(iface?.kind === 'api' && iface.item.kind).toBe('interface');
// The disambiguated slug also resolves the interface directly.
const bySlug = resolveEntry(leaves, 'editor', 'position-interface');
expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface');
});
it('throws when a slug contains a URI-reserved character', () => {
const bad: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z',
packages: [{
name: '@robonen/x', version: '1.0.0', description: '', slug: 'x', kind: 'guide', group: 'infra',
entryPoints: ['.'], categories: [], components: [],
sections: [{ title: 'Nested', slug: 'rules/no-foo', markdown: '#' }],
}],
};
expect(() => buildLeaves(bad)).toThrow(/reserved URI character/);
});
});
describe('grouping helpers', () => {
it('orders groups core → vue → configs and drops empties', () => {
expect(groupPackages(metadata).map(g => g.group)).toEqual(['core', 'vue', 'configs']);
});
it('counts entries per package kind', () => {
expect(countEntries(metadata.packages[0]!)).toBe(2);
expect(countEntries(metadata.packages[1]!)).toBe(1);
expect(countEntries(metadata.packages[2]!)).toBe(1);
});
});