Files
tools/docs/modules/mcp/docs-index.ts
T
robonen 09433415b6 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.
2026-06-08 15:52:03 +07:00

280 lines
9.3 KiB
TypeScript

/**
* Pure, framework-agnostic query layer over the extractor's {@link DocsMetadata}.
*
* The MCP server (and its tests) build a flat index of every documented leaf —
* api items, components and guide sections alike — and run search / lookup over
* it. Nothing here touches the MCP SDK or the filesystem, so it stays trivially
* unit-testable.
*/
import type {
CategoryMeta,
ComponentMeta,
DocsMetadata,
GuideSection,
ItemMeta,
PackageGroup,
PackageMeta,
} from '../extractor/types';
/** A normalised, discriminated reference to any documented leaf. */
export type DocEntry
= | { kind: 'api'; pkg: PackageMeta; category: CategoryMeta; item: ItemMeta }
| { kind: 'components'; pkg: PackageMeta; component: ComponentMeta }
| { kind: 'guide'; pkg: PackageMeta; section: GuideSection };
/** A flat, search-friendly record for a single documented leaf. */
export interface Leaf {
packageSlug: string;
packageName: string;
group: PackageGroup;
/** Display badge: the item kind, or `component` / `guide`. */
badge: string;
/** URL-friendly slug, unique within its package. */
slug: string;
/** Display name. */
name: string;
description: string;
/** Back-reference to the structured entry for rich rendering. */
entry: DocEntry;
/** Lowercased haystack used for substring search. */
haystack: string;
}
export interface SearchHit {
packageSlug: string;
packageName: string;
badge: string;
slug: string;
name: string;
description: string;
score: number;
}
export const GROUP_LABELS: Record<PackageGroup, string> = {
core: 'Core',
vue: 'Vue',
configs: 'Configs',
infra: 'Infra',
};
export const GROUP_ORDER: PackageGroup[] = ['core', 'vue', 'configs', 'infra'];
/** Join a list of strings into a lowercased search haystack, skipping empties. */
function haystackOf(parts: Array<string | undefined | null>): string {
return parts.filter(Boolean).join(' ').toLowerCase();
}
/**
* Characters reserved by the `robonen-docs://{package}/{slug}` resource template:
* the SDK's URI matcher treats `/` and `,` as variable delimiters, so a slug
* containing either would list but never read back. We assert against them.
*/
const RESERVED_URI_CHARS = /[/,]/;
/**
* Hand back a slug unique within its package. The extractor derives slugs from
* names case-insensitively (`useProjection` and `UseProjection` both → `use-projection`),
* so co-located function/type pairs would otherwise collide — one becoming an
* unreachable, duplicate resource URI. The first claimant keeps the clean slug;
* later ones are suffixed with their badge (e.g. `use-projection-type`).
*/
function uniqueSlug(used: Set<string>, base: string, badge: string): string {
let candidate = base;
if (used.has(candidate)) {
candidate = `${base}-${badge}`;
for (let n = 2; used.has(candidate); n++) candidate = `${base}-${badge}-${n}`;
}
used.add(candidate);
return candidate;
}
/** Flatten an entire metadata document into one searchable list of leaves. */
export function buildLeaves(metadata: DocsMetadata): Leaf[] {
const leaves: Leaf[] = [];
for (const pkg of metadata.packages) {
// Slugs only need to be unique *within* a package — the package slug is the
// other half of every reference and resource URI.
const used = new Set<string>();
const push = (leaf: Leaf): void => {
if (RESERVED_URI_CHARS.test(leaf.packageSlug) || RESERVED_URI_CHARS.test(leaf.slug)) {
throw new Error(
`Slug "${leaf.packageSlug}/${leaf.slug}" contains a reserved URI character ('/' or ','); `
+ 'the robonen-docs:// resource template cannot address it.',
);
}
leaves.push(leaf);
};
if (pkg.kind === 'api') {
for (const category of pkg.categories) {
for (const item of category.items) {
push({
packageSlug: pkg.slug,
packageName: pkg.name,
group: pkg.group,
badge: item.kind,
slug: uniqueSlug(used, item.slug, item.kind),
name: item.name,
description: item.description,
entry: { kind: 'api', pkg, category, item },
haystack: haystackOf([
item.name,
item.description,
category.name,
...item.signatures,
...item.params.flatMap(p => [p.name, p.type, p.description]),
item.returns?.description,
...item.examples,
...item.methods.map(m => m.name),
...item.properties.map(p => p.name),
...item.relatedTypes.map(t => t.name),
]),
});
}
}
}
else if (pkg.kind === 'components') {
for (const component of pkg.components) {
push({
packageSlug: pkg.slug,
packageName: pkg.name,
group: pkg.group,
badge: 'component',
slug: uniqueSlug(used, component.slug, 'component'),
name: component.name,
description: component.description,
entry: { kind: 'components', pkg, component },
haystack: haystackOf([
component.name,
component.description,
...component.parts.flatMap(part => [
part.name,
part.role,
part.description,
...part.props.map(p => p.name),
...part.emits.map(e => e.name),
]),
]),
});
}
}
else {
for (const section of pkg.sections) {
push({
packageSlug: pkg.slug,
packageName: pkg.name,
group: pkg.group,
badge: 'guide',
slug: uniqueSlug(used, section.slug, 'guide'),
name: section.title,
description: pkg.description,
entry: { kind: 'guide', pkg, section },
haystack: haystackOf([section.title, section.markdown]),
});
}
}
}
return leaves;
}
/** Score a single leaf against pre-lowercased, whitespace-split query tokens. */
function scoreLeaf(leaf: Leaf, tokens: string[]): number {
const name = leaf.name.toLowerCase();
const description = leaf.description.toLowerCase();
let total = 0;
for (const token of tokens) {
let best: number;
if (name === token) best = 1000;
else if (name.startsWith(token)) best = 500;
else if (name.includes(token)) best = 250;
else if (description.includes(token)) best = 80;
else if (leaf.haystack.includes(token)) best = 25;
// AND semantics: every token must appear somewhere, or it is not a match.
else return 0;
total += best;
}
// Nudge shorter (more specific) names ahead of longer ones on ties.
total += Math.max(0, 40 - name.length);
return total;
}
/** Rank leaves by relevance to `query`. Returns at most `limit` hits. */
export function search(leaves: Leaf[], query: string, limit = 20): SearchHit[] {
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return [];
return leaves
.map(leaf => ({ leaf, score: scoreLeaf(leaf, tokens) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score || a.leaf.name.localeCompare(b.leaf.name))
.slice(0, Math.max(1, limit))
.map(({ leaf, score }) => ({
packageSlug: leaf.packageSlug,
packageName: leaf.packageName,
badge: leaf.badge,
slug: leaf.slug,
name: leaf.name,
description: leaf.description,
score,
}));
}
/** Packages grouped & ordered for a table-of-contents view. */
export function groupPackages(
metadata: DocsMetadata,
): Array<{ group: PackageGroup; label: string; packages: PackageMeta[] }> {
return GROUP_ORDER
.map(group => ({
group,
label: GROUP_LABELS[group],
packages: metadata.packages.filter(p => p.group === group),
}))
.filter(g => g.packages.length > 0);
}
/** Number of documented leaves in a package, whatever its kind. */
export function countEntries(pkg: PackageMeta): number {
if (pkg.kind === 'api') return pkg.categories.reduce((s, c) => s + c.items.length, 0);
if (pkg.kind === 'components') return pkg.components.length;
return pkg.sections.length;
}
/** Look up a package by its slug (case-insensitive). */
export function getPackage(metadata: DocsMetadata, slug: string): PackageMeta | undefined {
const target = slug.trim().toLowerCase();
return metadata.packages.find(p => p.slug.toLowerCase() === target);
}
/**
* Resolve a `(packageSlug, slugOrName)` pair to a structured entry against the
* unique-slugged leaf index. Tries, in order: an exact (case-sensitive) name
* match — so `Position` and `position` reach different symbols — then the unique
* slug, then a forgiving case-insensitive match on either.
*/
export function resolveEntry(
leaves: Leaf[],
packageSlug: string,
slugOrName: string,
): DocEntry | undefined {
const pkg = packageSlug.trim().toLowerCase();
const candidates = leaves.filter(l => l.packageSlug.toLowerCase() === pkg);
if (candidates.length === 0) return undefined;
const query = slugOrName.trim();
const lower = query.toLowerCase();
const exactName = candidates.find(l => l.name === query);
if (exactName) return exactName.entry;
const exactSlug = candidates.find(l => l.slug === query);
if (exactSlug) return exactSlug.entry;
const loose = candidates.find(l => l.name.toLowerCase() === lower || l.slug.toLowerCase() === lower);
return loose?.entry;
}