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");