diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/benchmark-tool-tokens.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/benchmark-tool-tokens.ts new file mode 100644 index 000000000..b95362ce4 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/benchmark-tool-tokens.ts @@ -0,0 +1,186 @@ +/** + * Compare the token cost of get_doc vs get_example over the whole doc corpus. + * + * Counts tokens on the exact string each tool puts in content[0].text, so the + * numbers are the model-visible payload with no thinking/tool-call overhead. + * + * npx tsx scripts/benchmark-tool-tokens.ts + * npx tsx scripts/benchmark-tool-tokens.ts --framework angular --language typescript + * npx tsx scripts/benchmark-tool-tokens.ts --csv dist/tool-token-benchmark.csv + */ +import { encodingForModel } from "js-tiktoken"; +import { writeFileSync, mkdirSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { LocalDocsProvider } from "../src/providers/LocalDocsProvider.js"; +import { extractCodeExamples, formatCodeExamples } from "../src/tools/doc-tools.js"; + +const FRAMEWORKS = ["angular", "react", "webcomponents", "blazor"] as const; + +// Language filter representative of what a caller on each framework would ask for. +const PRIMARY_LANGUAGE: Record = { + angular: "typescript", + react: "tsx", + webcomponents: "typescript", + blazor: "razor", +}; + +function parseArgs() { + const argv = process.argv.slice(2); + const get = (flag: string) => { + const i = argv.indexOf(flag); + return i >= 0 ? argv[i + 1] : undefined; + }; + return { + framework: get("--framework"), + language: get("--language"), + csv: get("--csv"), + }; +} + +interface Row { + framework: string; + name: string; + docTokens: number; + exampleTokens: number; + exampleLangTokens: number; + examples: number; +} + +function stats(values: number[]) { + if (values.length === 0) return { mean: 0, median: 0, p90: 0, max: 0, total: 0 }; + const sorted = [...values].sort((a, b) => a - b); + const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]; + const total = values.reduce((a, b) => a + b, 0); + return { + mean: Math.round(total / values.length), + median: at(0.5), + p90: at(0.9), + max: sorted[sorted.length - 1], + total, + }; +} + +function pct(from: number, to: number): string { + if (from === 0) return "n/a"; + return `${Math.round(((from - to) / from) * 100)}%`; +} + +async function main() { + const args = parseArgs(); + const enc = encodingForModel("gpt-4o"); + const count = (s: string) => enc.encode(s).length; + + // Run from source, so resolve the built DB rather than LocalDocsProvider's + // dist-relative default. + const dbPath = + process.env.DB_PATH ?? join(dirname(fileURLToPath(import.meta.url)), "..", "dist", "igniteui-docs.db"); + const provider = new LocalDocsProvider(dbPath); + await provider.init(); + + const frameworks = args.framework ? [args.framework] : [...FRAMEWORKS]; + const rows: Row[] = []; + + for (const framework of frameworks) { + const language = args.language ?? PRIMARY_LANGUAGE[framework]; + const listing = await provider.listComponents(framework); + const names = [...listing.matchAll(/\(`([^`]+)`\)/g)].map((m) => m[1]); + + process.stdout.write(`${framework}: ${names.length} docs`); + + for (const name of names) { + const { text, found } = await provider.getDoc(framework, name); + if (!found) continue; + + // get_doc returns the doc body verbatim. + const docTokens = count(text); + + // get_example returns the formatted examples, or a one-line miss message. + const all = extractCodeExamples(text); + const exampleText = all.length + ? formatCodeExamples(all, { framework, docName: name }) + : `No code examples found in \`${name}\` (${framework}). Use get_doc for the full doc, or try a different topic.`; + + const filtered = extractCodeExamples(text, { language }); + const exampleLangText = filtered.length + ? formatCodeExamples(filtered, { framework, docName: name, language }) + : `No code examples in \`${language}\` found in \`${name}\` (${framework}). Use get_doc for the full doc, or try a different topic.`; + + rows.push({ + framework, + name, + docTokens, + exampleTokens: count(exampleText), + exampleLangTokens: count(exampleLangText), + examples: all.length, + }); + } + process.stdout.write(" ✓\n"); + } + + console.log("\n=== Average tokens returned per call (gpt-4o / o200k_base) ===\n"); + const header = ["framework", "docs", "get_doc", "get_example", "vs doc", "get_example+lang", "vs doc", "no-example docs"]; + console.log(header.join("\t")); + + const report = (label: string, subset: Row[]) => { + if (subset.length === 0) return; + const doc = stats(subset.map((r) => r.docTokens)); + const ex = stats(subset.map((r) => r.exampleTokens)); + const exLang = stats(subset.map((r) => r.exampleLangTokens)); + const empty = subset.filter((r) => r.examples === 0).length; + console.log( + [ + label, + subset.length, + doc.mean, + ex.mean, + pct(doc.mean, ex.mean), + exLang.mean, + pct(doc.mean, exLang.mean), + `${empty} (${Math.round((empty / subset.length) * 100)}%)`, + ].join("\t") + ); + }; + + for (const framework of frameworks) { + report(framework, rows.filter((r) => r.framework === framework)); + } + report("ALL", rows); + + console.log("\n=== Distribution, docs that actually have examples ===\n"); + console.log(["framework", "docs", "median doc", "median ex", "p90 doc", "p90 ex", "max doc", "max ex"].join("\t")); + for (const framework of frameworks) { + const subset = rows.filter((r) => r.framework === framework && r.examples > 0); + if (subset.length === 0) continue; + const doc = stats(subset.map((r) => r.docTokens)); + const ex = stats(subset.map((r) => r.exampleTokens)); + console.log( + [framework, subset.length, doc.median, ex.median, doc.p90, ex.p90, doc.max, ex.max].join("\t") + ); + } + + const withExamples = rows.filter((r) => r.examples > 0); + const docTotal = stats(withExamples.map((r) => r.docTokens)).total; + const exTotal = stats(withExamples.map((r) => r.exampleTokens)).total; + console.log( + `\nCorpus totals (docs with examples, n=${withExamples.length}): ` + + `get_doc ${docTotal.toLocaleString()} tok vs get_example ${exTotal.toLocaleString()} tok — ${pct(docTotal, exTotal)} lower.` + ); + + if (args.csv) { + mkdirSync(dirname(args.csv), { recursive: true }); + const csv = [ + "framework,name,doc_tokens,example_tokens,example_lang_tokens,examples", + ...rows.map((r) => + [r.framework, r.name, r.docTokens, r.exampleTokens, r.exampleLangTokens, r.examples].join(",") + ), + ].join("\n"); + writeFileSync(args.csv, csv); + console.log(`\nPer-doc rows written to ${args.csv}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/index.get-example.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/index.get-example.test.ts new file mode 100644 index 000000000..a5f03032f --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/index.get-example.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => ({ + getDoc: vi.fn(), + searchDocs: vi.fn(), + registeredTools: new Map Promise>(), +})); + +vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({ + McpServer: class { + registerTool(name: string, _config: unknown, handler: (...args: any[]) => Promise) { + mockState.registeredTools.set(name, handler); + } + registerPrompt() {} + async connect() {} + }, +})); + +vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ + StdioServerTransport: class {}, +})); + +vi.mock("dotenv", () => ({ + default: { config: vi.fn() }, +})); + +vi.mock("../providers/LocalDocsProvider.js", () => ({ + LocalDocsProvider: class { + async init() {} + async listComponents() { + return ""; + } + async getDoc(framework: string, name: string) { + return mockState.getDoc(framework, name); + } + async searchDocs(framework: string, query: string) { + return mockState.searchDocs(framework, query); + } + }, +})); + +vi.mock("../providers/RemoteDocsProvider.js", () => ({ + RemoteDocsProvider: class {}, +})); + +vi.mock("../lib/api-doc-loader.js", () => ({ + ApiDocLoader: class { + load() {} + }, +})); + +vi.mock("../config/platforms.js", () => ({ + PLATFORMS: ["angular", "react", "blazor", "webcomponents"], + getPlatforms: () => [], +})); + +describe("get_example tool", () => { + beforeEach(async () => { + vi.resetModules(); + mockState.registeredTools.clear(); + mockState.getDoc.mockReset(); + mockState.searchDocs.mockReset(); + mockState.searchDocs.mockResolvedValue(""); + process.argv = ["node", "index.js"]; + await import("../index.js"); + }); + + it("returns isError when the doc is not found", async () => { + const handler = mockState.registeredTools.get("get_example")!; + // Use component+topic so the requested name is "grid-editing" (already prefixed, + // no further fallback attempts) and mock all calls as not found. + mockState.getDoc.mockResolvedValue({ text: "not found", found: false }); + + const result = await handler({ framework: "angular", component: "grid", topic: "editing" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("not found"); + }); + + it("combines component and topic into the doc name lookup", async () => { + const handler = mockState.registeredTools.get("get_example")!; + mockState.getDoc.mockResolvedValueOnce({ + text: "```typescript\nconst x = 1;\n```", + found: true, + }); + + await handler({ framework: "angular", component: "grid", topic: "editing" }); + + expect(mockState.getDoc).toHaveBeenCalledWith("angular", "grid-editing"); + }); + + it("returns 'no examples found' message when doc has no code blocks", async () => { + const handler = mockState.registeredTools.get("get_example")!; + // Use component+topic so the requested name is "grid-editing" — already prefixed, + // which avoids the grid- fallback retry in resolveDoc. + mockState.getDoc + .mockResolvedValueOnce({ text: "This doc has no code examples.", found: true }); + + const result = await handler({ framework: "angular", component: "grid", topic: "editing" }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("No code examples"); + expect(result.content[0].text).toContain("`grid-editing`"); + }); + + it("includes language in the 'no examples found' message when language is specified", async () => { + const handler = mockState.registeredTools.get("get_example")!; + mockState.getDoc + .mockResolvedValueOnce({ text: "```html\n
\n```", found: true }); + + const result = await handler({ framework: "angular", component: "grid", topic: "editing", language: "typescript" }); + + expect(result.content[0].text).toContain("`typescript`"); + expect(result.content[0].text).toContain("No code examples"); + }); + + it("prepends substitution notice when a fuzzy match is used and examples are present", async () => { + const handler = mockState.registeredTools.get("get_example")!; + // Simulate the fuzzy path: searchDocs returns a parseable doc name that shares + // a token with "grid-editing", and the follow-up getDoc call succeeds. + mockState.getDoc + .mockResolvedValueOnce({ text: "not found", found: false }) // direct "grid-editing" lookup + .mockResolvedValueOnce({ text: "```typescript\nconst x = 1;\n```", found: true }); // fuzzy hit + mockState.searchDocs.mockResolvedValueOnce("(`grid-editing`)"); + + const result = await handler({ framework: "angular", component: "grid", topic: "editing" }); + + // The response must include the code example text and the substitution notice. + expect(result.content[0].text).toContain("```typescript"); + expect(result.content[0].text).toContain("grid-editing"); + }); + + it("includes 'no examples found' message with servedName when doc has no matching language blocks", async () => { + const handler = mockState.registeredTools.get("get_example")!; + mockState.getDoc + .mockResolvedValueOnce({ text: "```html\n
\n```", found: true }); + + const result = await handler({ framework: "angular", component: "grid", topic: "editing", language: "scss" }); + + expect(result.content[0].text).toContain("No code examples"); + expect(result.content[0].text).toContain("`scss`"); + expect(result.content[0].text).toContain("`grid-editing`"); + }); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts index a9f75fe75..7e477d106 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from 'vitest'; -import { applyDocAlias, normalizeDocName, sanitizeSearchDocsQuery } from '../../tools/doc-tools.js'; +import { + applyCompactGridPrefix, + applyDocAlias, + canonicalLang, + extractCodeExamples, + formatCodeExamples, + formatSubstitutionNotice, + mergeExamplesByHeading, + normalizeDocName, + resolveDoc, + sanitizeSearchDocsQuery, +} from '../../tools/doc-tools.js'; +import type { DocsProvider } from '../../providers/DocsProvider.js'; describe('sanitizeSearchDocsQuery', () => { it('quotes plain terms with AND (implicit in FTS4)', () => { @@ -81,6 +93,28 @@ describe('sanitizeSearchDocsQuery', () => { it('handles realistic user query with special chars injected', () => { expect(sanitizeSearchDocsQuery('grid" OR "1=1')).toBe('"grid" "OR" "1=1"'); }); + + it('strips natural-language stopwords (how do I ...)', () => { + expect(sanitizeSearchDocsQuery('how do I enable row editing')).toBe( + '"enable" "row" "editing"', + ); + }); + + it('strips a leading article', () => { + expect(sanitizeSearchDocsQuery('the grid selection')).toBe('"grid" "selection"'); + }); + + it('keeps and/or/but as ordinary terms (not stopwords)', () => { + expect(sanitizeSearchDocsQuery('drag and drop')).toBe('"drag" "and" "drop"'); + }); + + it('falls back to full terms when every term is a stopword', () => { + expect(sanitizeSearchDocsQuery('how do I')).toBe('"how" "do" "I"'); + }); + + it('does not strip meaningful component words that resemble nothing in the list', () => { + expect(sanitizeSearchDocsQuery('column pinning')).toBe('"column" "pinning"'); + }); }); describe('normalizeDocName', () => { @@ -127,6 +161,26 @@ describe('normalizeDocName', () => { it('falls back to lowercased input when normalization yields empty string', () => { expect(normalizeDocName('Igx')).toBe('igx'); }); + + it('kebab-cases a spaced multi-word name', () => { + expect(normalizeDocName('date picker')).toBe('date-picker'); + }); + + it('kebab-cases a three-word name', () => { + expect(normalizeDocName('navigation drawer panel')).toBe('navigation-drawer-panel'); + }); + + it('collapses multiple spaces and trims', () => { + expect(normalizeDocName(' tree grid ')).toBe('tree-grid'); + }); + + it('converts underscores to hyphens', () => { + expect(normalizeDocName('color_editor')).toBe('color-editor'); + }); + + it('leaves a class name equivalent to its spaced form', () => { + expect(normalizeDocName('IgxDatePicker')).toBe(normalizeDocName('date picker')); + }); }); describe('applyDocAlias', () => { @@ -195,3 +249,416 @@ describe('applyDocAlias', () => { expect(applyDocAlias('react', normalized)).toBe('overview'); }); }); + +describe('extractCodeExamples', () => { + it('returns an empty array for prose-only content', () => { + expect(extractCodeExamples('# Title\n\nJust some text, no code.')).toEqual([]); + }); + + it('extracts a single code block with its language', () => { + const md = '## Setup\n\n```typescript\nconst x = 1;\n```\n'; + expect(extractCodeExamples(md)).toEqual([ + { heading: 'Setup', blocks: [{ lang: 'typescript', code: 'const x = 1;' }] }, + ]); + }); + + it('groups consecutive blocks separated only by blank lines into one example', () => { + const md = [ + '## Editing', + '', + '```typescript', + 'const a = 1;', + '```', + '', + '```html', + '
', + '```', + '', + ].join('\n'); + + const result = extractCodeExamples(md); + expect(result).toHaveLength(1); + expect(result[0].heading).toBe('Editing'); + expect(result[0].blocks.map((b) => b.lang)).toEqual(['typescript', 'html']); + }); + + it('splits blocks separated by prose into separate examples', () => { + const md = [ + '## Editing', + '', + '```typescript', + 'const a = 1;', + '```', + '', + 'Then wire up the template:', + '', + '```html', + '
', + '```', + ].join('\n'); + + const result = extractCodeExamples(md); + expect(result).toHaveLength(2); + expect(result[0].blocks[0].lang).toBe('typescript'); + expect(result[1].blocks[0].lang).toBe('html'); + }); + + it('starts a new example at each heading', () => { + const md = [ + '## First', + '```ts', + 'a', + '```', + '## Second', + '```ts', + 'b', + '```', + ].join('\n'); + + const result = extractCodeExamples(md); + expect(result.map((e) => e.heading)).toEqual(['First', 'Second']); + }); + + it('labels the nearest preceding heading', () => { + const md = '# Top\n\n## Nested\n\n```ts\nx\n```'; + expect(extractCodeExamples(md)[0].heading).toBe('Nested'); + }); + + it('uses an empty heading when code precedes any heading', () => { + const md = '```ts\nx\n```'; + expect(extractCodeExamples(md)[0].heading).toBe(''); + }); + + it('filters blocks by language, keeping only matches', () => { + const md = [ + '## Editing', + '```typescript', + 'const a = 1;', + '```', + '```html', + '
', + '```', + ].join('\n'); + + const result = extractCodeExamples(md, { language: 'html' }); + expect(result).toHaveLength(1); + expect(result[0].blocks).toEqual([{ lang: 'html', code: '
' }]); + }); + + it('is case-insensitive on the language filter', () => { + const md = '```TypeScript\nx\n```'; + expect(extractCodeExamples(md, { language: 'typescript' })).toHaveLength(1); + }); + + it('matches aliased fence languages (typescript filter keeps ```ts)', () => { + const md = '## H\n```ts\nconst a = 1;\n```'; + const result = extractCodeExamples(md, { language: 'typescript' }); + expect(result).toHaveLength(1); + expect(result[0].blocks[0].lang).toBe('ts'); // original fence tag preserved + }); + + it('matches ```cs when filtering by csharp', () => { + const md = '```cs\nvar a = 1;\n```'; + expect(extractCodeExamples(md, { language: 'csharp' })).toHaveLength(1); + }); + + it('matches ```cmd when filtering by shell', () => { + const md = '```cmd\nng add igniteui-angular\n```'; + expect(extractCodeExamples(md, { language: 'shell' })).toHaveLength(1); + }); + + it('accepts an alias as the filter value too (ts filter keeps ```typescript)', () => { + const md = '```typescript\nx\n```'; + expect(extractCodeExamples(md, { language: 'ts' })).toHaveLength(1); + }); + + it('ignores empty code blocks', () => { + const md = '## Empty\n```ts\n\n```'; + expect(extractCodeExamples(md)).toEqual([]); + }); + + it('handles blocks with no language tag', () => { + const md = '```\nplain text\n```'; + expect(extractCodeExamples(md)).toEqual([ + { heading: '', blocks: [{ lang: '', code: 'plain text' }] }, + ]); + }); + + it('does not treat frontmatter --- delimiters as code fences', () => { + const md = '---\ncomponent: IgxGrid\n---\n\n## Setup\n```ts\nx\n```'; + const result = extractCodeExamples(md); + expect(result).toHaveLength(1); + expect(result[0].heading).toBe('Setup'); + }); +}); + +describe('resolveDoc', () => { + // Stub provider backed by a fixed set of known doc filenames. searchDocs + // returns the LocalDocsProvider-style markdown (all known docs, in insertion + // order = rank order) so the fallback parser, guard, and iteration are exercised. + function makeProvider(known: Record): DocsProvider { + return { + async listComponents() { + return ''; + }, + async getDoc(_framework: string, name: string) { + const key = name.replace(/\.md$/, ''); + return key in known + ? { text: known[key], found: true } + : { text: 'not found', found: false }; + }, + async searchDocs(_framework: string, _query: string) { + const keys = Object.keys(known); + if (keys.length === 0) return 'No results'; + return keys.map((k) => `- **X** (\`${k}\`)`).join('\n'); + }, + }; + } + + it('resolves a direct name', async () => { + const p = makeProvider({ accordion: 'ACC' }); + const r = await resolveDoc(p, 'angular', 'accordion'); + expect(r).toMatchObject({ found: true, servedName: 'accordion', text: 'ACC' }); + }); + + it('applies the grid- prefix fallback for bare feature names', async () => { + const p = makeProvider({ 'grid-sorting': 'SORT' }); + const r = await resolveDoc(p, 'angular', 'sorting'); + expect(r).toMatchObject({ found: true, servedName: 'grid-sorting' }); + }); + + it('falls back to search when the name does not resolve mechanically', async () => { + const p = makeProvider({ navdrawer: 'NAV' }); + const r = await resolveDoc(p, 'angular', 'navigation drawer'); + expect(r.found).toBe(true); + expect(r.servedName).toBe('navdrawer'); + expect(r.text).toBe('NAV'); + }); + + it('returns not found when search also yields nothing', async () => { + const p = makeProvider({}); // searchDocs returns "No results" + const r = await resolveDoc(p, 'angular', 'totally unknown widget'); + expect(r.found).toBe(false); + }); + + it('rejects an unrelated search hit that shares no token with the request', async () => { + const p = makeProvider({ 'grid-paste-excel': 'X' }); + const r = await resolveDoc(p, 'angular', 'textarea'); + expect(r.found).toBe(false); // guard rejects; better an honest miss than a wrong doc + }); + + it('skips an unrelated top hit and accepts a lower-ranked one that shares a token', async () => { + // Top hit unrelated; second hit shares the "drawer" token. + const p = makeProvider({ 'grid-paste-excel': 'X', navdrawer: 'NAV' }); + const r = await resolveDoc(p, 'angular', 'navigation drawer'); + expect(r).toMatchObject({ found: true, servedName: 'navdrawer', text: 'NAV' }); + }); + + it('accepts a feature doc that shares the component token', async () => { + const p = makeProvider({ 'treegrid-export-excel': 'T' }); + const r = await resolveDoc(p, 'angular', 'treegrid'); + expect(r).toMatchObject({ found: true, servedName: 'treegrid-export-excel' }); + }); + + it('rewrites an angular tree-grid- topic name to the compact doc key', async () => { + const p = makeProvider({ 'treegrid-filtering': 'TF' }); + const r = await resolveDoc(p, 'angular', 'tree-grid-filtering'); + expect(r).toMatchObject({ found: true, servedName: 'treegrid-filtering', fuzzy: false }); + }); + + it('rewrites angular hierarchical-grid- and pivot-grid- topic names', async () => { + const p = makeProvider({ 'hierarchicalgrid-paging': 'HP', 'pivotgrid-sorting': 'PS' }); + await expect(resolveDoc(p, 'angular', 'hierarchical-grid-paging')).resolves.toMatchObject({ + found: true, + servedName: 'hierarchicalgrid-paging', + }); + await expect(resolveDoc(p, 'angular', 'pivot-grid-sorting')).resolves.toMatchObject({ + found: true, + servedName: 'pivotgrid-sorting', + }); + }); + + it('prefers the exact compact doc over a related search hit', async () => { + // Regression: "tree-grid-editing" used to fall through to search and serve + // treegrid-batch-editing, which covers a different feature. + const p = makeProvider({ 'treegrid-batch-editing': 'BATCH', 'treegrid-editing': 'EDIT' }); + const r = await resolveDoc(p, 'angular', 'tree-grid-editing'); + expect(r).toMatchObject({ found: true, servedName: 'treegrid-editing', text: 'EDIT', fuzzy: false }); + }); + + it('does not rewrite grid prefixes for non-angular frameworks', async () => { + // React keys these docs with the hyphenated form; a rewrite would break them. + const p = makeProvider({ 'tree-grid-filtering': 'TF' }); + const r = await resolveDoc(p, 'react', 'tree-grid-filtering'); + expect(r).toMatchObject({ found: true, servedName: 'tree-grid-filtering' }); + }); + + it('marks a search-fallback resolution as fuzzy', async () => { + const p = makeProvider({ navdrawer: 'NAV' }); + const r = await resolveDoc(p, 'angular', 'navigation drawer'); + expect(r.fuzzy).toBe(true); + }); + + it('does not mark deterministic resolutions as fuzzy', async () => { + const direct = await resolveDoc(makeProvider({ accordion: 'ACC' }), 'angular', 'accordion'); + expect(direct.fuzzy).toBe(false); + + const aliased = await resolveDoc(makeProvider({ 'grid-grid': 'G' }), 'angular', 'IgxGrid'); + expect(aliased).toMatchObject({ found: true, fuzzy: false }); + + const gridPrefixed = await resolveDoc(makeProvider({ 'grid-sorting': 'S' }), 'angular', 'sorting'); + expect(gridPrefixed).toMatchObject({ found: true, fuzzy: false }); + }); + + it('is not fuzzy when nothing was found at all', async () => { + const r = await resolveDoc(makeProvider({}), 'angular', 'totally unknown widget'); + expect(r).toMatchObject({ found: false, fuzzy: false }); + }); +}); + +describe('applyCompactGridPrefix', () => { + it('rewrites the three angular grid-variant prefixes', () => { + expect(applyCompactGridPrefix('angular', 'tree-grid-filtering')).toBe('treegrid-filtering'); + expect(applyCompactGridPrefix('angular', 'hierarchical-grid-paging')).toBe('hierarchicalgrid-paging'); + expect(applyCompactGridPrefix('angular', 'pivot-grid-sorting')).toBe('pivotgrid-sorting'); + }); + + it('returns null when no prefix matches', () => { + expect(applyCompactGridPrefix('angular', 'grid-sorting')).toBeNull(); + expect(applyCompactGridPrefix('angular', 'accordion')).toBeNull(); + }); + + it('returns null for the bare component name (no topic suffix)', () => { + expect(applyCompactGridPrefix('angular', 'tree-grid')).toBeNull(); + }); + + it('returns null for non-angular frameworks', () => { + expect(applyCompactGridPrefix('react', 'tree-grid-filtering')).toBeNull(); + expect(applyCompactGridPrefix('blazor', 'hierarchical-grid-paging')).toBeNull(); + expect(applyCompactGridPrefix('webcomponents', 'pivot-grid-sorting')).toBeNull(); + }); + + it('preserves multi-segment topics', () => { + expect(applyCompactGridPrefix('angular', 'tree-grid-column-moving')).toBe('treegrid-column-moving'); + }); +}); + +describe('formatSubstitutionNotice', () => { + it('names both the requested and the served doc', () => { + const notice = formatSubstitutionNotice('tree-grid-editing', 'treegrid-batch-editing'); + expect(notice).toContain('`tree-grid-editing`'); + expect(notice).toContain('`treegrid-batch-editing`'); + }); + + it('points at the discovery tools', () => { + const notice = formatSubstitutionNotice('x', 'y'); + expect(notice).toContain('list_components'); + expect(notice).toContain('search_docs'); + }); +}); + +describe('canonicalLang', () => { + it('maps ts to typescript', () => { + expect(canonicalLang('ts')).toBe('typescript'); + }); + + it('maps cs and c# to csharp', () => { + expect(canonicalLang('cs')).toBe('csharp'); + expect(canonicalLang('C#')).toBe('csharp'); + }); + + it('maps cmd/bash/sh to shell', () => { + expect(canonicalLang('cmd')).toBe('shell'); + expect(canonicalLang('bash')).toBe('shell'); + expect(canonicalLang('SH')).toBe('shell'); + }); + + it('maps cshtml to razor', () => { + expect(canonicalLang('cshtml')).toBe('razor'); + }); + + it('leaves an unknown language unchanged (lowercased)', () => { + expect(canonicalLang('TSX')).toBe('tsx'); + }); +}); + +describe('formatCodeExamples', () => { + it('renders examples as code-only markdown under headings', () => { + const examples = [ + { heading: 'Setup', blocks: [{ lang: 'ts', code: 'const x = 1;' }] }, + ]; + const out = formatCodeExamples(examples, { framework: 'angular', docName: 'grid-editing' }); + expect(out).toContain('Code examples from `grid-editing` (angular):'); + expect(out).toContain('## Setup'); + expect(out).toContain('```ts\nconst x = 1;\n```'); + }); + + it('notes the language filter in the header', () => { + const out = formatCodeExamples( + [{ heading: 'H', blocks: [{ lang: 'html', code: '' }] }], + { framework: 'react', docName: 'grid', language: 'html' }, + ); + expect(out).toContain('(html only)'); + }); + + it('falls back to a numbered title when a heading is empty', () => { + const out = formatCodeExamples( + [{ heading: '', blocks: [{ lang: 'ts', code: 'x' }] }], + { framework: 'angular', docName: 'grid' }, + ); + expect(out).toContain('## Example 1'); + }); + + it('merges adjacent examples that share a heading into one section', () => { + const out = formatCodeExamples( + [ + { heading: 'Displaying initials', blocks: [{ lang: 'html', code: '' }] }, + { heading: 'Displaying initials', blocks: [{ lang: 'html', code: '' }] }, + { heading: 'Displaying initials', blocks: [{ lang: 'scss', code: '.x{}' }] }, + ], + { framework: 'angular', docName: 'avatar' }, + ); + // Heading appears exactly once; all three blocks are under it. + expect(out.match(/## Displaying initials/g)).toHaveLength(1); + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).toContain('.x{}'); + }); + + it('does not merge a heading interrupted by a different one', () => { + const out = formatCodeExamples( + [ + { heading: 'Setup', blocks: [{ lang: 'ts', code: 'a' }] }, + { heading: 'Usage', blocks: [{ lang: 'ts', code: 'b' }] }, + { heading: 'Setup', blocks: [{ lang: 'ts', code: 'c' }] }, + ], + { framework: 'angular', docName: 'grid' }, + ); + // Two non-adjacent "Setup" sections are numbered, not merged. + expect(out).toContain('## Setup (1)'); + expect(out).toContain('## Setup (2)'); + expect(out).toContain('## Usage'); + }); + + it('does not mutate the input examples array', () => { + const input = [ + { heading: 'H', blocks: [{ lang: 'ts', code: 'a' }] }, + { heading: 'H', blocks: [{ lang: 'ts', code: 'b' }] }, + ]; + formatCodeExamples(input, { framework: 'angular', docName: 'grid' }); + expect(input[0].blocks).toHaveLength(1); // first group still has only its own block + }); +}); + +describe('mergeExamplesByHeading', () => { + it('collapses consecutive same-heading examples', () => { + const merged = mergeExamplesByHeading([ + { heading: 'A', blocks: [{ lang: 'ts', code: '1' }] }, + { heading: 'A', blocks: [{ lang: 'html', code: '2' }] }, + { heading: 'B', blocks: [{ lang: 'ts', code: '3' }] }, + ]); + expect(merged).toHaveLength(2); + expect(merged[0].blocks.map((b) => b.code)).toEqual(['1', '2']); + expect(merged[1].heading).toBe('B'); + }); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts index ebb689c79..97feefca5 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts @@ -12,7 +12,7 @@ import { RemoteDocsProvider } from "./providers/RemoteDocsProvider.js"; import { LocalDocsProvider } from "./providers/LocalDocsProvider.js"; import { getApiReferenceSchema, searchApiSchema } from "./tools/schemas.js"; import { createGetApiReferenceHandler, createSearchApiHandler } from "./tools/handlers.js"; -import { applyDocAlias, buildProjectSetupGuide, normalizeDocName, sanitizeSearchDocsQuery } from "./tools/doc-tools.js"; +import { buildProjectSetupGuide, extractCodeExamples, formatCodeExamples, formatSubstitutionNotice, normalizeDocName, resolveDoc, sanitizeSearchDocsQuery } from "./tools/doc-tools.js"; import { ApiDocLoader } from "./lib/api-doc-loader.js"; import { getPlatforms } from "./config/platforms.js"; @@ -144,24 +144,76 @@ function registerDocTools(server: McpServer, docsProvider: DocsProvider) { }, async ({ framework, name }) => { const start = performance.now(); - const resolvedName = applyDocAlias(framework, normalizeDocName(name.trim())); - let { text, found } = await docsProvider.getDoc(framework, resolvedName); - - // Generic grid-prefix fallback: if the doc isn't found and the name doesn't - // already start with a component-type prefix, try "grid-{name}". - // This handles bare feature names like "sorting", "remote-data-operations", - // "row-editing" etc. without needing an explicit alias for every grid sub-doc. - let servedName = resolvedName; - if (!found && !/^(grid|hierarchical|tree|pivot|hierarchicalgrid|treegrid|pivotgrid|combo|drop-down|select|for-of)[-]/.test(resolvedName)) { - const withGridPrefix = await docsProvider.getDoc(framework, `grid-${resolvedName}`); - if (withGridPrefix.found) { - ({ text, found } = withGridPrefix); - servedName = `grid-${resolvedName}`; - } + const { text, found, servedName, fuzzy } = await resolveDoc(docsProvider, framework, name); + + const body = fuzzy ? `${formatSubstitutionNotice(name, servedName)}\n\n${text}` : text; + + log("get_doc", { framework, name: servedName }, body, Math.round(performance.now() - start)); + return { content: [{ type: "text" as const, text: body }], ...(found ? {} : { isError: true }) }; + } + ); + + server.registerTool( + "get_example", + { + description: TOOL_DESCRIPTIONS.get_example, + annotations: { readOnlyHint: true, openWorldHint: false }, + inputSchema: { + framework: FRAMEWORK_ENUM, + component: z + .string() + .min(1, "Component name is required") + .describe( + 'Component name (class name or kebab-case doc name). ' + + 'Examples: "grid", "IgxCombo", "date-picker". Resolves to a single doc.' + ), + topic: z + .string() + .optional() + .describe( + 'Optional sub-feature to target a specific doc for the component. ' + + 'Example: component "grid" + topic "editing" → the grid-editing doc. ' + + 'Omit to use the component\'s primary/overview doc.' + ), + language: z + .string() + .optional() + .describe( + 'Fence language filter — return only blocks in this language. Pass it whenever ' + + 'the target language is known: it roughly halves the response versus an unfiltered call. ' + + 'Alias-aware: "typescript" also matches ts, "csharp" matches cs, "shell" matches cmd. ' + + 'Angular → "typescript" / "html" / "scss", React → "tsx", Web Components → "typescript" / "html", ' + + 'Blazor → "razor" / "csharp". Omit only when several languages are needed side by side.' + ), + }, + }, + async ({ framework, component, topic, language }) => { + const start = performance.now(); + const topicPart = topic?.trim(); + const requested = topicPart + ? `${normalizeDocName(component)}-${normalizeDocName(topicPart)}` + : component.trim(); + + const { text, found, servedName, fuzzy } = await resolveDoc(docsProvider, framework, requested); + + if (!found) { + log("get_example", { framework, component, topic }, text, Math.round(performance.now() - start)); + return { content: [{ type: "text" as const, text }], isError: true }; } - log("get_doc", { framework, name: servedName }, text, Math.round(performance.now() - start)); - return { content: [{ type: "text" as const, text }], ...(found ? {} : { isError: true }) }; + const notice = fuzzy ? `${formatSubstitutionNotice(requested, servedName)}\n\n` : ""; + const examples = extractCodeExamples(text, { language }); + + if (examples.length === 0) { + const langNote = language ? ` in \`${language}\`` : ""; + const msg = `${notice}No code examples${langNote} found in \`${servedName}\` (${framework}). Use get_doc for the full doc, or try a different topic.`; + log("get_example", { framework, component, topic }, msg, Math.round(performance.now() - start)); + return { content: [{ type: "text" as const, text: msg }] }; + } + + const result = notice + formatCodeExamples(examples, { framework, docName: servedName, language }); + log("get_example", { framework, component, topic }, result, Math.round(performance.now() - start)); + return { content: [{ type: "text" as const, text: result }] }; } ); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts index 3a3e094bf..b255a8917 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts @@ -37,6 +37,17 @@ For grid feature docs, the bare feature name works without the "grid-" prefix Returns YAML frontmatter (component, keywords, summary) followed by the complete markdown body with code samples, tables, and links. Returns isError if the doc name is not found, with a suggestion to use list_components. +`, + + get_example: `Return only the runnable code examples for one Ignite UI component doc — no prose. Resolves the component to a single doc (the same name resolution as get_doc) and extracts the fenced code blocks, grouped per example and labelled by their section heading. + +Use this when the user wants to see how to use a component in code, not read the full documentation. For the surrounding explanation, tables, and API links, use get_doc instead. + +Pass component as a class name or kebab-case doc name (e.g. "grid", "IgxCombo", "date-picker"). Add topic to target a sub-feature doc (e.g. component "grid" + topic "editing" → grid-editing). + +Always pass language when the target language is known — it is by far the biggest lever on response size. Measured across the whole doc corpus, a language-filtered call returns ~59% fewer tokens than get_doc, against ~36% for an unfiltered one; on the largest chart and grid docs an unfiltered call saves almost nothing. Typical values: Angular → "typescript", "html", "scss"; React → "tsx"; Web Components → "typescript", "html"; Blazor → "razor", "csharp". Matching is alias-aware, so "typescript" also matches ts fences and "csharp" matches cs. Omit language only when the answer genuinely needs several languages side by side (e.g. an Angular component's TS + HTML + SCSS) — and in that case narrow with topic instead. + +Returns code blocks grouped under their section headings. Returns isError if the doc cannot be resolved (with a suggestion to use list_components or search_docs); returns a plain message if the doc exists but contains no matching code examples. Note: examples reflect the compressed docs, so large sample data sets may be trimmed to a representative subset. `, search_docs: `Full-text search across all Ignite UI documentation for a specific framework. diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts index fedd48f66..22d1808aa 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts @@ -17,23 +17,39 @@ export const MISSING_FRAMEWORK_MESSAGE = // terms must appear in the document. This is far more precise than OR: // "virtual scroll" → `"virtual" "scroll"` (both required) // Single-word and prefix queries are unaffected by this change. +// Natural-language filler words dropped before FTS4 matching. FTS4 uses implicit +// AND, so leaving "how"/"do"/"i" in a query like "how do I enable row editing" +// forces those words to appear in a doc and collapses recall to near zero. +// Deliberately excludes and/or/but — those are left as ordinary terms. +const SEARCH_STOPWORDS = new Set([ + 'how', 'do', 'does', 'did', 'i', 'a', 'an', 'the', 'to', 'of', 'in', 'on', + 'is', 'are', 'am', 'be', 'my', 'me', 'we', 'you', 'your', 'it', 'its', + 'this', 'that', 'these', 'those', 'when', 'what', 'which', 'who', 'why', + 'want', 'need', 'can', 'could', 'would', 'should', 'please', 'help', +]); + +// Quote a plain term for FTS4, or pass through a prefix query (grid*). Bare +// asterisks have no prefix and would be an FTS4 syntax error — drop them. +function quoteOrPrefixTerm(term: string): string | null { + if (term.endsWith('*')) { + return /[^*]/.test(term) ? term : null; + } + return `"${term}"`; +} + export function sanitizeSearchDocsQuery(queryText: string): string | null { - const sanitized = queryText + const rawTerms = queryText .replace(/["(){}[\]:@]/g, ' ') .split(/\s+/) - .filter(Boolean) - .map((term) => { - // Terms ending with * are prefix queries — don't quote them - // because FTS4 treats "grid*" as a literal match for the - // asterisk character, while unquoted grid* does prefix expansion. - // Drop terms that are only asterisks (e.g. *, **) — they have - // no actual prefix and would cause an FTS4 syntax error. - if (term.endsWith('*')) { - return /[^*]/.test(term) ? term : null; - } + .filter(Boolean); + + // Strip stopwords, but if that leaves nothing (e.g. a pure "how do I" query) + // fall back to the full term list rather than returning no query at all. + const meaningful = rawTerms.filter((t) => !SEARCH_STOPWORDS.has(t.toLowerCase())); + const terms = meaningful.length > 0 ? meaningful : rawTerms; - return `"${term}"`; - }) + const sanitized = terms + .map(quoteOrPrefixTerm) .filter((term): term is string => Boolean(term)) .join(' '); @@ -52,9 +68,13 @@ export function sanitizeSearchDocsQuery(queryText: string): string | null { * 3. Convert PascalCase / camelCase to kebab-case and lowercase */ export function normalizeDocName(name: string): string { - let normalized = name.replace(/^Ig[xrcb]/i, ''); + let normalized = name.trim().replace(/^Ig[xrcb]/i, ''); normalized = normalized.replace(/Component$/i, ''); - normalized = normalized.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); + normalized = normalized.replace(/([a-z0-9])([A-Z])/g, '$1-$2'); + // Collapse spaces/underscores to hyphens so multi-word names ("date picker", + // "tree grid") resolve like their kebab-case doc keys. + normalized = normalized.replace(/[\s_]+/g, '-').toLowerCase(); + normalized = normalized.replace(/-+/g, '-').replace(/^-|-$/g, ''); return normalized || name.toLowerCase(); } @@ -199,6 +219,326 @@ export function applyDocAlias(framework: string, normalizedName: string): string return DOC_ALIASES[framework]?.[normalizedName] ?? normalizedName; } +/** + * Angular keys its grid-variant feature docs with a compact, unhyphenated + * component prefix (treegrid-filtering, hierarchicalgrid-paging), while the + * user-facing component name — and the DOC_ALIASES entry for it — is + * hyphenated (tree-grid). Composing a component and a topic therefore yields + * names like "tree-grid-filtering" that no doc uses. Rewriting the prefix + * resolves ~90 Angular docs that would otherwise fall through to the search + * fallback and land on a related-but-wrong doc (e.g. tree-grid-editing → + * treegrid-batch-editing). + * + * React, Web Components and Blazor key these docs with the hyphenated form + * (hierarchical-grid-advanced-filtering), so the rewrite is Angular-only. + */ +const ANGULAR_COMPACT_GRID_PREFIXES: Array<[string, string]> = [ + ['hierarchical-grid-', 'hierarchicalgrid-'], + ['tree-grid-', 'treegrid-'], + ['pivot-grid-', 'pivotgrid-'], +]; + +/** + * Rewrite a hyphenated Angular grid-variant prefix to its compact doc-key form. + * Returns null when no rewrite applies, so callers can skip the extra lookup. + */ +export function applyCompactGridPrefix(framework: string, name: string): string | null { + if (framework !== 'angular') return null; + for (const [hyphenated, compact] of ANGULAR_COMPACT_GRID_PREFIXES) { + if (name.startsWith(hyphenated)) { + return compact + name.slice(hyphenated.length); + } + } + return null; +} + +// Names that already carry a component-type prefix — skip the generic grid- retry for these. +const PREFIXED_DOC_RE = + /^(grid|hierarchical|tree|pivot|hierarchicalgrid|treegrid|pivotgrid|combo|drop-down|select|for-of)[-]/; + +/** Extract result doc names, in rank order, from searchDocs markdown (the `(`name`)` tokens). */ +function parseDocNames(searchOutput: string): string[] { + return [...searchOutput.matchAll(/\(`([^`]+)`\)/g)].map((m) => m[1]); +} + +/** + * True when the requested name and a candidate doc name share a meaningful token + * (substring either direction, min 3 chars). Guards the search fallback against + * accepting an unrelated top hit — e.g. "textarea" → "grid-paste-excel" (no + * shared token, rejected) while still allowing "navigation-drawer" → "navdrawer" + * ("navdrawer" contains "drawer"). + */ +function sharesToken(requestName: string, docName: string): boolean { + const reqTokens = requestName.split('-').filter((t) => t.length >= 3); + const docTokens = docName.split('-').filter((t) => t.length >= 3); + return ( + reqTokens.some((t) => docName.includes(t)) || + docTokens.some((t) => requestName.includes(t)) + ); +} + +export interface ResolvedDoc { + text: string; + found: boolean; + servedName: string; + /** + * True when the doc was located by the full-text search fallback rather than + * by a deterministic name mapping. The served doc is only a best guess at + * what the caller meant, so callers should say so in their response. + */ + fuzzy: boolean; +} + +/** + * Resolve a caller-supplied doc name to actual doc content, shared by get_doc + * and get_example. Applies normalizeDocName + applyDocAlias, then the Angular + * compact grid prefix rewrite (tree-grid-x → treegrid-x), then a generic grid- + * prefix fallback for bare feature names (e.g. "sorting" → "grid-sorting"). + * As a last resort, runs a full-text search and serves the top hit — this + * catches names that don't map mechanically (e.g. angular "navigation drawer" + * → navdrawer, angular charts under the types- prefix) and is the only path + * that sets fuzzy. + */ +export async function resolveDoc( + docsProvider: DocsProvider, + framework: string, + name: string, +): Promise { + const resolvedName = applyDocAlias(framework, normalizeDocName(name.trim())); + let { text, found } = await docsProvider.getDoc(framework, resolvedName); + let servedName = resolvedName; + + if (!found) { + const compactName = applyCompactGridPrefix(framework, resolvedName); + if (compactName) { + const rewritten = await docsProvider.getDoc(framework, compactName); + if (rewritten.found) { + ({ text, found } = rewritten); + servedName = compactName; + } + } + } + + if (!found && !PREFIXED_DOC_RE.test(resolvedName)) { + const withGridPrefix = await docsProvider.getDoc(framework, `grid-${resolvedName}`); + if (withGridPrefix.found) { + ({ text, found } = withGridPrefix); + servedName = `grid-${resolvedName}`; + } + } + + let fuzzy = false; + + if (!found) { + const query = sanitizeSearchDocsQuery(resolvedName.replace(/-/g, ' ')); + if (query) { + const results = await docsProvider.searchDocs(framework, query); + // Accept the highest-ranked hit that shares a token with the request. + // Checking the top few (not just #1) recovers cases where the best hit + // ranks second, without accepting an unrelated doc. + const candidates = parseDocNames(results) + .slice(0, 5) + .filter((name) => sharesToken(resolvedName, name)); + for (const candidate of candidates) { + const hit = await docsProvider.getDoc(framework, candidate); + if (hit.found) { + ({ text, found } = hit); + servedName = candidate; + fuzzy = true; + break; + } + } + } + } + + return { text, found, servedName, fuzzy }; +} + +/** + * Notice prepended to a response whose doc came from the search fallback, so the + * caller can tell "here is the doc you asked for" apart from "here is the + * nearest thing I found". Without it a request for tree-grid-editing that lands + * on treegrid-batch-editing reads as an exact hit. + */ +export function formatSubstitutionNotice(requestedName: string, servedName: string): string { + return ( + `Note: no doc named \`${requestedName}\` exists — showing the closest match, \`${servedName}\`. ` + + `The content below may cover a different feature than requested; use list_components or search_docs to see other options.` + ); +} + +export interface CodeBlock { + lang: string; + code: string; +} + +export interface CodeExample { + heading: string; + blocks: CodeBlock[]; +} + +// Fence-language aliases → canonical name, so a language filter of "typescript" +// also matches ```ts, "csharp" matches ```cs, "shell" matches ```cmd, etc. +const LANG_ALIASES: Record = { + ts: 'typescript', + typescript: 'typescript', + js: 'javascript', + javascript: 'javascript', + cs: 'csharp', + 'c#': 'csharp', + csharp: 'csharp', + razor: 'razor', + cshtml: 'razor', + html: 'html', + htm: 'html', + scss: 'scss', + sass: 'scss', + sh: 'shell', + bash: 'shell', + shell: 'shell', + cmd: 'shell', + powershell: 'shell', +}; + +/** Normalise a fence info-string to its canonical language for filter matching. */ +export function canonicalLang(lang: string): string { + const l = lang.trim().toLowerCase(); + return LANG_ALIASES[l] ?? l; +} + +/** + * Extract fenced code blocks from doc markdown, discarding all prose. + * + * Consecutive fenced blocks separated only by blank lines are grouped into one + * example — this is how the inject pipeline emits a single sample + * as back-to-back TS/HTML/SCSS blocks. Any heading or prose line between two + * blocks starts a new example. Each example is labelled with the nearest + * preceding markdown heading. An optional language filter keeps only blocks + * whose fence info-string matches (e.g. "html", "typescript") — matching is + * alias-aware, so "typescript" also matches ```ts (see canonicalLang). + */ +export function extractCodeExamples( + content: string, + opts?: { language?: string }, +): CodeExample[] { + const langFilter = opts?.language?.trim() ? canonicalLang(opts.language) : null; + const lines = content.split(/\r?\n/); + const examples: CodeExample[] = []; + + let currentHeading = ''; + let currentExample: CodeExample | null = null; + // Whether a heading or prose line has appeared since the last collected block. + // When true, the next block starts a fresh example instead of grouping. + let brokeGroup = true; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + const headingMatch = line.match(/^#{1,6}\s+(.*)$/); + if (headingMatch) { + currentHeading = headingMatch[1].trim(); + brokeGroup = true; + currentExample = null; + i++; + continue; + } + + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/); + if (fenceMatch) { + const fence = fenceMatch[1]; + const fenceChar = fence[0]; + const lang = fenceMatch[2].trim().split(/\s+/)[0].toLowerCase(); + + const codeLines: string[] = []; + i++; + while (i < lines.length) { + const close = lines[i].match(/^\s*(`{3,}|~{3,})\s*$/); + if (close && close[1][0] === fenceChar && close[1].length >= fence.length) { + i++; + break; + } + codeLines.push(lines[i]); + i++; + } + + const code = codeLines.join('\n').replace(/\n+$/, ''); + const keep = code.trim().length > 0 && (!langFilter || canonicalLang(lang) === langFilter); + if (keep) { + const block: CodeBlock = { lang, code }; + if (currentExample && !brokeGroup) { + currentExample.blocks.push(block); + } else { + currentExample = { heading: currentHeading, blocks: [block] }; + examples.push(currentExample); + } + } + brokeGroup = false; + continue; + } + + if (line.trim() !== '') { + brokeGroup = true; + } + i++; + } + + return examples; +} + +/** + * Merge adjacent examples that share a heading. A single doc section is often + * split into several demos by intervening prose, which surfaces as + * consecutive examples under the same heading; merging their blocks collapses + * that back into one section instead of repeating the heading. + */ +export function mergeExamplesByHeading(examples: CodeExample[]): CodeExample[] { + const merged: CodeExample[] = []; + for (const ex of examples) { + const last = merged[merged.length - 1]; + if (last && ex.heading && last.heading === ex.heading) { + last.blocks.push(...ex.blocks); + } else { + merged.push({ heading: ex.heading, blocks: [...ex.blocks] }); + } + } + return merged; +} + +/** Render extracted examples as a code-only markdown response. */ +export function formatCodeExamples( + examples: CodeExample[], + meta: { framework: string; docName: string; language?: string }, +): string { + const merged = mergeExamplesByHeading(examples); + + // Count headings so any that still repeat (non-adjacent sections with the same + // title) can be numbered — "Setup (1)", "Setup (2)" — instead of duplicated. + const counts = new Map(); + for (const ex of merged) { + if (ex.heading) counts.set(ex.heading, (counts.get(ex.heading) ?? 0) + 1); + } + const seen = new Map(); + + const langNote = meta.language ? ` (${meta.language} only)` : ''; + const header = `Code examples from \`${meta.docName}\` (${meta.framework})${langNote}:`; + + const sections = merged.map((ex, idx) => { + let title = ex.heading || `Example ${idx + 1}`; + if (ex.heading && (counts.get(ex.heading) ?? 0) > 1) { + const n = (seen.get(ex.heading) ?? 0) + 1; + seen.set(ex.heading, n); + title = `${ex.heading} (${n})`; + } + const body = ex.blocks + .map((b) => '```' + b.lang + '\n' + b.code + '\n```') + .join('\n\n'); + return `## ${title}\n\n${body}`; + }); + + return `${header}\n\n${sections.join('\n\n')}`; +} + // Build the setup-guide response for the requested framework. // For Blazor, combine the base .NET guide with any MCP-fetched docs // that are available for the configured setup document names.