Skip to content

Conversation

@ArgoZhang
Copy link
Member

@ArgoZhang ArgoZhang commented Jan 3, 2026

Link issues

fixes #886

Summary By Copilot

Regression?

  • Yes
  • No

Risk

  • High
  • Medium
  • Low

Verification

  • Manual (required)
  • Automated

Packaging changes reviewed?

  • Yes
  • No
  • N/A

☑️ Self Check before Merge

⚠️ Please check all items below before review. ⚠️

  • Doc is updated/provided or not needed
  • Demo is updated/provided or not needed
  • Merge the latest code from the main branch

Summary by Sourcery

Introduce a new LLM-oriented documentation generator tool for BootstrapBlazor components, including Roslyn-based analysis, markdown generation, and a CLI entrypoint.

New Features:

  • Add a ComponentAnalyzer utility that inspects Blazor component source files with Roslyn to extract metadata such as parameters, methods, and summaries.
  • Add a MarkdownBuilder utility that generates LLM-friendly markdown/txt documentation for components and an index file, organized by component category.
  • Add a DocsGenerator orchestrator and CLI (llms-docs) that can generate all docs, per-component docs, or just the index, and check whether docs are up to date for CI/CD use.
  • Add English and Chinese README documentation describing the LlmsDocsGenerator tool, its purpose, architecture, usage, and CI/CD integration.

Copilot AI review requested due to automatic review settings January 3, 2026 10:24
@bb-auto bb-auto bot added the enhancement New feature or request label Jan 3, 2026
@bb-auto bb-auto bot added this to the v9.2.0 milestone Jan 3, 2026
@sourcery-ai
Copy link

sourcery-ai bot commented Jan 3, 2026

Reviewer's Guide

Adds a new CLI tool project BootstrapBlazor.LLMsDocsGenerator that analyzes BootstrapBlazor component source code with Roslyn and generates LLM‑friendly markdown documentation (index + per‑component files), including CI/CD check support and English/Chinese README documentation.

Sequence diagram for CLI-driven docs generation and check

sequenceDiagram
    actor Developer
    participant CLI as llms_docs
    participant Program
    participant Generator as DocsGenerator
    participant Analyzer as ComponentAnalyzer
    participant Builder as MarkdownBuilder
    participant FS as FileSystem

    Developer->>CLI: run with args
    CLI->>Program: pass args
    Program->>Program: parse options
    Program->>Generator: new DocsGenerator(rootFolder, debug)

    alt --check option
        Program->>Generator: CheckAsync()
        Generator->>Analyzer: AnalyzeAllComponentsAsync()
        Analyzer->>FS: read component source files
        FS-->>Analyzer: source contents and timestamps
        Analyzer-->>Generator: List<ComponentInfo>
        Generator->>FS: check llms.txt timestamp
        Generator->>FS: check components/*.txt timestamps
        FS-->>Generator: file timestamps
        Generator-->>Program: bool isUpToDate
        Program->>Program: set Environment.ExitCode
    else --index-only option
        Program->>Generator: GenerateIndexAsync()
        Generator->>Analyzer: AnalyzeAllComponentsAsync()
        Analyzer->>FS: read component source files
        FS-->>Analyzer: source contents
        Analyzer-->>Generator: List<ComponentInfo>
        Generator->>Builder: BuildIndex(components)
        Builder-->>Generator: string indexMarkdown
        Generator->>FS: write llms.txt
    else component specified
        Program->>Generator: GenerateComponentAsync(componentName)
        Generator->>Analyzer: AnalyzeComponentAsync(componentName)
        Analyzer->>FS: read component source
        FS-->>Analyzer: source content
        Analyzer-->>Generator: ComponentInfo
        Generator->>Builder: BuildComponentDoc(component)
        Builder-->>Generator: string componentMarkdown
        Generator->>FS: write components/ComponentName.txt
    else no options
        Program->>Generator: GenerateAllAsync()
        Generator->>Analyzer: AnalyzeAllComponentsAsync()
        Analyzer->>FS: read all component sources
        FS-->>Analyzer: source contents
        Analyzer-->>Generator: List<ComponentInfo>
        Generator->>Builder: BuildIndex(components)
        Builder-->>Generator: string indexMarkdown
        Generator->>FS: write llms.txt
        loop for each ComponentInfo
            Generator->>Builder: BuildComponentDoc(component)
            Builder-->>Generator: string componentMarkdown
            Generator->>FS: write components/ComponentName.txt
        end
    end
Loading

Class diagram for LlmsDocsGenerator core types

classDiagram
    class Program {
        +Main(string[] args)
    }

    class DocsGenerator {
        -string _outputPath
        -string _componentsOutputPath
        -string _sourcePath
        -ComponentAnalyzer _analyzer
        -MarkdownBuilder _markdownBuilder
        -bool _debug
        +DocsGenerator(string rootFolder, bool debug)
        +Task GenerateAllAsync()
        +Task GenerateIndexAsync()
        +Task GenerateComponentAsync(string componentName)
        +Task<bool> CheckAsync()
    }

    class ComponentAnalyzer {
        -string _sourcePath
        -string _componentsPath
        -string _samplesPath
        +ComponentAnalyzer(string sourcePath)
        +Task<List<ComponentInfo>> AnalyzeAllComponentsAsync()
        +Task<ComponentInfo?> AnalyzeComponentAsync(string componentName)
    }

    class MarkdownBuilder {
        -string GitHubBaseUrl
        -StringBuilder _sb
        +string BuildIndex(List<ComponentInfo> components)
        +string BuildComponentDoc(ComponentInfo component)
        +string BuildParameterTable(List<ParameterInfo> parameters)
    }

    class ComponentInfo {
        +string Name
        +string FullName
        +string? Summary
        +List<string> TypeParameters
        +List<ParameterInfo> Parameters
        +List<MethodInfo> PublicMethods
        +string? BaseClass
        +string SourcePath
        +DateTime LastModified
        +string? SamplePath
    }

    class ParameterInfo {
        +string Name
        +string Type
        +string? DefaultValue
        +string? Description
        +bool IsRequired
        +bool IsObsolete
        +string? ObsoleteMessage
        +bool IsEventCallback
    }

    class MethodInfo {
        +string Name
        +string ReturnType
        +List<(string Type, string Name)> Parameters
        +string? Description
        +bool IsJSInvokable
    }

    Program --> DocsGenerator : creates
    DocsGenerator --> ComponentAnalyzer : uses
    DocsGenerator --> MarkdownBuilder : uses
    ComponentAnalyzer --> ComponentInfo : returns
    ComponentInfo --> ParameterInfo : contains
    ComponentInfo --> MethodInfo : contains
Loading

Flow diagram for LlmsDocsGenerator inputs and outputs

flowchart LR
    A[BootstrapBlazor_source_tree
src/BootstrapBlazor] --> B[ComponentAnalyzer]
    B --> C[ComponentInfo_models
Parameters_methods_metadata]
    C --> D[MarkdownBuilder]

    subgraph DocsGenerator
        D --> E[index_content
llms.txt]
        D --> F[per_component_docs
components/*.txt]
    end

    E --> G[wwwroot/llms/llms.txt]
    F --> H[wwwroot/llms/components]
Loading

File-Level Changes

Change Details Files
Introduce Roslyn-based component analyzer to extract metadata from Blazor component source files.
  • Scan src/BootstrapBlazor/Components for component .razor.cs and *Base.cs files.
  • Use Roslyn to find component classes, XML doc summaries, base types, [Parameter] properties, and public methods.
  • Collect parameter metadata including type, default value, required/obsolete status, and EventCallback detection.
  • Collect public method metadata including signature, XML summary, and [JSInvokable] attribute.
  • Resolve GitHub-relative source and sample paths based on repository layout.
tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs
tools/BootstrapBlazor.LLMsDocsGenerator/ComponentInfo.cs
Add markdown generation utilities to produce LLM-friendly docs for the library and each component.
  • Generate a top-level llms.txt index with quick start instructions, component groups by category, and repository file structure guidance.
  • Generate individual per-component .txt files with headers, type parameters, parameters table, event callbacks section, public methods list, and source/sample links.
  • Provide a reusable parameter-table builder for embedding into other docs.
  • Include categorization heuristics and summary truncation for cleaner index output.
  • Escape markdown table cells to avoid formatting corruption from special characters.
tools/BootstrapBlazor.LLMsDocsGenerator/MarkdownBuilder.cs
Add orchestration layer and CLI entry point to generate and validate documentation from the repo root.
  • Implement DocsGenerator to locate the project root, derive source and output paths, and coordinate analysis and markdown generation.
  • Generate all docs, index-only, or single-component docs into src/BootstrapBlazor.Server/wwwroot/llms with a components subfolder.
  • Implement a freshness check that compares component source timestamps to generated docs and index for CI/CD use, returning a non-zero exit code when stale.
  • Add debug logging toggle to trace paths and operations during generation.
tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs
tools/BootstrapBlazor.LLMsDocsGenerator/Program.cs
Document the new LLM docs generator tool for both English and Chinese audiences.
  • Describe the purpose, architecture, and per-component file design optimized for LLM/code agent usage.
  • Provide installation instructions as a .NET global tool and via source, including update/uninstall commands.
  • Document CLI usage patterns for generating all docs, specific components, index-only, freshness checks, and custom output directories.
  • Show CI/CD and Docker integration snippets to keep documentation in sync with code.
  • Explain the output format and how downstream projects/LLM agents can consume the generated docs.
tools/BootstrapBlazor.LLMsDocsGenerator/README.md
tools/BootstrapBlazor.LLMsDocsGenerator/README.zh-CN.md
Wire the new LLM docs generator into the solution/project structure.
  • Add a new BootstrapBlazor.LLMsDocsGenerator tool project under the tools folder (project file content not shown in diff).
  • Register the new project in the solution file so it can be built and run with the rest of the repository.
BootstrapBlazor.Extensions.slnx
tools/BootstrapBlazor.LLMsDocsGenerator/BootstrapBlazor.LLMsDocsGenerator.csproj

Assessment against linked issues

Issue Objective Addressed Explanation
#886 Add a new LLMs documentation generator project/tool to the repository that can analyze BootstrapBlazor components and generate LLM-friendly documentation files.
#886 Provide documentation for the new LLMs docs generator explaining its purpose, architecture, usage, and CI/CD integration.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@ArgoZhang ArgoZhang merged commit 8475d1a into master Jan 3, 2026
5 of 6 checks passed
@ArgoZhang ArgoZhang deleted the dev-tools branch January 3, 2026 10:25
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues, and left some high level feedback:

  • There are two separate implementations of component categorization (MarkdownBuilder.CategorizeComponents and the unused DocsGenerator.GetComponentCategory); consider consolidating this logic into a single shared implementation to avoid divergence over time.
  • The ParameterInfo.IsObsolete/ObsoleteMessage fields are populated but then filtered out and never used in the markdown output; either surface obsolete information in the generated docs (e.g., marking deprecated parameters) or remove the fields/logic to keep the model and analyzer minimal.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- There are two separate implementations of component categorization (`MarkdownBuilder.CategorizeComponents` and the unused `DocsGenerator.GetComponentCategory`); consider consolidating this logic into a single shared implementation to avoid divergence over time.
- The `ParameterInfo.IsObsolete`/`ObsoleteMessage` fields are populated but then filtered out and never used in the markdown output; either surface obsolete information in the generated docs (e.g., marking deprecated parameters) or remove the fields/logic to keep the model and analyzer minimal.

## Individual Comments

### Comment 1
<location> `tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs:34-43` </location>
<code_context>
+        _markdownBuilder = new MarkdownBuilder();
+    }
+
+    private string FindSourcePath(string? rootFolder)
+    {
+        // Try to find src/BootstrapBlazor from current directory or parent directories
+        var current = rootFolder ?? AppContext.BaseDirectory;
+        Logger($"Root path: {current}");
+
+        while (!string.IsNullOrEmpty(current))
+        {
+            var parent = Directory.GetParent(current);
+            if (parent == null)
+            {
+                break;
+            }
+            if (parent.Name.Equals("BootstrapBlazor", StringComparison.OrdinalIgnoreCase))
+            {
+                return parent.FullName;
</code_context>

<issue_to_address>
**issue (bug_risk):** Passing the repo root via --root is likely to fail the lookup logic in FindSourcePath

Because the loop only checks `parent.Name` for `"BootstrapBlazor"`, passing `--root` as the repo root (whose name is `BootstrapBlazor`) means `parent` becomes the directory above the repo and the check never matches. The search then walks past the repo and eventually throws at the filesystem root.

You can avoid this by also checking the current directory (or treating a non-null `rootFolder` as already resolved), for example:

```csharp
var current = rootFolder ?? AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(current))
{
    var dir = new DirectoryInfo(current);
    if (dir.Name.Equals("BootstrapBlazor", StringComparison.OrdinalIgnoreCase))
    {
        return dir.FullName;
    }

    var parent = dir.Parent;
    if (parent == null) break;
    current = parent.FullName;
}
```
</issue_to_address>

### Comment 2
<location> `tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs:307-316` </location>
<code_context>
+        return value;
+    }
+
+    private string SimplifyTypeName(string typeName)
+    {
+        // Remove common namespace prefixes
+        var result = typeName
+            .Replace("System.", "")
+            .Replace("Collections.Generic.", "")
+            .Replace("Threading.Tasks.", "");
+
+        // Handle Nullable<T> -> T? (must be done carefully to not break other generics)
+        result = NullableRegex().Replace(result, "$1?");
+
+        // Simplify primitive type names
+        result = result
+            .Replace("Int32", "int")
+            .Replace("Int64", "long")
+            .Replace("Boolean", "bool")
+            .Replace("String", "string")
+            .Replace("Object", "object");
+
+        return result;
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Naive string replacements in SimplifyTypeName can corrupt more complex type names

`string.Replace` here can alter substrings inside larger identifiers or namespaces, e.g. `MyInt32Wrapper``MyintWrapper`, or a custom namespace containing `System.`. It also won’t robustly handle forms like `global::System.Int32` or nested generics. Prefer deriving the display name from Roslyn symbol info where possible, or at least restrict replacements to whole identifiers (e.g., via regex with word boundaries) to prevent unintended substitutions.

Suggested implementation:

```csharp
    private string SimplifyTypeName(string typeName)
    {
        // First, normalize primitive type names based on identifier tokens (with optional System/global::System prefixes)
        var result = PrimitiveTypeRegex().Replace(
            typeName,
            static m =>
            {
                // Group 1 contains the primitive identifier without namespace
                return m.Groups[1].Value switch
                {
                    "Int32" => "int",
                    "Int64" => "long",
                    "Boolean" => "bool",
                    "String" => "string",
                    "Object" => "object",
                    _ => m.Value
                };
            });

        // Handle Nullable<T> -> T? (must be done carefully to not break other generics)
        result = NullableRegex().Replace(result, "$1?");

        // Remove common namespace prefixes, but only when they appear as whole identifiers
        result = SystemNamespaceRegex().Replace(result, string.Empty);
        result = CollectionsGenericNamespaceRegex().Replace(result, string.Empty);
        result = ThreadingTasksNamespaceRegex().Replace(result, string.Empty);

        return result;
    }

    [GeneratedRegex(@"Nullable<([^>]+)>")]
    private static partial Regex NullableRegex();

    // Matches primitive CLR types with optional System/global::System prefix:
    //   Int32
    //   System.Int32
    //   global::System.Int32
    [GeneratedRegex(@"\b(?:(?:global::)?System\.)?(Int32|Int64|Boolean|String|Object)\b")]
    private static partial Regex PrimitiveTypeRegex();

    // Match System. / global::System. when used as a namespace (not inside a larger identifier)
    [GeneratedRegex(@"\b(?:global::)?System\.")]
    private static partial Regex SystemNamespaceRegex();

    // Match Collections.Generic. when used as a namespace segment
    [GeneratedRegex(@"\bCollections\.Generic\.")]
    private static partial Regex CollectionsGenericNamespaceRegex();

    // Match Threading.Tasks. when used as a namespace segment
    [GeneratedRegex(@"\bThreading\.Tasks\.")]
    private static partial Regex ThreadingTasksNamespaceRegex();

```

This change assumes `ComponentAnalyzer` is already declared as a `partial` class to support `partial` regex methods via `GeneratedRegex` (as is already the case for `NullableRegex`). If the class is not partial elsewhere in the file, ensure its declaration includes the `partial` keyword so these generated methods compile correctly.
</issue_to_address>

### Comment 3
<location> `tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs:329-330` </location>
<code_context>
+        return result;
+    }
+
+    [GeneratedRegex(@"Nullable<([^>]+)>")]
+    private static partial Regex NullableRegex();
+
+    private bool HasAttribute(MemberDeclarationSyntax member, string attributeName)
</code_context>

<issue_to_address>
**issue (bug_risk):** NullableRegex does not handle nested generic type arguments correctly

The regex stops at the first `>`, so nested generics like `Nullable<List<int>>` won’t be matched correctly and the type string can be partially rewritten, leading to misleading docs. To avoid mangling, either use a more robust approach (e.g., syntax tree/type model) for `Nullable<T>` or explicitly limit the regex to simple cases with no inner `>` and skip more complex types.
</issue_to_address>

### Comment 4
<location> `tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs:191-200` </location>
<code_context>
+    private static string GetComponentCategory(string componentName)
</code_context>

<issue_to_address>
**suggestion:** GetComponentCategory in DocsGenerator appears unused and overlaps with MarkdownBuilder logic

This method isn’t used in `DocsGenerator`, and similar categorization logic already exists in `MarkdownBuilder`. Unless there’s an immediate plan to use it, consider removing it or extracting a shared categorization helper for both classes to avoid duplication and dead code.

Suggested implementation:

```csharp

```

1. If `DocsGenerator` later needs component categorization, extract the existing logic from `MarkdownBuilder` into a shared helper (e.g., a static utility class in a shared project/namespace) and have both `DocsGenerator` and `MarkdownBuilder` call that shared helper instead of duplicating the logic.
2. Ensure any future categorization changes are made only in that shared helper to keep behavior consistent across both classes.
</issue_to_address>

### Comment 5
<location> `tools/BootstrapBlazor.LLMsDocsGenerator/README.md:198` </location>
<code_context>
+
+## Output Format
+
+Each component documentation includes:
+
+```markdown
</code_context>

<issue_to_address>
**suggestion (typo):** Minor grammar improvement for "Each component documentation includes".

Consider changing this to either "Each component's documentation includes:" or "Each component documentation file includes:" for more natural phrasing.

```suggestion
Each component's documentation includes:
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +34 to +43
private string FindSourcePath(string? rootFolder)
{
// Try to find src/BootstrapBlazor from current directory or parent directories
var current = rootFolder ?? AppContext.BaseDirectory;
Logger($"Root path: {current}");

while (!string.IsNullOrEmpty(current))
{
var parent = Directory.GetParent(current);
if (parent == null)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Passing the repo root via --root is likely to fail the lookup logic in FindSourcePath

Because the loop only checks parent.Name for "BootstrapBlazor", passing --root as the repo root (whose name is BootstrapBlazor) means parent becomes the directory above the repo and the check never matches. The search then walks past the repo and eventually throws at the filesystem root.

You can avoid this by also checking the current directory (or treating a non-null rootFolder as already resolved), for example:

var current = rootFolder ?? AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(current))
{
    var dir = new DirectoryInfo(current);
    if (dir.Name.Equals("BootstrapBlazor", StringComparison.OrdinalIgnoreCase))
    {
        return dir.FullName;
    }

    var parent = dir.Parent;
    if (parent == null) break;
    current = parent.FullName;
}

Comment on lines +307 to +316
private string SimplifyTypeName(string typeName)
{
// Remove common namespace prefixes
var result = typeName
.Replace("System.", "")
.Replace("Collections.Generic.", "")
.Replace("Threading.Tasks.", "");

// Handle Nullable<T> -> T? (must be done carefully to not break other generics)
result = NullableRegex().Replace(result, "$1?");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Naive string replacements in SimplifyTypeName can corrupt more complex type names

string.Replace here can alter substrings inside larger identifiers or namespaces, e.g. MyInt32WrapperMyintWrapper, or a custom namespace containing System.. It also won’t robustly handle forms like global::System.Int32 or nested generics. Prefer deriving the display name from Roslyn symbol info where possible, or at least restrict replacements to whole identifiers (e.g., via regex with word boundaries) to prevent unintended substitutions.

Suggested implementation:

    private string SimplifyTypeName(string typeName)
    {
        // First, normalize primitive type names based on identifier tokens (with optional System/global::System prefixes)
        var result = PrimitiveTypeRegex().Replace(
            typeName,
            static m =>
            {
                // Group 1 contains the primitive identifier without namespace
                return m.Groups[1].Value switch
                {
                    "Int32" => "int",
                    "Int64" => "long",
                    "Boolean" => "bool",
                    "String" => "string",
                    "Object" => "object",
                    _ => m.Value
                };
            });

        // Handle Nullable<T> -> T? (must be done carefully to not break other generics)
        result = NullableRegex().Replace(result, "$1?");

        // Remove common namespace prefixes, but only when they appear as whole identifiers
        result = SystemNamespaceRegex().Replace(result, string.Empty);
        result = CollectionsGenericNamespaceRegex().Replace(result, string.Empty);
        result = ThreadingTasksNamespaceRegex().Replace(result, string.Empty);

        return result;
    }

    [GeneratedRegex(@"Nullable<([^>]+)>")]
    private static partial Regex NullableRegex();

    // Matches primitive CLR types with optional System/global::System prefix:
    //   Int32
    //   System.Int32
    //   global::System.Int32
    [GeneratedRegex(@"\b(?:(?:global::)?System\.)?(Int32|Int64|Boolean|String|Object)\b")]
    private static partial Regex PrimitiveTypeRegex();

    // Match System. / global::System. when used as a namespace (not inside a larger identifier)
    [GeneratedRegex(@"\b(?:global::)?System\.")]
    private static partial Regex SystemNamespaceRegex();

    // Match Collections.Generic. when used as a namespace segment
    [GeneratedRegex(@"\bCollections\.Generic\.")]
    private static partial Regex CollectionsGenericNamespaceRegex();

    // Match Threading.Tasks. when used as a namespace segment
    [GeneratedRegex(@"\bThreading\.Tasks\.")]
    private static partial Regex ThreadingTasksNamespaceRegex();

This change assumes ComponentAnalyzer is already declared as a partial class to support partial regex methods via GeneratedRegex (as is already the case for NullableRegex). If the class is not partial elsewhere in the file, ensure its declaration includes the partial keyword so these generated methods compile correctly.

Comment on lines +329 to +330
[GeneratedRegex(@"Nullable<([^>]+)>")]
private static partial Regex NullableRegex();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): NullableRegex does not handle nested generic type arguments correctly

The regex stops at the first >, so nested generics like Nullable<List<int>> won’t be matched correctly and the type string can be partially rewritten, leading to misleading docs. To avoid mangling, either use a more robust approach (e.g., syntax tree/type model) for Nullable<T> or explicitly limit the regex to simple cases with no inner > and skip more complex types.

Comment on lines +191 to +200
private static string GetComponentCategory(string componentName)
{
return componentName.ToLowerInvariant() switch
{
// Table family
var n when n.Contains("table") => "table",

// Input family
var n when n.Contains("input") || n.Contains("textarea") ||
n.Contains("password") || n == "otpinput" => "input",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: GetComponentCategory in DocsGenerator appears unused and overlaps with MarkdownBuilder logic

This method isn’t used in DocsGenerator, and similar categorization logic already exists in MarkdownBuilder. Unless there’s an immediate plan to use it, consider removing it or extracting a shared categorization helper for both classes to avoid duplication and dead code.

Suggested implementation:

  1. If DocsGenerator later needs component categorization, extract the existing logic from MarkdownBuilder into a shared helper (e.g., a static utility class in a shared project/namespace) and have both DocsGenerator and MarkdownBuilder call that shared helper instead of duplicating the logic.
  2. Ensure any future categorization changes are made only in that shared helper to keep behavior consistent across both classes.


## Output Format

Each component documentation includes:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): Minor grammar improvement for "Each component documentation includes".

Consider changing this to either "Each component's documentation includes:" or "Each component documentation file includes:" for more natural phrasing.

Suggested change
Each component documentation includes:
Each component's documentation includes:

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new tool called LLMsDocsGenerator that automatically generates LLM-friendly documentation for BootstrapBlazor components by analyzing source code using Roslyn and creating structured Markdown files.

Key Changes:

  • Adds a .NET global tool that uses Roslyn to extract component parameters, methods, and documentation from source code
  • Generates individual documentation files per component (components/*.txt) plus a master index (llms.txt) for LLM consumption
  • Provides CI/CD integration capabilities to ensure documentation stays synchronized with code changes

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tools/BootstrapBlazor.LLMsDocsGenerator/README.md English documentation explaining the tool's purpose, architecture, and usage
tools/BootstrapBlazor.LLMsDocsGenerator/README.zh-CN.md Chinese translation of the documentation
tools/BootstrapBlazor.LLMsDocsGenerator/Program.cs CLI entry point with command-line argument parsing
tools/BootstrapBlazor.LLMsDocsGenerator/MarkdownBuilder.cs Generates Markdown documentation from component metadata
tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs Orchestrates the documentation generation workflow
tools/BootstrapBlazor.LLMsDocsGenerator/ComponentInfo.cs Data models for component, parameter, and method information
tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs Roslyn-based analyzer that extracts component information from C# source files
tools/BootstrapBlazor.LLMsDocsGenerator/BootstrapBlazor.LLMsDocsGenerator.csproj Project configuration defining the tool as a .NET global tool
BootstrapBlazor.Extensions.slnx Adds the new project to the solution

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +191 to +235
private static string GetComponentCategory(string componentName)
{
return componentName.ToLowerInvariant() switch
{
// Table family
var n when n.Contains("table") => "table",

// Input family
var n when n.Contains("input") || n.Contains("textarea") ||
n.Contains("password") || n == "otpinput" => "input",

// Select family
var n when n.Contains("select") || n.Contains("dropdown") ||
n.Contains("autocomplete") || n.Contains("cascader") ||
n.Contains("transfer") || n.Contains("multiselect") => "select",

// Button family
var n when n.Contains("button") || n == "gotop" ||
n.Contains("popconfirm") => "button",

// Dialog family
var n when n.Contains("dialog") || n.Contains("modal") ||
n.Contains("drawer") || n.Contains("swal") ||
n.Contains("toast") || n.Contains("message") => "dialog",

// Navigation family
var n when n.Contains("menu") || n.Contains("tab") ||
n.Contains("breadcrumb") || n.Contains("step") ||
n.Contains("anchor") || n.Contains("nav") => "nav",

// Card/Container family
var n when n.Contains("card") || n.Contains("collapse") ||
n.Contains("groupbox") || n.Contains("panel") => "card",

// TreeView
var n when n.Contains("tree") => "treeview",

// Form
var n when n.Contains("validateform") || n.Contains("editorform") ||
n.Contains("validator") => "form",

_ => "other"
};
}

Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method GetComponentCategory is defined but never called within DocsGenerator.cs. The categorization logic is actually performed in MarkdownBuilder.CategorizeComponents. This unused method adds confusion and should be removed to improve code clarity.

Suggested change
private static string GetComponentCategory(string componentName)
{
return componentName.ToLowerInvariant() switch
{
// Table family
var n when n.Contains("table") => "table",
// Input family
var n when n.Contains("input") || n.Contains("textarea") ||
n.Contains("password") || n == "otpinput" => "input",
// Select family
var n when n.Contains("select") || n.Contains("dropdown") ||
n.Contains("autocomplete") || n.Contains("cascader") ||
n.Contains("transfer") || n.Contains("multiselect") => "select",
// Button family
var n when n.Contains("button") || n == "gotop" ||
n.Contains("popconfirm") => "button",
// Dialog family
var n when n.Contains("dialog") || n.Contains("modal") ||
n.Contains("drawer") || n.Contains("swal") ||
n.Contains("toast") || n.Contains("message") => "dialog",
// Navigation family
var n when n.Contains("menu") || n.Contains("tab") ||
n.Contains("breadcrumb") || n.Contains("step") ||
n.Contains("anchor") || n.Contains("nav") => "nav",
// Card/Container family
var n when n.Contains("card") || n.Contains("collapse") ||
n.Contains("groupbox") || n.Contains("panel") => "card",
// TreeView
var n when n.Contains("tree") => "treeview",
// Form
var n when n.Contains("validateform") || n.Contains("editorform") ||
n.Contains("validator") => "form",
_ => "other"
};
}

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +38
{
Console.WriteLine($"Components directory not found: {_componentsPath}");
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Console.WriteLine call on line 38 should respect the debug flag. Currently, this message is always printed even when not in debug mode. Consider using the Logger method instead to maintain consistency with the rest of the application's logging behavior.

Suggested change
{
Console.WriteLine($"Components directory not found: {_componentsPath}");
{
#if DEBUG
Console.WriteLine($"Components directory not found: {_componentsPath}");
#endif

Copilot uses AI. Check for mistakes.
}
catch (Exception ex)
{
Console.WriteLine($"Error analyzing {filePath}: {ex.Message}");
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Console.WriteLine call on line 131 should use a structured logging approach or at minimum include more context about the error. Consider including the file path and more detailed error information to help with debugging when component analysis fails.

Suggested change
Console.WriteLine($"Error analyzing {filePath}: {ex.Message}");
Console.Error.WriteLine(
$"Error analyzing component file. filePath=\"{filePath}\", exceptionType=\"{ex.GetType().FullName}\", exception={ex}");

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +12
var componentOption = new Option<string?>("--component") { Description = "Generate documentation for a specific component only" };
var indexOnlyOption = new Option<bool>("--index-only") { Description = "Generate only the index file (llms.txt)" };
var checkOption = new Option<bool>("--check") { Description = "Check if documentation is up-to-date (for CI/CD)" };
var rootFolderOption = new Option<string?>("--root") { Description = "Set the root folder of project" };
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Description property of Option instances should end with a period for consistency and proper grammar. All option descriptions should follow the same punctuation style.

Suggested change
var componentOption = new Option<string?>("--component") { Description = "Generate documentation for a specific component only" };
var indexOnlyOption = new Option<bool>("--index-only") { Description = "Generate only the index file (llms.txt)" };
var checkOption = new Option<bool>("--check") { Description = "Check if documentation is up-to-date (for CI/CD)" };
var rootFolderOption = new Option<string?>("--root") { Description = "Set the root folder of project" };
var componentOption = new Option<string?>("--component") { Description = "Generate documentation for a specific component only." };
var indexOnlyOption = new Option<bool>("--index-only") { Description = "Generate only the index file (llms.txt)." };
var checkOption = new Option<bool>("--check") { Description = "Check if documentation is up-to-date (for CI/CD)." };
var rootFolderOption = new Option<string?>("--root") { Description = "Set the root folder of project." };

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +53
rootCommand.SetAction(async result =>
{
var debug = result.GetValue(debugOption);
var rootFolder = result.GetValue(rootFolderOption);
var generator = new DocsGenerator(rootFolder, debug);

var check = result.GetValue(checkOption);
if (check)
{
var isUpToDate = await generator.CheckAsync();
Environment.ExitCode = isUpToDate ? 0 : 1;
return;
}

var indexOnly = result.GetValue(indexOnlyOption);
if (indexOnly)
{
await generator.GenerateIndexAsync();
return;
}

var component = result.GetValue(componentOption);
if (!string.IsNullOrEmpty(component))
{
await generator.GenerateComponentAsync(component);
return;
}

await generator.GenerateAllAsync();
});
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method uses SetAction which is not a standard method on RootCommand in System.CommandLine. The standard approach is to set the Handler property or use SetHandler. This code will likely fail at runtime.

Copilot uses AI. Check for mistakes.
Comment on lines +236 to +241
private void Logger(string message)
{
if (_debug)
{
Console.WriteLine(message);
}
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Logger method only outputs when debug is true, but in non-debug mode, users won't see any output including important messages like "Component not found" or error messages. Consider always outputting critical messages (errors, warnings) regardless of the debug flag, and only suppress verbose/informational messages.

Suggested change
private void Logger(string message)
{
if (_debug)
{
Console.WriteLine(message);
}
private enum LogLevel
{
Info,
Warning,
Error
}
private void Logger(string message, LogLevel level = LogLevel.Info)
{
// In debug mode, log everything.
if (_debug)
{
Console.WriteLine(message);
return;
}
// In non-debug mode, only log warnings and errors.
if (level == LogLevel.Warning || level == LogLevel.Error)
{
Console.WriteLine(message);
}

Copilot uses AI. Check for mistakes.
Comment on lines +224 to +242
foreach (var method in methodDeclarations)
{
// Skip property accessors, overrides of base class methods
if (method.Modifiers.Any(m => m.IsKind(SyntaxKind.OverrideKeyword)))
continue;

var methodInfo = new MethodInfo
{
Name = method.Identifier.Text,
ReturnType = SimplifyTypeName(method.ReturnType.ToString()),
Description = ExtractXmlSummary(method),
IsJSInvokable = HasAttribute(method, "JSInvokable"),
Parameters = method.ParameterList.Parameters
.Select(p => (SimplifyTypeName(p.Type?.ToString() ?? ""), p.Identifier.Text))
.ToList()
};

methods.Add(methodInfo);
}
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +52
foreach (var file in files)
{
var component = await AnalyzeFileAsync(file);
if (component != null && component.Parameters.Count > 0)
{
components.Add(component);
}
}
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop immediately maps its iteration variable to another variable - consider mapping the sequence explicitly using '.Select(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +63
foreach (var file in files)
{
var component = await AnalyzeFileAsync(file);
if (component != null && component.Parameters.Count > 0)
{
components.Add(component);
}
}

// Also analyze .cs files that might be component base classes
var csFiles = Directory.GetFiles(_componentsPath, "*Base.cs", SearchOption.AllDirectories);
foreach (var file in csFiles)
{
var component = await AnalyzeFileAsync(file);
if (component != null && component.Parameters.Count > 0)
{
components.Add(component);
}
}
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop immediately maps its iteration variable to another variable - consider mapping the sequence explicitly using '.Select(...)'.

Suggested change
foreach (var file in files)
{
var component = await AnalyzeFileAsync(file);
if (component != null && component.Parameters.Count > 0)
{
components.Add(component);
}
}
// Also analyze .cs files that might be component base classes
var csFiles = Directory.GetFiles(_componentsPath, "*Base.cs", SearchOption.AllDirectories);
foreach (var file in csFiles)
{
var component = await AnalyzeFileAsync(file);
if (component != null && component.Parameters.Count > 0)
{
components.Add(component);
}
}
var fileAnalysisTasks = files.Select(file => AnalyzeFileAsync(file));
var fileAnalysisResults = await Task.WhenAll(fileAnalysisTasks);
components.AddRange(fileAnalysisResults
.Where(component => component != null && component.Parameters.Count > 0)!);
// Also analyze .cs files that might be component base classes
var csFiles = Directory.GetFiles(_componentsPath, "*Base.cs", SearchOption.AllDirectories);
var csFileAnalysisTasks = csFiles.Select(file => AnalyzeFileAsync(file));
var csFileAnalysisResults = await Task.WhenAll(csFileAnalysisTasks);
components.AddRange(csFileAnalysisResults
.Where(component => component != null && component.Parameters.Count > 0)!);

Copilot uses AI. Check for mistakes.

if (classDeclaration.TypeParameterList != null)
{
className += classDeclaration.TypeParameterList.ToString();
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant call to 'ToString' on a String object.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(LLMs): add LLMs docs generator project

2 participants