Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/actions/build-framework-docs/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
38 changes: 33 additions & 5 deletions .github/workflows/build-docs-db.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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."
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]
Expand Down
2 changes: 2 additions & 0 deletions packages/igniteui-mcp/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
shared-fixtures/**/*.txt text eol=lf
shared-fixtures/**/*.json text eol=lf
136 changes: 136 additions & 0 deletions packages/igniteui-mcp/docs-backend/docs-backend/ComponentRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Text;

namespace docs_backend;

/// <summary>
/// Port of the MCP server's <c>src/tools/render-components.ts</c>. The two must
/// produce byte-identical text for the same rows — the shared fixtures under
/// <c>packages/igniteui-mcp/shared-fixtures/list-components/</c> 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.
/// </summary>
public static class ComponentRenderer
{
/// <summary>Docs beyond this many lose their per-doc summaries, to keep a filtered response small.</summary>
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();
}

/// <summary>
/// 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.
/// </summary>
private static List<GroupedDocRow> Dedupe(IEnumerable<GroupedDocRow> 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<DocRow> 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<GroupRow> groups,
IReadOnlyList<GroupedDocRow> 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<string, List<GroupedDocRow>>(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<string>();
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<GroupedDocRow> 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<GroupRow> 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.";
}
}
Loading