/** * 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 = { 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 { 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, 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(); 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; }