diff --git a/.github/actions/build-framework-docs/action.yml b/.github/actions/build-framework-docs/action.yml index 9c5f60bfe..95815e357 100644 --- a/.github/actions/build-framework-docs/action.yml +++ b/.github/actions/build-framework-docs/action.yml @@ -172,6 +172,14 @@ runs: fi echo "$FW: $OUT of $IN documents compressed" + # The composite action inlines the pipeline rather than calling pipeline:*, so + # the summaries step has to be listed here too. Cached groups make no API call; + # credentials are already written by "Configure OpenAI credentials". + - name: Build group summaries + shell: bash + working-directory: packages/igniteui-mcp/igniteui-doc-mcp + run: npm run build:group-summaries -- --framework "${{ inputs.framework }}" + # Always runs, so a framework that skipped compression still reports why. - name: Report build summary if: always() @@ -203,3 +211,22 @@ runs: name: docs-baseline-${{ inputs.framework }} path: packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline/${{ inputs.framework }} retention-days: 5 + + # build-db's preflight requires a sidecar per framework. A silently absent one + # would surface hours later as an abort in assemble, with nothing pointing at + # the job that produced nothing — hence if-no-files-found: error. + - name: Upload TOC index + uses: actions/upload-artifact@v7 + with: + name: toc-index-${{ inputs.framework }} + path: packages/igniteui-mcp/igniteui-doc-mcp/dist/toc-index/${{ inputs.framework }}.json + if-no-files-found: error + retention-days: 5 + + - name: Upload group summaries + uses: actions/upload-artifact@v7 + with: + name: group-summaries-${{ inputs.framework }} + path: packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/${{ inputs.framework }}.json + if-no-files-found: error + retention-days: 5 diff --git a/.github/workflows/build-docs-db.yml b/.github/workflows/build-docs-db.yml index 3c0802b32..19193720c 100644 --- a/.github/workflows/build-docs-db.yml +++ b/.github/workflows/build-docs-db.yml @@ -118,6 +118,14 @@ jobs: with: pattern: docs-baseline-* path: packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline + - uses: actions/download-artifact@v8 + with: + pattern: toc-index-* + path: packages/igniteui-mcp/igniteui-doc-mcp/dist/toc-index + - uses: actions/download-artifact@v8 + with: + pattern: group-summaries-* + path: packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries # download-artifact nests each artifact under its own name; flatten to the # framework directories that build-db expects. @@ -132,7 +140,19 @@ jobs: [ -d "$src" ] && rm -rf "$dir/$fw" && mv "$src" "$dir/$fw" || true done done - ls -la dist/docs_final + + # These two artifacts are single files, not directories, so they need + # their own unwrapping. Overwriting the checkout's committed summaries + # for a rebuilt framework is deliberate — the regenerated ones must win. + for kind in toc-index:dist/toc-index group-summaries:data/group-summaries; do + prefix="${kind%%:*}"; dir="${kind##*:}" + for fw in angular react blazor webcomponents; do + src="$dir/$prefix-$fw/$fw.json" + [ -f "$src" ] && mv -f "$src" "$dir/$fw.json" && rm -rf "$dir/$prefix-$fw" || true + done + done + + ls -la dist/docs_final dist/toc-index data/group-summaries || true # Any framework missing from this run keeps the copy already committed, so the # database is always assembled from a complete set. --toc-stubs also emits the @@ -161,7 +181,7 @@ jobs: - name: Build database working-directory: packages/igniteui-mcp/igniteui-doc-mcp - run: npm run build:db + run: npm run release:db - name: Verify document counts run: | @@ -174,6 +194,7 @@ jobs: path: | packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline + packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries retention-days: 5 # The only job that writes to the repository. It opens a PR for review — nothing is @@ -205,9 +226,10 @@ jobs: # of compression, so it must not fail on a path guess. DB=$(find artifact -type f -name igniteui-docs.db | head -1) BASELINE=$(find artifact -type d -name docs_baseline | head -1) + SUMMARIES=$(find artifact -type d -name group-summaries | head -1) - if [ -z "$DB" ] || [ -z "$BASELINE" ]; then - echo "::error::Could not locate the database or baselines in the artifact." + if [ -z "$DB" ] || [ -z "$BASELINE" ] || [ -z "$SUMMARIES" ]; then + echo "::error::Could not locate the database, baselines or group summaries in the artifact." find artifact exit 1 fi @@ -219,6 +241,11 @@ jobs: cp "$DB" packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db rm -rf packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline cp -r "$BASELINE" packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline + # Without this the run regenerates summaries, builds them into the shipped + # DB, then throws the sources away — the committed cache would drift + # permanently from the committed database. + rm -rf packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries + cp -r "$SUMMARIES" packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries rm -rf artifact - name: Commit and open pull request @@ -235,7 +262,8 @@ jobs: # submodules out fresh, so recording them here would only add noise. git add packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db \ packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db \ - packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline + packages/igniteui-mcp/igniteui-doc-mcp/docs_baseline \ + packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries if git diff --cached --quiet; then echo "No changes to publish — the documentation is already up to date." diff --git a/eslint.config.mjs b/eslint.config.mjs index d1174dfe3..e96b6c857 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -156,6 +156,8 @@ export default [ '**/files/**/*', 'packages/igniteui-mcp/**/dist/**/*', 'packages/igniteui-mcp/**/*.test.ts', + // Excluded from the package's tsconfig, so typed linting has no project for them. + 'packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/**/*', 'packages/igniteui-mcp/igniteui-doc-mcp/scripts/**/*', 'packages/igniteui-mcp/igniteui-doc-mcp/vitest.config.ts', ] diff --git a/packages/igniteui-mcp/.gitattributes b/packages/igniteui-mcp/.gitattributes new file mode 100644 index 000000000..98d7e91c4 --- /dev/null +++ b/packages/igniteui-mcp/.gitattributes @@ -0,0 +1,2 @@ +shared-fixtures/**/*.txt text eol=lf +shared-fixtures/**/*.json text eol=lf diff --git a/packages/igniteui-mcp/docs-backend/docs-backend/ComponentRenderer.cs b/packages/igniteui-mcp/docs-backend/docs-backend/ComponentRenderer.cs new file mode 100644 index 000000000..c0293d52d --- /dev/null +++ b/packages/igniteui-mcp/docs-backend/docs-backend/ComponentRenderer.cs @@ -0,0 +1,136 @@ +using System.Text; + +namespace docs_backend; + +/// +/// Port of the MCP server's src/tools/render-components.ts. The two must +/// produce byte-identical text for the same rows — the shared fixtures under +/// packages/igniteui-mcp/shared-fixtures/list-components/ are what pins +/// that. Newlines are written as "\n" explicitly: StringBuilder.AppendLine uses +/// Environment.NewLine, which is CRLF on Windows and would diverge on every line. +/// +public static class ComponentRenderer +{ + /// Docs beyond this many lose their per-doc summaries, to keep a filtered response small. + public const int SummaryThreshold = 25; + + public sealed record DocRow(string Filename, string? TocName, string? Summary, bool Premium); + + public sealed record GroupedDocRow(string Filename, string? TocName, string? Summary, bool Premium, string GroupKey, long Ord); + + public sealed record GroupRow(string GroupKey, string? Summary); + + private static string DocName(string filename) => + filename.EndsWith(".md", StringComparison.Ordinal) ? filename[..^3] : filename; + + private static string DocEntry(string filename, string? tocName, string? summary, bool premium) + { + var name = DocName(filename); + var sb = new StringBuilder(); + sb.Append("- **").Append(string.IsNullOrEmpty(tocName) ? name : tocName).Append("** (`").Append(name).Append("`)"); + if (!string.IsNullOrEmpty(summary)) sb.Append("\n ").Append(summary); + if (premium) sb.Append("\n ⭐ Premium"); + return sb.ToString(); + } + + /// + /// A doc reachable from two TOC paths that land in the same group appears + /// twice; keep the earliest. A doc cross-listed in two different groups is + /// kept in each — that is editorial intent, not duplication. + /// + private static List Dedupe(IEnumerable rows) + { + // Keyed by the pair itself: concatenating with a separator would be + // ambiguous the moment either half could contain it. + var best = new Dictionary<(string GroupKey, string Filename), GroupedDocRow>(); + foreach (var row in rows) + { + var key = (row.GroupKey, row.Filename); + if (!best.TryGetValue(key, out var existing) || row.Ord < existing.Ord) best[key] = row; + } + return best.Values.OrderBy(r => r.Ord).ToList(); + } + + public static string RenderFlat(string framework, IReadOnlyList rows, string? filter) + { + var matching = string.IsNullOrEmpty(filter) ? "" : $" matching \"{filter}\""; + if (rows.Count == 0) return $"No components found for framework \"{framework}\"{matching}."; + + var entries = rows.Select(r => DocEntry(r.Filename, r.TocName, r.Summary, r.Premium)); + return $"Found {rows.Count} components for **{framework}**{matching}:\n\n" + string.Join("\n", entries); + } + + public static string RenderGroupedIndex( + string framework, + IReadOnlyList groups, + IReadOnlyList rows, + string? filter) + { + var matching = string.IsNullOrEmpty(filter) ? "" : $" matching \"{filter}\""; + var deduped = Dedupe(rows); + if (deduped.Count == 0) return $"No components found for framework \"{framework}\"{matching}."; + + var byGroup = new Dictionary>(StringComparer.Ordinal); + foreach (var row in deduped) + { + if (!byGroup.TryGetValue(row.GroupKey, out var list)) + { + list = []; + byGroup[row.GroupKey] = list; + } + list.Add(row); + } + + var total = deduped.Select(r => r.Filename).Distinct(StringComparer.Ordinal).Count(); + var withSummaries = total <= SummaryThreshold; + + var blocks = new List(); + foreach (var group in groups) + { + if (!byGroup.TryGetValue(group.GroupKey, out var members) || members.Count == 0) continue; + + var block = new StringBuilder(); + block.Append("## ").Append(group.GroupKey).Append(" (").Append(members.Count).Append(')'); + if (!string.IsNullOrEmpty(group.Summary)) block.Append('\n').Append(group.Summary); + block.Append('\n'); + + block.Append(withSummaries + ? string.Join("\n", members.Select(m => DocEntry(m.Filename, m.TocName, m.Summary, m.Premium))) + : string.Join(", ", members.Select(m => DocName(m.Filename) + (m.Premium ? " ⭐" : "")))); + + blocks.Add(block.ToString()); + } + + var header = + $"Found {total} component doc(s) for **{framework}**{matching} in {blocks.Count} group(s). " + + "Pass `group` with any heading below to get that group's docs with summaries" + + (withSummaries ? "" : "; ⭐ marks premium docs") + "."; + + return header + "\n\n" + string.Join("\n\n", blocks); + } + + public static string RenderGroup( + string framework, + GroupRow group, + IReadOnlyList rows, + string? filter) + { + var matching = string.IsNullOrEmpty(filter) ? "" : $" matching \"{filter}\""; + var members = Dedupe(rows).Where(r => string.Equals(r.GroupKey, group.GroupKey, StringComparison.Ordinal)).ToList(); + if (members.Count == 0) + return $"No components found in group \"{group.GroupKey}\" for framework \"{framework}\"{matching}."; + + var header = $"Found {members.Count} component doc(s) in **{framework}** > {group.GroupKey}{matching}:"; + var summary = string.IsNullOrEmpty(group.Summary) ? "" : "\n" + group.Summary + "\n"; + var entries = string.Join("\n", members.Select(m => DocEntry(m.Filename, m.TocName, m.Summary, m.Premium))); + + return header + "\n" + summary + "\n" + entries; + } + + public static string RenderUnknownGroup(string framework, string group, IReadOnlyList groups) + { + var keys = string.Join("\n", groups.Select(g => "- " + g.GroupKey)); + return $"No group \"{group}\" in **{framework}**. Valid groups:\n\n{keys}\n\n" + + "Omit `group` for the full grouped index, or pass `filter` to search across groups."; + } +} diff --git a/packages/igniteui-mcp/docs-backend/docs-backend/Controllers/DocsController.cs b/packages/igniteui-mcp/docs-backend/docs-backend/Controllers/DocsController.cs index 1d70de8a7..b952161e8 100644 --- a/packages/igniteui-mcp/docs-backend/docs-backend/Controllers/DocsController.cs +++ b/packages/igniteui-mcp/docs-backend/docs-backend/Controllers/DocsController.cs @@ -24,40 +24,160 @@ private static bool IsValidFramework(string framework, out string normalized) return ValidFrameworks.TryGetValue(framework.ToLowerInvariant(), out normalized!); } - [HttpGet] - public IActionResult List([FromQuery] string framework, [FromQuery] string? filter) + private static string? Str(SqliteDataReader r, string column) { - if (!IsValidFramework(framework, out var fw)) - return BadRequest($"Invalid framework \"{framework}\". Valid values: {string.Join(", ", ValidFrameworks.Keys)}"); + var i = r.GetOrdinal(column); + return r.IsDBNull(i) ? null : r.GetString(i); + } - framework = fw; - var sql = "SELECT framework, filename, component, toc_name, premium, summary FROM docs WHERE framework = @fw"; + /// + /// The MCP package ships a prebuilt database and the committed copy can be + /// updated independently, so this must tolerate a DB with no grouping tables, + /// and one where only some frameworks have been migrated. + /// + private bool HasGroups(string framework) + { + var probe = db.CreateCommand(); + probe.CommandText = + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('doc_toc', 'doc_groups')"; + if (Convert.ToInt64(probe.ExecuteScalar()) != 2) return false; + + var rows = db.CreateCommand(); + rows.CommandText = "SELECT COUNT(*) FROM doc_toc WHERE framework = @fw"; + rows.Parameters.AddWithValue("@fw", framework); + return Convert.ToInt64(rows.ExecuteScalar()) > 0; + } + + private List ReadGroups(string framework) + { + var cmd = db.CreateCommand(); + cmd.CommandText = "SELECT group_key, summary FROM doc_groups WHERE framework = @fw ORDER BY ord"; + cmd.Parameters.AddWithValue("@fw", framework); + + var groups = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + groups.Add(new ComponentRenderer.GroupRow(reader.GetString(0), Str(reader, "summary"))); + return groups; + } + + /// + /// Grouped mode also matches doc_toc.group_key, so a filter can select whole + /// sections. Flat mode deliberately does not — see . + /// + private List ReadGroupedRows(string framework, string? filter, string? group) + { var cmd = db.CreateCommand(); + var where = new List { "t.framework = @fw" }; cmd.Parameters.AddWithValue("@fw", framework); + if (group != null) + { + where.Add("t.group_key = @g"); + cmd.Parameters.AddWithValue("@g", group); + } if (!string.IsNullOrWhiteSpace(filter)) { - sql += " AND (filename LIKE @f OR toc_name LIKE @f OR component LIKE @f OR keywords LIKE @f OR summary LIKE @f)"; + where.Add("(d.filename LIKE @f OR d.component LIKE @f OR d.toc_name LIKE @f " + + "OR d.keywords LIKE @f OR d.summary LIKE @f OR t.group_key LIKE @f)"); cmd.Parameters.AddWithValue("@f", $"%{filter}%"); } - sql += " ORDER BY filename"; - cmd.CommandText = sql; + cmd.CommandText = + "SELECT d.filename, d.toc_name, d.premium, d.summary, t.group_key, t.ord " + + "FROM doc_toc t JOIN docs d ON d.framework = t.framework AND d.filename = t.filename " + + $"WHERE {string.Join(" AND ", where)} ORDER BY t.ord"; - var sb = new StringBuilder(); + var rows = new List(); using var reader = cmd.ExecuteReader(); while (reader.Read()) { - var filename = reader.GetString(reader.GetOrdinal("filename")); - var name = filename.EndsWith(".md") ? filename[..^3] : filename; - var component = reader.IsDBNull(reader.GetOrdinal("component")) ? null : reader.GetString(reader.GetOrdinal("component")); - var comp = !string.IsNullOrEmpty(component) ? $" [{component}]" : ""; - if (sb.Length > 0) sb.AppendLine(); - sb.Append($"{name}{comp}"); + rows.Add(new ComponentRenderer.GroupedDocRow( + reader.GetString(reader.GetOrdinal("filename")), + Str(reader, "toc_name"), + Str(reader, "summary"), + !reader.IsDBNull(reader.GetOrdinal("premium")) && reader.GetInt64(reader.GetOrdinal("premium")) != 0, + reader.GetString(reader.GetOrdinal("group_key")), + reader.GetInt64(reader.GetOrdinal("ord")))); } + return rows; + } - var text = sb.Length > 0 ? sb.ToString() : "No docs found."; - return Content(text, "text/plain"); + /// + /// Flat mode never reads through doc_toc: the join multiplies cross-listed + /// docs and reorders by TOC position. Where narrows + /// a flat listing, membership is resolved separately. + /// + private List ReadFlat(string framework, string? filter, string? group) + { + var cmd = db.CreateCommand(); + var sql = "SELECT filename, component, toc_name, premium, keywords, summary FROM docs WHERE framework = @fw"; + cmd.Parameters.AddWithValue("@fw", framework); + + if (!string.IsNullOrWhiteSpace(filter)) + { + sql += " AND (filename LIKE @f OR component LIKE @f OR toc_name LIKE @f OR keywords LIKE @f OR summary LIKE @f)"; + cmd.Parameters.AddWithValue("@f", $"%{filter}%"); + } + cmd.CommandText = sql + " ORDER BY toc_name"; + + var rows = new List(); + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + rows.Add(new ComponentRenderer.DocRow( + reader.GetString(reader.GetOrdinal("filename")), + Str(reader, "toc_name"), + Str(reader, "summary"), + !reader.IsDBNull(reader.GetOrdinal("premium")) && reader.GetInt64(reader.GetOrdinal("premium")) != 0)); + } + } + + if (group != null && HasGroups(framework)) + { + var members = new HashSet(StringComparer.Ordinal); + var cmd2 = db.CreateCommand(); + cmd2.CommandText = "SELECT DISTINCT filename FROM doc_toc WHERE framework = @fw AND group_key = @g"; + cmd2.Parameters.AddWithValue("@fw", framework); + cmd2.Parameters.AddWithValue("@g", group); + using var reader2 = cmd2.ExecuteReader(); + while (reader2.Read()) members.Add(reader2.GetString(0)); + rows = rows.Where(r => members.Contains(r.Filename)).ToList(); + } + + return rows; + } + + [HttpGet] + public IActionResult List( + [FromQuery] string framework, + [FromQuery] string? filter, + [FromQuery] string? detail = null, + [FromQuery] string? group = null) + { + if (!IsValidFramework(framework, out var fw)) + return BadRequest($"Invalid framework \"{framework}\". Valid values: {string.Join(", ", ValidFrameworks.Keys)}"); + + framework = fw; + + if (detail == "docs" || !HasGroups(framework)) + return Content(ComponentRenderer.RenderFlat(framework, ReadFlat(framework, filter, group), filter), "text/plain"); + + var groups = ReadGroups(framework); + + if (group != null) + { + var match = groups.FirstOrDefault(g => string.Equals(g.GroupKey, group, StringComparison.Ordinal)); + var text = match is null + ? ComponentRenderer.RenderUnknownGroup(framework, group, groups) + : ComponentRenderer.RenderGroup(framework, match, ReadGroupedRows(framework, filter, group), filter); + return Content(text, "text/plain"); + } + + return Content( + ComponentRenderer.RenderGroupedIndex(framework, groups, ReadGroupedRows(framework, filter, null), filter), + "text/plain"); } [HttpGet("{framework}/{name}")] diff --git a/packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db b/packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db index 910bf53b0..b094e6240 100644 Binary files a/packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db and b/packages/igniteui-mcp/docs-backend/docs-backend/igniteui-docs.db differ diff --git a/packages/igniteui-mcp/docs-backend/tests-docs-backend/DocsControllerTests.cs b/packages/igniteui-mcp/docs-backend/tests-docs-backend/DocsControllerTests.cs index 8d39224e7..7b634c123 100644 --- a/packages/igniteui-mcp/docs-backend/tests-docs-backend/DocsControllerTests.cs +++ b/packages/igniteui-mcp/docs-backend/tests-docs-backend/DocsControllerTests.cs @@ -60,17 +60,21 @@ public void TearDown() // --- List endpoint tests --- + // This fixture has no doc_toc/doc_groups, so it also covers the back-compat + // guard: a legacy-schema database must render flat, exactly as it did before + // grouping existed. ListFixtureTests covers the grouped modes. + [Test] - public void List_ReturnsAllAngularDocs() + public void List_LegacySchema_FallsBackToFlat() { var result = _controller.List("angular", null) as ContentResult; Assert.That(result, Is.Not.Null); Assert.That(result!.StatusCode, Is.Null.Or.EqualTo(200)); - var lines = result.Content!.Split('\n'); - Assert.That(lines, Has.Length.EqualTo(4)); - Assert.That(result.Content, Does.Contain("accordion [IgxAccordionComponent]")); - Assert.That(result.Content, Does.Contain("grid-editing [IgxGridComponent, IgxColumnComponent]")); + Assert.That(result.Content, Does.StartWith("Found 4 components for **angular**:")); + Assert.That(result.Content, Does.Contain("- **Accordion** (`accordion`)")); + Assert.That(result.Content, Does.Contain(" Accordion component overview")); + Assert.That(result.Content, Does.Contain(" ⭐ Premium")); } [Test] @@ -79,10 +83,9 @@ public void List_WithFilter_ReturnsMatchingDocs() var result = _controller.List("angular", "grid") as ContentResult; Assert.That(result, Is.Not.Null); - var lines = result!.Content!.Split('\n'); - Assert.That(lines, Has.Length.EqualTo(2)); - Assert.That(result.Content, Does.Contain("grid-editing")); - Assert.That(result.Content, Does.Contain("grid-filtering")); + Assert.That(result!.Content, Does.StartWith("Found 2 components for **angular** matching \"grid\":")); + Assert.That(result.Content, Does.Contain("(`grid-editing`)")); + Assert.That(result.Content, Does.Contain("(`grid-filtering`)")); } [Test] @@ -91,7 +94,8 @@ public void List_WithNoResults_ReturnsNoDocsFound() var result = _controller.List("angular", "nonexistent-xyz") as ContentResult; Assert.That(result, Is.Not.Null); - Assert.That(result!.Content, Is.EqualTo("No docs found.")); + Assert.That(result!.Content, + Is.EqualTo("No components found for framework \"angular\" matching \"nonexistent-xyz\".")); } [Test] @@ -112,12 +116,23 @@ public void List_FrameworkIsCaseInsensitive() } [Test] - public void List_DocWithEmptyComponent_NoSquareBrackets() + public void List_DocWithNoTocName_FallsBackToTheFilename() { var result = _controller.List("angular", "no-component") as ContentResult; Assert.That(result, Is.Not.Null); - Assert.That(result!.Content, Is.EqualTo("no-component")); + Assert.That(result!.Content, + Is.EqualTo("Found 1 components for **angular** matching \"no-component\":\n\n- **no-component** (`no-component`)")); + } + + [Test] + public void List_LegacySchema_IgnoresGroupAndDetail() + { + var grouped = _controller.List("angular", null) as ContentResult; + var asked = _controller.List("angular", null, "groups", "Grids & Lists") as ContentResult; + + Assert.That(asked, Is.Not.Null); + Assert.That(asked!.Content, Is.EqualTo(grouped!.Content)); } // --- Get endpoint tests --- diff --git a/packages/igniteui-mcp/docs-backend/tests-docs-backend/ListFixtureTests.cs b/packages/igniteui-mcp/docs-backend/tests-docs-backend/ListFixtureTests.cs new file mode 100644 index 000000000..a5052b46f --- /dev/null +++ b/packages/igniteui-mcp/docs-backend/tests-docs-backend/ListFixtureTests.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using docs_backend.Controllers; + +namespace tests_docs_backend; + +/// +/// Renders the fixtures under packages/igniteui-mcp/shared-fixtures/ through this +/// backend and compares to the same expected.txt the MCP server's vitest suite +/// compares against. A change to either renderer that is not mirrored in the +/// other fails here or there. Comparison is ordinal with no line-ending +/// normalisation — the fixtures are pinned to LF by .gitattributes. +/// +public class ListFixtureTests +{ + private static string FixturesDir => + Path.Combine(AppContext.BaseDirectory, "shared-fixtures", "list-components"); + + public static IEnumerable FixtureNames() + { + if (!Directory.Exists(FixturesDir)) yield break; + foreach (var dir in Directory.GetDirectories(FixturesDir).OrderBy(d => d, StringComparer.Ordinal)) + yield return Path.GetFileName(dir); + } + + private static string? Text(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind != JsonValueKind.Null ? v.GetString() : null; + + private static long Num(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt64() : 0; + + private static SqliteConnection BuildDb(JsonElement input) + { + var db = new SqliteConnection("Data Source=:memory:"); + db.Open(); + + void Exec(string sql, params (string, object?)[] ps) + { + var cmd = db.CreateCommand(); + cmd.CommandText = sql; + foreach (var (k, v) in ps) cmd.Parameters.AddWithValue(k, v ?? DBNull.Value); + cmd.ExecuteNonQuery(); + } + + Exec(@"CREATE TABLE docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, framework TEXT NOT NULL, filename TEXT NOT NULL, + component TEXT NOT NULL, toc_name TEXT, premium INTEGER DEFAULT 0, keywords TEXT, + summary TEXT, content TEXT NOT NULL, UNIQUE(framework, filename))"); + Exec(@"CREATE TABLE doc_toc (framework TEXT NOT NULL, filename TEXT NOT NULL, + group_key TEXT NOT NULL, section TEXT NOT NULL, group_label TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL, ord INTEGER NOT NULL, landing INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (framework, filename, path))"); + Exec(@"CREATE TABLE doc_groups (framework TEXT NOT NULL, group_key TEXT NOT NULL, + section TEXT NOT NULL, group_label TEXT NOT NULL DEFAULT '', summary TEXT, + doc_count INTEGER NOT NULL, ord INTEGER NOT NULL, PRIMARY KEY (framework, group_key))"); + + foreach (var d in input.GetProperty("docs").EnumerateArray()) + { + Exec(@"INSERT INTO docs (framework, filename, component, toc_name, premium, keywords, summary, content) + VALUES (@fw, @file, @comp, @toc, @prem, @kw, @sum, 'body')", + ("@fw", Text(d, "framework")), ("@file", Text(d, "filename")), + ("@comp", Text(d, "component")), ("@toc", Text(d, "toc_name")), + ("@prem", Num(d, "premium")), ("@kw", Text(d, "keywords") ?? ""), + ("@sum", Text(d, "summary") ?? "")); + } + + foreach (var t in input.GetProperty("docToc").EnumerateArray()) + { + Exec(@"INSERT INTO doc_toc (framework, filename, group_key, section, group_label, path, ord, landing) + VALUES (@fw, @file, @key, @sec, @label, @path, @ord, @landing)", + ("@fw", Text(t, "framework")), ("@file", Text(t, "filename")), + ("@key", Text(t, "group_key")), ("@sec", Text(t, "section")), + ("@label", Text(t, "group_label")), ("@path", Text(t, "path")), + ("@ord", Num(t, "ord")), ("@landing", Num(t, "landing"))); + } + + foreach (var g in input.GetProperty("docGroups").EnumerateArray()) + { + Exec(@"INSERT INTO doc_groups (framework, group_key, section, group_label, summary, doc_count, ord) + VALUES (@fw, @key, @sec, @label, @sum, @count, @ord)", + ("@fw", Text(g, "framework")), ("@key", Text(g, "group_key")), + ("@sec", Text(g, "section")), ("@label", Text(g, "group_label")), + ("@sum", Text(g, "summary")), ("@count", Num(g, "doc_count")), ("@ord", Num(g, "ord"))); + } + + return db; + } + + [Test] + public void FixturesArePresent() + { + Assert.That(FixtureNames().ToList(), Is.Not.Empty, + $"No shared fixtures found under {FixturesDir}. Check the Content include in the csproj."); + } + + [TestCaseSource(nameof(FixtureNames))] + public void RendersFixtureExactlyAsRecorded(string name) + { + var dir = Path.Combine(FixturesDir, name); + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir, "input.json"))); + var input = doc.RootElement; + + using var db = BuildDb(input); + var controller = new DocsController(db); + var result = controller.List( + input.GetProperty("framework").GetString()!, + Text(input, "filter"), + Text(input, "detail"), + Text(input, "group")) as ContentResult; + + Assert.That(result, Is.Not.Null); + var expected = File.ReadAllText(Path.Combine(dir, "expected.txt")); + Assert.That(result!.Content, Is.EqualTo(expected).Using(StringComparer.Ordinal)); + } +} diff --git a/packages/igniteui-mcp/docs-backend/tests-docs-backend/tests-docs-backend.csproj b/packages/igniteui-mcp/docs-backend/tests-docs-backend/tests-docs-backend.csproj index 0c53ad252..ec4398c8a 100644 --- a/packages/igniteui-mcp/docs-backend/tests-docs-backend/tests-docs-backend.csproj +++ b/packages/igniteui-mcp/docs-backend/tests-docs-backend/tests-docs-backend.csproj @@ -25,4 +25,10 @@ + + + + + diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/CHANGELOG.md b/packages/igniteui-mcp/igniteui-doc-mcp/CHANGELOG.md index 0d3e580b1..56e6e0c57 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/CHANGELOG.md +++ b/packages/igniteui-mcp/igniteui-doc-mcp/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## Unreleased + +### What's Changed +* **feat(mcp):** `list_components` now returns a grouped index built from the published documentation TOC, cutting an unfiltered call from ~17–24k tokens to well under 4k. New `group` argument drills into one group with per-doc summaries; an unknown value answers with the valid group names. `detail: "docs"` returns the previous flat list unchanged. +* **feat(mcp):** the documentation database gains additive `doc_toc` and `doc_groups` tables. The `docs` table and its FTS index are untouched. A server running against a database without them — or one where only some frameworks have been migrated — renders flat per framework, exactly as before. +* **feat(mcp):** `build:db` now builds into a temporary file, wraps every mutation and its validation gates in one transaction, and publishes `dist/` → backend → `db/` only after the staged database passes. `npm run release:db` adds the whole-database gates a release requires. +* **feat(mcp):** new `npm run report:toc-coverage` reports TOC coverage per framework, and `validate:package` refuses to ship a partially grouped database or one with missing group summaries. + ## 15.5.0 (2026-07-15) ### What's Changed diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/CLAUDE.md b/packages/igniteui-mcp/igniteui-doc-mcp/CLAUDE.md index 6be21764f..c45671dd8 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/CLAUDE.md +++ b/packages/igniteui-mcp/igniteui-doc-mcp/CLAUDE.md @@ -16,6 +16,11 @@ This is the **Ignite UI Documentation MCP Server** — a Model Context Protocol │ └── LocalDocsProvider.ts # Local mode — sql.js WASM SQLite with FTS4 ├── scripts/ │ ├── build-db.ts # Build SQLite DB from compressed docs (better-sqlite3) +│ ├── build-group-summaries.ts # LLM summaries per TOC group → data/group-summaries/.json +│ ├── report-toc-coverage.ts # Per-framework TOC coverage report (regression check) +│ ├── lib/toc-index.ts # Shared TOC walker — sections, groups, ordering +│ ├── lib/toc-sidecar.ts # dist/toc-index/.json writer + filename collision resolver +│ ├── lib/frontmatter.ts # Shared docs_final frontmatter parser │ ├── export-angular-docs.ts # Export Angular docs from docfx (toc-driven, template expansion, include resolution, API URL resolution) │ ├── inject-angular-docs.ts # Inject sample code into docs (replaces with component source) │ ├── compress-angular-docs.ts # LLM-based compression of docs (~50% size reduction, supports --batch mode) @@ -34,7 +39,7 @@ This is the **Ignite UI Documentation MCP Server** — a Model Context Protocol │ ├── export-wc-api.ts # Build Web Components API docs from blazor/api-docs submodule → docs/webcomponents-api/ │ └── export-blazor-api.ts # Build Blazor API docs from blazor/api-docs submodule → docs/blazor-api/ ├── docs/ -│ ├── knowledgebase.md # Lessons learned and issues for cross-platform reference (32 entries) +│ ├── knowledgebase.md # Lessons learned and issues for cross-platform reference (35 entries) │ ├── db.md # SQLite + FTS4 database integration (IMPLEMENTED) │ ├── batch-compression.md # OpenAI Batch API for compression (IMPLEMENTED) │ ├── incremental-processing.md # Plan: Incremental processing with diff-based pipeline (NOT YET IMPLEMENTED) @@ -125,9 +130,21 @@ Local mode requires `dist/igniteui-docs.db` to exist. Run the pipeline and `npm ```bash npm run build:db # full rebuild for all frameworks npm run build:db -- --framework react # rebuild only react rows +npm run release:db # full rebuild + whole-DB release gates +npm run report:toc-coverage # per-framework TOC coverage report ``` -The `build:db` step reads `dist/docs_final//` and `dist/docs_prepeared//`, and produces `dist/igniteui-docs.db`. It must run after compression and before starting the MCP server. +The `build:db` step reads `dist/docs_final//`, `dist/docs_prepeared//` and `dist/toc-index/.json`, and produces `dist/igniteui-docs.db`. It must run after compression and before starting the MCP server. + +It builds into `dist/igniteui-docs.db.tmp` and publishes `dist/` → backend → `db/` last, so a failure anywhere leaves the authoritative `db/igniteui-docs.db` byte-for-byte intact. A full rebuild preflights **all four** frameworks and aborts if any is missing compressed docs, prepared docs, or a TOC sidecar — deriving the set from what happens to be on disk used to drop a framework silently. + +Group summaries are generated separately and cached in the tracked `data/group-summaries/.json`: + +```bash +npm run build:group-summaries # all frameworks, cached groups make no API call +npm run group-summaries:angular # one framework +npm run build:group-summaries -- --force # ignore the cache +``` ## Angular Documentation Pipeline @@ -228,14 +245,19 @@ npm run pipeline:blazor # run all steps: clear → build → export - GitHub API tools use `octokit` (requires `GITHUB_TOKEN` env var) - CLI scaffolding tools use `igniteui-cli` via `npx` - Six registered tools: - - `list_components` — list/filter docs by `framework` (required) and optional `filter` keyword + - `list_components` — TOC-grouped doc index by `framework` (required), narrowed with `filter`, `group`, or `detail: "docs"` for the flat per-doc list - `get_doc` — retrieve full markdown content by `framework` (required) + `name` (required, without `.md`) - `search_docs` — full-text search by `framework` (required) + `query` (required), top 20 results with snippets - `search_api` — discover API entries by keyword or partial component name - `get_api_reference` — retrieve full API details for an exact component or class name - `get_project_setup_guide` — return setup guides for creating a new Ignite UI project (CLI docs for Angular/React/WC, dotnet + NuGet guides for Blazor) -**Build DB** (`scripts/build-db.ts`): Reads compressed docs from `dist/docs_final//`, looks up `_tocName` from `dist/docs_prepeared//`, and produces `dist/igniteui-docs.db` using `better-sqlite3`. Supports full rebuild or per-framework rebuild via `--framework` flag. DB schema: `docs` table + `docs_fts` FTS4 virtual table with external content, porter tokenizer, and prefix indexes. +**Build DB** (`scripts/build-db.ts`): Reads compressed docs from `dist/docs_final//`, looks up `_tocName` from `dist/docs_prepeared//`, and produces `dist/igniteui-docs.db` using `better-sqlite3`. Supports full rebuild or per-framework rebuild via `--framework` flag. DB schema: `docs` table + `docs_fts` FTS4 virtual table with external content, porter tokenizer, and prefix indexes, plus two additive grouping tables: + +- `doc_toc` — one row per TOC membership, keyed `(framework, filename, path)` so a cross-listed doc keeps both. Carries `group_key`, `section`, `group_label`, `ord` (TOC order) and `landing`. +- `doc_groups` — one row per `(framework, group_key)` with the generated group `summary`, `doc_count` and `ord`. + +Both are derived from `dist/toc-index/.json`, written by the export scripts via the shared walker in `scripts/lib/toc-index.ts`. `docs` and `docs_fts` are unchanged by grouping — no reindex, no content churn. A database without these tables, or one where a framework has no `doc_toc` rows, makes `list_components` render the flat list for that framework. ## Key Dependencies diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/angular.json b/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/angular.json new file mode 100644 index 000000000..dc68d8c9e --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/angular.json @@ -0,0 +1,317 @@ +[ + { + "groupKey": "AI-Assisted Development", + "section": "AI-Assisted Development", + "groupLabel": "", + "summary": "Configure Agent Skills and MCP servers to scaffold projects, generate components, palettes, themes, answer API questions, and validate complex tasks", + "hash": "57230cd42f0205711cd7da3001172115", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Charts", + "section": "Charts", + "groupLabel": "", + "summary": "Configure area, bar, bubble, column, line, pie, scatter, stock, treemap, polar, radial, spline, step, and sparkline charts with axes, series, and styling.", + "hash": "d332aff3648797a31d64891a5fd48a70", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Charts > Chart Features", + "section": "Charts", + "groupLabel": "Chart Features", + "summary": "Configure chart axes, annotations, animations, tooltips, legends, selections, highlights, overlays, trendlines, navigation, synchronization, filtering, and performance optimization.", + "hash": "a8ac95d1ed416f80b0c3ba1bbea94d09", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Dashboards", + "section": "Dashboards", + "groupLabel": "", + "summary": "Covers Dashboard Tile installation, module setup, data binding, automatic visualization selection, and toolbar customization for dashboard tiles.", + "hash": "de7ac08dd493db9a5ba92241292d60a6", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display", + "section": "Data Entry & Display", + "groupLabel": "", + "summary": "Configure selection, autocompletion, buttons, inputs, validation, keyboard navigation, styling, progress indicators, virtualization, and pagination", + "hash": "3e00a42a88975006e913d65f6eee524c", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display > Drop Down", + "section": "Data Entry & Display", + "groupLabel": "Drop Down", + "summary": "Covers drop-down selection, grouping, menus, multi-level navigation, keyboard input, theming, virtualization, remote loading, and hierarchical multi-selection", + "hash": "036ac177645b9882565d7d1ce45277dd", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display > Icon", + "section": "Data Entry & Display", + "groupLabel": "Icon", + "summary": "Covers font and SVG icon families, Material Symbols and Material Icons Extended, registration, references, retrieval, sizing, colors, themes, and service APIs.", + "hash": "8c7c4bed37e44b066e2a71b9d655b5ce", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display > Query Builder", + "section": "Data Entry & Display", + "groupLabel": "Query Builder", + "summary": "Build visual filtering queries with entities, fields, conditions, groups, AND/OR logic, expression trees, subqueries, SQL, serialization, and grid binding.", + "hash": "4917f20cbcf9ff53060a7b9251628e0c", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Frameworks > Excel Library", + "section": "Frameworks", + "groupLabel": "Excel Library", + "summary": "Create, load, save, and manipulate workbooks, worksheets, cells, tables, formulas, charts, and sparklines, with styling, filtering, protection, and XLSX export.", + "hash": "b2220c7e5785de1bdb2f951960eec885", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Gauges", + "section": "Gauges", + "groupLabel": "", + "summary": "Covers bullet graphs and linear/radial gauges with scales, ranges, needles, tick marks, labels, backings, orientation, highlighting, and animation.", + "hash": "3ace95749d2af12cc352b71081e0f9e0", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General", + "section": "General", + "groupLabel": "", + "summary": "Covers installation, licensing, localization, updates, SSR, code splitting, data binding, grid and chart configuration, data analysis, theming, and release changes.", + "hash": "b6d720cf98832e7134f83c2f89fa813f", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General > Angular Schematics & Ignite UI CLI", + "section": "General", + "groupLabel": "Angular Schematics & Ignite UI CLI", + "summary": "Scaffold projects, select templates and themes, generate component views, run applications, and configure CLI, Schematics, MCP, AI assistants, and authentication", + "hash": "6123db64120e15d29048c87115b98c97", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General > How to", + "section": "General", + "groupLabel": "How to", + "summary": "Build SignalR live-data streams, validate reactive forms, connect CRUD grids, customize Sass themes, use standalone components, and scaffold apps with MCP workflows.", + "hash": "bed468734bb6438a07d015812edc907d", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General > WPF to Angular guide", + "section": "General", + "groupLabel": "WPF to Angular guide", + "summary": "Create Angular apps and components, use one-way and two-way binding, events, pipes, structural directives, and recreate WPF layouts with Flexbox and Grid", + "hash": "ee09a9543e67cfa37fc9a27b43ead878", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists", + "section": "Grids & Lists", + "groupLabel": "", + "summary": "Covers grids, trees, and lists with virtualization, editing, filtering, sorting, grouping, selection, hierarchical navigation, templating, and export.", + "hash": "645d51febe4d19982d7e519783830668", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Combo", + "section": "Grids & Lists", + "groupLabel": "Combo", + "summary": "Editable and simple combo boxes support data and value binding, filtering, custom values, grouping, remote data, virtualization, templates, forms, and navigation.", + "hash": "a14f70a64b331f4402a598dd757d6dd2", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "groupLabel": "Data Grid", + "summary": "Configure Data Grid columns, editing, filtering, sorting, grouping, selection, virtualization, remote data, exporting, navigation, layouts, and state persistence.", + "hash": "20d34a03c6db66e3266a3e387e4089be", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Grid Lite", + "section": "Grids & Lists", + "groupLabel": "Grid Lite", + "summary": "Configure Grid Lite columns, data binding, cell and header templates, virtualization, keyboard navigation, filtering, sorting, and custom themes with palettes and typography", + "hash": "9a9539097948cf43b5688eb88f831117", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Hierarchical Grid", + "section": "Grids & Lists", + "groupLabel": "Hierarchical Grid", + "summary": "Configure hierarchical and load-on-demand data, editing, validation, filtering, sorting, paging, selection, virtualization, layout, styling, state, and export", + "hash": "3d673516b02c56cb178becc117290813", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Pivot Grid", + "section": "Grids & Lists", + "groupLabel": "Pivot Grid", + "summary": "Configure Pivot Grid dimensions, values, aggregations, filters, sorting, layouts, selection, resizing, remote data, Excel/PDF export, and state persistence.", + "hash": "5800f4992cd35af659bb41d5a6a7c1b4", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Spreadsheet", + "section": "Grids & Lists", + "groupLabel": "Spreadsheet", + "summary": "Covers workbook editing, loading and saving, activation, charts, clipboard, conditional formatting, data validation, hyperlinks, commands, and configuration.", + "hash": "c44e054bf7398d19f7b90ca0c5b6f057", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Tree Grid", + "section": "Grids & Lists", + "groupLabel": "Tree Grid", + "summary": "Configure hierarchical Tree Grids with editing, selection, filtering, sorting, grouping, paging, virtualization, load-on-demand, styling, state, and export.", + "hash": "5c110307191a990878909bc86612b644", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Interactions", + "section": "Interactions", + "groupLabel": "", + "summary": "Build interactive interfaces with chat, dialogs, sliders, ripples, toggles, tooltips, drag-and-drop, zoom controls, and contextual action strips.", + "hash": "f9fedbf1290228fbf2f439ab61a23088", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Interactions > Overlay", + "section": "Interactions", + "groupLabel": "Overlay", + "summary": "Covers rendering overlay content, attaching and detaching, positioning strategies and offsets, scroll strategies, modal behavior, and global or scoped styling.", + "hash": "fa41bbdbbea7af01f1e8ac04b6835401", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Interactivity", + "section": "Interactivity", + "groupLabel": "", + "summary": "Covers right-to-left (RTL) direction, application-level and component-level directionality, plus Section 508, WCAG, WAI-ARIA, and accessibility caveats.", + "hash": "d9294cd8bf90ea8bd32a52368e3e8dda", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Layouts", + "section": "Layouts", + "groupLabel": "", + "summary": "Create responsive and fluid layouts with direction, ordering, spacing, alignment, and wrapping, alongside tabs, cards, accordions, splitters, steppers, and tiles", + "hash": "6db4a6fa0f56567ff0d8fe645d727422", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Maps", + "section": "Maps", + "groupLabel": "", + "summary": "Cover navigation, coordinate conversion, data binding, styling, tooltips, and geographic scatter, bubble, symbol, density, contour, polygon, and polyline series", + "hash": "ccd384121c7d2fbfef5a7773536b4356", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Maps > Geographic Map Features", + "section": "Maps", + "groupLabel": "Geographic Map Features", + "summary": "Display and navigate geographic maps with shapefiles, CSV, JSON, and custom models; overlay symbols, routes, shapes, imagery, heat maps, styling, and geographic utilities.", + "hash": "7059374523d1e3ada45f564c3e86afe8", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Menus", + "section": "Menus", + "groupLabel": "", + "summary": "Covers toolbar chart integration, tool actions, icons, orientation and color editing; navigation drawer routing, modes and variants; and navbar headers with menu, icon and back actions.", + "hash": "a4a2f9d648dadbdb7e686b859b1f0b66", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Notifications", + "section": "Notifications", + "groupLabel": "", + "summary": "Covers persistent banners, brief snackbars, and auto-hiding toasts, with actions, positioning, customization, animations, events, state handling, and theming", + "hash": "8f184a91511105107c37f7f74c8e63fd", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Scheduling", + "section": "Scheduling", + "groupLabel": "", + "summary": "Covers date-time editing, calendar selection, date, date-range, month, and time pickers, with formatting, validation, localization, and keyboard navigation.", + "hash": "925f2859fb7fe10e0ceac4c97d30681a", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Services", + "section": "Services", + "groupLabel": "", + "summary": "Export raw and grid data to CSV, TSV, TAB, Excel, or PDF with format options, row and column filtering, export events, fonts, and Unicode support.", + "hash": "2ffe7381fe47c0e852f49c9c44730d98", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Services > Transaction Service", + "section": "Services", + "groupLabel": "Transaction Service", + "summary": "Covers data changes, ADD/UPDATE/DELETE staging, pending transactions, commits, clearing, undo/redo, transaction pipes, and flat or hierarchical services.", + "hash": "32d92c85e585486aba4df8ee6581bc26", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Styling & Themes", + "section": "Styling & Themes", + "groupLabel": "", + "summary": "Customize themes with CSS variables or Sass, including palettes, elevations, typography, roundness, spacing, display density, scoping, and component-specific styling.", + "hash": "20310cc68675a654dd89f4fcb537209a", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Styling & Themes > Sass", + "section": "Styling & Themes", + "groupLabel": "Sass", + "summary": "Configure Sass themes with palettes, schemas, typography, elevations, roundness, animations, scoped component themes, presets, utilities, printing, and integrations", + "hash": "e5e713f955c15874ab22771e2dbc08d2", + "model": "gpt-5.6-luna", + "promptVersion": 1 + } +] diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/blazor.json b/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/blazor.json new file mode 100644 index 000000000..23ca03371 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/blazor.json @@ -0,0 +1,245 @@ +[ + { + "groupKey": "AI-Assisted Development", + "section": "AI-Assisted Development", + "groupLabel": "", + "summary": "Configure Agent Skills and MCP servers for scaffolding, component generation, documentation and API answers, palettes, themes, typography, CSS, Sass, design-token overrides, and step plans", + "hash": "d4d13f572a1100d09d2d131407de6f9a", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Charts", + "section": "Charts", + "groupLabel": "", + "summary": "Build area, bar, bubble, column, line, pie, donut, scatter, polar, radial, stock, treemap, and sparkline charts with series, data binding, axes, styling, and legends.", + "hash": "cf160d0edb6e45999dd967316a49338e", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Charts > Chart Features", + "section": "Charts", + "groupLabel": "Chart Features", + "summary": "Configure chart axes, annotations, animations, highlighting, markers, navigation, overlays, performance, tooltips, trendlines, filtering, and aggregation.", + "hash": "ec4b08d4c381c4f817ae8b68a6d7aaff", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Dashboards", + "section": "Dashboards", + "groupLabel": "", + "summary": "Configure dashboard tiles with automatic visualization selection, data binding, supported visualizations, and toolbar tools for changing and configuring views.", + "hash": "196a4f1cda43d6eda9a5a5711d252c52", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display", + "section": "Data Entry & Display", + "groupLabel": "", + "summary": "Handles inputs, masked and date-time entry, selection, buttons, dropdowns, badges, chips, icons, ratings, switches, progress indicators, validation, styling", + "hash": "50c3746d1085383d17cd2579e20fabd9", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display > Combo Box", + "section": "Data Entry & Display", + "groupLabel": "Combo Box", + "summary": "Covers ComboBox data binding, single selection, quick filtering, grouping, sorting, validation, keyboard navigation, styling, disabled states, and templates.", + "hash": "1086484e5a5921630dd8e27d104602d0", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Frameworks > Excel Library", + "section": "Frameworks", + "groupLabel": "Excel Library", + "summary": "Create, load, edit, format, protect, and export Excel workbooks with cells, tables, worksheets, charts, and sparklines, including formulas and filtering.", + "hash": "88e000a5d3eca053caa6d49488df3a83", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Gauges", + "section": "Gauges", + "groupLabel": "", + "summary": "Configure bullet graphs and linear or radial gauges with scales, needles, ranges, tick marks, labels, backing, highlights, animation, and dragging.", + "hash": "a74e8a92a3cc941fb6339b8dabe3de47", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General", + "section": "General", + "groupLabel": "", + "summary": "Covers open-source versus Premium licensing, available controls, grid upgrades, and release notes for new components, API changes, breaking changes, and bug fixes.", + "hash": "3554387b18a229c563d250548c138c51", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General > Getting Started", + "section": "General", + "groupLabel": "Getting Started", + "summary": "Create server, WebAssembly, Web App, and hybrid applications; install and register packages, configure styles and scripts, and render components", + "hash": "6e0698d58dd94293606c83a9fe70afba", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General > Installation", + "section": "General", + "groupLabel": "Installation", + "summary": "Configure private NuGet feeds and install packages using Visual Studio, the .NET CLI, or Package Manager, with licensed and trial sources covered", + "hash": "19434fd8865f3735021242c27ad43fa2", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists", + "section": "Grids & Lists", + "groupLabel": "", + "summary": "Display text/contact and hierarchical data with headers, slots, avatars, buttons, static or data-bound items, expansion, selection, keyboard navigation, and styling", + "hash": "ad5ac512f0820f197a2871a02d9ddf2a", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Grid", + "section": "Grids & Lists", + "groupLabel": "Grid", + "summary": "Configure Grid data binding, columns, filtering, sorting, editing, selection, grouping, paging, exporting, clipboard, virtualization, styling, and state saving.", + "hash": "e421ad1e356d5e713a1ca7b482fb3c1a", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Grid Lite", + "section": "Grids & Lists", + "groupLabel": "Grid Lite", + "summary": "Configure data binding, column configuration, filtering, sorting, virtualization, keyboard navigation, theming with CSS custom properties, and data-source updates.", + "hash": "7f6453974d0904788bee78d18ac7c233", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Hierarchical Grid", + "section": "Grids & Lists", + "groupLabel": "Hierarchical Grid", + "summary": "Configure hierarchical grids with nested data, row islands, load-on-demand, editing, filtering, sorting, selection, export, state, and virtualization.", + "hash": "a422873e40f0bcad5dbd77afc9619165", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Pivot Grid", + "section": "Grids & Lists", + "groupLabel": "Pivot Grid", + "summary": "Configure Pivot Grid rows, columns, values, filters, and aggregations; persist state and manage dimensions with sorting, resizing, selection, and compact mode.", + "hash": "d9f5e6092f70bd97cde111f7e50b76c0", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Tree Grid", + "section": "Grids & Lists", + "groupLabel": "Tree Grid", + "summary": "Covers hierarchical data binding, expandable rows, editing, filtering, sorting, selection, exporting, virtualization, state persistence, sizing, and live data.", + "hash": "fe7486681ad402e571e2b83a0cf7a50a", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Interactions", + "section": "Interactions", + "groupLabel": "", + "summary": "Covers ripple animations and color customization, tooltips with anchors, placement, triggers, and accessibility, plus sliders with ranges, ticks, labels, and events.", + "hash": "a313de8ca9e21b98bb3f33ffc37a1ff7", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Interactivity", + "section": "Interactivity", + "groupLabel": "", + "summary": "Covers accessibility support against Section 508 and WCAG guidelines, including compliance matrices, legends, and WAI-ARIA guidance.", + "hash": "75d7c9b2a6f38a71d3392e5d95469421", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Layouts", + "section": "Layouts", + "groupLabel": "", + "summary": "Build expandable panels, cards, carousels, tabs, steppers, avatars, dividers, and tile layouts with navigation, customization, styling, and accessibility", + "hash": "af39442cd5ca84aac1ec12a914eabb28", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Layouts > Dock Manager", + "section": "Layouts", + "groupLabel": "Dock Manager", + "summary": "Configure split panes, document hosts, tab groups, pinned/floating panes; update content and embed charts, gauges, and maps with iframes, plus styling", + "hash": "34a7905d3d0e753c2bd3366d1878a296", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Maps", + "section": "Maps", + "groupLabel": "", + "summary": "Maps include scatter area, contour, density, bubble, and symbol series; polygons and polylines, shapefiles, data binding, scales, and coordinate conversion", + "hash": "d2abb1ff3906a258eb52b202dde98499", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Maps > Geographic Map Features", + "section": "Maps", + "groupLabel": "Geographic Map Features", + "summary": "Display geographic data on imagery maps, bind CSV, JSON, models, and Shapefiles, overlay geographic series and imagery, navigate, and use map utilities", + "hash": "0ef2ef33b93278d0693b96117cc09d0a", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Menus", + "section": "Menus", + "groupLabel": "", + "summary": "Covers toolbars with chart components, custom actions, commands, icons, orientation, navbars, and expandable navigation drawers with mini mode and styling", + "hash": "9ef15ab69f98a67d4376a7e185cc69be", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Notifications", + "section": "Notifications", + "groupLabel": "", + "summary": "Display banners, snackbars, toasts, and dialogs with custom content, actions, timing, positioning, events, modal prompts, forms, behavior, and styling.", + "hash": "0322ccba726baaa05fd26d2c486f6465", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Scheduling", + "section": "Scheduling", + "groupLabel": "", + "summary": "Covers calendar date selection, disabled and special dates, keyboard navigation, date picker input and formatting, and date range binding and validation", + "hash": "d6f85e2e65c55a8c041a92f25528bf9d", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Styling & Themes", + "section": "Styling & Themes", + "groupLabel": "", + "summary": "Load and configure bundled themes, switch light and dark stylesheet paths, and customize type scales, font families, and individual styles with CSS variables.", + "hash": "e4d81ce2fc6175a01174e0792020bc8d", + "model": "gpt-5.6-luna", + "promptVersion": 1 + } +] diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/react.json b/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/react.json new file mode 100644 index 000000000..6feb2f05b --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/react.json @@ -0,0 +1,254 @@ +[ + { + "groupKey": "AI-Assisted Development", + "section": "AI-Assisted Development", + "groupLabel": "", + "summary": "Configure AI coding assistants with MCP tools for project scaffolding, component APIs, documentation, design tokens, themes, and validated executable plans.", + "hash": "a72059eec4fa4219a2dd50881eb88320", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Charts", + "section": "Charts", + "groupLabel": "", + "summary": "Create area, bar, column, line, pie, donut, scatter, polar, radial, stock, treemap, and sparkline charts with axes, series, legends, styling, and interaction.", + "hash": "89cf5307d7cd3da883729065fd78a252", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Charts > Chart Features", + "section": "Charts", + "groupLabel": "Chart Features", + "summary": "Configure chart axes, annotations, animation, highlighting, navigation, overlays, tooltips, trendlines, selection, synchronization, and performance.", + "hash": "52d64ad56f47c3c6f59dab53dc35790c", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Dashboards", + "section": "Dashboards", + "groupLabel": "", + "summary": "Covers dashboard tile module registration, data binding, automatic visualization selection, toolbar tools, and supported visualization types.", + "hash": "79bebf5c77ce3313046b8cd7998eac9f", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display", + "section": "Data Entry & Display", + "groupLabel": "", + "summary": "Covers buttons, chips, badges, icons, inputs, masked date-time fields, selects, dropdowns, checkboxes, radios, switches, ratings, color editing, and progress", + "hash": "57160be47ed167f3b23823f8e1d6441b", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display > Combo Box", + "section": "Data Entry & Display", + "groupLabel": "Combo Box", + "summary": "Cover virtualized lists, data binding, filtering, grouping, single selection, validation, keyboard navigation, styling, and content templates", + "hash": "3a66d8ed59161103b64cd134d504916a", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Frameworks > Excel Library", + "section": "Frameworks", + "groupLabel": "Excel Library", + "summary": "Create, load, save, and manipulate Excel workbooks, worksheets, cells, tables, charts, and sparklines with formulas, formatting, filtering, protection, and export.", + "hash": "8a5396b87f508648b94fa2094959fb72", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Gauges", + "section": "Gauges", + "groupLabel": "", + "summary": "Compare values and targets against scales with bullet, linear, and radial gauges using needles, ranges, labels, tick marks, backings, and animation.", + "hash": "19ecaa289fd76d5a49aa72174cad882c", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General", + "section": "General", + "groupLabel": "", + "summary": "Install and scaffold applications, render grids, localize components, manage licensing, compare component tiers, use client/server patterns, and track updates.", + "hash": "57d54f41eaf1d226b3d15c002cfe65ba", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General > How to", + "section": "General", + "groupLabel": "How to", + "summary": "Follow an end-to-end workflow to scaffold an app, connect MCP servers, add features, query documentation, and apply custom themes.", + "hash": "188f25174be9aa6e708242e9911e75ff", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General > Ignite UI CLI", + "section": "General", + "groupLabel": "Ignite UI CLI", + "summary": "Scaffold projects and component views, configure templates, themes, Vite development commands, AI tooling, and MCP integration through an interactive wizard.", + "hash": "c0ce5432ddce0a726fc42d4f99364bc3", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists", + "section": "Grids & Lists", + "groupLabel": "", + "summary": "Display text and templated items with headers, slots, avatars, and buttons, or show hierarchical data with expansion, selection, keyboard navigation, and styling.", + "hash": "1c97b115f7d1c7f6f6e729d59924b2e8", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Grid", + "section": "Grids & Lists", + "groupLabel": "Grid", + "summary": "Configure data, columns, editing, filtering, sorting, paging, grouping, selection, virtualization, exporting, clipboard, pinning, theming, and state persistence", + "hash": "e9e29648a31d1627981fbbd97230129d", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Grid Lite", + "section": "Grids & Lists", + "groupLabel": "Grid Lite", + "summary": "Configure data binding, declarative or generated columns, cell and header templates, filtering, sorting, runtime data replacement, and custom light/dark themes", + "hash": "6e1948fcce0b22c227b20d3dc936095d", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Hierarchical Grid", + "section": "Grids & Lists", + "groupLabel": "Hierarchical Grid", + "summary": "Covers nested binding, row islands, editing, filtering, sorting, selection, column management, exporting, remote operations, state, summaries, and virtualization", + "hash": "de2a6acdba170dd4f0fb036a19213ec9", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Pivot Grid", + "section": "Grids & Lists", + "groupLabel": "Pivot Grid", + "summary": "Configure pivot grids for multidimensional data analysis with dimensions, values, aggregations, filtering, selection, sorting, resizing, and state persistence", + "hash": "03615af8fbb7d329e5bc4bfc022b52b8", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Spreadsheet", + "section": "Grids & Lists", + "groupLabel": "Spreadsheet", + "summary": "Manage Excel-like workbooks with editing, navigation, clipboard, charts, conditional formatting, data validation, hyperlinks, commands, and file import/export", + "hash": "912e7f4afe961351c3ac6225a2b408f5", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Tree Grid", + "section": "Grids & Lists", + "groupLabel": "Tree Grid", + "summary": "Covers hierarchical data, expandable rows, editing, filtering, sorting, selection, exporting, summaries, state persistence, and virtualization.", + "hash": "86e17329e1ce10a2123936f37fbccbc6", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Interactions", + "section": "Interactions", + "groupLabel": "", + "summary": "Build chats with attachments and Markdown, add ripples and tooltips, configure sliders and zoom navigation, and create query builders with expression trees.", + "hash": "63aac80606e4276a58671d3fbfe4c50c", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Interactivity", + "section": "Interactivity", + "groupLabel": "", + "summary": "Covers accessibility compliance, including Section 508, WCAG, and WAI-ARIA support, compliance matrices, legends, and implementation considerations.", + "hash": "3964f9c3c24a65f6a0feecfcd503844b", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Layouts", + "section": "Layouts", + "groupLabel": "", + "summary": "Build expandable panels, cards, carousels, tabs, steppers, split panes, dividers, avatars, and tile layouts with navigation, customization, and styling.", + "hash": "4015e445f009167366f29656e88565e4", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Layouts > Dock Manager", + "section": "Layouts", + "groupLabel": "Dock Manager", + "summary": "Configure split, tab, document, content, and floating panes with docking, persistence, events, styling, keyboard navigation, pane updates, and iframe embedding.", + "hash": "14e5f1a55833fe209aecb0c544156846", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Maps", + "section": "Maps", + "groupLabel": "", + "summary": "Configure maps with scatter area, contour, density, bubble, symbol, polygon, and polyline series, covering binding, scales, styling, tooltips, and navigation", + "hash": "6d2b32054774f756fa069be9a601cb3d", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Maps > Geographic Map Features", + "section": "Maps", + "groupLabel": "Geographic Map Features", + "summary": "Covers geographic maps with imagery tiles, CSV, JSON, model, and shapefile data; overlays series, styles shapes, adds heat maps, and supports map navigation.", + "hash": "d7464d539c8e03d1b475661d8a1cbdf8", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Menus", + "section": "Menus", + "groupLabel": "", + "summary": "Covers Toolbar actions, chart integration, icons, and color editing; Navbar titles, content, and icons; and Navigation Drawer items, toggling, selection, and styling", + "hash": "7da18519e479d8ca654e6e5ec7ebd42b", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Notifications", + "section": "Notifications", + "groupLabel": "", + "summary": "Display banners, snackbars, toasts, and modal dialogs with customized content, actions, timing, visibility duration, closing behavior, forms, slots, and styling.", + "hash": "1045610aec7af9b68f0d8ea727656e61", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Scheduling", + "section": "Scheduling", + "groupLabel": "", + "summary": "Configure calendars and date pickers for date selection, ranges, entry, formatting, validation, localization, disabled dates, events, keyboard navigation, and styling", + "hash": "ac1f5b60b7cb6769d26fbdd7bf554f85", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Styling & Themes", + "section": "Styling & Themes", + "groupLabel": "", + "summary": "Load and configure bundled Bootstrap, Material, Fluent, and Indigo themes, with light and dark theme paths and the ConfigureTheme API.", + "hash": "86ffc94fd61992d17ffa33b747cd05f1", + "model": "gpt-5.6-luna", + "promptVersion": 1 + } +] diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/webcomponents.json b/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/webcomponents.json new file mode 100644 index 000000000..02c4ab338 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/data/group-summaries/webcomponents.json @@ -0,0 +1,254 @@ +[ + { + "groupKey": "AI-Assisted Development", + "section": "AI-Assisted Development", + "groupLabel": "", + "summary": "Configure Agent Skills and MCP servers to scaffold projects, generate components, answer API questions, create themes, and orchestrate validated AI workflows.", + "hash": "9a5f628e6c1f82c312fa2639ea4b80a9", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Charts", + "section": "Charts", + "groupLabel": "", + "summary": "Create area, bar, line, pie, polar, radial, scatter, stock, treemap, sparkline, stacked, combo, and step charts with data binding, styling, interaction, and APIs", + "hash": "f5884031a310933714342205e9eaf7ee", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Charts > Chart Features", + "section": "Charts", + "groupLabel": "Chart Features", + "summary": "Configure axes, annotations, animations, navigation, overlays, tooltips, trendlines, selection, highlighting, legends, filtering, and performance.", + "hash": "50d06c6a42ecafdc6f8efe9a1b2325eb", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Dashboards", + "section": "Dashboards", + "groupLabel": "", + "summary": "Dashboard tiles automatically select visualizations from data sources, with toolbar tools to change visualization types and configure data and display settings.", + "hash": "db4226a2f7b1a044c6b021ddea6b0364", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display", + "section": "Data Entry & Display", + "groupLabel": "", + "summary": "Covers badges, buttons, chips, dropdowns, selects, checkboxes, radios, switches, ratings, progress indicators, and text, date, file, mask, and color inputs.", + "hash": "8d12074a43b06a03d43006c5d4340aaa", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Data Entry & Display > Combo Box", + "section": "Data Entry & Display", + "groupLabel": "Combo Box", + "summary": "Covers ComboBox setup, data binding, filtering, grouping, selection, validation, keyboard navigation, disabled states, styling, templates, and content slots.", + "hash": "661eb86e82e97a493401dcb4c927f8ce", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Frameworks > Excel Library", + "section": "Frameworks", + "groupLabel": "Excel Library", + "summary": "Create, load, save, and configure Excel workbooks and worksheets, manipulate cells and tables, add charts and sparklines, and load or save XLSX files.", + "hash": "a2b6726c0e871b112e9f230ae779f9fe", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Gauges", + "section": "Gauges", + "groupLabel": "", + "summary": "Configure bullet graphs, linear gauges, and radial gauges with measures, scales, needles, ranges, labels, tick marks, backing, highlights, and animation.", + "hash": "cf3304e266884ee2d2f997fe925c64e9", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General", + "section": "General", + "groupLabel": "", + "summary": "Install and configure components via CLI or npm, localize dates and numbers, manage licensing and npm feeds, compare editions, and review release changes.", + "hash": "6eb3a8465fae9c45a8b9bd0bb5d5ef3c", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General > How to", + "section": "General", + "groupLabel": "How to", + "summary": "Follow an end-to-end workflow for scaffolding, extending, documenting, and theming an app through AI chat, with CLI MCP and Theming MCP working together.", + "hash": "26552f71a235dd677afbe29054b7e448", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "General > Ignite UI CLI", + "section": "General", + "groupLabel": "Ignite UI CLI", + "summary": "Install and use the CLI to scaffold projects, select templates and themes, add component views, run applications, and configure AI assistants through MCP.", + "hash": "fbcd06c425c2430282aad43ac9cac904", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists", + "section": "Grids & Lists", + "groupLabel": "", + "summary": "Display templated lists and hierarchical trees with headers, slots, actions, selection, keyboard navigation, load on demand, virtualization, and styling.", + "hash": "3fe064142806e729470555cf8b08ea85", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Grid", + "section": "Grids & Lists", + "groupLabel": "Grid", + "summary": "Configure data grids with editing, filtering, sorting, grouping, selection, navigation, column and row management, clipboard, export, virtualization, sizing, and theming.", + "hash": "0b208accce47ce3798464bace8ba3865", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Grid Lite", + "section": "Grids & Lists", + "groupLabel": "Grid Lite", + "summary": "Covers installation, data binding, column configuration, cell and header templates, filtering, sorting, theming, performance, customization, and licensing", + "hash": "8b9160b57eba0b0e533a97b2f447dd9b", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Hierarchical Grid", + "section": "Grids & Lists", + "groupLabel": "Hierarchical Grid", + "summary": "Configure hierarchical data binding, load-on-demand, editing, selection, filtering, sorting, exporting, styling, sizing, virtualization, and state persistence.", + "hash": "8d2e90a7751b224573653dd745d192f0", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Pivot Grid", + "section": "Grids & Lists", + "groupLabel": "Pivot Grid", + "summary": "Configure pivot grids with dimensions, values, aggregations, hierarchies, pivot calculation keys, state persistence, and remote grouping, filtering, and sorting", + "hash": "9bd8b0a755af4c1f9889341775358198", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Spreadsheet", + "section": "Grids & Lists", + "groupLabel": "Spreadsheet", + "summary": "Configure spreadsheets, load and save Excel workbooks, edit and format cells, use formulas, validation, hyperlinks, charts, clipboard, and commands.", + "hash": "df2e062c7166ac319be2d1acf00a55be", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Grids & Lists > Tree Grid", + "section": "Grids & Lists", + "groupLabel": "Tree Grid", + "summary": "Build hierarchical and flat Tree Grids with editing, filtering, sorting, selection, paging, virtualization, exporting, state persistence, and load-on-demand", + "hash": "dd715f303e6bbdb624c91b29fe07faa4", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Interactions", + "section": "Interactions", + "groupLabel": "", + "summary": "Build chat experiences, touch and click ripples, tooltips, sliders, chart zooming, and query-builder filtering with expressions, events, accessibility, and styling.", + "hash": "885e11a79583d7c1ea208523ba2b74ca", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Interactivity", + "section": "Interactivity", + "groupLabel": "", + "summary": "Covers accessibility compliance through Section 508, WCAG, and WAI-ARIA support, with coverage matrices for grids and UI components.", + "hash": "60dabe59ac2d6555ba2e06100c27623c", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Layouts", + "section": "Layouts", + "groupLabel": "", + "summary": "Build expandable panels, cards, carousels, tabs, steppers, split panes, and tile layouts with avatars, dividers, resizing, navigation, and styling.", + "hash": "d34b49973e85b78d69216646bc166351", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Layouts > Dock Manager", + "section": "Layouts", + "groupLabel": "Dock Manager", + "summary": "Configure content, split, tab group, document, and floating panes with runtime docking, persistence, customization, localization, and Electron window dragging", + "hash": "68a2e9be1884303f50aa8e7d6002a45c", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Maps", + "section": "Maps", + "groupLabel": "", + "summary": "Configure maps with symbol, bubble, density, contour, area, polygon, and polyline series, including coordinate binding, triangulation, scales, shapefiles, and navigation", + "hash": "cf8a7c969c5b9d65eb923fac75babbfb", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Maps > Geographic Map Features", + "section": "Maps", + "groupLabel": "Geographic Map Features", + "summary": "Display and bind geographic data from CSV, JSON, models, and shape files; overlay map series, imagery, navigation, shape styling, and world-data utilities.", + "hash": "45b576c449a12fc3351dfd34a609b816", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Menus", + "section": "Menus", + "groupLabel": "", + "summary": "Toolbars cover built-in/custom actions, icons, orientation, color editing; navbars cover navigation, while drawers cover items, mini mode, positioning, styling.", + "hash": "7aee27018ba3bfd01b239015d00da53f", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Notifications", + "section": "Notifications", + "groupLabel": "", + "summary": "Display banners, snackbars, toast notifications, and modal dialogs with custom content, actions, events, visibility controls, forms, and styling.", + "hash": "4087b3a0196f983f3dfce3ffc14c334b", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Scheduling", + "section": "Scheduling", + "groupLabel": "", + "summary": "Covers calendar, date picker, and date range picker setup, selection, formatting, localization, validation, ranges, keyboard navigation, events, and styling.", + "hash": "98309a8283b0c7608d7c7176e566c549", + "model": "gpt-5.6-luna", + "promptVersion": 1 + }, + { + "groupKey": "Styling & Themes", + "section": "Styling & Themes", + "groupLabel": "", + "summary": "Load and switch Bootstrap, Material, Fluent, and Indigo themes; customize palettes, typography, elevation, roundness, size, spacing, CSS parts, and Tailwind utilities", + "hash": "4a7c77749dbab952d29ae356be467b20", + "model": "gpt-5.6-luna", + "promptVersion": 1 + } +] diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db b/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db index 910bf53b0..b094e6240 100644 Binary files a/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db and b/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db differ diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md b/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md index 428f20687..95a605f7d 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md +++ b/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md @@ -397,6 +397,30 @@ The model listed the **sample application's own classes** while the document bod **Rule:** A `full` rebuild rewrites essentially the whole corpus even when nothing upstream changed. Prefer `incremental`, which only recompresses genuinely changed documents and therefore cannot churn metadata wholesale. +## 35. TOC-Driven Grouping for `list_components` + +**Problem:** an unfiltered `list_components` returned every doc as a flat bullet list with a full summary each — 17–24k tokens per call, 77% of it the `summary` column, spent before any documentation was read. + +**Fix:** group docs by the documentation TOC both sources already maintain (`toc.yml` for Angular, `toc.json` for the xplat platforms) and pay for one group summary instead of hundreds of per-doc summaries. Coverage is 100% in all four frameworks — every doc in the DB is reachable from the TOC, so there is no "Other" bucket and no curated family map. Measured on the shipped corpus: **-89% to -90%** before group summaries are generated (angular 95,873 → 10,262 chars). + +**Rule: header nodes carry their own `href`.** A walker that does `if (header) continue;` drops the section landing pages (`Grids & Lists -> grids-and-lists.md`, `Charts -> charts/chart-overview.md`) and, worse, loses the section for every following sibling. The walker keeps headers for section tracking; the xplat exporters still filter their landing pages out of the *export*, exactly as before. + +**Rule: an excluded header still updates the section.** Otherwise an xplat platform that excludes a header inherits the previous section for every entry after it. + +**Rule: group membership is decided by the top-level node below the header, not by depth.** `Data Grid` carries both an `href` and children, so `grid/grid.md` has a one-element ancestor chain — the same length as `accordion.md`, which must land at section level. An `ancestors.length > 1` test files the group's own overview page outside its group, which is precisely the doc a caller drilling into it wants. That pair is the regression test; 17 docs move in angular alone. + +**Rule: keep the duplicate write for a cross-listed href.** `injectTocMetadata` runs per TOC entry, so the two writes differ — `dashboard-tile.md` ships with `toc_name = "Charting in Dashboards"` (the second entry) in all four frameworks. Skipping the second write flips `docs.toc_name` and its `ORDER BY toc_name` position. Cache only the resolved *filename*, so the page stays one doc. + +**Rule: flat mode must not read through `doc_toc`.** The join multiplies cross-listed docs and reorders by TOC position, breaking both the byte-identical criterion and `ORDER BY toc_name`. Where `group` narrows a flat listing, membership is resolved with a separate `SELECT DISTINCT filename` and applied as a filter. + +**Rule: never render a filtered group's count from `doc_groups.doc_count`.** That column stores the full group size and would print `(45)` above three listed docs. Counts come from the matched rows, deduplicated by `(group_key, filename)`. + +**Rule: `build:db` publishes through a temp file.** It builds into `dist/igniteui-docs.db.tmp`, wraps schema changes, deletes, inserts, the FTS rebuild and the gates in one transaction, validates the staged file on a separate read-only connection, then renames `dist/` → backend → `db/` last. `db/igniteui-docs.db` is authoritative (`scripts/build.ts` copies it into `dist/` on every build), so it is the seed for an incremental run and the last thing published. Every handle is closed before a rename — Windows refuses to rename over an open SQLite file. + +**Rule: a full rebuild preflights all four frameworks.** Deriving the set from what happens to be on disk turned a missing framework into a silent omission, and a full rebuild drops and recreates the tables — so the previously good rows for it were simply gone. See also the `clear:build` hazard: it wipes `docs_processing` and `docs_prepeared` for *every* framework, so a full `build:db` at the wrong moment writes `toc_name = NULL` for the three that were not just processed. + +**Rule: the group set comes from the TOC, never from the summary cache.** Loading `doc_groups` from `data/group-summaries/.json` would make a new or renamed group vanish along with all of its docs, instead of appearing with a NULL summary. NULL summaries are a development state; `--release` is what refuses to ship one. + ## Related Documentation | Document | Description | Status | diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/package.json b/packages/igniteui-mcp/igniteui-doc-mcp/package.json index 8d623986f..5def1f12e 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/package.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/package.json @@ -41,6 +41,8 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "build:db": "npx tsx scripts/build-db.ts", + "release:db": "npm run build:db -- --release", + "report:toc-coverage": "npx tsx scripts/report-toc-coverage.ts", "validate:package": "npx tsx scripts/validate-package.ts", "inspector": "npx @modelcontextprotocol/inspector dist/index.js", "clear": "npx tsx -e \"import{rmSync}from'fs';rmSync('dist',{recursive:true,force:true})\"", @@ -73,6 +75,11 @@ "derive-components:react": "npx tsx scripts/derive-components.ts --framework react", "rewrite-api-urls:webcomponents": "npx tsx scripts/rewrite-api-links.ts --platform webcomponents", "derive-components:webcomponents": "npx tsx scripts/derive-components.ts --framework webcomponents", + "build:group-summaries": "npx tsx --env-file-if-exists=.env scripts/build-group-summaries.ts", + "group-summaries:angular": "npm run build:group-summaries -- --framework angular", + "group-summaries:blazor": "npm run build:group-summaries -- --framework blazor", + "group-summaries:react": "npm run build:group-summaries -- --framework react", + "group-summaries:webcomponents": "npm run build:group-summaries -- --framework webcomponents", "compress:angular": "npx tsx --env-file=.env scripts/compress-angular-docs.ts", "compress:blazor": "npx tsx --env-file=.env scripts/compress-blazor-docs.ts", "compress:react": "npx tsx --env-file=.env scripts/compress-react-docs.ts", @@ -98,14 +105,14 @@ "update-baseline:react": "npx tsx scripts/update-baseline.ts --framework react --manifest dist/diff-manifest.json", "update-baseline:webcomponents": "npx tsx scripts/update-baseline.ts --framework webcomponents --manifest dist/diff-manifest.json", "clear:build": "npx tsx -e \"import{rmSync}from'fs';['docs_processing','docs_prepeared'].forEach(d=>{rmSync('dist/'+d,{recursive:true,force:true})})\"", - "pipeline:angular": "npm run clear:build && npm run export:angular && npm run inject:angular && npm run rewrite-api-urls:angular && npm run diff:angular && npm run compress:angular -- --batch submit --manifest dist/diff-manifest.json && npm run compress:angular -- --batch poll && npm run derive-components:angular && npm run update-baseline:angular && npm run build:db -- --framework angular", - "pipeline:blazor": "npm run clear:build && npm run build:xplat-blazor && npm run export:blazor && npm run inject:blazor && npm run rewrite-api-urls:blazor && npm run diff:blazor && npm run compress:blazor -- --batch submit --manifest dist/diff-manifest.json && npm run compress:blazor -- --batch poll && npm run derive-components:blazor && npm run update-baseline:blazor && npm run build:db -- --framework blazor", - "pipeline:react": "npm run clear:build && npm run build:xplat-react && npm run export:react && npm run inject:react && npm run rewrite-api-urls:react && npm run diff:react && npm run compress:react -- --batch submit --manifest dist/diff-manifest.json && npm run compress:react -- --batch poll && npm run derive-components:react && npm run update-baseline:react && npm run build:db -- --framework react", - "pipeline:webcomponents": "npm run clear:build && npm run build:xplat-wc && npm run export:webcomponents && npm run inject:webcomponents && npm run rewrite-api-urls:webcomponents && npm run diff:webcomponents && npm run compress:webcomponents -- --batch submit --manifest dist/diff-manifest.json && npm run compress:webcomponents -- --batch poll && npm run derive-components:webcomponents && npm run update-baseline:webcomponents && npm run build:db -- --framework webcomponents", - "pipeline:angular:full": "npm run clear:angular && npm run export:angular && npm run inject:angular && npm run rewrite-api-urls:angular && npm run compress:angular -- --batch submit && npm run compress:angular -- --batch poll && npm run derive-components:angular && npx tsx scripts/update-baseline.ts --framework angular --full && npm run build:db -- --framework angular", - "pipeline:blazor:full": "npm run clear:blazor && npm run build:xplat-blazor && npm run export:blazor && npm run inject:blazor && npm run rewrite-api-urls:blazor && npm run compress:blazor -- --batch submit && npm run compress:blazor -- --batch poll && npm run derive-components:blazor && npx tsx scripts/update-baseline.ts --framework blazor --full && npm run build:db -- --framework blazor", - "pipeline:react:full": "npm run clear:react && npm run build:xplat-react && npm run export:react && npm run inject:react && npm run rewrite-api-urls:react && npm run compress:react -- --batch submit && npm run compress:react -- --batch poll && npm run derive-components:react && npx tsx scripts/update-baseline.ts --framework react --full && npm run build:db -- --framework react", - "pipeline:webcomponents:full": "npm run clear:webcomponents && npm run build:xplat-wc && npm run export:webcomponents && npm run inject:webcomponents && npm run rewrite-api-urls:webcomponents && npm run compress:webcomponents -- --batch submit && npm run compress:webcomponents -- --batch poll && npm run derive-components:webcomponents && npx tsx scripts/update-baseline.ts --framework webcomponents --full && npm run build:db -- --framework webcomponents" + "pipeline:angular": "npm run clear:build && npm run export:angular && npm run inject:angular && npm run rewrite-api-urls:angular && npm run diff:angular && npm run compress:angular -- --batch submit --manifest dist/diff-manifest.json && npm run compress:angular -- --batch poll && npm run derive-components:angular && npm run update-baseline:angular && npm run group-summaries:angular && npm run build:db -- --framework angular", + "pipeline:blazor": "npm run clear:build && npm run build:xplat-blazor && npm run export:blazor && npm run inject:blazor && npm run rewrite-api-urls:blazor && npm run diff:blazor && npm run compress:blazor -- --batch submit --manifest dist/diff-manifest.json && npm run compress:blazor -- --batch poll && npm run derive-components:blazor && npm run update-baseline:blazor && npm run group-summaries:blazor && npm run build:db -- --framework blazor", + "pipeline:react": "npm run clear:build && npm run build:xplat-react && npm run export:react && npm run inject:react && npm run rewrite-api-urls:react && npm run diff:react && npm run compress:react -- --batch submit --manifest dist/diff-manifest.json && npm run compress:react -- --batch poll && npm run derive-components:react && npm run update-baseline:react && npm run group-summaries:react && npm run build:db -- --framework react", + "pipeline:webcomponents": "npm run clear:build && npm run build:xplat-wc && npm run export:webcomponents && npm run inject:webcomponents && npm run rewrite-api-urls:webcomponents && npm run diff:webcomponents && npm run compress:webcomponents -- --batch submit --manifest dist/diff-manifest.json && npm run compress:webcomponents -- --batch poll && npm run derive-components:webcomponents && npm run update-baseline:webcomponents && npm run group-summaries:webcomponents && npm run build:db -- --framework webcomponents", + "pipeline:angular:full": "npm run clear:angular && npm run export:angular && npm run inject:angular && npm run rewrite-api-urls:angular && npm run compress:angular -- --batch submit && npm run compress:angular -- --batch poll && npm run derive-components:angular && npx tsx scripts/update-baseline.ts --framework angular --full && npm run group-summaries:angular && npm run build:db -- --framework angular", + "pipeline:blazor:full": "npm run clear:blazor && npm run build:xplat-blazor && npm run export:blazor && npm run inject:blazor && npm run rewrite-api-urls:blazor && npm run compress:blazor -- --batch submit && npm run compress:blazor -- --batch poll && npm run derive-components:blazor && npx tsx scripts/update-baseline.ts --framework blazor --full && npm run group-summaries:blazor && npm run build:db -- --framework blazor", + "pipeline:react:full": "npm run clear:react && npm run build:xplat-react && npm run export:react && npm run inject:react && npm run rewrite-api-urls:react && npm run compress:react -- --batch submit && npm run compress:react -- --batch poll && npm run derive-components:react && npx tsx scripts/update-baseline.ts --framework react --full && npm run group-summaries:react && npm run build:db -- --framework react", + "pipeline:webcomponents:full": "npm run clear:webcomponents && npm run build:xplat-wc && npm run export:webcomponents && npm run inject:webcomponents && npm run rewrite-api-urls:webcomponents && npm run compress:webcomponents -- --batch submit && npm run compress:webcomponents -- --batch poll && npm run derive-components:webcomponents && npx tsx scripts/update-baseline.ts --framework webcomponents --full && npm run group-summaries:webcomponents && npm run build:db -- --framework webcomponents" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-db.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-db.ts index 853af4113..060e0fdfd 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-db.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-db.ts @@ -1,11 +1,17 @@ import Database from "better-sqlite3"; import * as fs from "fs"; import * as path from "path"; +import { parseFrontmatter } from "./lib/frontmatter.js"; +import type { TocSidecarRecord } from "./lib/toc-sidecar.js"; const DIST_DIR = path.resolve("dist"); const DOCS_FINAL_DIR = path.join(DIST_DIR, "docs_final"); const DOCS_PREPARED_DIR = path.join(DIST_DIR, "docs_prepeared"); +const TOC_INDEX_DIR = path.join(DIST_DIR, "toc-index"); +const GROUP_SUMMARIES_DIR = path.resolve("data", "group-summaries"); const DB_PATH = path.join(DIST_DIR, "igniteui-docs.db"); +const GIT_DB_PATH = path.resolve("db", "igniteui-docs.db"); +const BACKEND_DB_PATH = path.resolve("..", "docs-backend", "docs-backend", "igniteui-docs.db"); const FRAMEWORKS = ["angular", "react", "blazor", "webcomponents"]; const CREATE_DOCS = ` @@ -28,35 +34,42 @@ CREATE VIRTUAL TABLE docs_fts USING fts4( content='docs', tokenize=porter, prefix="2,3" )`; -function parseFrontmatter(raw: string): { - component: string; - keywords: string; +// group_key is NOT NULL and is the join key to doc_groups: SQLite allows several +// NULLs in a rowid table's PRIMARY KEY, so a nullable grouping column would both +// admit duplicate rows and drop every section-level group from the join. +// `path` is section-qualified so one file cross-listed in two sections cannot +// collide on the primary key. +const CREATE_DOC_TOC = ` +CREATE TABLE IF NOT EXISTS doc_toc ( + framework TEXT NOT NULL, + filename TEXT NOT NULL, + group_key TEXT NOT NULL, + section TEXT NOT NULL, + group_label TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL, + ord INTEGER NOT NULL, + landing INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (framework, filename, path) +)`; + +const CREATE_DOC_TOC_INDEX = + `CREATE INDEX IF NOT EXISTS idx_doc_toc_group ON doc_toc(framework, group_key, ord)`; + +const CREATE_DOC_GROUPS = ` +CREATE TABLE IF NOT EXISTS doc_groups ( + framework TEXT NOT NULL, + group_key TEXT NOT NULL, + section TEXT NOT NULL, + group_label TEXT NOT NULL DEFAULT '', + summary TEXT, + doc_count INTEGER NOT NULL, + ord INTEGER NOT NULL, + PRIMARY KEY (framework, group_key) +)`; + +interface GroupSummaryEntry { + groupKey: string; summary: string; - premium: boolean; - content: string; -} { - const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!match) return { component: "", keywords: "", summary: "", premium: false, content: raw }; - - const block = match[1]; - let component = ""; - let keywords = ""; - let summary = ""; - let premium = false; - - for (const line of block.split("\n")) { - const m1 = line.match(/^component:\s*(.+)/); - if (m1) component = m1[1].trim(); - const m2 = line.match(/^keywords:\s*(.+)/); - if (m2) keywords = m2[1].trim(); - const m3 = line.match(/^summary:\s*(.+)/); - if (m3) summary = m3[1].trim(); - const m4 = line.match(/^premium:\s*(.+)/); - if (m4) premium = m4[1].trim() === "true"; - } - - const content = raw.slice(match[0].length).replace(/^\r?\n/, ""); - return { component, keywords, summary, premium, content }; } function extractTocName(filePath: string): string | null { @@ -85,138 +98,381 @@ function collectMdFiles(dir: string): string[] { function buildPreparedIndex(preparedDir: string): Map { const index = new Map(); for (const fullPath of collectMdFiles(preparedDir)) { - const base = path.basename(fullPath); - index.set(base, fullPath); + index.set(path.basename(fullPath), fullPath); } return index; } -function main() { - const args = process.argv.slice(2); - const fwIdx = args.indexOf("--framework"); - const targetFramework = fwIdx !== -1 ? args[fwIdx + 1] : null; +function sidecarPath(framework: string): string { + return path.join(TOC_INDEX_DIR, `${framework}.json`); +} - if (targetFramework && !FRAMEWORKS.includes(targetFramework)) { - console.error(`Unknown framework: ${targetFramework}. Valid: ${FRAMEWORKS.join(", ")}`); - process.exit(1); +/** + * Abort before touching anything if an input is missing. + * + * Deriving the framework set from whatever happens to be on disk turns a missing + * framework into a silent omission: a full rebuild drops and recreates the + * tables, so the previously good rows for that framework are simply gone. + */ +function preflight(frameworks: string[]): void { + const problems: string[] = []; + + for (const fw of frameworks) { + if (collectMdFiles(path.join(DOCS_FINAL_DIR, fw)).length === 0) { + problems.push(`dist/docs_final/${fw}/ is missing or has no .md files`); + } + if (collectMdFiles(path.join(DOCS_PREPARED_DIR, fw)).length === 0) { + problems.push(`dist/docs_prepeared/${fw}/ is missing or has no .md files (toc_name comes from here)`); + } + if (!fs.existsSync(sidecarPath(fw))) { + problems.push(`dist/toc-index/${fw}.json is missing (run export:${fw})`); + } } - if (!fs.existsSync(DOCS_FINAL_DIR)) { - console.error(`dist/docs_final/ not found. Run the pipeline first.`); + if (problems.length > 0) { + console.error(`Preflight failed — refusing to build:`); + for (const p of problems) console.error(` - ${p}`); process.exit(1); } +} - const frameworksToProcess = targetFramework ? [targetFramework] : FRAMEWORKS; - const existingFrameworks = frameworksToProcess.filter((fw) => { - const dir = path.join(DOCS_FINAL_DIR, fw); - if (!fs.existsSync(dir)) return false; - const mdFiles = fs.readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("_")); - return mdFiles.length > 0; - }); - - if (existingFrameworks.length === 0) { - console.error(`No .md files found in any framework directory under dist/docs_final/`); - process.exit(1); +function loadSidecar(framework: string): TocSidecarRecord[] { + const raw = fs.readFileSync(sidecarPath(framework), "utf-8"); + const records = JSON.parse(raw) as TocSidecarRecord[]; + if (!Array.isArray(records)) { + throw new Error(`Malformed TOC sidecar for ${framework}: expected an array`); } + return records; +} - const isFullRebuild = !targetFramework; - // Captured before opening — opening the database creates the file. - const dbExisted = fs.existsSync(DB_PATH); - let db: Database.Database; - - if (isFullRebuild || !dbExisted) { - db = new Database(DB_PATH); - db.exec("DROP TABLE IF EXISTS docs_fts"); - db.exec("DROP TABLE IF EXISTS docs"); - db.exec(CREATE_DOCS); - db.exec(CREATE_FTS); - } else { - db = new Database(DB_PATH); - db.exec(`DELETE FROM docs WHERE framework = '${targetFramework}'`); - } - - const insert = db.prepare(` +function loadGroupSummaries(framework: string): GroupSummaryEntry[] { + const file = path.join(GROUP_SUMMARIES_DIR, `${framework}.json`); + if (!fs.existsSync(file)) return []; + return JSON.parse(fs.readFileSync(file, "utf-8")) as GroupSummaryEntry[]; +} + +function ingestFramework(db: Database.Database, framework: string): void { + const finalDir = path.join(DOCS_FINAL_DIR, framework); + const preparedIndex = buildPreparedIndex(path.join(DOCS_PREPARED_DIR, framework)); + + const insertDoc = db.prepare(` INSERT INTO docs (framework, filename, component, toc_name, premium, keywords, summary, content) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); - const stats: Record = {}; + const mdFiles = fs + .readdirSync(finalDir) + .filter((f) => f.endsWith(".md") && !f.startsWith("_")); - for (const fw of existingFrameworks) { - const finalDir = path.join(DOCS_FINAL_DIR, fw); - const preparedDir = path.join(DOCS_PREPARED_DIR, fw); - const preparedIndex = buildPreparedIndex(preparedDir); + for (const file of mdFiles) { + const raw = fs.readFileSync(path.join(finalDir, file), "utf-8"); + const { component, keywords, summary, premium, content } = parseFrontmatter(raw); - const mdFiles = fs - .readdirSync(finalDir) - .filter((f) => f.endsWith(".md") && !f.startsWith("_")); + const preparedPath = preparedIndex.get(file); + const tocName = preparedPath ? extractTocName(preparedPath) : null; + if (!preparedPath) { + console.warn(` [warn] No prepared doc for ${framework}/${file} — toc_name will be null`); + } - const insertMany = db.transaction((files: string[]) => { - for (const file of files) { - const raw = fs.readFileSync(path.join(finalDir, file), "utf-8"); - const { component, keywords, summary, premium, content } = parseFrontmatter(raw); + insertDoc.run(framework, file, component, tocName, premium ? 1 : 0, keywords, summary, content); + } + console.log(` ${framework}: ${mdFiles.length} docs inserted`); - let tocName: string | null = null; - const preparedPath = preparedIndex.get(file); - if (preparedPath) { - tocName = extractTocName(preparedPath); - } else { - console.warn(` [warn] No prepared doc for ${fw}/${file} — toc_name will be null`); - } + // A record whose file never made it into `docs` is a warning, not a failure — + // the reverse (a doc with no group) is what the coverage gate rejects. + const known = new Set(mdFiles); + const insertToc = db.prepare(` + INSERT OR IGNORE INTO doc_toc (framework, filename, group_key, section, group_label, path, ord, landing) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); - insert.run(fw, file, component, tocName, premium ? 1 : 0, keywords, summary, content); - } + let inserted = 0; + let ignored = 0; + let orphaned = 0; + for (const rec of loadSidecar(framework)) { + if (!known.has(rec.file)) { + orphaned++; + continue; + } + const info = insertToc.run( + framework, + rec.file, + rec.groupKey, + rec.section, + rec.groupLabel ?? "", + rec.path, + rec.ord, + rec.landing ? 1 : 0 + ); + if (info.changes === 0) ignored++; + else inserted++; + } + console.log( + ` ${framework}: ${inserted} TOC membership(s) inserted` + + (ignored ? `, ${ignored} duplicate(s) ignored` : "") + + (orphaned ? `, ${orphaned} record(s) with no matching doc` : "") + ); + if (orphaned > 0) { + console.warn(` [warn] ${framework}: ${orphaned} TOC record(s) reference a doc that is not in docs_final`); + } + + // The group set is defined by the TOC, never by the summary cache: loading + // groups from the cache would make a new or renamed group vanish along with + // all of its docs instead of appearing with a NULL summary. + db.prepare(` + INSERT INTO doc_groups (framework, group_key, section, group_label, summary, doc_count, ord) + SELECT framework, group_key, MIN(section), MIN(group_label), NULL, + COUNT(DISTINCT filename), MIN(ord) + FROM doc_toc WHERE framework = ? GROUP BY framework, group_key + `).run(framework); + + const groupCount = ( + db.prepare(`SELECT COUNT(*) AS cnt FROM doc_groups WHERE framework = ?`).get(framework) as any + ).cnt; + + const updateSummary = db.prepare( + `UPDATE doc_groups SET summary = ? WHERE framework = ? AND group_key = ?` + ); + let applied = 0; + const stale: string[] = []; + for (const entry of loadGroupSummaries(framework)) { + if (!entry.summary) continue; + const info = updateSummary.run(entry.summary, framework, entry.groupKey); + if (info.changes === 0) stale.push(entry.groupKey); + else applied++; + } + console.log(` ${framework}: ${groupCount} group(s), ${applied} summary/summaries applied`); + for (const key of stale) { + console.warn(` [warn] ${framework}: cached summary for unknown group "${key}" — stale cache`); + } +} + +/** + * @param frameworks frameworks this invocation processed; always checked. + * @param release additionally apply the whole-DB gates. They are opt-in because + * they cannot pass mid-migration, and because a NULL group summary is a valid + * development state but never a shippable one. + */ +function validate(db: Database.Database, frameworks: string[], release: boolean): void { + const failures: string[] = []; + const placeholders = frameworks.map(() => "?").join(", "); + + const uncovered = db.prepare(` + SELECT d.framework, d.filename FROM docs d + WHERE d.framework IN (${placeholders}) + AND NOT EXISTS ( + SELECT 1 FROM doc_toc t WHERE t.framework = d.framework AND t.filename = d.filename + ) + LIMIT 20 + `).all(...frameworks) as { framework: string; filename: string }[]; + if (uncovered.length > 0) { + failures.push( + `${uncovered.length}+ doc(s) have no TOC group, e.g. ` + + uncovered.slice(0, 5).map((r) => `${r.framework}/${r.filename}`).join(", ") + ); + } + + const nullToc = db.prepare(` + SELECT framework, COUNT(*) AS cnt FROM docs + WHERE framework IN (${placeholders}) AND toc_name IS NULL + GROUP BY framework + `).all(...frameworks) as { framework: string; cnt: number }[]; + if (nullToc.length > 0) { + failures.push( + `NULL toc_name rows: ` + nullToc.map((r) => `${r.framework}=${r.cnt}`).join(", ") + ); + } + + if (release) { + const missingFw = FRAMEWORKS.filter((fw) => { + const row = db.prepare(`SELECT COUNT(*) AS cnt FROM doc_toc WHERE framework = ?`).get(fw) as any; + return row.cnt === 0; }); + if (missingFw.length > 0) { + failures.push(`framework(s) with no doc_toc rows: ${missingFw.join(", ")}`); + } - insertMany(mdFiles); - stats[fw] = mdFiles.length; - console.log(` ${fw}: ${mdFiles.length} docs inserted`); + const uncoveredAll = ( + db.prepare(` + SELECT COUNT(*) AS cnt FROM docs d + WHERE NOT EXISTS ( + SELECT 1 FROM doc_toc t WHERE t.framework = d.framework AND t.filename = d.filename + ) + `).get() as any + ).cnt; + if (uncoveredAll > 0) failures.push(`${uncoveredAll} doc(s) across the DB have no TOC group`); + + const nullTocAll = ( + db.prepare(`SELECT COUNT(*) AS cnt FROM docs WHERE toc_name IS NULL`).get() as any + ).cnt; + if (nullTocAll > 0) failures.push(`${nullTocAll} doc(s) across the DB have a NULL toc_name`); + + const nullSummaries = db.prepare(` + SELECT framework, group_key FROM doc_groups WHERE summary IS NULL OR summary = '' LIMIT 20 + `).all() as { framework: string; group_key: string }[]; + if (nullSummaries.length > 0) { + failures.push( + `${nullSummaries.length}+ group(s) have no summary — run build:group-summaries: ` + + nullSummaries.slice(0, 5).map((r) => `${r.framework}/${r.group_key}`).join(", ") + ); + } } - db.exec("INSERT INTO docs_fts(docs_fts) VALUES('rebuild')"); + if (failures.length > 0) { + throw new Error( + `Validation failed:\n` + failures.map((f) => ` - ${f}`).join("\n") + ); + } +} - // DROP/DELETE frees pages but never shrinks the file, and this DB is committed to git. - // A file created by this run has no free pages, so only vacuum an inherited one. - if (dbExisted) { - db.exec("VACUUM"); +function removeDbFiles(file: string): void { + for (const suffix of ["", "-wal", "-shm"]) { + fs.rmSync(`${file}${suffix}`, { force: true }); } +} - db.pragma("optimize"); +function main() { + const args = process.argv.slice(2); + const fwIdx = args.indexOf("--framework"); + const targetFramework = fwIdx !== -1 ? args[fwIdx + 1] : null; + const release = args.includes("--release"); - const totalRows = (db.prepare("SELECT COUNT(*) AS cnt FROM docs").get() as any).cnt; - db.close(); + if (targetFramework && !FRAMEWORKS.includes(targetFramework)) { + console.error(`Unknown framework: ${targetFramework}. Valid: ${FRAMEWORKS.join(", ")}`); + process.exit(1); + } - console.log(`\nDatabase built: ${DB_PATH}`); - console.log(`Total docs: ${totalRows}`); - for (const [fw, count] of Object.entries(stats)) { - console.log(` ${fw}: ${count}`); + const isFullRebuild = !targetFramework; + const frameworksToProcess = targetFramework ? [targetFramework] : FRAMEWORKS; + preflight(frameworksToProcess); + + // Build into a temp file so a failure anywhere leaves every published artifact + // untouched. `db/igniteui-docs.db` is authoritative — `scripts/build.ts` copies + // it into `dist/` on every build — so an incremental run seeds from there, not + // from the derived `dist/` copy. + const tmpPath = `${DB_PATH}.tmp`; + const stagedPaths: { staged: string; final: string }[] = []; + removeDbFiles(tmpPath); + fs.mkdirSync(DIST_DIR, { recursive: true }); + + const seeded = !isFullRebuild && fs.existsSync(GIT_DB_PATH); + if (seeded) { + fs.copyFileSync(GIT_DB_PATH, tmpPath); + } else if (!isFullRebuild) { + console.warn(`${GIT_DB_PATH} not found — building ${targetFramework} into a fresh database.`); } - const sizeKB = (fs.statSync(DB_PATH).size / 1024).toFixed(1); - console.log(`DB size: ${sizeKB} KB`); + const cleanup = () => { + removeDbFiles(tmpPath); + for (const { staged } of stagedPaths) removeDbFiles(staged); + }; + + let totalRows = 0; + const db = new Database(tmpPath); + try { + // No -wal/-shm siblings to clean up before the renames. + db.pragma("journal_mode = DELETE"); + + // One transaction spans the schema changes, the deletes, the inserts, the + // FTS rebuild and the gates, so a gate failure cannot leave a half-updated + // database behind. SQLite DDL is transactional, so this rolls back cleanly. + db.transaction(() => { + if (seeded) { + db.exec(CREATE_DOC_TOC); + db.exec(CREATE_DOC_TOC_INDEX); + db.exec(CREATE_DOC_GROUPS); + const del = (table: string) => + db.prepare(`DELETE FROM ${table} WHERE framework = ?`).run(targetFramework!); + del("docs"); + del("doc_toc"); + del("doc_groups"); + } else { + db.exec("DROP TABLE IF EXISTS docs_fts"); + db.exec("DROP TABLE IF EXISTS docs"); + db.exec("DROP TABLE IF EXISTS doc_groups"); + db.exec("DROP TABLE IF EXISTS doc_toc"); + db.exec(CREATE_DOCS); + db.exec(CREATE_FTS); + db.exec(CREATE_DOC_TOC); + db.exec(CREATE_DOC_TOC_INDEX); + db.exec(CREATE_DOC_GROUPS); + } - // Copy to db/ directory (tracked in git) - const gitDbPath = path.resolve("db/igniteui-docs.db"); - fs.mkdirSync(path.dirname(gitDbPath), { recursive: true }); - fs.copyFileSync(DB_PATH, gitDbPath); - console.log(`Copied DB to ${gitDbPath}`); + for (const fw of frameworksToProcess) { + ingestFramework(db, fw); + } + + db.exec("INSERT INTO docs_fts(docs_fts) VALUES('rebuild')"); + validate(db, frameworksToProcess, release); + })(); + + // VACUUM cannot run inside a transaction. A file this run created has no + // free pages, so only vacuum one inherited from the seed. + if (seeded) db.exec("VACUUM"); + db.pragma("optimize"); - const backendDbPath = path.resolve("../docs-backend/docs-backend/igniteui-docs.db"); - const backendDir = path.dirname(backendDbPath); - if (fs.existsSync(backendDir)) { + totalRows = (db.prepare("SELECT COUNT(*) AS cnt FROM docs").get() as any).cnt; + } catch (err) { + db.close(); + cleanup(); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } finally { + // better-sqlite3 keeps the file open, and Windows refuses to rename or copy + // over an open SQLite file. try { - fs.copyFileSync(DB_PATH, backendDbPath); - console.log(`Copied DB to ${backendDbPath}`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.warn( - `Could not copy DB to ${backendDbPath}: ${message}\n` + - `The DB was built and saved successfully — this copy is optional.` - ); + db.close(); + } catch { + /* already closed on the error path */ } - } else { - console.warn(`Backend dir not found (${backendDir}), skipping copy.`); } + + try { + // `dist/igniteui-docs.db.tmp` is already the stage for the dist/ copy, so + // only the other two destinations need one. Renames are per-file atomic; + // copies are not, so every copy happens before any rename. + stagedPaths.push({ staged: tmpPath, final: DB_PATH }); + + if (fs.existsSync(path.dirname(BACKEND_DB_PATH))) { + const backendTmp = `${BACKEND_DB_PATH}.tmp`; + fs.copyFileSync(tmpPath, backendTmp); + stagedPaths.push({ staged: backendTmp, final: BACKEND_DB_PATH }); + } else { + console.warn(`Backend dir not found (${path.dirname(BACKEND_DB_PATH)}), skipping copy.`); + } + + fs.mkdirSync(path.dirname(GIT_DB_PATH), { recursive: true }); + const gitTmp = `${GIT_DB_PATH}.tmp`; + fs.copyFileSync(tmpPath, gitTmp); + stagedPaths.push({ staged: gitTmp, final: GIT_DB_PATH }); + + // Validate what is about to be published, on a connection of its own, and + // close it before any rename for the same reason the writer is closed. + const check = new Database(tmpPath, { readonly: true }); + try { + validate(check, frameworksToProcess, release); + } finally { + check.close(); + } + + // db/ is the commit point and goes last: any failure before it leaves the + // authoritative database and its committed copy byte-for-byte intact. + for (const { staged, final } of stagedPaths) { + if (staged !== final) fs.renameSync(staged, final); + } + } catch (err) { + cleanup(); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + console.log(`\nDatabase built: ${DB_PATH}`); + console.log(`Total docs: ${totalRows}`); + console.log(`DB size: ${(fs.statSync(DB_PATH).size / 1024).toFixed(1)} KB`); + for (const { final } of stagedPaths) { + if (final !== DB_PATH) console.log(`Published to ${final}`); + } + if (release) console.log(`Release gates passed.`); } main(); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-group-summaries.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-group-summaries.ts new file mode 100644 index 000000000..ab614223f --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/build-group-summaries.ts @@ -0,0 +1,217 @@ +import { createHash } from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import OpenAI from "openai"; +import { parseFrontmatter } from "./lib/frontmatter.js"; +import type { TocSidecarRecord } from "./lib/toc-sidecar.js"; + +const ROOT = path.resolve("."); +const DOCS_FINAL_DIR = path.join(ROOT, "dist", "docs_final"); +const TOC_INDEX_DIR = path.join(ROOT, "dist", "toc-index"); +const OUTPUT_DIR = path.join(ROOT, "data", "group-summaries"); +const FRAMEWORKS = ["angular", "react", "blazor", "webcomponents"]; + +// Bump when the prompt changes: it is part of the cache hash, so every group +// regenerates rather than silently keeping output from the previous wording. +const PROMPT_VERSION = 1; + +const SYSTEM_PROMPT = `You write one-line summaries for groups of component documentation. + +You are given the name of a documentation group and the summaries of the docs it contains. Write a single sentence describing what the group covers, so a developer can decide whether to open it. + +Rules: +- 120-160 characters. Never exceed 160. +- Name the concrete capabilities the group covers, in the docs' own vocabulary. +- No framework names, no product names, no "this group", no "documentation for". +- No markdown, no quotes, no trailing period beyond the single sentence's own. +- Output the sentence and nothing else.`; + +interface CacheEntry { + groupKey: string; + section: string; + groupLabel: string; + summary: string; + hash: string; + model: string; + promptVersion: number; +} + +interface GroupInput { + groupKey: string; + section: string; + groupLabel: string; + members: { file: string; summary: string }[]; + landing: string; +} + +function loadCache(framework: string): Map { + const file = path.join(OUTPUT_DIR, `${framework}.json`); + if (!fs.existsSync(file)) return new Map(); + const entries = JSON.parse(fs.readFileSync(file, "utf-8")) as CacheEntry[]; + return new Map(entries.map((e) => [e.groupKey, e])); +} + +function docSummary(framework: string, file: string): string { + const full = path.join(DOCS_FINAL_DIR, framework, file); + if (!fs.existsSync(full)) return ""; + return parseFrontmatter(fs.readFileSync(full, "utf-8")).summary; +} + +/** + * Group the sidecar's memberships, deduplicating by `(groupKey, file)` and + * keeping the lowest `ord` — the same rule the renderer and `doc_groups.doc_count` + * apply. Feeding raw records in would repeat a doc in the prompt and hash a + * member list that does not match what gets rendered. + */ +function collectGroups(framework: string): GroupInput[] { + const sidecarPath = path.join(TOC_INDEX_DIR, `${framework}.json`); + if (!fs.existsSync(sidecarPath)) { + console.error(`No TOC sidecar for ${framework} at ${sidecarPath} — run export:${framework} first.`); + process.exit(1); + } + const records = JSON.parse(fs.readFileSync(sidecarPath, "utf-8")) as TocSidecarRecord[]; + + const byGroup = new Map>(); + const landings = new Map(); + + for (const rec of records) { + let members = byGroup.get(rec.groupKey); + if (!members) { + members = new Map(); + byGroup.set(rec.groupKey, members); + } + const existing = members.get(rec.file); + if (!existing || rec.ord < existing.ord) members.set(rec.file, rec); + if (rec.landing) landings.set(rec.groupKey, rec.file); + } + + const groups: GroupInput[] = []; + for (const [groupKey, members] of byGroup) { + const ordered = [...members.values()].sort((a, b) => a.ord - b.ord); + const landingFile = landings.get(groupKey); + groups.push({ + groupKey, + section: ordered[0].section, + groupLabel: ordered[0].groupLabel, + members: ordered.map((r) => ({ file: r.file, summary: docSummary(framework, r.file) })), + landing: landingFile ? docSummary(framework, landingFile) : "", + }); + } + return groups.sort((a, b) => a.groupKey.localeCompare(b.groupKey)); +} + +function hashOf(group: GroupInput, model: string): string { + const payload = JSON.stringify({ + members: group.members.map((m) => [m.file, m.summary]), + landing: group.landing, + promptVersion: PROMPT_VERSION, + model, + }); + return createHash("sha256").update(payload).digest("hex").slice(0, 32); +} + +function userPrompt(group: GroupInput): string { + const lines = [`Group: ${group.groupKey}`]; + if (group.landing) lines.push(`Group overview: ${group.landing}`); + lines.push("", "Docs in this group:"); + for (const m of group.members) { + lines.push(`- ${m.file.replace(/\.md$/, "")}${m.summary ? `: ${m.summary}` : ""}`); + } + return lines.join("\n"); +} + +async function generate(client: OpenAI, model: string, group: GroupInput): Promise { + const response = await client.chat.completions.create({ + model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userPrompt(group) }, + ], + max_completion_tokens: 2000, + }); + return (response.choices[0].message.content ?? "").trim().replace(/\s+/g, " "); +} + +async function run(framework: string, model: string, force: boolean, apiBase?: string) { + const groups = collectGroups(framework); + const cache = loadCache(framework); + + const stale = groups.filter((g) => { + if (force) return true; + const hit = cache.get(g.groupKey); + return !hit || !hit.summary || hit.hash !== hashOf(g, model); + }); + + console.log(`${framework}: ${groups.length} group(s), ${stale.length} to regenerate, ${groups.length - stale.length} cached`); + + let client: OpenAI | null = null; + if (stale.length > 0) { + if (!process.env.OPENAI_API_KEY) { + console.error( + `${stale.length} group(s) need a summary but OPENAI_API_KEY is not set. ` + + `Provide a key, or leave the cache as-is (groups build with a NULL summary).` + ); + process.exit(1); + } + const clientOpts: ConstructorParameters[0] = {}; + if (apiBase) clientOpts.baseURL = apiBase; + client = new OpenAI(clientOpts); + } + + const staleKeys = new Set(stale.map((g) => g.groupKey)); + const out: CacheEntry[] = []; + + for (const group of groups) { + if (!staleKeys.has(group.groupKey)) { + // Copy the cached summary through; make no call. + out.push({ ...cache.get(group.groupKey)!, section: group.section, groupLabel: group.groupLabel }); + continue; + } + + const summary = await generate(client!, model, group); + if (!summary) { + console.warn(` [warn] empty summary for "${group.groupKey}" — leaving it uncached`); + continue; + } + console.log(` ${group.groupKey} (${group.members.length} docs) -> ${summary.length} ch`); + out.push({ + groupKey: group.groupKey, + section: group.section, + groupLabel: group.groupLabel, + summary, + hash: hashOf(group, model), + model, + promptVersion: PROMPT_VERSION, + }); + } + + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const file = path.join(OUTPUT_DIR, `${framework}.json`); + const tmp = `${file}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, "utf-8"); + fs.renameSync(tmp, file); + console.log(`${framework}: wrote ${out.length} summary/summaries -> ${file}`); +} + +async function main() { + const args = process.argv.slice(2); + const arg = (name: string) => { + const i = args.indexOf(name); + return i !== -1 ? args[i + 1] : undefined; + }; + + const targetFramework = arg("--framework"); + if (targetFramework && !FRAMEWORKS.includes(targetFramework)) { + console.error(`Unknown framework: ${targetFramework}. Valid: ${FRAMEWORKS.join(", ")}`); + process.exit(1); + } + + const model = arg("--model") || process.env.COMPRESS_MODEL || "gpt-5.6-luna"; + const force = args.includes("--force"); + + for (const fw of targetFramework ? [targetFramework] : FRAMEWORKS) { + await run(fw, model, force, arg("--api-base")); + } +} + +main(); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-angular-docs.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-angular-docs.ts index 43113d5cb..7e04f078e 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-angular-docs.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-angular-docs.ts @@ -1,6 +1,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "fs"; import { join, dirname, resolve, relative } from "path"; import yaml from "js-yaml"; +import { walkTocYaml, type TocEntry, type TocNode } from "./lib/toc-index.js"; +import { TocSidecar, resolveUniqueName } from "./lib/toc-sidecar.js"; const ROOT = resolve(import.meta.dirname, ".."); const DOCFX_ROOT = join(ROOT, "angular", "igniteui-docfx"); @@ -67,37 +69,10 @@ const gridsConfigs: Record> = { }, }; -interface TocEntry { - name: string; - href: string; - premium: boolean; -} - function parseToc(tocPath: string): TocEntry[] { const raw = readFileSync(tocPath, "utf-8"); - const entries: TocEntry[] = []; - - function flatten(items: any[]) { - for (const item of items) { - if (item.href) { - entries.push({ - name: item.name || "", - href: item.href, - premium: item.premium === true, - }); - } - if (Array.isArray(item.items)) { - flatten(item.items); - } - } - } - - const parsed = yaml.load(raw) as any[]; - if (Array.isArray(parsed)) { - flatten(parsed); - } - - return entries; + const parsed = yaml.load(raw) as TocNode[]; + return Array.isArray(parsed) ? walkTocYaml(parsed) : []; } function injectTocMetadata(content: string, entry: TocEntry): string { @@ -262,6 +237,9 @@ function main() { mkdirSync(OUTPUT_DIR, { recursive: true }); let totalFiles = 0; + const usedNames = new Map(); + const writtenFiles = new Set(); + const sidecar = new TocSidecar("angular", ROOT); // Step 3: Process toc entries console.error("Processing toc entries..."); @@ -298,12 +276,24 @@ function main() { content = replaceEnvironmentVars(content); content = stripImages(content); - const flatName = flattenPath(relPath); + // A cross-listed page appears under two TOC paths. Reuse the name resolved + // the first time so it stays one doc, but still write: injectTocMetadata has + // already run for this entry and last-write-wins is observable in the DB. + let flatName = sidecar.nameFor(relPath); + if (!flatName) { + flatName = resolveUniqueName(flattenPath(relPath), relPath, usedNames); + usedNames.set(flatName, relPath); + } + const outPath = join(OUTPUT_DIR, flatName); writeFileSync(outPath, content, "utf-8"); + writtenFiles.add(flatName); + sidecar.record(entry, flatName); totalFiles++; } + sidecar.write(writtenFiles); + console.error(`\nDone!`); console.error(` Files exported: ${totalFiles}`); console.error(` Output: ${OUTPUT_DIR}`); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-blazor-docs.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-blazor-docs.ts index e361e54e9..02a1be476 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-blazor-docs.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-blazor-docs.ts @@ -1,6 +1,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "fs"; -import { join, resolve, relative, basename } from "path"; +import { join, resolve, relative } from "path"; import { execSync } from "child_process"; +import { walkTocJson, type TocEntry, type TocNode } from "./lib/toc-index.js"; +import { TocSidecar, resolveUniqueName } from "./lib/toc-sidecar.js"; const ROOT = resolve(import.meta.dirname, ".."); const XPLAT_ROOT = join(ROOT, "common", "igniteui-xplat-docs"); @@ -19,38 +21,14 @@ function stripImages(content: string): string { return content.replace(/!\[[^\]]*\]\([^)]*\)/g, ""); } -interface TocEntry { - name: string; - href: string; - premium: boolean; -} - function parseTocJson(tocPath: string): TocEntry[] { const raw = readFileSync(tocPath, "utf-8"); - const data = JSON.parse(raw) as any[]; - const entries: TocEntry[] = []; - - function flatten(items: any[], parentExcluded: boolean) { - for (const item of items) { - const excludes: string[] = item.exclude || []; - const excluded = parentExcluded || excludes.includes("Blazor"); - - if (!excluded && item.href && !item.header) { - entries.push({ - name: item.name || "", - href: item.href, - premium: item.premium === true, - }); - } - - if (Array.isArray(item.items)) { - flatten(item.items, excluded); - } - } - } + const data = JSON.parse(raw) as TocNode[]; - flatten(data, false); - return entries; + // Header nodes are walked so `section` is tracked correctly, but their own + // landing pages have never been exported for the xplat platforms — keeping + // that filter here leaves the exported set unchanged. + return walkTocJson(data, { excludePlatform: "Blazor" }).filter((e) => !e.landing); } function injectTocMetadata(content: string, entry: TocEntry): string { @@ -138,6 +116,8 @@ function main() { let totalFiles = 0; let skipped = 0; const usedNames = new Map(); + const writtenFiles = new Set(); + const sidecar = new TocSidecar("blazor", ROOT); for (const entry of tocEntries) { const href = entry.href.replace(/\\/g, "/"); @@ -157,24 +137,24 @@ function main() { content = injectTocMetadata(content, entry); - let flatName = flattenPath(href); - - if (usedNames.has(flatName)) { - const parts = href.replace(/\\/g, "/").split("/"); - if (parts.length >= 2) { - flatName = `${parts[parts.length - 2]}-${basename(href)}`; - } - if (usedNames.has(flatName)) { - flatName = href.replace(/\//g, "-"); - } + // A cross-listed page appears under two TOC paths. Reuse the name resolved + // the first time so it stays one doc, but still write: injectTocMetadata has + // already run for this entry and last-write-wins is observable in the DB. + let flatName = sidecar.nameFor(href); + if (!flatName) { + flatName = resolveUniqueName(flattenPath(href), href, usedNames); + usedNames.set(flatName, href); } - usedNames.set(flatName, href); const outPath = join(OUTPUT_DIR, flatName); writeFileSync(outPath, content, "utf-8"); + writtenFiles.add(flatName); + sidecar.record(entry, flatName); totalFiles++; } + sidecar.write(writtenFiles); + console.error(`\nDone!`); console.error(` Files exported: ${totalFiles}`); console.error(` Skipped (not found): ${skipped}`); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-react-docs.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-react-docs.ts index e8aa273e3..6a91cf80c 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-react-docs.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-react-docs.ts @@ -1,6 +1,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "fs"; -import { join, resolve, relative, basename } from "path"; +import { join, resolve, relative } from "path"; import { execSync } from "child_process"; +import { walkTocJson, type TocEntry, type TocNode } from "./lib/toc-index.js"; +import { TocSidecar, resolveUniqueName } from "./lib/toc-sidecar.js"; const ROOT = resolve(import.meta.dirname, ".."); const XPLAT_ROOT = join(ROOT, "common", "igniteui-xplat-docs"); @@ -19,38 +21,14 @@ function stripImages(content: string): string { return content.replace(/!\[[^\]]*\]\([^)]*\)/g, ""); } -interface TocEntry { - name: string; - href: string; - premium: boolean; -} - function parseTocJson(tocPath: string): TocEntry[] { const raw = readFileSync(tocPath, "utf-8"); - const data = JSON.parse(raw) as any[]; - const entries: TocEntry[] = []; - - function flatten(items: any[], parentExcluded: boolean) { - for (const item of items) { - const excludes: string[] = item.exclude || []; - const excluded = parentExcluded || excludes.includes("React"); - - if (!excluded && item.href && !item.header) { - entries.push({ - name: item.name || "", - href: item.href, - premium: item.premium === true, - }); - } - - if (Array.isArray(item.items)) { - flatten(item.items, excluded); - } - } - } + const data = JSON.parse(raw) as TocNode[]; - flatten(data, false); - return entries; + // Header nodes are walked so `section` is tracked correctly, but their own + // landing pages have never been exported for the xplat platforms — keeping + // that filter here leaves the exported set unchanged. + return walkTocJson(data, { excludePlatform: "React" }).filter((e) => !e.landing); } function injectTocMetadata(content: string, entry: TocEntry): string { @@ -147,7 +125,9 @@ function main() { let totalFiles = 0; let skipped = 0; - const usedNames = new Map(); // flat name → original href (for collision detection) + const usedNames = new Map(); + const writtenFiles = new Set(); + const sidecar = new TocSidecar("react", ROOT); for (const entry of tocEntries) { const href = entry.href.replace(/\\/g, "/"); @@ -168,25 +148,24 @@ function main() { content = injectTocMetadata(content, entry); - let flatName = flattenPath(href); - - // Handle naming collisions - if (usedNames.has(flatName)) { - const parts = href.replace(/\\/g, "/").split("/"); - if (parts.length >= 2) { - flatName = `${parts[parts.length - 2]}-${basename(href)}`; - } - if (usedNames.has(flatName)) { - flatName = href.replace(/\//g, "-"); - } + // A cross-listed page appears under two TOC paths. Reuse the name resolved + // the first time so it stays one doc, but still write: injectTocMetadata has + // already run for this entry and last-write-wins is observable in the DB. + let flatName = sidecar.nameFor(href); + if (!flatName) { + flatName = resolveUniqueName(flattenPath(href), href, usedNames); + usedNames.set(flatName, href); } - usedNames.set(flatName, href); const outPath = join(OUTPUT_DIR, flatName); writeFileSync(outPath, content, "utf-8"); + writtenFiles.add(flatName); + sidecar.record(entry, flatName); totalFiles++; } + sidecar.write(writtenFiles); + console.error(`\nDone!`); console.error(` Files exported: ${totalFiles}`); console.error(` Skipped (not found): ${skipped}`); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-wc-docs.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-wc-docs.ts index 89ca3ccfb..f8104d042 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-wc-docs.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/export-wc-docs.ts @@ -1,6 +1,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "fs"; -import { join, resolve, relative, basename } from "path"; +import { join, resolve, relative } from "path"; import { execSync } from "child_process"; +import { walkTocJson, type TocEntry, type TocNode } from "./lib/toc-index.js"; +import { TocSidecar, resolveUniqueName } from "./lib/toc-sidecar.js"; const ROOT = resolve(import.meta.dirname, ".."); const XPLAT_ROOT = join(ROOT, "common", "igniteui-xplat-docs"); @@ -19,38 +21,14 @@ function stripImages(content: string): string { return content.replace(/!\[[^\]]*\]\([^)]*\)/g, ""); } -interface TocEntry { - name: string; - href: string; - premium: boolean; -} - function parseTocJson(tocPath: string): TocEntry[] { const raw = readFileSync(tocPath, "utf-8"); - const data = JSON.parse(raw) as any[]; - const entries: TocEntry[] = []; - - function flatten(items: any[], parentExcluded: boolean) { - for (const item of items) { - const excludes: string[] = item.exclude || []; - const excluded = parentExcluded || excludes.includes("WebComponents"); - - if (!excluded && item.href && !item.header) { - entries.push({ - name: item.name || "", - href: item.href, - premium: item.premium === true, - }); - } - - if (Array.isArray(item.items)) { - flatten(item.items, excluded); - } - } - } + const data = JSON.parse(raw) as TocNode[]; - flatten(data, false); - return entries; + // Header nodes are walked so `section` is tracked correctly, but their own + // landing pages have never been exported for the xplat platforms — keeping + // that filter here leaves the exported set unchanged. + return walkTocJson(data, { excludePlatform: "WebComponents" }).filter((e) => !e.landing); } function injectTocMetadata(content: string, entry: TocEntry): string { @@ -138,6 +116,8 @@ function main() { let totalFiles = 0; let skipped = 0; const usedNames = new Map(); + const writtenFiles = new Set(); + const sidecar = new TocSidecar("webcomponents", ROOT); for (const entry of tocEntries) { const href = entry.href.replace(/\\/g, "/"); @@ -157,24 +137,24 @@ function main() { content = injectTocMetadata(content, entry); - let flatName = flattenPath(href); - - if (usedNames.has(flatName)) { - const parts = href.replace(/\\/g, "/").split("/"); - if (parts.length >= 2) { - flatName = `${parts[parts.length - 2]}-${basename(href)}`; - } - if (usedNames.has(flatName)) { - flatName = href.replace(/\//g, "-"); - } + // A cross-listed page appears under two TOC paths. Reuse the name resolved + // the first time so it stays one doc, but still write: injectTocMetadata has + // already run for this entry and last-write-wins is observable in the DB. + let flatName = sidecar.nameFor(href); + if (!flatName) { + flatName = resolveUniqueName(flattenPath(href), href, usedNames); + usedNames.set(flatName, href); } - usedNames.set(flatName, href); const outPath = join(OUTPUT_DIR, flatName); writeFileSync(outPath, content, "utf-8"); + writtenFiles.add(flatName); + sidecar.record(entry, flatName); totalFiles++; } + sidecar.write(writtenFiles); + console.error(`\nDone!`); console.error(` Files exported: ${totalFiles}`); console.error(` Skipped (not found): ${skipped}`); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/frontmatter.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/frontmatter.ts new file mode 100644 index 000000000..ed2b37898 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/frontmatter.ts @@ -0,0 +1,33 @@ +export interface DocFrontmatter { + component: string; + keywords: string; + summary: string; + premium: boolean; + content: string; +} + +/** Parse the frontmatter a compression run writes at the top of a `docs_final` doc. */ +export function parseFrontmatter(raw: string): DocFrontmatter { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return { component: "", keywords: "", summary: "", premium: false, content: raw }; + + const block = match[1]; + let component = ""; + let keywords = ""; + let summary = ""; + let premium = false; + + for (const line of block.split("\n")) { + const m1 = line.match(/^component:\s*(.+)/); + if (m1) component = m1[1].trim(); + const m2 = line.match(/^keywords:\s*(.+)/); + if (m2) keywords = m2[1].trim(); + const m3 = line.match(/^summary:\s*(.+)/); + if (m3) summary = m3[1].trim(); + const m4 = line.match(/^premium:\s*(.+)/); + if (m4) premium = m4[1].trim() === "true"; + } + + const content = raw.slice(match[0].length).replace(/^\r?\n/, ""); + return { component, keywords, summary, premium, content }; +} diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/list-fixtures.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/list-fixtures.ts new file mode 100644 index 000000000..80db839f9 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/list-fixtures.ts @@ -0,0 +1,69 @@ +import { readFileSync, readdirSync } from "fs"; +import { join, resolve } from "path"; + +/** + * Fixtures shared by the TypeScript renderer tests and the .NET backend tests. + * Both load these rows into an in-memory SQLite database and compare the rendered + * text to `expected.txt` with ordinal equality — that is what keeps the two + * renderers from drifting. + */ +export interface ListFixture { + name: string; + framework: string; + filter?: string; + detail?: "groups" | "docs"; + group?: string; + docs: { + framework: string; + filename: string; + component: string; + toc_name: string | null; + premium?: number; + keywords?: string; + summary?: string; + }[]; + docToc: { + framework: string; + filename: string; + group_key: string; + section: string; + group_label: string; + path: string; + ord: number; + landing?: number; + }[]; + docGroups: { + framework: string; + group_key: string; + section: string; + group_label: string; + summary: string | null; + doc_count: number; + ord: number; + }[]; +} + +export const FIXTURES_DIR = resolve( + import.meta.dirname, + "..", + "..", + "..", + "shared-fixtures", + "list-components" +); + +export function fixtureNames(): string[] { + return readdirSync(FIXTURES_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); +} + +export function loadFixture(name: string): ListFixture { + const input = JSON.parse(readFileSync(join(FIXTURES_DIR, name, "input.json"), "utf-8")); + return { name, ...input }; +} + +export function expectedPath(name: string): string { + return join(FIXTURES_DIR, name, "expected.txt"); +} diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/toc-index.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/toc-index.ts new file mode 100644 index 000000000..1b36dfe7b --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/toc-index.ts @@ -0,0 +1,147 @@ +/** + * Shared TOC walker for the documentation exporters. + * + * Both TOC sources (angular's `toc.yml` and the xplat `toc.json`) are arrays of + * nodes with the same shape: `name`, optional `href`, optional `items`, and + * `header: true` for the editorial section dividers. The exporters previously + * flattened these to `{ name, href, premium }` and discarded the tree; this + * walker keeps the structure so docs can be grouped by it. + * + * Derivation rules (normative, see TOC-GROUPING-PLAN.md §4.1): + * + * 1. `section` is the name of the most recent `header: true` node. Headers are + * siblings of the entries they introduce, never ancestors, so a header sets + * `section` and does not push onto `ancestors`. An excluded header still + * updates `section` so later siblings never inherit a stale one. + * 2. `ancestors` is the chain of names from the first node *below* the header + * down to and including the entry itself. A header's own href yields `[]`. + * `path` is that chain prefixed with the section. + * 3. `groupLabel` is decided by the top-level node below the header: if that + * node has children its name labels the whole subtree however deep, + * otherwise the entry sits at section level. Depth alone is not enough — + * `Data Grid` carries both an href and children, so `grid/grid.md` has a + * one-element ancestor chain yet must group with its 44 children. + * 4. `groupKey` is the section, or `"
> "`. + */ + +export interface TocNode { + name?: string; + href?: string; + header?: boolean; + premium?: boolean; + exclude?: string[]; + items?: TocNode[]; +} + +export interface TocEntry { + name: string; + href: string; + section: string; + ancestors: string[]; + groupKey: string; + groupLabel: string; + path: string; + ord: number; + premium: boolean; + landing: boolean; +} + +export interface WalkOptions { + /** + * Platform token filtered on by the xplat `toc.json` (`"Angular"`, `"React"`, + * `"Blazor"`, `"WebComponents"`). Omitted for `toc.yml`, which has no + * `exclude` entries. + */ + excludePlatform?: string; +} + +export function buildGroupKey(section: string, groupLabel: string): string { + return groupLabel ? `${section} > ${groupLabel}` : section; +} + +function buildPath(section: string, ancestors: string[]): string { + return [section, ...ancestors].filter((p) => p !== "").join(" > "); +} + +function walk(nodes: TocNode[], opts: WalkOptions): TocEntry[] { + const entries: TocEntry[] = []; + const platform = opts.excludePlatform; + let section = ""; + let ord = 0; + + const emit = ( + node: TocNode, + ancestors: string[], + groupLabel: string, + landing: boolean + ) => { + entries.push({ + name: node.name || "", + href: node.href!, + section, + ancestors, + groupKey: buildGroupKey(section, groupLabel), + groupLabel, + path: buildPath(section, ancestors), + ord: ord++, + premium: node.premium === true, + landing, + }); + }; + + const visit = ( + items: TocNode[], + ancestors: string[], + groupLabel: string | undefined, + parentExcluded: boolean + ) => { + for (const node of items) { + const excluded = + parentExcluded || + (platform !== undefined && Array.isArray(node.exclude) && node.exclude.includes(platform)); + + if (node.header === true) { + // Rule 1 — update the section even when excluded, then reset the + // ancestor chain: a header is a divider, not a parent. + section = node.name || ""; + if (!excluded && node.href) { + emit(node, [], "", true); + } + if (Array.isArray(node.items)) { + visit(node.items, [], undefined, excluded); + } + continue; + } + + const nodeAncestors = [...ancestors, node.name || ""]; + // Rule 3 — the label is fixed by the top-level node below the header and + // then inherited unchanged; `undefined` marks "not yet decided". + const label = + groupLabel !== undefined + ? groupLabel + : Array.isArray(node.items) && node.items.length > 0 + ? node.name || "" + : ""; + + if (!excluded && node.href) { + emit(node, nodeAncestors, label, false); + } + if (Array.isArray(node.items)) { + visit(node.items, nodeAncestors, label, excluded); + } + } + }; + + visit(nodes, [], undefined, false); + return entries; +} + +/** Walk a parsed `toc.yml` (angular). */ +export function walkTocYaml(nodes: TocNode[], opts: WalkOptions = {}): TocEntry[] { + return walk(nodes, opts); +} + +/** Walk a parsed `toc.json` (xplat), honouring per-platform `exclude`. */ +export function walkTocJson(nodes: TocNode[], opts: WalkOptions = {}): TocEntry[] { + return walk(nodes, opts); +} diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/toc-sidecar.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/toc-sidecar.ts new file mode 100644 index 000000000..3974d8f0a --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/lib/toc-sidecar.ts @@ -0,0 +1,149 @@ +import { mkdirSync, renameSync, rmSync, writeFileSync } from "fs"; +import { basename, dirname, join, resolve } from "path"; +import type { TocEntry } from "./toc-index.js"; + +/** + * One membership of a doc in the documentation TOC. A record always corresponds + * to a file the exporter actually wrote — there are no metadata-only records. + * A cross-listed page (reachable from two TOC paths) produces two records that + * share `file` and differ in `path`. + */ +export interface TocSidecarRecord { + file: string; + section: string; + groupKey: string; + groupLabel: string; + path: string; + ord: number; + landing: boolean; + /** + * Display-only, written by the exporter. A sidecar round-tripped from the + * committed DB (`restore-docs-final.ts --toc-stubs`, for a framework a CI run + * did not rebuild) cannot supply these, so ingestion must not require them. + */ + href?: string; + name?: string; + premium?: boolean; +} + +/** + * Accumulates sidecar records in memory and replaces `dist/toc-index/.json` + * atomically at the end of an export run. + * + * The file must be replaced rather than merged: `clear:build` deliberately does + * not reach `dist/toc-index/`, so a merged sidecar would keep resurrecting + * memberships for TOC entries that have since been deleted, renamed, or newly + * excluded for a platform — docs would vanish from the docs site but keep + * appearing under their old group in `list_components`. + */ +export class TocSidecar { + private records: TocSidecarRecord[] = []; + private files = new Set(); + private hrefNames = new Map(); + private outPath: string; + + constructor(framework: string, root: string) { + this.outPath = join(resolve(root), "dist", "toc-index", `${framework}.json`); + } + + /** Flat name already resolved for this href, if it has been seen before. */ + nameFor(href: string): string | undefined { + return this.hrefNames.get(href); + } + + record(entry: TocEntry, file: string): void { + this.hrefNames.set(entry.href, file); + this.files.add(file); + this.records.push({ + file, + href: entry.href, + name: entry.name, + section: entry.section, + groupKey: entry.groupKey, + groupLabel: entry.groupLabel, + path: entry.path, + ord: entry.ord, + premium: entry.premium, + landing: entry.landing, + }); + } + + get recordCount(): number { + return this.records.length; + } + + get fileCount(): number { + return this.files.size; + } + + /** + * @param writtenFiles distinct output filenames the export loop produced. + * Asserted equal to the sidecar's distinct files so a record can never + * describe a doc that is not in `docs_processing`, or vice versa. + */ + write(writtenFiles: Set): void { + if (writtenFiles.size !== this.files.size) { + throw new Error( + `TOC sidecar mismatch: ${this.files.size} distinct recorded file(s) but ` + + `${writtenFiles.size} distinct file(s) written.` + ); + } + for (const f of writtenFiles) { + if (!this.files.has(f)) { + throw new Error(`TOC sidecar mismatch: ${f} was written but not recorded.`); + } + } + + mkdirSync(dirname(this.outPath), { recursive: true }); + const tmp = `${this.outPath}.tmp`; + try { + writeFileSync(tmp, `${JSON.stringify(this.records, null, 2)}\n`, "utf-8"); + renameSync(tmp, this.outPath); + } catch (err) { + rmSync(tmp, { force: true }); + throw err; + } + console.error(` TOC sidecar: ${this.records.length} record(s) -> ${this.outPath}`); + } +} + +/** + * Pick a flat filename that is not already taken. + * + * The previous implementation tried two fixed candidates and used the second + * unconditionally, silently overwriting another doc when it was also taken. + * Candidates are tried in the old order — flattened, `parent-file`, then the + * full href — and only then fall back to a numeric suffix, so names produced + * today do not change. + */ +export function resolveUniqueName( + flatName: string, + href: string, + usedNames: Map +): string { + const normalized = href.replace(/\\/g, "/"); + const parts = normalized.split("/"); + + const candidates = [flatName]; + if (parts.length >= 2) { + candidates.push(`${parts[parts.length - 2]}-${basename(normalized)}`); + } + candidates.push(normalized.replace(/\//g, "-")); + + for (const candidate of candidates) { + if (!usedNames.has(candidate)) return candidate; + } + + const ext = flatName.endsWith(".md") ? ".md" : ""; + const stem = ext ? flatName.slice(0, -ext.length) : flatName; + for (let n = 2; ; n++) { + const candidate = `${stem}-${n}${ext}`; + if (!usedNames.has(candidate)) { + console.error( + `[WARN] Filename collision for "${href}" — falling back to "${candidate}". ` + + `A TOC restructure may have changed doc identities.` + ); + return candidate; + } + } +} diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/report-toc-coverage.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/report-toc-coverage.ts new file mode 100644 index 000000000..28538db4e --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/report-toc-coverage.ts @@ -0,0 +1,105 @@ +import Database from "better-sqlite3"; +import * as fs from "fs"; +import * as path from "path"; + +const FRAMEWORKS = ["angular", "react", "blazor", "webcomponents"]; +const DEFAULT_DB = path.resolve("db", "igniteui-docs.db"); + +interface Row { + framework: string; + docs: number; + covered: number; + memberships: number; + sections: number; + groups: number; + crossListed: number; + noSummary: number; +} + +function report(dbPath: string, frameworks: string[]): Row[] { + const db = new Database(dbPath, { readonly: true }); + try { + const grouped = + db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('doc_toc','doc_groups')`) + .all().length === 2; + if (!grouped) { + console.error(`${dbPath} has no doc_toc/doc_groups tables — nothing to report.`); + process.exit(1); + } + + return frameworks.map((framework) => { + const one = (sql: string) => Number((db.prepare(sql).get(framework) as any)?.n ?? 0); + return { + framework, + docs: one(`SELECT COUNT(*) n FROM docs WHERE framework = ?`), + covered: one(` + SELECT COUNT(*) n FROM docs d WHERE d.framework = ? + AND EXISTS (SELECT 1 FROM doc_toc t WHERE t.framework = d.framework AND t.filename = d.filename)`), + memberships: one(`SELECT COUNT(*) n FROM doc_toc WHERE framework = ?`), + sections: one(`SELECT COUNT(DISTINCT section) n FROM doc_toc WHERE framework = ?`), + groups: one(`SELECT COUNT(*) n FROM doc_groups WHERE framework = ?`), + crossListed: one(` + SELECT COUNT(*) n FROM ( + SELECT filename FROM doc_toc WHERE framework = ? + GROUP BY filename HAVING COUNT(DISTINCT group_key) > 1)`), + noSummary: one(` + SELECT COUNT(*) n FROM doc_groups WHERE framework = ? AND (summary IS NULL OR summary = '')`), + }; + }); + } finally { + db.close(); + } +} + +function main() { + const args = process.argv.slice(2); + const arg = (name: string) => { + const i = args.indexOf(name); + return i !== -1 ? args[i + 1] : undefined; + }; + + const dbPath = path.resolve(arg("--db") ?? DEFAULT_DB); + if (!fs.existsSync(dbPath)) { + console.error(`Database not found: ${dbPath}`); + process.exit(1); + } + + const target = arg("--framework"); + if (target && !FRAMEWORKS.includes(target)) { + console.error(`Unknown framework: ${target}. Valid: ${FRAMEWORKS.join(", ")}`); + process.exit(1); + } + + const rows = report(dbPath, target ? [target] : FRAMEWORKS); + const cols: [string, (r: Row) => string | number][] = [ + ["framework", (r) => r.framework], + ["docs", (r) => r.docs], + ["uncovered", (r) => r.docs - r.covered], + ["memberships", (r) => r.memberships], + ["sections", (r) => r.sections], + ["groups", (r) => r.groups], + ["cross-listed", (r) => r.crossListed], + ["no summary", (r) => r.noSummary], + ]; + + const widths = cols.map(([head, get]) => + Math.max(head.length, ...rows.map((r) => String(get(r)).length)) + ); + const row = (cells: (string | number)[]) => + cells.map((c, i) => (i === 0 ? String(c).padEnd(widths[i]) : String(c).padStart(widths[i]))).join(" "); + + console.log(row(cols.map(([h]) => h))); + for (const r of rows) console.log(row(cols.map(([, get]) => get(r)))); + + const uncovered = rows.filter((r) => r.docs !== r.covered); + if (uncovered.length > 0) { + console.error( + `\n${uncovered.length} framework(s) have docs with no TOC group: ` + + uncovered.map((r) => `${r.framework} (${r.docs - r.covered})`).join(", ") + ); + process.exit(1); + } + console.log(`\nCoverage is complete for ${rows.length} framework(s).`); +} + +main(); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/restore-docs-final.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/restore-docs-final.ts index a43ea81af..676a27a6b 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/restore-docs-final.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/restore-docs-final.ts @@ -23,6 +23,7 @@ import * as path from "path"; const FRAMEWORKS = ["angular", "react", "blazor", "webcomponents"]; const DOCS_FINAL_DIR = path.resolve("dist", "docs_final"); const DOCS_PREPARED_DIR = path.resolve("dist", "docs_prepeared"); +const TOC_INDEX_DIR = path.resolve("dist", "toc-index"); const DEFAULT_DB = path.resolve("db", "igniteui-docs.db"); interface DocRow { @@ -52,6 +53,52 @@ function buildDoc(row: DocRow): string { return `${lines.join("\n")}\n${row.content}`; } +/** + * Rebuild dist/toc-index/.json from the committed DB's own doc_toc rows. + * + * Re-deriving it from a TOC would mean checking out a submodule this run never + * touched; round-tripping keeps a non-rebuilt framework's groups exactly as they + * were published. Only the columns build-db ingests are available here — the + * exporter's display-only fields are absent by design. + */ +function restoreSidecar(db: Database.Database, framework: string): number | null { + const hasDocToc = + db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'doc_toc'").get() !== undefined; + if (!hasDocToc) return null; + + const rows = db + .prepare( + "SELECT filename, group_key, section, group_label, path, ord, landing FROM doc_toc WHERE framework = ? ORDER BY ord" + ) + .all(framework) as { + filename: string; + group_key: string; + section: string; + group_label: string; + path: string; + ord: number; + landing: number; + }[]; + if (rows.length === 0) return 0; + + const records = rows.map((r) => ({ + file: r.filename, + section: r.section, + groupKey: r.group_key, + groupLabel: r.group_label, + path: r.path, + ord: r.ord, + landing: r.landing === 1 + })); + + fs.mkdirSync(TOC_INDEX_DIR, { recursive: true }); + const out = path.join(TOC_INDEX_DIR, `${framework}.json`); + const tmp = `${out}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(records, null, 2)}\n`, "utf-8"); + fs.renameSync(tmp, out); + return records.length; +} + function main(): void { const args = process.argv.slice(2); @@ -104,6 +151,17 @@ function main(): void { grandTotal += rows.length; console.log(` ${fw}: ${rows.length} docs restored to dist/docs_final/${fw}/${tocStubs ? " (+ toc stubs)" : ""}`); + + if (tocStubs) { + const memberships = restoreSidecar(db, fw); + if (memberships === null) { + console.warn(` [warn] ${fw}: ${path.basename(dbPath)} has no doc_toc table — no sidecar restored`); + } else if (memberships === 0) { + console.warn(` [warn] ${fw}: no doc_toc rows in ${path.basename(dbPath)} — no sidecar restored`); + } else { + console.log(` ${fw}: ${memberships} TOC membership(s) restored to dist/toc-index/${fw}.json`); + } + } } db.close(); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/validate-package.ts b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/validate-package.ts index 68bb7ed7a..37d0a54ee 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/scripts/validate-package.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/scripts/validate-package.ts @@ -1,3 +1,4 @@ +import Database from "better-sqlite3"; import { readdirSync, readFileSync, statSync, existsSync } from "fs"; import { join, resolve } from "path"; import { fileURLToPath } from "url"; @@ -6,6 +7,7 @@ const PKG_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); const DB_PATH = join(PKG_ROOT, "dist", "igniteui-docs.db"); const DB_MIN_BYTES = 20 * 1024 * 1024; // 20 MB minimum for the SQLite DB const DOCS_ROOT = join(PKG_ROOT, "docs"); +const FRAMEWORKS = ["angular", "react", "blazor", "webcomponents"]; const FRAMEWORK_DIRS = ["angular-api", "react-api", "webcomponents-api", "blazor-api"]; const FRAMEWORK_MIN_BYTES = 300 * 1024; // 300 KB minimum for each docs/-api directory @@ -86,6 +88,71 @@ if (!existsSync(DB_PATH)) { } else { console.log(`OK db ${formatSize(size)} ${DB_PATH}`); } + validateGrouping(DB_PATH); +} + +/** + * The shipped DB must be fully grouped. A NULL group summary is a valid + * development state — Phase 2 and the renderer both tolerate it — but shipping + * one means the index arrives as bare headings and name lists, and nothing else + * in the pipeline would complain. + */ +function validateGrouping(dbPath: string): void { + const db = new Database(dbPath, { readonly: true }); + try { + const tables = db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('doc_toc', 'doc_groups')`) + .all() as { name: string }[]; + if (tables.length !== 2) { + errors.push(`DB is not grouped: doc_toc/doc_groups missing — run build:db`); + return; + } + + for (const framework of FRAMEWORKS) { + const { cnt } = db + .prepare(`SELECT COUNT(*) AS cnt FROM doc_toc WHERE framework = ?`) + .get(framework) as { cnt: number }; + if (cnt === 0) errors.push(`DB has no TOC groups for ${framework}`); + } + + const uncovered = db.prepare(` + SELECT framework, filename FROM docs d + WHERE NOT EXISTS ( + SELECT 1 FROM doc_toc t WHERE t.framework = d.framework AND t.filename = d.filename + ) LIMIT 5 + `).all() as { framework: string; filename: string }[]; + if (uncovered.length > 0) { + errors.push( + `DB has docs with no TOC group, e.g. ` + + uncovered.map((r) => `${r.framework}/${r.filename}`).join(", ") + ); + } + + const nullToc = db + .prepare(`SELECT framework, COUNT(*) AS cnt FROM docs WHERE toc_name IS NULL GROUP BY framework`) + .all() as { framework: string; cnt: number }[]; + if (nullToc.length > 0) { + errors.push(`DB has NULL toc_name rows: ${nullToc.map((r) => `${r.framework}=${r.cnt}`).join(", ")}`); + } + + const noSummary = db.prepare(` + SELECT framework, group_key FROM doc_groups + WHERE summary IS NULL OR summary = '' LIMIT 5 + `).all() as { framework: string; group_key: string }[]; + if (noSummary.length > 0) { + errors.push( + `DB has groups with no summary — run build:group-summaries: ` + + noSummary.map((r) => `${r.framework}/${r.group_key}`).join(", ") + ); + } + + if (errors.length === 0) { + const { groups } = db.prepare(`SELECT COUNT(*) AS groups FROM doc_groups`).get() as { groups: number }; + console.log(`OK toc ${String(groups).padStart(10)} groups across ${FRAMEWORKS.length} frameworks`); + } + } finally { + db.close(); + } } for (const framework of FRAMEWORK_DIRS) { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/providers/LocalDocsProvider.list.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/providers/LocalDocsProvider.list.test.ts new file mode 100644 index 000000000..e08f3c97a --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/providers/LocalDocsProvider.list.test.ts @@ -0,0 +1,222 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { createRequire } from "module"; +import { tmpdir } from "os"; +import { join } from "path"; +import initSqlJs from "sql.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { LocalDocsProvider } from "../../providers/LocalDocsProvider.js"; + +const require = createRequire(import.meta.url); + +interface Doc { + framework: string; + filename: string; + component: string; + toc_name: string; + premium?: number; + keywords?: string; + summary?: string; +} + +interface Membership { + framework: string; + filename: string; + group_key: string; + section: string; + group_label: string; + path: string; + ord: number; +} + +const DOCS: Doc[] = [ + { framework: "angular", filename: "grid-grid.md", component: "IgxGridComponent", toc_name: "Data Grid", summary: "The grid." }, + { framework: "angular", filename: "grid-sorting.md", component: "IgxGridComponent", toc_name: "Sorting", summary: "Sort rows.", premium: 1 }, + { framework: "angular", filename: "accordion.md", component: "IgxAccordionComponent", toc_name: "Accordion", summary: "Panels." }, + { framework: "angular", filename: "excel-utility.md", component: "IgxExcelUtility", toc_name: "Excel Utility", summary: "Excel helpers." }, + { framework: "react", filename: "grid-editing.md", component: "IgrGrid", toc_name: "Editing", summary: "Edit cells." }, +]; + +const MEMBERSHIPS: Membership[] = [ + { framework: "angular", filename: "grid-grid.md", group_key: "Grids & Lists > Data Grid", section: "Grids & Lists", group_label: "Data Grid", path: "Grids & Lists > Data Grid", ord: 0 }, + { framework: "angular", filename: "grid-sorting.md", group_key: "Grids & Lists > Data Grid", section: "Grids & Lists", group_label: "Data Grid", path: "Grids & Lists > Data Grid > Sorting", ord: 1 }, + { framework: "angular", filename: "excel-utility.md", group_key: "Grids & Lists > Data Grid", section: "Grids & Lists", group_label: "Data Grid", path: "Grids & Lists > Data Grid > Excel", ord: 2 }, + { framework: "angular", filename: "accordion.md", group_key: "Layouts", section: "Layouts", group_label: "", path: "Layouts > Accordion", ord: 3 }, + { framework: "angular", filename: "excel-utility.md", group_key: "Frameworks > Excel Library", section: "Frameworks", group_label: "Excel Library", path: "Frameworks > Excel Library > Excel Utility", ord: 4 }, +]; + +let SQL: Awaited>; +let dir: string; + +/** Build a fixture DB on disk; `frameworksWithToc` selects which get grouping rows. */ +function makeDb(name: string, opts: { toc: boolean; frameworksWithToc?: string[] }): string { + const db = new SQL.Database(); + db.run(`CREATE TABLE docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, framework TEXT NOT NULL, filename TEXT NOT NULL, + component TEXT NOT NULL, toc_name TEXT, premium INTEGER DEFAULT 0, keywords TEXT, + summary TEXT, content TEXT NOT NULL, UNIQUE(framework, filename))`); + for (const d of DOCS) { + db.run( + `INSERT INTO docs (framework, filename, component, toc_name, premium, keywords, summary, content) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [d.framework, d.filename, d.component, d.toc_name, d.premium ?? 0, d.keywords ?? "", d.summary ?? "", "body"] + ); + } + + if (opts.toc) { + const keep = opts.frameworksWithToc ?? ["angular", "react"]; + db.run(`CREATE TABLE doc_toc (framework TEXT NOT NULL, filename TEXT NOT NULL, + group_key TEXT NOT NULL, section TEXT NOT NULL, group_label TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL, ord INTEGER NOT NULL, landing INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (framework, filename, path))`); + db.run(`CREATE TABLE doc_groups (framework TEXT NOT NULL, group_key TEXT NOT NULL, + section TEXT NOT NULL, group_label TEXT NOT NULL DEFAULT '', summary TEXT, + doc_count INTEGER NOT NULL, ord INTEGER NOT NULL, PRIMARY KEY (framework, group_key))`); + + for (const m of MEMBERSHIPS.filter((m) => keep.includes(m.framework))) { + db.run( + `INSERT INTO doc_toc (framework, filename, group_key, section, group_label, path, ord, landing) + VALUES (?, ?, ?, ?, ?, ?, ?, 0)`, + [m.framework, m.filename, m.group_key, m.section, m.group_label, m.path, m.ord] + ); + } + db.run(`INSERT INTO doc_groups (framework, group_key, section, group_label, summary, doc_count, ord) + SELECT framework, group_key, MIN(section), MIN(group_label), NULL, + COUNT(DISTINCT filename), MIN(ord) + FROM doc_toc GROUP BY framework, group_key`); + db.run(`UPDATE doc_groups SET summary = 'Sorting, filtering, editing.' + WHERE group_key = 'Grids & Lists > Data Grid'`); + } + + const file = join(dir, name); + writeFileSync(file, Buffer.from(db.export())); + db.close(); + return file; +} + +async function provider(path: string): Promise { + const p = new LocalDocsProvider(path); + await p.init(); + return p; +} + +/** The renderer as it stood before grouping, used to prove flat mode is untouched. */ +function legacyFlat(framework: string, filter?: string): string { + const like = filter?.toLowerCase(); + const rows = DOCS.filter( + (d) => + d.framework === framework && + (!like || + [d.filename, d.component, d.toc_name, d.keywords ?? "", d.summary ?? ""].some((v) => + v.toLowerCase().includes(like) + )) + ).sort((a, b) => (a.toc_name < b.toc_name ? -1 : a.toc_name > b.toc_name ? 1 : 0)); + + if (rows.length === 0) { + return `No components found for framework "${framework}"${filter ? ` matching "${filter}"` : ""}.`; + } + const lines = rows.map((r) => { + const name = r.filename.replace(/\.md$/, ""); + const parts = [`- **${r.toc_name || name}** (\`${name}\`)`]; + if (r.summary) parts.push(` ${r.summary}`); + if (r.premium) parts.push(` ⭐ Premium`); + return parts.join("\n"); + }); + return `Found ${rows.length} components for **${framework}**${filter ? ` matching "${filter}"` : ""}:\n\n${lines.join("\n")}`; +} + +beforeAll(async () => { + const wasm = readFileSync(require.resolve("sql.js/dist/sql-wasm.wasm")); + SQL = await initSqlJs({ + wasmBinary: wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength), + }); + dir = mkdtempSync(join(tmpdir(), "local-docs-")); +}); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe("LocalDocsProvider.listComponents — back-compat guard", () => { + it("renders flat against a legacy-schema DB", async () => { + const p = await provider(makeDb("legacy.db", { toc: false })); + expect(await p.listComponents("angular")).toBe(legacyFlat("angular")); + }); + + it("renders flat for a framework that has no doc_toc rows yet", async () => { + const p = await provider(makeDb("mixed.db", { toc: true, frameworksWithToc: ["angular"] })); + expect(await p.listComponents("react")).toBe(legacyFlat("react")); + expect(await p.listComponents("angular")).toContain("## Grids & Lists > Data Grid"); + }); +}); + +describe("LocalDocsProvider.listComponents — grouped modes", () => { + let p: LocalDocsProvider; + beforeAll(async () => { + p = await provider(makeDb("migrated.db", { toc: true })); + }); + + it("groups by default and counts a cross-listed doc once overall", async () => { + const out = await p.listComponents("angular"); + expect(out).toContain("Found 4 component doc(s) for **angular** in 3 group(s)"); + expect(out).toContain("## Grids & Lists > Data Grid (3)"); + expect(out).toContain("## Frameworks > Excel Library (1)"); + expect(out).toContain("Sorting, filtering, editing."); + }); + + it("drills into one group in TOC order", async () => { + const out = await p.listComponents("angular", { group: "Grids & Lists > Data Grid" }); + expect(out).toContain("Found 3 component doc(s) in **angular** > Grids & Lists > Data Grid"); + expect(out.indexOf("grid-grid")).toBeLessThan(out.indexOf("grid-sorting")); + }); + + it("answers an unknown group with the valid keys", async () => { + const out = await p.listComponents("angular", { group: "Nope" }); + expect(out).toContain('No group "Nope"'); + expect(out).toContain("- Layouts"); + }); + + it("lets a filter match a group name", async () => { + const out = await p.listComponents("angular", { filter: "Excel Library" }); + expect(out).toContain("## Frameworks > Excel Library (1)"); + expect(out).not.toContain("## Layouts"); + }); + + it("narrows within a group when filter and group are combined", async () => { + const out = await p.listComponents("angular", { + group: "Grids & Lists > Data Grid", + filter: "sort", + }); + expect(out).toContain("Found 1 component doc(s)"); + expect(out).toContain("grid-sorting"); + }); +}); + +describe("LocalDocsProvider.listComponents — flat mode fidelity", () => { + it("is byte-identical to the pre-grouping output on a migrated DB", async () => { + const p = await provider(makeDb("migrated2.db", { toc: true })); + expect(await p.listComponents("angular", { detail: "docs" })).toBe(legacyFlat("angular")); + expect(await p.listComponents("angular", { detail: "docs", filter: "grid" })).toBe( + legacyFlat("angular", "grid") + ); + }); + + it("emits a cross-listed doc once, and never matches a group name", async () => { + const p = await provider(makeDb("migrated3.db", { toc: true })); + const out = await p.listComponents("angular", { detail: "docs" }); + expect(out.match(/\(`excel-utility`\)/g)).toHaveLength(1); + expect(await p.listComponents("angular", { detail: "docs", filter: "Excel Library" })).toBe( + legacyFlat("angular", "Excel Library") + ); + }); + + it("restricts a flat listing to a group's members without joining", async () => { + const p = await provider(makeDb("migrated4.db", { toc: true })); + const out = await p.listComponents("angular", { + detail: "docs", + group: "Grids & Lists > Data Grid", + }); + expect(out).toContain("Found 3 components for **angular**"); + expect(out).not.toContain("accordion"); + // Flat order stays ORDER BY toc_name — Data Grid, Excel Utility, Sorting. + expect(out.indexOf("(`grid-grid`)")).toBeLessThan(out.indexOf("(`excel-utility`)")); + expect(out.indexOf("(`excel-utility`)")).toBeLessThan(out.indexOf("(`grid-sorting`)")); + }); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/providers/RemoteDocsProvider.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/providers/RemoteDocsProvider.test.ts index 977d6bd2c..15fbbe1ae 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/providers/RemoteDocsProvider.test.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/providers/RemoteDocsProvider.test.ts @@ -40,13 +40,28 @@ describe('RemoteDocsProvider', () => { vi.stubGlobal('fetch', fetch); const provider = new RemoteDocsProvider(BACKEND_URL); - await provider.listComponents('angular', 'grid'); + await provider.listComponents('angular', { filter: 'grid' }); const url = new URL(fetch.mock.calls[0][0]); expect(url.searchParams.get('filter')).toBe('grid'); }); - it('omits filter param when not provided', async () => { + it('forwards group and detail params', async () => { + const fetch = mockFetch(200, '- IgxGrid'); + vi.stubGlobal('fetch', fetch); + + const provider = new RemoteDocsProvider(BACKEND_URL); + await provider.listComponents('angular', { + group: 'Grids & Lists > Data Grid', + detail: 'docs', + }); + + const url = new URL(fetch.mock.calls[0][0]); + expect(url.searchParams.get('group')).toBe('Grids & Lists > Data Grid'); + expect(url.searchParams.get('detail')).toBe('docs'); + }); + + it('omits filter, group and detail params when not provided', async () => { const fetch = mockFetch(200, ''); vi.stubGlobal('fetch', fetch); @@ -55,6 +70,8 @@ describe('RemoteDocsProvider', () => { const url = new URL(fetch.mock.calls[0][0]); expect(url.searchParams.has('filter')).toBe(false); + expect(url.searchParams.has('group')).toBe(false); + expect(url.searchParams.has('detail')).toBe(false); }); it('throws when backend returns a non-ok status', async () => { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/scripts/build-db.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/scripts/build-db.test.ts new file mode 100644 index 000000000..5a344068c --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/scripts/build-db.test.ts @@ -0,0 +1,341 @@ +import { spawnSync } from "child_process"; +import { createHash } from "crypto"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; +import { afterEach, describe, expect, it } from "vitest"; + +/** + * Drives scripts/build-db.ts against a throwaway working directory. This is the + * destructive path — it drops tables, deletes rows and republishes a committed + * artifact — so the failure modes are exercised here rather than for the first + * time during a release. + */ +const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const REPO_ROOT = resolve(PKG_ROOT, "..", "..", ".."); +const TSX = join(REPO_ROOT, "node_modules", "tsx", "dist", "cli.mjs"); +const BUILD_DB = join(PKG_ROOT, "scripts", "build-db.ts"); +const FRAMEWORKS = ["angular", "react", "blazor", "webcomponents"]; + +const dirs: string[] = []; +afterEach(() => { + while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); +}); + +interface DocSpec { + file: string; + prepared?: boolean; + tocName?: string | null; + groups?: string[]; +} + +function docsFor(framework: string): DocSpec[] { + return [ + { file: `${framework}-a.md`, groups: ["Grids & Lists > Data Grid"] }, + { file: `${framework}-b.md`, groups: ["Layouts"] }, + ]; +} + +/** Lay out dist/docs_final, dist/docs_prepeared and dist/toc-index for a framework. */ +function seedFramework(root: string, framework: string, specs: DocSpec[]): void { + const finalDir = join(root, "dist", "docs_final", framework); + const prepDir = join(root, "dist", "docs_prepeared", framework); + // Replace, never merge — a leftover prepared doc from an earlier call would + // mask exactly the missing-input cases these tests exist to cover. + rmSync(finalDir, { recursive: true, force: true }); + rmSync(prepDir, { recursive: true, force: true }); + mkdirSync(finalDir, { recursive: true }); + mkdirSync(prepDir, { recursive: true }); + + const records: unknown[] = []; + let ord = 0; + + for (const spec of specs) { + writeFileSync( + join(finalDir, spec.file), + `---\ncomponent: IgxThing\nkeywords: k\nsummary: Summary for ${spec.file}\n---\n\n# ${spec.file}\n`, + "utf-8" + ); + if (spec.prepared !== false) { + const tocName = spec.tocName === undefined ? spec.file.replace(/\.md$/, "") : spec.tocName; + writeFileSync( + join(prepDir, spec.file), + tocName === null ? `---\ncomponent: IgxThing\n---\n` : `---\n_tocName: ${tocName}\n---\n`, + "utf-8" + ); + } + for (const groupKey of spec.groups ?? []) { + const [section, label] = groupKey.includes(" > ") ? groupKey.split(" > ") : [groupKey, ""]; + records.push({ + file: spec.file, + section, + groupKey, + groupLabel: label, + path: `${groupKey} > ${spec.file}`, + ord: ord++, + landing: false, + }); + } + } + + const tocDir = join(root, "dist", "toc-index"); + mkdirSync(tocDir, { recursive: true }); + writeFileSync(join(tocDir, `${framework}.json`), `${JSON.stringify(records, null, 2)}\n`, "utf-8"); +} + +function makeRoot(overrides: Record = {}): string { + const root = mkdtempSync(join(tmpdir(), "build-db-")); + dirs.push(root); + for (const framework of FRAMEWORKS) { + seedFramework(root, framework, overrides[framework] ?? docsFor(framework)); + } + return root; +} + +function run(root: string, args: string[] = []) { + return spawnSync(process.execPath, [TSX, BUILD_DB, ...args], { + cwd: root, + encoding: "utf-8", + env: { ...process.env, NO_COLOR: "1" }, + }); +} + +function hash(file: string): string { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function tmpResidue(root: string): string[] { + const out: string[] = []; + for (const dir of [join(root, "dist"), join(root, "db")]) { + if (!existsSync(dir)) continue; + for (const name of readdirSync(dir)) { + if (name.endsWith(".tmp") || name.endsWith("-wal") || name.endsWith("-shm")) out.push(name); + } + } + return out; +} + +describe("build-db — happy path", () => { + it("publishes to dist/ and db/ and leaves no residue", () => { + const root = makeRoot(); + const result = run(root); + + expect(result.status, result.stderr).toBe(0); + expect(existsSync(join(root, "dist", "igniteui-docs.db"))).toBe(true); + expect(existsSync(join(root, "db", "igniteui-docs.db"))).toBe(true); + expect(hash(join(root, "dist", "igniteui-docs.db"))).toBe(hash(join(root, "db", "igniteui-docs.db"))); + expect(tmpResidue(root)).toEqual([]); + }); + + it("rebuilds one framework incrementally, seeding from db/", () => { + const root = makeRoot(); + expect(run(root).status).toBe(0); + + // Change one framework's docs and rebuild only it. + seedFramework(root, "react", [ + ...docsFor("react"), + { file: "react-c.md", groups: ["Layouts"] }, + ]); + const result = run(root, ["--framework", "react"]); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("react: 3 docs inserted"); + expect(result.stdout).not.toContain("angular:"); + expect(tmpResidue(root)).toEqual([]); + }); +}); + +describe("build-db — full-rebuild preflight", () => { + const cases: [string, (root: string) => void, RegExp][] = [ + [ + "a framework with no compressed docs", + (root) => rmSync(join(root, "dist", "docs_final", "blazor"), { recursive: true, force: true }), + /docs_final\/blazor/, + ], + [ + "a framework with no prepared docs", + (root) => rmSync(join(root, "dist", "docs_prepeared", "react"), { recursive: true, force: true }), + /docs_prepeared\/react/, + ], + [ + "a framework with no TOC sidecar", + (root) => rmSync(join(root, "dist", "toc-index", "webcomponents.json"), { force: true }), + /toc-index\/webcomponents\.json/, + ], + ]; + + for (const [label, breakInputs, expected] of cases) { + it(`aborts on ${label} and leaves the committed DB untouched`, () => { + const root = makeRoot(); + expect(run(root).status).toBe(0); // publish a good committed DB first + + const before = hash(join(root, "db", "igniteui-docs.db")); + breakInputs(root); + const result = run(root); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Preflight failed/); + expect(result.stderr).toMatch(expected); + expect(hash(join(root, "db", "igniteui-docs.db"))).toBe(before); + expect(tmpResidue(root)).toEqual([]); + }); + } +}); + +describe("build-db — validation gates", () => { + it("rolls back and preserves the artifacts when a doc has no TOC group", () => { + const root = makeRoot(); + expect(run(root).status).toBe(0); + const before = hash(join(root, "db", "igniteui-docs.db")); + const distBefore = hash(join(root, "dist", "igniteui-docs.db")); + + // A doc present in docs_final but absent from the sidecar. + seedFramework(root, "angular", [ + ...docsFor("angular"), + { file: "angular-orphan.md", groups: [] }, + ]); + + const result = run(root, ["--framework", "angular"]); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/no TOC group/); + expect(result.stderr).toContain("angular/angular-orphan.md"); + expect(hash(join(root, "db", "igniteui-docs.db"))).toBe(before); + expect(hash(join(root, "dist", "igniteui-docs.db"))).toBe(distBefore); + expect(tmpResidue(root)).toEqual([]); + }); + + it("rolls back on a NULL toc_name rather than warning past it", () => { + const root = makeRoot(); + expect(run(root).status).toBe(0); + const before = hash(join(root, "db", "igniteui-docs.db")); + + seedFramework(root, "blazor", [ + { file: "blazor-a.md", groups: ["Layouts"], tocName: null }, + { file: "blazor-b.md", groups: ["Layouts"] }, + ]); + + const result = run(root, ["--framework", "blazor"]); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/NULL toc_name/); + expect(hash(join(root, "db", "igniteui-docs.db"))).toBe(before); + }); + + it("rejects a summary-less DB under --release but accepts it without", () => { + const root = makeRoot(); + expect(run(root).status).toBe(0); + + const released = run(root, ["--release"]); + expect(released.status).not.toBe(0); + expect(released.stderr).toMatch(/no summary/); + }); + + it("passes --release once every group has a summary", () => { + const root = makeRoot(); + expect(run(root).status).toBe(0); + + const summaryDir = join(root, "data", "group-summaries"); + mkdirSync(summaryDir, { recursive: true }); + for (const framework of FRAMEWORKS) { + writeFileSync( + join(summaryDir, `${framework}.json`), + JSON.stringify([ + { groupKey: "Grids & Lists > Data Grid", summary: "The data grid." }, + { groupKey: "Layouts", summary: "Layout components." }, + ]), + "utf-8" + ); + } + + const result = run(root, ["--release"]); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Release gates passed."); + }); + + it("warns about a cached summary for a group that no longer exists", () => { + const root = makeRoot(); + const summaryDir = join(root, "data", "group-summaries"); + mkdirSync(summaryDir, { recursive: true }); + writeFileSync( + join(summaryDir, "angular.json"), + JSON.stringify([{ groupKey: "Gone > Group", summary: "Stale." }]), + "utf-8" + ); + + const result = run(root, ["--framework", "angular"]); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr + result.stdout).toMatch(/unknown group "Gone > Group"/); + }); +}); + +describe("build-db — sidecar ingestion", () => { + it("keeps a cross-listed doc in both groups and counts it once per group", () => { + const root = makeRoot({ + angular: [ + { file: "angular-a.md", groups: ["Grids & Lists > Spreadsheet", "Frameworks > Excel Library"] }, + { file: "angular-b.md", groups: ["Layouts"] }, + ], + }); + expect(run(root).status).toBe(0); + + const out = run(root, ["--framework", "angular"]); + expect(out.status, out.stderr).toBe(0); + expect(out.stdout).toContain("angular: 3 TOC membership(s) inserted"); + expect(out.stdout).toContain("angular: 3 group(s)"); + }); + + it("warns about a sidecar record with no matching doc", () => { + const root = makeRoot(); + const sidecar = join(root, "dist", "toc-index", "react.json"); + const records = JSON.parse(readFileSync(sidecar, "utf-8")); + records.push({ + file: "deleted.md", + section: "Layouts", + groupKey: "Layouts", + groupLabel: "", + path: "Layouts > Deleted", + ord: 99, + landing: false, + }); + writeFileSync(sidecar, JSON.stringify(records), "utf-8"); + + const result = run(root, ["--framework", "react"]); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout + result.stderr).toMatch(/1 record\(s\) with no matching doc/); + }); +}); + +describe("build-db — publication", () => { + it("copies to the backend path when it exists", () => { + const root = makeRoot(); + const backend = join(root, "..", "docs-backend", "docs-backend"); + mkdirSync(backend, { recursive: true }); + dirs.push(resolve(root, "..", "docs-backend")); + + const result = run(root); + expect(result.status, result.stderr).toBe(0); + expect(existsSync(join(backend, "igniteui-docs.db"))).toBe(true); + expect(hash(join(backend, "igniteui-docs.db"))).toBe(hash(join(root, "db", "igniteui-docs.db"))); + }); + + it("leaves every published artifact untouched when validation fails after staging", () => { + const root = makeRoot(); + expect(run(root).status).toBe(0); + + const backup = join(root, "backup"); + mkdirSync(backup, { recursive: true }); + cpSync(join(root, "db", "igniteui-docs.db"), join(backup, "db.db")); + cpSync(join(root, "dist", "igniteui-docs.db"), join(backup, "dist.db")); + + seedFramework(root, "webcomponents", [ + { file: "webcomponents-a.md", groups: ["Layouts"], prepared: false }, + { file: "webcomponents-b.md", groups: ["Layouts"] }, + ]); + + const result = run(root, ["--framework", "webcomponents"]); + expect(result.status).not.toBe(0); + expect(hash(join(root, "db", "igniteui-docs.db"))).toBe(hash(join(backup, "db.db"))); + expect(hash(join(root, "dist", "igniteui-docs.db"))).toBe(hash(join(backup, "dist.db"))); + expect(tmpResidue(root)).toEqual([]); + }); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/scripts/toc-index.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/scripts/toc-index.test.ts new file mode 100644 index 000000000..308b848eb --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/scripts/toc-index.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { + walkTocJson, + walkTocYaml, + type TocEntry, + type TocNode, +} from "../../../scripts/lib/toc-index.js"; + +/** + * Mirrors the shape of `angular/igniteui-docfx/en/components/toc.yml`: headers + * are top-level siblings of the entries they introduce, and a header may carry + * its own landing-page href. + */ +const ANGULAR_TOC: TocNode[] = [ + { name: "Grids & Lists", href: "grids-and-lists.md", header: true }, + { + name: "Data Grid", + href: "grid/grid.md", + items: [ + { name: "Sorting", href: "grid/sorting.md" }, + { name: "Row Actions", href: "grid/row-actions.md", premium: true }, + ], + }, + { name: "Excel Utility", href: "excel-utility.md" }, + { name: "Layouts", header: true }, + { name: "Accordion", href: "accordion.md" }, + { name: "Styling & Themes", header: true }, + { + name: "Sass", + href: "themes/sass/index.md", + items: [ + { name: "Palettes", href: "themes/sass/palettes.md" }, + { + name: "Predefined Themes", + items: [{ name: "Material", href: "themes/sass/presets/material.md" }], + }, + ], + }, + { name: "Frameworks", header: true }, + { name: "Excel Library", items: [{ name: "Excel Utility", href: "excel-utility.md" }] }, +]; + +function byHref(entries: TocEntry[], href: string): TocEntry[] { + return entries.filter((e) => e.href === href); +} + +function shape(entry: TocEntry) { + return { + section: entry.section, + ancestors: entry.ancestors, + groupLabel: entry.groupLabel, + groupKey: entry.groupKey, + path: entry.path, + landing: entry.landing, + }; +} + +describe("walkTocYaml — §4.1 worked examples", () => { + const entries = walkTocYaml(ANGULAR_TOC); + + it("groups a node that has both an href and children with its own children", () => { + expect(shape(byHref(entries, "grid/grid.md")[0])).toEqual({ + section: "Grids & Lists", + ancestors: ["Data Grid"], + groupLabel: "Data Grid", + groupKey: "Grids & Lists > Data Grid", + path: "Grids & Lists > Data Grid", + landing: false, + }); + }); + + it("groups a descendant under the top-level node below the header", () => { + expect(shape(byHref(entries, "grid/sorting.md")[0])).toEqual({ + section: "Grids & Lists", + ancestors: ["Data Grid", "Sorting"], + groupLabel: "Data Grid", + groupKey: "Grids & Lists > Data Grid", + path: "Grids & Lists > Data Grid > Sorting", + landing: false, + }); + }); + + it("leaves a childless top-level node at section level", () => { + expect(shape(byHref(entries, "accordion.md")[0])).toEqual({ + section: "Layouts", + ancestors: ["Accordion"], + groupLabel: "", + groupKey: "Layouts", + path: "Layouts > Accordion", + landing: false, + }); + }); + + it("keeps a deep descendant under the top-level node, not its immediate parent", () => { + expect(shape(byHref(entries, "themes/sass/presets/material.md")[0])).toEqual({ + section: "Styling & Themes", + ancestors: ["Sass", "Predefined Themes", "Material"], + groupLabel: "Sass", + groupKey: "Styling & Themes > Sass", + path: "Styling & Themes > Sass > Predefined Themes > Material", + landing: false, + }); + }); + + it("groups a second-level entry under its top-level node", () => { + expect(shape(byHref(entries, "themes/sass/palettes.md")[0])).toEqual({ + section: "Styling & Themes", + ancestors: ["Sass", "Palettes"], + groupLabel: "Sass", + groupKey: "Styling & Themes > Sass", + path: "Styling & Themes > Sass > Palettes", + landing: false, + }); + }); + + it("emits a header's own href as the section landing page", () => { + expect(shape(byHref(entries, "grids-and-lists.md")[0])).toEqual({ + section: "Grids & Lists", + ancestors: [], + groupLabel: "", + groupKey: "Grids & Lists", + path: "Grids & Lists", + landing: true, + }); + }); + + // The pair that fails any `ancestors.length > 1` implementation: both have a + // single-element ancestor chain and must land in different groups. + it("separates grid/grid.md from accordion.md despite equal ancestor depth", () => { + const grid = byHref(entries, "grid/grid.md")[0]; + const accordion = byHref(entries, "accordion.md")[0]; + expect(grid.ancestors).toHaveLength(1); + expect(accordion.ancestors).toHaveLength(1); + expect(grid.groupKey).not.toBe(accordion.groupKey); + }); +}); + +describe("walkTocYaml — structure", () => { + const entries = walkTocYaml(ANGULAR_TOC); + + it("emits one entry per href in document order", () => { + expect(entries.map((e) => e.ord)).toEqual(entries.map((_, i) => i)); + expect(entries.map((e) => e.href)).toEqual([ + "grids-and-lists.md", + "grid/grid.md", + "grid/sorting.md", + "grid/row-actions.md", + "excel-utility.md", + "accordion.md", + "themes/sass/index.md", + "themes/sass/palettes.md", + "themes/sass/presets/material.md", + "excel-utility.md", + ]); + }); + + it("emits a cross-listed href once per TOC path, in different sections", () => { + const excel = byHref(entries, "excel-utility.md"); + expect(excel).toHaveLength(2); + expect(excel.map((e) => e.path)).toEqual([ + "Grids & Lists > Excel Utility", + "Frameworks > Excel Library > Excel Utility", + ]); + expect(excel.map((e) => e.groupKey)).toEqual([ + "Grids & Lists", + "Frameworks > Excel Library", + ]); + }); + + it("carries premium through", () => { + expect(byHref(entries, "grid/row-actions.md")[0].premium).toBe(true); + expect(byHref(entries, "grid/sorting.md")[0].premium).toBe(false); + }); +}); + +describe("walkTocJson — platform exclusion", () => { + const XPLAT_TOC: TocNode[] = [ + { name: "General", header: true }, + { + name: "Installation", + exclude: ["Angular", "React"], + items: [{ name: "NuGet Feed", href: "general-nuget-feed.md" }], + }, + { name: "Licensing", href: "general-licensing.md" }, + { name: "Charts", href: "charts/chart-overview.md", header: true }, + { name: "Area Chart", href: "charts/types/area-chart.md" }, + ]; + + it("drops an excluded subtree for the excluded platform only", () => { + const react = walkTocJson(XPLAT_TOC, { excludePlatform: "React" }); + const blazor = walkTocJson(XPLAT_TOC, { excludePlatform: "Blazor" }); + expect(react.map((e) => e.href)).not.toContain("general-nuget-feed.md"); + expect(blazor.map((e) => e.href)).toContain("general-nuget-feed.md"); + }); + + it("keeps header hrefs, so the exporter can filter them itself", () => { + const entries = walkTocJson(XPLAT_TOC, { excludePlatform: "React" }); + const overview = byHref(entries, "charts/chart-overview.md")[0]; + expect(overview.landing).toBe(true); + expect(entries.filter((e) => !e.landing).map((e) => e.href)).toEqual([ + "general-licensing.md", + "charts/types/area-chart.md", + ]); + }); + + it("lets an excluded header update the section so later siblings are not stale", () => { + const toc: TocNode[] = [ + { name: "General", header: true }, + { name: "Grids", header: true, exclude: ["React"] }, + { name: "Data Grid", href: "grids/grid/grid.md" }, + ]; + const entries = walkTocJson(toc, { excludePlatform: "React" }); + expect(entries).toHaveLength(1); + expect(entries[0].section).toBe("Grids"); + expect(entries[0].groupKey).toBe("Grids"); + }); + + it("walks with no exclusion when no platform is given", () => { + expect(walkTocJson(XPLAT_TOC).map((e) => e.href)).toContain("general-nuget-feed.md"); + }); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/scripts/toc-sidecar.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/scripts/toc-sidecar.test.ts new file mode 100644 index 000000000..65f3dc424 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/scripts/toc-sidecar.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + TocSidecar, + resolveUniqueName, + type TocSidecarRecord, +} from "../../../scripts/lib/toc-sidecar.js"; +import { walkTocYaml, type TocEntry, type TocNode } from "../../../scripts/lib/toc-index.js"; + +const TOC: TocNode[] = [ + { name: "Grids & Lists", header: true }, + { name: "Excel Utility", href: "excel-utility.md" }, + { name: "Frameworks", header: true }, + { name: "Excel Library", items: [{ name: "Excel Utility", href: "excel-utility.md" }] }, +]; + +const dirs: string[] = []; + +function tempRoot(): string { + const dir = mkdtempSync(join(tmpdir(), "toc-sidecar-")); + dirs.push(dir); + return dir; +} + +afterEach(() => { + while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function read(root: string, framework: string): TocSidecarRecord[] { + return JSON.parse(readFileSync(join(root, "dist", "toc-index", `${framework}.json`), "utf-8")); +} + +describe("TocSidecar", () => { + it("records a cross-listed href twice against one file", () => { + const root = tempRoot(); + const sidecar = new TocSidecar("angular", root); + const written = new Set(); + + for (const entry of walkTocYaml(TOC)) { + const file = sidecar.nameFor(entry.href) ?? "excel-utility.md"; + written.add(file); + sidecar.record(entry, file); + } + sidecar.write(written); + + const records = read(root, "angular"); + expect(records).toHaveLength(2); + expect(new Set(records.map((r) => r.file)).size).toBe(1); + expect(records.map((r) => r.groupKey)).toEqual([ + "Grids & Lists", + "Frameworks > Excel Library", + ]); + expect(records.map((r) => r.path)).toEqual([ + "Grids & Lists > Excel Utility", + "Frameworks > Excel Library > Excel Utility", + ]); + }); + + it("returns the cached name for an href it has already seen", () => { + const sidecar = new TocSidecar("react", tempRoot()); + const [first] = walkTocYaml(TOC); + expect(sidecar.nameFor(first.href)).toBeUndefined(); + sidecar.record(first, "excel-utility.md"); + expect(sidecar.nameFor(first.href)).toBe("excel-utility.md"); + }); + + it("replaces the previous sidecar rather than merging into it", () => { + const root = tempRoot(); + const stale: TocEntry = walkTocYaml(TOC)[0]; + + const first = new TocSidecar("blazor", root); + first.record(stale, "excel-utility.md"); + first.record({ ...stale, href: "gone.md", ord: 1 }, "gone.md"); + first.write(new Set(["excel-utility.md", "gone.md"])); + expect(read(root, "blazor")).toHaveLength(2); + + // A later run where `gone.md` has been deleted from the TOC. + const second = new TocSidecar("blazor", root); + second.record(stale, "excel-utility.md"); + second.write(new Set(["excel-utility.md"])); + + const records = read(root, "blazor"); + expect(records).toHaveLength(1); + expect(records[0].file).toBe("excel-utility.md"); + }); + + it("rejects a record set that does not match the files written", () => { + const sidecar = new TocSidecar("angular", tempRoot()); + sidecar.record(walkTocYaml(TOC)[0], "excel-utility.md"); + expect(() => sidecar.write(new Set(["excel-utility.md", "extra.md"]))).toThrow( + /sidecar mismatch/ + ); + }); + + it("leaves the previous sidecar intact when the write fails", () => { + const root = tempRoot(); + const entry = walkTocYaml(TOC)[0]; + + const first = new TocSidecar("angular", root); + first.record(entry, "excel-utility.md"); + first.write(new Set(["excel-utility.md"])); + const before = readFileSync(join(root, "dist", "toc-index", "angular.json"), "utf-8"); + + const second = new TocSidecar("angular", root); + second.record(entry, "excel-utility.md"); + second.record({ ...entry, href: "other.md", ord: 1 }, "other.md"); + expect(() => second.write(new Set(["excel-utility.md"]))).toThrow(); + + expect(readFileSync(join(root, "dist", "toc-index", "angular.json"), "utf-8")).toBe(before); + }); +}); + +describe("resolveUniqueName", () => { + it("keeps the flattened name when it is free", () => { + expect(resolveUniqueName("editing.md", "grids/grid/editing.md", new Map())).toBe("editing.md"); + }); + + it("falls back to parent-file, then to the full href", () => { + const used = new Map([["editing.md", "charts/editing.md"]]); + expect(resolveUniqueName("editing.md", "grids/grid/editing.md", used)).toBe("grid-editing.md"); + + used.set("grid-editing.md", "x"); + expect(resolveUniqueName("editing.md", "grids/grid/editing.md", used)).toBe( + "grids-grid-editing.md" + ); + }); + + it("keeps looking when every fixed candidate is taken, instead of overwriting", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const used = new Map([ + ["editing.md", "a"], + ["grid-editing.md", "b"], + ["grids-grid-editing.md", "c"], + ]); + const name = resolveUniqueName("editing.md", "grids/grid/editing.md", used); + expect(name).toBe("editing-2.md"); + expect(used.has(name)).toBe(false); + }); + + it("gives distinct names to two different hrefs that flatten alike", () => { + const used = new Map(); + const first = resolveUniqueName("overview.md", "charts/overview.md", used); + used.set(first, "charts/overview.md"); + const second = resolveUniqueName("overview.md", "maps/overview.md", used); + expect(first).toBe("overview.md"); + expect(second).toBe("maps-overview.md"); + }); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/shared/list-fixtures.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/shared/list-fixtures.test.ts new file mode 100644 index 000000000..c954c3699 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/shared/list-fixtures.test.ts @@ -0,0 +1,46 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { expectedPath, fixtureNames, loadFixture } from "../../../scripts/lib/list-fixtures.js"; +import { renderFixture } from "./list-fixtures.js"; + +/** + * The .NET backend renders the same fixtures through its own port of the + * renderer and compares against the same `expected.txt`, so a change to either + * implementation that is not mirrored in the other fails here or there. + * + * Regenerate after a deliberate format change: + * UPDATE_LIST_FIXTURES=1 npx vitest run src/__tests__/shared + */ +const UPDATE = process.env.UPDATE_LIST_FIXTURES === "1"; + +let dir: string; +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "list-fixtures-")); +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe("shared list_components fixtures", () => { + const names = fixtureNames(); + + it("has fixtures to run", () => { + expect(names.length).toBeGreaterThan(0); + }); + + for (const name of names) { + it(`renders ${name} exactly as recorded`, async () => { + const fixture = loadFixture(name); + const actual = await renderFixture(fixture, join(dir, `${name}.db`)); + const file = expectedPath(name); + + if (UPDATE || !existsSync(file)) { + writeFileSync(file, actual, "utf-8"); + } + + // Ordinal comparison, no line-ending normalisation — that is what makes + // drift between the TS and C# renderers fail loudly. + expect(actual).toBe(readFileSync(file, "utf-8")); + }); + } +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/shared/list-fixtures.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/shared/list-fixtures.ts new file mode 100644 index 000000000..a801950ec --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/shared/list-fixtures.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "fs"; +import { createRequire } from "module"; +import initSqlJs from "sql.js"; +import type { ListFixture } from "../../../scripts/lib/list-fixtures.js"; +import { LocalDocsProvider } from "../../providers/LocalDocsProvider.js"; + +const require = createRequire(import.meta.url); + +/** Load a fixture's rows into an in-memory DB and render it through the provider. */ +export async function renderFixture(fixture: ListFixture, dbFile: string): Promise { + const wasm = readFileSync(require.resolve("sql.js/dist/sql-wasm.wasm")); + const SQL = await initSqlJs({ + wasmBinary: wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength), + }); + + const db = new SQL.Database(); + db.run(`CREATE TABLE docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, framework TEXT NOT NULL, filename TEXT NOT NULL, + component TEXT NOT NULL, toc_name TEXT, premium INTEGER DEFAULT 0, keywords TEXT, + summary TEXT, content TEXT NOT NULL, UNIQUE(framework, filename))`); + db.run(`CREATE TABLE doc_toc (framework TEXT NOT NULL, filename TEXT NOT NULL, + group_key TEXT NOT NULL, section TEXT NOT NULL, group_label TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL, ord INTEGER NOT NULL, landing INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (framework, filename, path))`); + db.run(`CREATE TABLE doc_groups (framework TEXT NOT NULL, group_key TEXT NOT NULL, + section TEXT NOT NULL, group_label TEXT NOT NULL DEFAULT '', summary TEXT, + doc_count INTEGER NOT NULL, ord INTEGER NOT NULL, PRIMARY KEY (framework, group_key))`); + + for (const d of fixture.docs) { + db.run( + `INSERT INTO docs (framework, filename, component, toc_name, premium, keywords, summary, content) + VALUES (?, ?, ?, ?, ?, ?, ?, 'body')`, + [d.framework, d.filename, d.component, d.toc_name, d.premium ?? 0, d.keywords ?? "", d.summary ?? ""] + ); + } + for (const t of fixture.docToc) { + db.run( + `INSERT INTO doc_toc (framework, filename, group_key, section, group_label, path, ord, landing) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [t.framework, t.filename, t.group_key, t.section, t.group_label, t.path, t.ord, t.landing ?? 0] + ); + } + for (const g of fixture.docGroups) { + db.run( + `INSERT INTO doc_groups (framework, group_key, section, group_label, summary, doc_count, ord) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [g.framework, g.group_key, g.section, g.group_label, g.summary, g.doc_count, g.ord] + ); + } + + const { writeFileSync } = await import("fs"); + writeFileSync(dbFile, Buffer.from(db.export())); + db.close(); + + const provider = new LocalDocsProvider(dbFile); + await provider.init(); + return provider.listComponents(fixture.framework, { + filter: fixture.filter, + detail: fixture.detail, + group: fixture.group, + }); +} diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/render-components.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/render-components.test.ts new file mode 100644 index 000000000..f17e2cda1 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/render-components.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + SUMMARY_THRESHOLD, + renderFlat, + renderGroup, + renderGroupedIndex, + renderUnknownGroup, + type GroupRow, + type GroupedDocRow, +} from "../../tools/render-components.js"; + +const GROUPS: GroupRow[] = [ + { group_key: "Grids & Lists", section: "Grids & Lists", group_label: "", summary: "Lists and grids.", doc_count: 1, ord: 0 }, + { group_key: "Grids & Lists > Data Grid", section: "Grids & Lists", group_label: "Data Grid", summary: "Sorting, filtering, editing.", doc_count: 2, ord: 1 }, + { group_key: "Frameworks > Excel Library", section: "Frameworks", group_label: "Excel Library", summary: null, doc_count: 1, ord: 3 }, +]; + +const ROWS: GroupedDocRow[] = [ + { filename: "grids-and-lists.md", toc_name: "Grids & Lists", summary: "Overview.", premium: 0, group_key: "Grids & Lists", ord: 0 }, + { filename: "excel-utility.md", toc_name: "Excel Utility", summary: "Excel helpers.", premium: 0, group_key: "Grids & Lists", ord: 1 }, + { filename: "grid-grid.md", toc_name: "Data Grid", summary: "The grid.", premium: 0, group_key: "Grids & Lists > Data Grid", ord: 2 }, + { filename: "grid-sorting.md", toc_name: "Sorting", summary: "Sort rows.", premium: 1, group_key: "Grids & Lists > Data Grid", ord: 3 }, + { filename: "excel-utility.md", toc_name: "Excel Utility", summary: "Excel helpers.", premium: 0, group_key: "Frameworks > Excel Library", ord: 9 }, +]; + +describe("renderGroupedIndex", () => { + it("renders a heading, the group summary and the member names per group", () => { + const many = Array.from({ length: 30 }, (_, i) => ({ + filename: `doc-${i}.md`, + toc_name: `Doc ${i}`, + summary: "s", + premium: 0, + group_key: "Grids & Lists > Data Grid", + ord: i, + })); + const out = renderGroupedIndex("angular", GROUPS, many); + expect(out).toContain("## Grids & Lists > Data Grid (30)"); + expect(out).toContain("Sorting, filtering, editing."); + expect(out).toContain("doc-0, doc-1"); + expect(out).not.toContain("- **Doc 0**"); + }); + + it("omits a group with no rows", () => { + const out = renderGroupedIndex("angular", GROUPS, ROWS.slice(2, 4)); + expect(out).toContain("## Grids & Lists > Data Grid (2)"); + expect(out).not.toContain("## Frameworks > Excel Library"); + }); + + it("renders a group with no summary as heading plus members", () => { + const out = renderGroupedIndex("angular", GROUPS, [ROWS[4]]); + expect(out).toContain("## Frameworks > Excel Library (1)"); + expect(out).toContain("excel-utility"); + }); + + it("counts a cross-listed doc once in the header and once per group", () => { + const out = renderGroupedIndex("angular", GROUPS, ROWS); + // Four distinct files, five memberships. + expect(out).toContain("Found 4 component doc(s)"); + expect(out).toContain("## Grids & Lists (2)"); + expect(out).toContain("## Frameworks > Excel Library (1)"); + }); + + it("includes per-doc summaries only while the match set is small", () => { + const small = renderGroupedIndex("angular", GROUPS, ROWS, "grid"); + expect(small).toContain("- **Data Grid** (`grid-grid`)"); + expect(small).toContain(" The grid."); + + const large = Array.from({ length: SUMMARY_THRESHOLD + 1 }, (_, i) => ({ + filename: `doc-${i}.md`, + toc_name: `Doc ${i}`, + summary: "s", + premium: 0, + group_key: "Grids & Lists > Data Grid", + ord: i, + })); + const big = renderGroupedIndex("angular", GROUPS, large, "grid"); + expect(big).not.toContain("- **Doc 0**"); + expect(big).toContain("doc-0, doc-1"); + }); + + it("marks premium docs when summaries are omitted", () => { + const many = [ + ...ROWS, + ...Array.from({ length: 25 }, (_, i) => ({ + filename: `pad-${i}.md`, + toc_name: `Pad ${i}`, + summary: "s", + premium: 0, + group_key: "Grids & Lists > Data Grid", + ord: 100 + i, + })), + ]; + expect(renderGroupedIndex("angular", GROUPS, many)).toContain("grid-sorting ⭐"); + }); + + it("keeps the lowest ord when one doc reaches a group by two paths", () => { + const twice: GroupedDocRow[] = [ + { filename: "b.md", toc_name: "B", summary: null, premium: 0, group_key: "Grids & Lists", ord: 1 }, + { filename: "a.md", toc_name: "A", summary: null, premium: 0, group_key: "Grids & Lists", ord: 5 }, + { filename: "a.md", toc_name: "A", summary: null, premium: 0, group_key: "Grids & Lists", ord: 0 }, + ]; + const out = renderGroupedIndex("angular", GROUPS, twice); + expect(out).toContain("## Grids & Lists (2)"); + // a.md is listed once, at its earliest TOC position — before b.md. + expect(out.indexOf("(`a`)")).toBeLessThan(out.indexOf("(`b`)")); + expect(out.match(/\(`a`\)/g)).toHaveLength(1); + }); + + it("reports an empty result set", () => { + expect(renderGroupedIndex("angular", GROUPS, [], "zzz")).toBe( + 'No components found for framework "angular" matching "zzz".' + ); + }); +}); + +describe("renderGroup", () => { + it("lists one group's docs with summaries in TOC order", () => { + const out = renderGroup("angular", GROUPS[1], ROWS); + expect(out.startsWith("Found 2 component doc(s) in **angular** > Grids & Lists > Data Grid:")).toBe(true); + expect(out).toContain("Sorting, filtering, editing."); + expect(out.indexOf("grid-grid")).toBeLessThan(out.indexOf("grid-sorting")); + expect(out).toContain(" ⭐ Premium"); + }); + + it("reports an empty group", () => { + expect(renderGroup("angular", GROUPS[2], [], "zzz")).toBe( + 'No components found in group "Frameworks > Excel Library" for framework "angular" matching "zzz".' + ); + }); +}); + +describe("renderUnknownGroup", () => { + it("answers with the valid keys rather than an error", () => { + const out = renderUnknownGroup("angular", "Nope", GROUPS); + expect(out).toContain('No group "Nope" in **angular**'); + for (const g of GROUPS) expect(out).toContain(`- ${g.group_key}`); + }); +}); + +describe("renderFlat", () => { + it("keeps the established per-doc shape", () => { + expect(renderFlat("angular", [ROWS[3]])).toBe( + "Found 1 components for **angular**:\n\n" + + "- **Sorting** (`grid-sorting`)\n Sort rows.\n ⭐ Premium" + ); + }); + + it("mentions the filter in the header and the empty message", () => { + expect(renderFlat("angular", [ROWS[3]], "sort")).toContain('matching "sort":'); + expect(renderFlat("angular", [], "sort")).toBe( + 'No components found for framework "angular" matching "sort".' + ); + }); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts index ebb689c79..5c8c4eac2 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts @@ -111,16 +111,32 @@ function registerDocTools(server: McpServer, docsProvider: DocsProvider) { .string() .optional() .describe( - "Keyword to match against filename, component name, keywords, or summary. " + - "Case-insensitive substring match. Example: 'grid', 'combo', 'chart'. " + - "Omit to return all docs for the framework." + "Keyword to match against filename, component name, keywords, summary, or " + + "group name. Case-insensitive substring match. Example: 'grid', 'combo', 'chart'. " + + "Omit to return the full index for the framework." + ), + group: z + .string() + .optional() + .describe( + "A group heading exactly as printed by the grouped index, e.g. " + + "'Grids & Lists > Data Grid'. Returns that group's docs with summaries. " + + "An unknown value returns the list of valid groups." + ), + detail: z + .enum(["groups", "docs"]) + .optional() + .describe( + "'groups' (default) returns the compact grouped index. 'docs' returns the " + + "flat per-doc list with a summary for every doc — far larger; prefer " + + "'group' or 'filter' first." ), }, }, - async ({ framework, filter }) => { + async ({ framework, filter, group, detail }) => { const start = performance.now(); - const text = await docsProvider.listComponents(framework, filter); - log("list_components", { framework, filter }, text, Math.round(performance.now() - start)); + const text = await docsProvider.listComponents(framework, { filter, group, detail }); + log("list_components", { framework, filter, group, detail }, text, Math.round(performance.now() - start)); return { content: [{ type: "text" as const, text }] }; } ); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/DocsProvider.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/DocsProvider.ts index f3b9b9fa0..4bd42c636 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/DocsProvider.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/DocsProvider.ts @@ -1,5 +1,14 @@ +export interface ListComponentsOptions { + /** Substring match. Grouped mode also matches the group key. */ + filter?: string; + /** `"groups"` (default) renders the grouped index; `"docs"` renders the flat list. */ + detail?: "groups" | "docs"; + /** A `group_key` as printed by the grouped index. */ + group?: string; +} + export interface DocsProvider { - listComponents(framework: string, filter?: string): Promise; + listComponents(framework: string, opts?: ListComponentsOptions): Promise; getDoc(framework: string, name: string): Promise<{ text: string; found: boolean }>; searchDocs(framework: string, query: string): Promise; } diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/LocalDocsProvider.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/LocalDocsProvider.ts index 3b6e24d43..b21219d11 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/LocalDocsProvider.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/LocalDocsProvider.ts @@ -3,13 +3,24 @@ import { readFileSync, existsSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { createRequire } from "module"; -import type { DocsProvider } from "./DocsProvider.js"; +import type { DocsProvider, ListComponentsOptions } from "./DocsProvider.js"; +import { + renderFlat, + renderGroup, + renderGroupedIndex, + renderUnknownGroup, + type DocRow, + type GroupRow, + type GroupedDocRow, +} from "../tools/render-components.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); export class LocalDocsProvider implements DocsProvider { private db: Database | null = null; private dbPath: string; + private tocTablesPresent: boolean | null = null; + private groupedFrameworks = new Map(); constructor(dbPath?: string) { this.dbPath = dbPath || process.env.DB_PATH || join(__dirname, "..", "igniteui-docs.db"); @@ -36,51 +47,136 @@ export class LocalDocsProvider implements DocsProvider { return this.db; } - async listComponents(framework: string, filter?: string): Promise { - const db = this.ensureDb(); + private query(sql: string, params: Record = {}): Record[] { + const stmt = this.ensureDb().prepare(sql); + stmt.bind(params as never); + const rows: Record[] = []; + while (stmt.step()) { + rows.push(stmt.getAsObject()); + } + stmt.free(); + return rows; + } - let sql: string; - let params: Record; + /** + * The package ships a prebuilt DB and `db/igniteui-docs.db` is committed, so a + * client can update the server without rebuilding the database. A DB with no + * `doc_toc`, or one where this framework has not been migrated yet, renders + * exactly as it does today. + */ + private hasGroups(framework: string): boolean { + if (this.tocTablesPresent === null) { + this.tocTablesPresent = + this.query( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name IN ('doc_toc', 'doc_groups')` + ).length === 2; + } + if (!this.tocTablesPresent) return false; - if (filter) { - const like = `%${filter}%`; - sql = `SELECT filename, component, toc_name, premium, keywords, summary - FROM docs - WHERE framework = $framework - AND (filename LIKE $like OR component LIKE $like OR toc_name LIKE $like - OR keywords LIKE $like OR summary LIKE $like) - ORDER BY toc_name`; - params = { $framework: framework, $like: like }; - } else { - sql = `SELECT filename, component, toc_name, premium, keywords, summary - FROM docs - WHERE framework = $framework - ORDER BY toc_name`; - params = { $framework: framework }; + const cached = this.groupedFrameworks.get(framework); + if (cached !== undefined) return cached; + + const row = this.query(`SELECT COUNT(*) AS cnt FROM doc_toc WHERE framework = $framework`, { + $framework: framework, + })[0]; + const present = Number(row?.cnt ?? 0) > 0; + this.groupedFrameworks.set(framework, present); + return present; + } + + async listComponents(framework: string, opts: ListComponentsOptions = {}): Promise { + const { filter, detail, group } = opts; + + if (detail === "docs" || !this.hasGroups(framework)) { + return this.listFlat(framework, filter, group); } - const stmt = db.prepare(sql); - stmt.bind(params); + const groups = this.query( + `SELECT group_key, section, group_label, summary, doc_count, ord + FROM doc_groups WHERE framework = $framework ORDER BY ord`, + { $framework: framework } + ) as unknown as GroupRow[]; - const rows: Record[] = []; - while (stmt.step()) { - rows.push(stmt.getAsObject()); + if (group !== undefined) { + const match = groups.find((g) => g.group_key === group); + if (!match) return renderUnknownGroup(framework, group, groups); + return renderGroup(framework, match, this.groupedRows(framework, filter, group), filter); } - stmt.free(); - if (rows.length === 0) { - return `No components found for framework "${framework}"${filter ? ` matching "${filter}"` : ""}.`; + return renderGroupedIndex(framework, groups, this.groupedRows(framework, filter), filter); + } + + /** + * Grouped mode also matches `doc_toc.group_key`, so a filter can select whole + * sections. Flat mode deliberately does not — see `listFlat`. + */ + private groupedRows(framework: string, filter?: string, group?: string): GroupedDocRow[] { + const conditions = [`t.framework = $framework`]; + const params: Record = { $framework: framework }; + + if (group !== undefined) { + conditions.push(`t.group_key = $group`); + params.$group = group; + } + if (filter) { + conditions.push( + `(d.filename LIKE $like OR d.component LIKE $like OR d.toc_name LIKE $like + OR d.keywords LIKE $like OR d.summary LIKE $like OR t.group_key LIKE $like)` + ); + params.$like = `%${filter}%`; } - const lines = rows.map((r) => { - const name = (r.filename as string).replace(/\.md$/, ""); - const parts = [`- **${r.toc_name || name}** (\`${name}\`)`]; - if (r.summary) parts.push(` ${r.summary}`); - if (r.premium) parts.push(` ⭐ Premium`); - return parts.join("\n"); - }); + return this.query( + `SELECT d.filename, d.toc_name, d.premium, d.summary, t.group_key, t.ord + FROM doc_toc t + JOIN docs d ON d.framework = t.framework AND d.filename = t.filename + WHERE ${conditions.join(" AND ")} + ORDER BY t.ord`, + params + ) as unknown as GroupedDocRow[]; + } + + /** + * Flat mode never reads through `doc_toc`: the join multiplies cross-listed + * docs and reorders by TOC position. Where `group` narrows a flat listing, + * membership is resolved separately and applied to the unchanged query. + */ + private listFlat(framework: string, filter?: string, group?: string): string { + let rows: Record[]; + + if (filter) { + rows = this.query( + `SELECT filename, component, toc_name, premium, keywords, summary + FROM docs + WHERE framework = $framework + AND (filename LIKE $like OR component LIKE $like OR toc_name LIKE $like + OR keywords LIKE $like OR summary LIKE $like) + ORDER BY toc_name`, + { $framework: framework, $like: `%${filter}%` } + ); + } else { + rows = this.query( + `SELECT filename, component, toc_name, premium, keywords, summary + FROM docs + WHERE framework = $framework + ORDER BY toc_name`, + { $framework: framework } + ); + } + + if (group !== undefined && this.hasGroups(framework)) { + const members = new Set( + this.query( + `SELECT DISTINCT filename FROM doc_toc + WHERE framework = $framework AND group_key = $group`, + { $framework: framework, $group: group } + ).map((r) => r.filename as string) + ); + rows = rows.filter((r) => members.has(r.filename as string)); + } - return `Found ${rows.length} components for **${framework}**${filter ? ` matching "${filter}"` : ""}:\n\n${lines.join("\n")}`; + return renderFlat(framework, rows as unknown as DocRow[], filter); } async getDoc(framework: string, name: string): Promise<{ text: string; found: boolean }> { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/RemoteDocsProvider.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/RemoteDocsProvider.ts index 0b364bad2..6a295f105 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/RemoteDocsProvider.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/RemoteDocsProvider.ts @@ -1,4 +1,4 @@ -import type { DocsProvider } from "./DocsProvider.js"; +import type { DocsProvider, ListComponentsOptions } from "./DocsProvider.js"; export class RemoteDocsProvider implements DocsProvider { private backendUrl: string; @@ -13,10 +13,12 @@ export class RemoteDocsProvider implements DocsProvider { return resp.text(); } - async listComponents(framework: string, filter?: string): Promise { + async listComponents(framework: string, opts: ListComponentsOptions = {}): Promise { const url = new URL("/api/docs", this.backendUrl); url.searchParams.set("framework", framework); - if (filter) url.searchParams.set("filter", filter); + if (opts.filter) url.searchParams.set("filter", opts.filter); + if (opts.detail) url.searchParams.set("detail", opts.detail); + if (opts.group) url.searchParams.set("group", opts.group); return this.fetchText(url); } 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..7e54e5fb2 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts @@ -21,11 +21,16 @@ This tool is read-only: it does not create files, run commands, detect the curre Template rule: start with the base template unless the user explicitly needs multiple routed views. Use side-nav only when multi-view routing is actually required. `, - list_components: `List all available Ignite UI component documentation entries for a given framework. Optionally filter by keyword matched against filename, component name, keywords, or summary. + list_components: `Browse the Ignite UI component documentation index for a framework, grouped by the published documentation table of contents. -Use this to discover what docs exist before calling get_doc — e.g. to browse available grid docs, filter with "grid". For feature-based or free-text queries ("how do I enable row editing"), use search_docs instead. +By default returns a compact index: one heading per group with a short group summary and the doc names it contains. Use it to discover what docs exist before calling get_doc. For feature-based or free-text queries ("how do I enable row editing"), use search_docs instead. -Returns a formatted list where each entry includes: doc name (pass this as the 'name' parameter to get_doc), display name, summary, and premium status. Without a filter, returns the full catalog for the framework. +Narrowing, cheapest first: +- 'group' — pass a heading exactly as printed by the index (e.g. "Grids & Lists > Data Grid") to get that group's docs with a summary each, in documentation order. An unknown value returns the valid headings rather than an error. +- 'filter' — case-insensitive substring match on filename, component name, keywords, summary, or group name. Groups with no match are omitted; per-doc summaries are included when few enough docs match. Combine with 'group' to search inside one group. +- 'detail: "docs"' — the flat per-doc list with a summary for every doc. This is the largest response by a wide margin; reach for it only when you genuinely need every summary at once. It matches the five doc columns only, not group names. + +Doc names printed by any mode are what you pass as the 'name' parameter to get_doc. ⭐ marks premium docs. No pagination — the full result set is returned in one call. `, diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/render-components.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/render-components.ts new file mode 100644 index 000000000..20bbd4677 Binary files /dev/null and b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/render-components.ts differ diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/ambiguous-keys/expected.txt b/packages/igniteui-mcp/shared-fixtures/list-components/ambiguous-keys/expected.txt new file mode 100644 index 000000000..84830f7a4 --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/ambiguous-keys/expected.txt @@ -0,0 +1,11 @@ +Found 2 component doc(s) for **angular** in 2 group(s). Pass `group` with any heading below to get that group's docs with summaries. + +## A (1) +Section-level group whose key is a prefix of the other. +- **B c** (`B c`) + Filename containing a space, in group "A". + +## A B (1) +Group whose key plus a space equals the other key plus its filename. +- **c** (`c`) + Filename without a space, in group "A B". \ No newline at end of file diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/ambiguous-keys/input.json b/packages/igniteui-mcp/shared-fixtures/list-components/ambiguous-keys/input.json new file mode 100644 index 000000000..60cd2c99a --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/ambiguous-keys/input.json @@ -0,0 +1,65 @@ +{ + "framework": "angular", + "docs": [ + { + "framework": "angular", + "filename": "B c.md", + "component": "IgxThing", + "toc_name": "B c", + "premium": 0, + "keywords": "", + "summary": "Filename containing a space, in group \"A\"." + }, + { + "framework": "angular", + "filename": "c.md", + "component": "IgxThing", + "toc_name": "c", + "premium": 0, + "keywords": "", + "summary": "Filename without a space, in group \"A B\"." + } + ], + "docToc": [ + { + "framework": "angular", + "filename": "B c.md", + "group_key": "A", + "section": "A", + "group_label": "", + "path": "A > B c", + "ord": 0, + "landing": 0 + }, + { + "framework": "angular", + "filename": "c.md", + "group_key": "A B", + "section": "A", + "group_label": "B", + "path": "A > B > c", + "ord": 1, + "landing": 0 + } + ], + "docGroups": [ + { + "framework": "angular", + "group_key": "A", + "section": "A", + "group_label": "", + "summary": "Section-level group whose key is a prefix of the other.", + "doc_count": 1, + "ord": 0 + }, + { + "framework": "angular", + "group_key": "A B", + "section": "A", + "group_label": "B", + "summary": "Group whose key plus a space equals the other key plus its filename.", + "doc_count": 1, + "ord": 1 + } + ] +} diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/filtered/expected.txt b/packages/igniteui-mcp/shared-fixtures/list-components/filtered/expected.txt new file mode 100644 index 000000000..7179a6925 --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/filtered/expected.txt @@ -0,0 +1,11 @@ +Found 1 component doc(s) for **angular** matching "excel" in 2 group(s). Pass `group` with any heading below to get that group's docs with summaries. + +## Grids & Lists (1) +Grid and list components, plus the Excel interop utility. +- **Excel Utility** (`excel-utility`) + Load and save Excel workbooks. + +## Frameworks > Excel Library (1) +Read, write and format Excel workbooks without Excel installed. +- **Excel Utility** (`excel-utility`) + Load and save Excel workbooks. \ No newline at end of file diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/filtered/input.json b/packages/igniteui-mcp/shared-fixtures/list-components/filtered/input.json new file mode 100644 index 000000000..b00d36dd2 --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/filtered/input.json @@ -0,0 +1,217 @@ +{ + "framework": "angular", + "filter": "excel", + "docs": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "component": "IgxGridComponent", + "toc_name": "Grids & Lists", + "premium": 0, + "keywords": "grid list", + "summary": "Section overview for grids and lists." + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "component": "IgxGridComponent", + "toc_name": "Data Grid", + "premium": 0, + "keywords": "grid data", + "summary": "The data grid: setup, columns, binding." + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "component": "IgxGridComponent", + "toc_name": "Sorting", + "premium": 1, + "keywords": "sort ordering", + "summary": "Sort grid rows by one or more columns." + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "component": "IgxGridComponent", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit cell row", + "summary": "Edit cells and rows, batch and transactional." + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "component": "IgxExcelUtility", + "toc_name": "Excel Utility", + "premium": 0, + "keywords": "excel workbook", + "summary": "Load and save Excel workbooks." + }, + { + "framework": "angular", + "filename": "accordion.md", + "component": "IgxAccordionComponent", + "toc_name": "Accordion", + "premium": 0, + "keywords": "accordion panel", + "summary": "Expand and collapse stacked panels." + }, + { + "framework": "angular", + "filename": "no-component.md", + "component": "", + "toc_name": null, + "premium": 0, + "keywords": "", + "summary": "" + }, + { + "framework": "react", + "filename": "grid-editing.md", + "component": "IgrGrid", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit", + "summary": "Edit cells in the React grid." + } + ], + "docToc": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists", + "ord": 0, + "landing": 1 + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid", + "ord": 1, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Sorting", + "ord": 2, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 3, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists > Excel Utility", + "ord": 4, + "landing": 0 + }, + { + "framework": "angular", + "filename": "accordion.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Accordion", + "ord": 5, + "landing": 0 + }, + { + "framework": "angular", + "filename": "no-component.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Unnamed", + "ord": 6, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "path": "Frameworks > Excel Library > Excel Utility", + "ord": 7, + "landing": 0 + }, + { + "framework": "react", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 0, + "landing": 0 + } + ], + "docGroups": [ + { + "framework": "angular", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "summary": "Grid and list components, plus the Excel interop utility.", + "doc_count": 2, + "ord": 0 + }, + { + "framework": "angular", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid: sorting, filtering, editing, virtualization, export.", + "doc_count": 3, + "ord": 1 + }, + { + "framework": "angular", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "summary": null, + "doc_count": 2, + "ord": 5 + }, + { + "framework": "angular", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "summary": "Read, write and format Excel workbooks without Excel installed.", + "doc_count": 1, + "ord": 7 + }, + { + "framework": "react", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid editing.", + "doc_count": 1, + "ord": 0 + } + ] +} diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/flat/expected.txt b/packages/igniteui-mcp/shared-fixtures/list-components/flat/expected.txt new file mode 100644 index 000000000..49bcfae2e --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/flat/expected.txt @@ -0,0 +1,16 @@ +Found 7 components for **angular**: + +- **no-component** (`no-component`) +- **Accordion** (`accordion`) + Expand and collapse stacked panels. +- **Data Grid** (`grid-grid`) + The data grid: setup, columns, binding. +- **Editing** (`grid-editing`) + Edit cells and rows, batch and transactional. +- **Excel Utility** (`excel-utility`) + Load and save Excel workbooks. +- **Grids & Lists** (`grids-and-lists`) + Section overview for grids and lists. +- **Sorting** (`grid-sorting`) + Sort grid rows by one or more columns. + ⭐ Premium \ No newline at end of file diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/flat/input.json b/packages/igniteui-mcp/shared-fixtures/list-components/flat/input.json new file mode 100644 index 000000000..2b761a0cf --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/flat/input.json @@ -0,0 +1,217 @@ +{ + "framework": "angular", + "detail": "docs", + "docs": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "component": "IgxGridComponent", + "toc_name": "Grids & Lists", + "premium": 0, + "keywords": "grid list", + "summary": "Section overview for grids and lists." + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "component": "IgxGridComponent", + "toc_name": "Data Grid", + "premium": 0, + "keywords": "grid data", + "summary": "The data grid: setup, columns, binding." + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "component": "IgxGridComponent", + "toc_name": "Sorting", + "premium": 1, + "keywords": "sort ordering", + "summary": "Sort grid rows by one or more columns." + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "component": "IgxGridComponent", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit cell row", + "summary": "Edit cells and rows, batch and transactional." + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "component": "IgxExcelUtility", + "toc_name": "Excel Utility", + "premium": 0, + "keywords": "excel workbook", + "summary": "Load and save Excel workbooks." + }, + { + "framework": "angular", + "filename": "accordion.md", + "component": "IgxAccordionComponent", + "toc_name": "Accordion", + "premium": 0, + "keywords": "accordion panel", + "summary": "Expand and collapse stacked panels." + }, + { + "framework": "angular", + "filename": "no-component.md", + "component": "", + "toc_name": null, + "premium": 0, + "keywords": "", + "summary": "" + }, + { + "framework": "react", + "filename": "grid-editing.md", + "component": "IgrGrid", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit", + "summary": "Edit cells in the React grid." + } + ], + "docToc": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists", + "ord": 0, + "landing": 1 + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid", + "ord": 1, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Sorting", + "ord": 2, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 3, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists > Excel Utility", + "ord": 4, + "landing": 0 + }, + { + "framework": "angular", + "filename": "accordion.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Accordion", + "ord": 5, + "landing": 0 + }, + { + "framework": "angular", + "filename": "no-component.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Unnamed", + "ord": 6, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "path": "Frameworks > Excel Library > Excel Utility", + "ord": 7, + "landing": 0 + }, + { + "framework": "react", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 0, + "landing": 0 + } + ], + "docGroups": [ + { + "framework": "angular", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "summary": "Grid and list components, plus the Excel interop utility.", + "doc_count": 2, + "ord": 0 + }, + { + "framework": "angular", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid: sorting, filtering, editing, virtualization, export.", + "doc_count": 3, + "ord": 1 + }, + { + "framework": "angular", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "summary": null, + "doc_count": 2, + "ord": 5 + }, + { + "framework": "angular", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "summary": "Read, write and format Excel workbooks without Excel installed.", + "doc_count": 1, + "ord": 7 + }, + { + "framework": "react", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid editing.", + "doc_count": 1, + "ord": 0 + } + ] +} diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/grouped-index/expected.txt b/packages/igniteui-mcp/shared-fixtures/list-components/grouped-index/expected.txt new file mode 100644 index 000000000..70c03ea14 --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/grouped-index/expected.txt @@ -0,0 +1,28 @@ +Found 7 component doc(s) for **angular** in 4 group(s). Pass `group` with any heading below to get that group's docs with summaries. + +## Grids & Lists (2) +Grid and list components, plus the Excel interop utility. +- **Grids & Lists** (`grids-and-lists`) + Section overview for grids and lists. +- **Excel Utility** (`excel-utility`) + Load and save Excel workbooks. + +## Grids & Lists > Data Grid (3) +Data grid: sorting, filtering, editing, virtualization, export. +- **Data Grid** (`grid-grid`) + The data grid: setup, columns, binding. +- **Sorting** (`grid-sorting`) + Sort grid rows by one or more columns. + ⭐ Premium +- **Editing** (`grid-editing`) + Edit cells and rows, batch and transactional. + +## Layouts (2) +- **Accordion** (`accordion`) + Expand and collapse stacked panels. +- **no-component** (`no-component`) + +## Frameworks > Excel Library (1) +Read, write and format Excel workbooks without Excel installed. +- **Excel Utility** (`excel-utility`) + Load and save Excel workbooks. \ No newline at end of file diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/grouped-index/input.json b/packages/igniteui-mcp/shared-fixtures/list-components/grouped-index/input.json new file mode 100644 index 000000000..a00c990b8 --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/grouped-index/input.json @@ -0,0 +1,216 @@ +{ + "framework": "angular", + "docs": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "component": "IgxGridComponent", + "toc_name": "Grids & Lists", + "premium": 0, + "keywords": "grid list", + "summary": "Section overview for grids and lists." + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "component": "IgxGridComponent", + "toc_name": "Data Grid", + "premium": 0, + "keywords": "grid data", + "summary": "The data grid: setup, columns, binding." + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "component": "IgxGridComponent", + "toc_name": "Sorting", + "premium": 1, + "keywords": "sort ordering", + "summary": "Sort grid rows by one or more columns." + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "component": "IgxGridComponent", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit cell row", + "summary": "Edit cells and rows, batch and transactional." + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "component": "IgxExcelUtility", + "toc_name": "Excel Utility", + "premium": 0, + "keywords": "excel workbook", + "summary": "Load and save Excel workbooks." + }, + { + "framework": "angular", + "filename": "accordion.md", + "component": "IgxAccordionComponent", + "toc_name": "Accordion", + "premium": 0, + "keywords": "accordion panel", + "summary": "Expand and collapse stacked panels." + }, + { + "framework": "angular", + "filename": "no-component.md", + "component": "", + "toc_name": null, + "premium": 0, + "keywords": "", + "summary": "" + }, + { + "framework": "react", + "filename": "grid-editing.md", + "component": "IgrGrid", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit", + "summary": "Edit cells in the React grid." + } + ], + "docToc": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists", + "ord": 0, + "landing": 1 + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid", + "ord": 1, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Sorting", + "ord": 2, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 3, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists > Excel Utility", + "ord": 4, + "landing": 0 + }, + { + "framework": "angular", + "filename": "accordion.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Accordion", + "ord": 5, + "landing": 0 + }, + { + "framework": "angular", + "filename": "no-component.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Unnamed", + "ord": 6, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "path": "Frameworks > Excel Library > Excel Utility", + "ord": 7, + "landing": 0 + }, + { + "framework": "react", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 0, + "landing": 0 + } + ], + "docGroups": [ + { + "framework": "angular", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "summary": "Grid and list components, plus the Excel interop utility.", + "doc_count": 2, + "ord": 0 + }, + { + "framework": "angular", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid: sorting, filtering, editing, virtualization, export.", + "doc_count": 3, + "ord": 1 + }, + { + "framework": "angular", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "summary": null, + "doc_count": 2, + "ord": 5 + }, + { + "framework": "angular", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "summary": "Read, write and format Excel workbooks without Excel installed.", + "doc_count": 1, + "ord": 7 + }, + { + "framework": "react", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid editing.", + "doc_count": 1, + "ord": 0 + } + ] +} diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/single-group/expected.txt b/packages/igniteui-mcp/shared-fixtures/list-components/single-group/expected.txt new file mode 100644 index 000000000..cfe2f474c --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/single-group/expected.txt @@ -0,0 +1,11 @@ +Found 3 component doc(s) in **angular** > Grids & Lists > Data Grid: + +Data grid: sorting, filtering, editing, virtualization, export. + +- **Data Grid** (`grid-grid`) + The data grid: setup, columns, binding. +- **Sorting** (`grid-sorting`) + Sort grid rows by one or more columns. + ⭐ Premium +- **Editing** (`grid-editing`) + Edit cells and rows, batch and transactional. \ No newline at end of file diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/single-group/input.json b/packages/igniteui-mcp/shared-fixtures/list-components/single-group/input.json new file mode 100644 index 000000000..26d2a3886 --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/single-group/input.json @@ -0,0 +1,217 @@ +{ + "framework": "angular", + "group": "Grids & Lists > Data Grid", + "docs": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "component": "IgxGridComponent", + "toc_name": "Grids & Lists", + "premium": 0, + "keywords": "grid list", + "summary": "Section overview for grids and lists." + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "component": "IgxGridComponent", + "toc_name": "Data Grid", + "premium": 0, + "keywords": "grid data", + "summary": "The data grid: setup, columns, binding." + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "component": "IgxGridComponent", + "toc_name": "Sorting", + "premium": 1, + "keywords": "sort ordering", + "summary": "Sort grid rows by one or more columns." + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "component": "IgxGridComponent", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit cell row", + "summary": "Edit cells and rows, batch and transactional." + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "component": "IgxExcelUtility", + "toc_name": "Excel Utility", + "premium": 0, + "keywords": "excel workbook", + "summary": "Load and save Excel workbooks." + }, + { + "framework": "angular", + "filename": "accordion.md", + "component": "IgxAccordionComponent", + "toc_name": "Accordion", + "premium": 0, + "keywords": "accordion panel", + "summary": "Expand and collapse stacked panels." + }, + { + "framework": "angular", + "filename": "no-component.md", + "component": "", + "toc_name": null, + "premium": 0, + "keywords": "", + "summary": "" + }, + { + "framework": "react", + "filename": "grid-editing.md", + "component": "IgrGrid", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit", + "summary": "Edit cells in the React grid." + } + ], + "docToc": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists", + "ord": 0, + "landing": 1 + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid", + "ord": 1, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Sorting", + "ord": 2, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 3, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists > Excel Utility", + "ord": 4, + "landing": 0 + }, + { + "framework": "angular", + "filename": "accordion.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Accordion", + "ord": 5, + "landing": 0 + }, + { + "framework": "angular", + "filename": "no-component.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Unnamed", + "ord": 6, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "path": "Frameworks > Excel Library > Excel Utility", + "ord": 7, + "landing": 0 + }, + { + "framework": "react", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 0, + "landing": 0 + } + ], + "docGroups": [ + { + "framework": "angular", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "summary": "Grid and list components, plus the Excel interop utility.", + "doc_count": 2, + "ord": 0 + }, + { + "framework": "angular", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid: sorting, filtering, editing, virtualization, export.", + "doc_count": 3, + "ord": 1 + }, + { + "framework": "angular", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "summary": null, + "doc_count": 2, + "ord": 5 + }, + { + "framework": "angular", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "summary": "Read, write and format Excel workbooks without Excel installed.", + "doc_count": 1, + "ord": 7 + }, + { + "framework": "react", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid editing.", + "doc_count": 1, + "ord": 0 + } + ] +} diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/unknown-group/expected.txt b/packages/igniteui-mcp/shared-fixtures/list-components/unknown-group/expected.txt new file mode 100644 index 000000000..58754b749 --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/unknown-group/expected.txt @@ -0,0 +1,8 @@ +No group "Nope" in **angular**. Valid groups: + +- Grids & Lists +- Grids & Lists > Data Grid +- Layouts +- Frameworks > Excel Library + +Omit `group` for the full grouped index, or pass `filter` to search across groups. \ No newline at end of file diff --git a/packages/igniteui-mcp/shared-fixtures/list-components/unknown-group/input.json b/packages/igniteui-mcp/shared-fixtures/list-components/unknown-group/input.json new file mode 100644 index 000000000..3a11a8208 --- /dev/null +++ b/packages/igniteui-mcp/shared-fixtures/list-components/unknown-group/input.json @@ -0,0 +1,217 @@ +{ + "framework": "angular", + "group": "Nope", + "docs": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "component": "IgxGridComponent", + "toc_name": "Grids & Lists", + "premium": 0, + "keywords": "grid list", + "summary": "Section overview for grids and lists." + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "component": "IgxGridComponent", + "toc_name": "Data Grid", + "premium": 0, + "keywords": "grid data", + "summary": "The data grid: setup, columns, binding." + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "component": "IgxGridComponent", + "toc_name": "Sorting", + "premium": 1, + "keywords": "sort ordering", + "summary": "Sort grid rows by one or more columns." + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "component": "IgxGridComponent", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit cell row", + "summary": "Edit cells and rows, batch and transactional." + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "component": "IgxExcelUtility", + "toc_name": "Excel Utility", + "premium": 0, + "keywords": "excel workbook", + "summary": "Load and save Excel workbooks." + }, + { + "framework": "angular", + "filename": "accordion.md", + "component": "IgxAccordionComponent", + "toc_name": "Accordion", + "premium": 0, + "keywords": "accordion panel", + "summary": "Expand and collapse stacked panels." + }, + { + "framework": "angular", + "filename": "no-component.md", + "component": "", + "toc_name": null, + "premium": 0, + "keywords": "", + "summary": "" + }, + { + "framework": "react", + "filename": "grid-editing.md", + "component": "IgrGrid", + "toc_name": "Editing", + "premium": 0, + "keywords": "edit", + "summary": "Edit cells in the React grid." + } + ], + "docToc": [ + { + "framework": "angular", + "filename": "grids-and-lists.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists", + "ord": 0, + "landing": 1 + }, + { + "framework": "angular", + "filename": "grid-grid.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid", + "ord": 1, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-sorting.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Sorting", + "ord": 2, + "landing": 0 + }, + { + "framework": "angular", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 3, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "path": "Grids & Lists > Excel Utility", + "ord": 4, + "landing": 0 + }, + { + "framework": "angular", + "filename": "accordion.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Accordion", + "ord": 5, + "landing": 0 + }, + { + "framework": "angular", + "filename": "no-component.md", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "path": "Layouts > Unnamed", + "ord": 6, + "landing": 0 + }, + { + "framework": "angular", + "filename": "excel-utility.md", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "path": "Frameworks > Excel Library > Excel Utility", + "ord": 7, + "landing": 0 + }, + { + "framework": "react", + "filename": "grid-editing.md", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "path": "Grids & Lists > Data Grid > Editing", + "ord": 0, + "landing": 0 + } + ], + "docGroups": [ + { + "framework": "angular", + "group_key": "Grids & Lists", + "section": "Grids & Lists", + "group_label": "", + "summary": "Grid and list components, plus the Excel interop utility.", + "doc_count": 2, + "ord": 0 + }, + { + "framework": "angular", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid: sorting, filtering, editing, virtualization, export.", + "doc_count": 3, + "ord": 1 + }, + { + "framework": "angular", + "group_key": "Layouts", + "section": "Layouts", + "group_label": "", + "summary": null, + "doc_count": 2, + "ord": 5 + }, + { + "framework": "angular", + "group_key": "Frameworks > Excel Library", + "section": "Frameworks", + "group_label": "Excel Library", + "summary": "Read, write and format Excel workbooks without Excel installed.", + "doc_count": 1, + "ord": 7 + }, + { + "framework": "react", + "group_key": "Grids & Lists > Data Grid", + "section": "Grids & Lists", + "group_label": "Data Grid", + "summary": "Data grid editing.", + "doc_count": 1, + "ord": 0 + } + ] +} diff --git a/spec/unit/mcp-runtime-spec.ts b/spec/unit/mcp-runtime-spec.ts index 3f924e50c..e6ad8a672 100644 --- a/spec/unit/mcp-runtime-spec.ts +++ b/spec/unit/mcp-runtime-spec.ts @@ -393,7 +393,7 @@ describe("Unit - MCP runtime", () => { const fetchSpy = spyOn(globalThis, "fetch").and.resolveTo(new Response("grid docs", { status: 200 })); const provider = new RemoteDocsProvider("https://docs.example.test/base/"); - const result = await provider.listComponents("angular", "grid"); + const result = await provider.listComponents("angular", { filter: "grid" }); const requestUrl = fetchSpy.calls.mostRecent().args[0] as URL; expect(result).toBe("grid docs"); @@ -671,7 +671,7 @@ describe("Unit - MCP runtime", () => { const provider = new LocalDocsProvider(dbFixturePath); await provider.init(); - const result = await provider.listComponents("angular", "grid"); + const result = await provider.listComponents("angular", { filter: "grid" }); expect(result).toContain("Grid Editing"); expect(result).not.toContain("Combo Overview");