From 74ae533e50549e107b346d44dda458d6ba4c733a Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 30 Oct 2026 11:51:08 +0800 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20=E8=B0=83=E6=95=B4=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E9=BB=98=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArgumentsHelper.cs | 17 ++- .../CategoryProvider.cs | 4 + .../ComponentAnalyzer.cs | 103 +++++++++++++++--- .../ComponentInfo.cs | 9 +- .../DocsGenerator.cs | 80 +++++++++++++- .../MarkdownBuilder.cs | 27 +++-- .../Program.cs | 2 +- .../README.md | 69 ++++++++++-- .../README.zh-CN.md | 69 ++++++++++-- .../Runner.cs | 4 +- 10 files changed, 322 insertions(+), 62 deletions(-) diff --git a/tools/BootstrapBlazor.LLMsDocsGenerator/ArgumentsHelper.cs b/tools/BootstrapBlazor.LLMsDocsGenerator/ArgumentsHelper.cs index b738813a..a118d615 100644 --- a/tools/BootstrapBlazor.LLMsDocsGenerator/ArgumentsHelper.cs +++ b/tools/BootstrapBlazor.LLMsDocsGenerator/ArgumentsHelper.cs @@ -11,8 +11,17 @@ internal static class ArgumentsHelper { public static ParseResult Parse(string[] args) { - var rootFolderOption = new Option("--root") { Description = "Set the root folder of project" }; - var outputFolderOption = new Option("--output") { Description = "Set the publish folder of project" }; + const string defaultFolder = "."; + var rootFolderOption = new Option("--root") + { + Description = "Set the root folder of project", + DefaultValueFactory = _ => defaultFolder + }; + var outputFolderOption = new Option("--output") + { + Description = "Set the publish folder of project", + DefaultValueFactory = _ => defaultFolder + }; var rootCommand = new RootCommand("BootstrapBlazor LLMs Documentation Generator") { @@ -25,13 +34,13 @@ public static ParseResult Parse(string[] args) var rootFolder = result.GetValue(rootFolderOption); if (string.IsNullOrEmpty(rootFolder)) { - return; + throw new ArgumentException("Project root folder must not be empty."); } var outputFolder = result.GetValue(outputFolderOption); if (string.IsNullOrEmpty(outputFolder)) { - return; + throw new ArgumentException("Publish folder must not be empty."); } await DocsGenerator.GenerateAllAsync(rootFolder, outputFolder); diff --git a/tools/BootstrapBlazor.LLMsDocsGenerator/CategoryProvider.cs b/tools/BootstrapBlazor.LLMsDocsGenerator/CategoryProvider.cs index 1446aeef..cbd774c7 100644 --- a/tools/BootstrapBlazor.LLMsDocsGenerator/CategoryProvider.cs +++ b/tools/BootstrapBlazor.LLMsDocsGenerator/CategoryProvider.cs @@ -48,6 +48,10 @@ internal sealed class CategoryProvider public CategoryProvider(string serverPath) { _docsFile = Path.Combine(serverPath, "docs.json"); + if (!File.Exists(_docsFile)) + { + throw new FileNotFoundException("Documentation configuration not found.", _docsFile); + } } /// diff --git a/tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs b/tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs index 6d395474..0b1fb7d3 100644 --- a/tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs +++ b/tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs @@ -7,6 +7,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using System.Text.RegularExpressions; +using System.Xml.Linq; namespace BootstrapBlazorLLMsDocsGenerator; @@ -26,6 +27,7 @@ public partial class ComponentAnalyzer private readonly string _componentsPath; private readonly string _samplesPath; private readonly string? _extensionsSourcePath; + private readonly IReadOnlyDictionary? _extensionProjects; private CSharpCompilation? _compilation; private readonly Dictionary _treeByPath = new(StringComparer.OrdinalIgnoreCase); @@ -43,12 +45,14 @@ public partial class ComponentAnalyzer typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters); - public ComponentAnalyzer(string sourcePath, string? extensionsSourcePath = null) + public ComponentAnalyzer(string sourcePath, string? extensionsSourcePath = null, + IReadOnlyDictionary? extensionProjects = null) { _sourcePath = sourcePath; _componentsPath = Path.Combine(sourcePath, "Components"); _samplesPath = Path.Combine(Path.GetDirectoryName(sourcePath)!, "BootstrapBlazor.Server", "Components", "Samples"); _extensionsSourcePath = extensionsSourcePath; + _extensionProjects = extensionProjects; } /// @@ -69,8 +73,11 @@ public async Task> AnalyzeAllComponentsAsync() // Entry points: component code-behind files and explicit base classes. // Other partial files (e.g. Table.razor.Toolbar.cs) are NOT entry points; // their members are merged into the component via the symbol model. + // Razor-only components use the synthesized @inherits declaration. var entryFiles = Directory.GetFiles(_componentsPath, "*.razor.cs", SearchOption.AllDirectories) .Concat(Directory.GetFiles(_componentsPath, "*Base.cs", SearchOption.AllDirectories)) + .Concat(Directory.GetFiles(_componentsPath, "*.razor", SearchOption.AllDirectories) + .Where(file => !File.Exists(file + ".cs") && _treeByPath.ContainsKey(Path.GetFullPath(file)))) .Distinct(StringComparer.OrdinalIgnoreCase); var seenTypes = new HashSet(StringComparer.Ordinal); @@ -104,6 +111,10 @@ public async Task> AnalyzeAllComponentsAsync() { files = Directory.GetFiles(_componentsPath, $"{componentName}.cs", SearchOption.AllDirectories); } + if (files.Length == 0) + { + files = Directory.GetFiles(_componentsPath, $"{componentName}.razor", SearchOption.AllDirectories); + } return files.Length == 0 ? null : AnalyzeEntryFile(files[0]); } @@ -126,7 +137,7 @@ public async Task> AnalyzeServicesAsync(IEnumerable var syntax = symbol?.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(); if (symbol == null || syntax == null) { - continue; + throw new InvalidDataException($"Service type not found in analyzed sources: {name}"); } services.Add(new ComponentInfo @@ -147,7 +158,8 @@ public async Task> AnalyzeServicesAsync(IEnumerable /// ComponentInfo records with parameters, methods and source path. The compilation /// must include both the extension sources and the core library (for base types). /// - public async Task> AnalyzeTypesByNameAsync(IEnumerable typeNames, bool resolveRegistration = false) + public async Task> AnalyzeTypesByNameAsync(IEnumerable typeNames, + bool resolveRegistration = false, IReadOnlyList? registrationPackages = null) { await EnsureCompilationAsync(); @@ -161,7 +173,7 @@ public async Task> AnalyzeTypesByNameAsync(IEnumerable> AnalyzeTypesByNameAsync(IEnumerable p.Services.Contains(name)) + .Select(p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase)) + : new() }); } @@ -192,15 +207,17 @@ public async Task> AnalyzeTypesByNameAsync(IEnumerable? _registrationIndex; + private Dictionary>? _registrationIndex; /// /// Look up the extension method that registers a service type into DI. /// - private ServiceRegistration? ResolveRegistration(INamedTypeSymbol serviceType) + private List ResolveRegistrations(INamedTypeSymbol serviceType, HashSet? packageNames) { BuildRegistrationIndex(); - return _registrationIndex!.TryGetValue(serviceType, out var reg) ? reg : null; + return _registrationIndex!.TryGetValue(serviceType, out var registrations) + ? registrations.Where(reg => packageNames == null || packageNames.Contains(reg.PackageName)).ToList() + : new(); } /// @@ -218,10 +235,14 @@ private void BuildRegistrationIndex() return; } - _registrationIndex = new Dictionary(SymbolEqualityComparer.Default); + _registrationIndex = new Dictionary>(SymbolEqualityComparer.Default); foreach (var tree in _compilation!.SyntaxTrees) { + var packageName = _extensionProjects?.FirstOrDefault(project => + Path.GetFullPath(tree.FilePath).StartsWith( + Path.GetFullPath(project.Value) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)).Key ?? ""; SemanticModel? model = null; foreach (var method in tree.GetRoot().DescendantNodes().OfType()) { @@ -253,14 +274,21 @@ private void BuildRegistrationIndex() continue; } - // First writer wins so the most direct registration method sticks. - if (!_registrationIndex.ContainsKey(serviceType)) + if (!_registrationIndex.TryGetValue(serviceType, out var registrations)) { - _registrationIndex[serviceType] = new ServiceRegistration + registrations = new(); + _registrationIndex[serviceType] = registrations; + } + + // First writer wins within each package so the most direct registration method sticks. + if (!registrations.Any(reg => reg.PackageName == packageName)) + { + registrations.Add(new ServiceRegistration { + PackageName = packageName, MethodName = method.Identifier.Text, Parameters = BuildRegistrationParameters(method) - }; + }); } } } @@ -298,14 +326,16 @@ private async Task EnsureCompilationAsync() // Include the core library and, when provided, the extensions repository so that // extension components (which inherit core base types) resolve their full chain. + // Solution project paths exclude leftover directories outside the solution. var sourceRoots = new List { _sourcePath }; if (!string.IsNullOrEmpty(_extensionsSourcePath) && Directory.Exists(_extensionsSourcePath)) { - sourceRoots.Add(_extensionsSourcePath); + sourceRoots.AddRange(_extensionProjects?.Values ?? new[] { _extensionsSourcePath }); } foreach (var root in sourceRoots) { + var projectUsings = ReadProjectUsings(root); foreach (var file in Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories).Where(IsCompilableSource)) { var fullPath = Path.GetFullPath(file); @@ -316,6 +346,14 @@ private async Task EnsureCompilationAsync() var code = await File.ReadAllTextAsync(file); var tree = CSharpSyntaxTree.ParseText(code, parseOptions, path: fullPath); + if (projectUsings.Count > 0) + { + // Keep project imports local to each file in the combined compilation. + var syntaxRoot = tree.GetCompilationUnitRoot(); + var imports = projectUsings.Where(import => + !syntaxRoot.Usings.Any(existing => existing.IsEquivalentTo(import))); + tree = CSharpSyntaxTree.Create(syntaxRoot.AddUsings(imports.ToArray()), parseOptions, fullPath); + } trees.Add(tree); _treeByPath[fullPath] = tree; } @@ -339,7 +377,9 @@ private async Task EnsureCompilationAsync() var shim = await BuildInheritsShimAsync(razorFile); if (shim != null) { - trees.Add(CSharpSyntaxTree.ParseText(shim, parseOptions)); + var tree = CSharpSyntaxTree.ParseText(shim, parseOptions, path: razorFile); + trees.Add(tree); + _treeByPath[Path.GetFullPath(razorFile)] = tree; } } } @@ -351,6 +391,35 @@ private async Task EnsureCompilationAsync() options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); } + private static List ReadProjectUsings(string projectDirectory) + { + var imports = new List(); + foreach (var projectFile in Directory.EnumerateFiles(projectDirectory, "*.csproj")) + { + var project = XDocument.Load(projectFile); + foreach (var item in project.Descendants("Using")) + { + var name = (string?)item.Attribute("Include"); + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + var alias = (string?)item.Attribute("Alias"); + var prefix = !string.IsNullOrWhiteSpace(alias) ? $"{alias} = " + : string.Equals((string?)item.Attribute("Static"), "true", StringComparison.OrdinalIgnoreCase) + ? "static " : ""; + var syntax = SyntaxFactory.ParseCompilationUnit($"using {prefix}{name};"); + if (syntax.ContainsDiagnostics) + { + throw new InvalidDataException($"Invalid project Using '{name}' in {projectFile}"); + } + imports.AddRange(syntax.Usings); + } + } + return imports; + } + /// /// Read the @inherits / @typeparam / @namespace directives from a /// .razor file and emit a minimal partial class that re-states the base type, so the @@ -720,6 +789,10 @@ private List CollectMethods(INamedTypeSymbol type) private static string DeriveClassName(string filePath) { var fileName = Path.GetFileName(filePath); + if (fileName.EndsWith(".razor", StringComparison.OrdinalIgnoreCase)) + { + return fileName[..^".razor".Length]; + } if (fileName.EndsWith(".razor.cs", StringComparison.OrdinalIgnoreCase)) { return fileName[..^".razor.cs".Length]; diff --git a/tools/BootstrapBlazor.LLMsDocsGenerator/ComponentInfo.cs b/tools/BootstrapBlazor.LLMsDocsGenerator/ComponentInfo.cs index d41d3340..a394d565 100644 --- a/tools/BootstrapBlazor.LLMsDocsGenerator/ComponentInfo.cs +++ b/tools/BootstrapBlazor.LLMsDocsGenerator/ComponentInfo.cs @@ -69,9 +69,9 @@ public class ComponentInfo /// /// Dependency-injection registration for an extension service, resolved from the /// AddXxx(this IServiceCollection) extension method that registers this type. - /// Null for components and for services with no discoverable registration method. + /// Empty for components and for services with no discoverable registration method. /// - public ServiceRegistration? Registration { get; set; } + public List Registrations { get; set; } = new(); } /// @@ -80,6 +80,11 @@ public class ComponentInfo /// public class ServiceRegistration { + /// + /// Extension package providing this registration. + /// + public string PackageName { get; set; } = string.Empty; + /// /// Registration extension method name, e.g. AddBootstrapBlazorWinBoxService. /// diff --git a/tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs b/tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs index 022192d0..94a16626 100644 --- a/tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs +++ b/tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone +using System.Xml.Linq; + namespace BootstrapBlazorLLMsDocsGenerator; internal static class DocsGenerator @@ -20,7 +22,7 @@ public static async Task GenerateAllAsync(string rootFolder, string outputFolder if (!Directory.Exists(_sourcePath)) { - return; + throw new DirectoryNotFoundException($"Core source directory not found: {_sourcePath}"); } Logger("Analyzing components..."); @@ -31,7 +33,11 @@ public static async Task GenerateAllAsync(string rootFolder, string outputFolder var extensionsSrc = Path.Combine(Path.GetDirectoryName(coreRepoRoot)!, "BootstrapBlazor.Extensions", "src"); var hasExtensions = Directory.Exists(extensionsSrc); - var _analyzer = new ComponentAnalyzer(_sourcePath, hasExtensions ? extensionsSrc : null); + var extensionProjects = hasExtensions + ? ReadExtensionProjects(Path.GetDirectoryName(extensionsSrc)!) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + var _analyzer = new ComponentAnalyzer(_sourcePath, hasExtensions ? extensionsSrc : null, + extensionProjects); var components = await _analyzer.AnalyzeAllComponentsAsync(); Logger($"Found {components.Count} components"); @@ -43,12 +49,28 @@ public static async Task GenerateAllAsync(string rootFolder, string outputFolder var services = await _analyzer.AnalyzeServicesAsync(provider.ParseServices()); Logger($"Found {services.Count} services"); - var extPackages = provider.ParseExtensions(); + var configuredPackages = provider.ParseExtensions(); + if (!hasExtensions && configuredPackages.Count > 0) + { + throw new DirectoryNotFoundException($"Extension sources required by docs.json were not found: {extensionsSrc}"); + } + + var extPackages = configuredPackages.Where(package => + { + if (extensionProjects.ContainsKey(package.Name)) + { + return true; + } + + Console.Error.WriteLine($"Skipping extension package not in the solution: {package.Name}"); + return false; + }).ToList(); var extComponents = hasExtensions ? await _analyzer.AnalyzeTypesByNameAsync(extPackages.SelectMany(p => p.Components).Distinct()) : new List(); var extServices = hasExtensions - ? await _analyzer.AnalyzeTypesByNameAsync(extPackages.SelectMany(p => p.Services).Distinct(), resolveRegistration: true) + ? await _analyzer.AnalyzeTypesByNameAsync(extPackages.SelectMany(p => p.Services).Distinct(), + resolveRegistration: true, registrationPackages: extPackages) : new List(); Logger($"Found {extComponents.Count} extension components, {extServices.Count} extension services"); @@ -101,9 +123,59 @@ public static async Task GenerateAllAsync(string rootFolder, string outputFolder Logger($"Generated {extComponents.Count + extServices.Count} extension files in {extOutputPath}"); } + RemoveStaleDocuments(componentsOutputPath, components.Concat(services)); + RemoveStaleDocuments(Path.Combine(_outputPath, "extensions"), extComponents.Concat(extServices)); + Logger("Documentation generation complete!"); } + private static Dictionary ReadExtensionProjects(string repositoryRoot) + { + var solutionPath = Path.Combine(repositoryRoot, "BootstrapBlazor.Extensions.slnx"); + var solution = XDocument.Load(solutionPath); + var sourceRoot = Path.GetFullPath(Path.Combine(repositoryRoot, "src")) + Path.DirectorySeparatorChar; + var projects = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var project in solution.Descendants("Project")) + { + var relativePath = (string?)project.Attribute("Path") + ?? throw new InvalidDataException($"Project without a path in {solutionPath}"); + var projectPath = Path.GetFullPath(Path.Combine(repositoryRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!projectPath.StartsWith(sourceRoot, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!File.Exists(projectPath)) + { + throw new FileNotFoundException("Extension solution project not found.", projectPath); + } + + projects.Add(Path.GetFileNameWithoutExtension(projectPath), Path.GetDirectoryName(projectPath)!); + } + + return projects; + } + + private static void RemoveStaleDocuments(string directory, IEnumerable types) + { + if (!Directory.Exists(directory)) + { + return; + } + + var expected = types.Select(type => $"{type.Name}.txt").ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var file in Directory.EnumerateFiles(directory, "*.txt")) + { + if (!expected.Contains(Path.GetFileName(file)) + && File.ReadAllText(file).Contains("` marker, while preserving other files. + +If extension packages are configured but the sibling extension source checkout is missing, generation fails before changing existing documentation. Unresolved configured component or service types also fail generation before writing files or removing stale documents. Services shared by multiple packages retain one document listing the registration method for each configured package; choose the registration matching the package in use. + +Explicit `` items in each source project's `.csproj` (including aliases and static imports) are applied to that project's C# files, without leaking these imports into other projects in the combined analysis. + +This is source analysis, not a full MSBuild evaluation: conditions, property expansion, imported `.props`/`.targets`, and `Using Remove` are not evaluated. Razor-only support synthesizes inheritance declarations; it does not compile Razor or extract members from `@code` blocks. + +Declare extension services by the types actually registered for injection. For example, both speech packages use `"services": ["IRecognizerProvider", "ISynthesizerProvider"]`, not their provider implementation classes. + +Generation errors and explicitly empty paths return a nonzero process exit code so MSBuild and CI can stop. + +Run the integration checks with PowerShell: + +```powershell +.\tools\BootstrapBlazor.LLMsDocsGenerator\tests\Verify-Generation.ps1 +``` ### Example -Running from the BootstrapBlazor repository root, pointing both options at the server project: +With the `BootstrapBlazor` and `BootstrapBlazor.Extensions` repositories checked out as sibling directories, run from the `BootstrapBlazor` repository root and point both options at the server project: ```bash llms-docs --root src/BootstrapBlazor.Server --output src/BootstrapBlazor.Server @@ -131,26 +159,43 @@ This reads components from `src/BootstrapBlazor` and produces: ``` src/BootstrapBlazor.Server/wwwroot/llms/llms.txt src/BootstrapBlazor.Server/wwwroot/llms/components/*.txt +src/BootstrapBlazor.Server/wwwroot/llms/extensions/*.txt ``` -The same command works through `dotnet run`: +The index groups extension docs by package, for example `BootstrapBlazor.Region`; `SelectCity` is generated as `extensions/SelectCity.txt`. -```bash -dotnet run --project tools/BootstrapBlazor.LLMsDocsGenerator -- --root src/BootstrapBlazor.Server --output src/BootstrapBlazor.Server +From the same `BootstrapBlazor` repository root, run the sibling source project: + +```powershell +dotnet run --project "..\BootstrapBlazor.Extensions\tools\BootstrapBlazor.LLMsDocsGenerator" --root "src\BootstrapBlazor.Server" --output "src\BootstrapBlazor.Server" ``` ## CI/CD Integration -Regenerate the documentation as part of a workflow that publishes the doc site: +Check out both repositories as siblings and use an SDK matching the checked-out generator project. The following steps assume the workflow belongs to `dotnetcore/BootstrapBlazor`; pin compatible refs when building a release: ```yaml +- uses: actions/checkout@v4 + with: + path: BootstrapBlazor +- uses: actions/checkout@v4 + with: + repository: BootstrapBlazor/BootstrapBlazor.Extensions + path: BootstrapBlazor.Extensions +- uses: actions/setup-dotnet@v4 + with: + dotnet-version: '11.0.x' + dotnet-quality: 'preview' - name: Generate LLM Documentation + working-directory: BootstrapBlazor run: > - dotnet run --project tools/BootstrapBlazor.LLMsDocsGenerator -- + dotnet run --project ../BootstrapBlazor.Extensions/tools/BootstrapBlazor.LLMsDocsGenerator --root src/BootstrapBlazor.Server --output src/BootstrapBlazor.Server ``` +The BB Server publish target instead restores and runs the local `llms-docs` tool pinned in BB's `dotnet-tools.json`. Its working directory is the Server project directory (`$(MSBuildThisFileDirectory)`); it uses the default `--root` and passes only `--output` with the absolute publish directory. Editing generator source does not update that package; publish a new tool version supporting these defaults and update the manifest before using the simplified target. + ## Output Format Documentation is generated in Chinese, extracted from the bilingual `` blocks in the source XML comments. Each component file (`components/.txt`) contains markdown like: diff --git a/tools/BootstrapBlazor.LLMsDocsGenerator/README.zh-CN.md b/tools/BootstrapBlazor.LLMsDocsGenerator/README.zh-CN.md index c57d42b4..126c2f7c 100644 --- a/tools/BootstrapBlazor.LLMsDocsGenerator/README.zh-CN.md +++ b/tools/BootstrapBlazor.LLMsDocsGenerator/README.zh-CN.md @@ -33,6 +33,9 @@ AI 编程助手(Claude Code、Cursor、GitHub Copilot)经常因为缺乏准 │ ├── Table.txt → Table 组件 API 参考 │ │ ├── Select.txt → Select 组件 API 参考 │ │ └── ... → 每个组件一个文件 │ +│ extensions/ → BootstrapBlazor.Extensions 文档 │ +│ ├── SelectCity.txt → 扩展组件 API 参考 │ +│ └── ... → 每个扩展类型一个文件 │ └─────────────────────────────────────────────────────────────┘ ``` @@ -81,14 +84,17 @@ AI 编程助手(Claude Code、Cursor、GitHub Copilot)经常因为缺乏准 ## 环境要求 -- .NET 10 SDK +- 与工程 `TargetFrameworks` 匹配的 SDK(当前源码构建使用 .NET 11)。 +- 本地 `BootstrapBlazor` 仓库;`docs.json` 配置了扩展包时,还需要同级的 `BootstrapBlazor.Extensions` 仓库。生成器不会自动克隆仓库。 ## 安装 ### 从源代码运行 -```bash -dotnet run --project tools/BootstrapBlazor.LLMsDocsGenerator -- --root --output +在 `BootstrapBlazor.Extensions` 仓库根目录执行: + +```powershell +dotnet run --project tools\BootstrapBlazor.LLMsDocsGenerator --root "..\BootstrapBlazor\src\BootstrapBlazor.Server" --output "..\BootstrapBlazor\src\BootstrapBlazor.Server" ``` ### 作为全局工具安装 @@ -111,16 +117,38 @@ dotnet tool uninstall --global BootstrapBlazor.LLMsDocsGenerator ## 使用方法 -生成器要求**同时**提供 `--root` 和 `--output`,缺少任意一个都会直接退出且不生成任何文件。 +两个参数均可省略,各自默认为当前工作目录(`.`)。在 BB 的 `src/BootstrapBlazor.Server` 目录直接执行 `llms-docs` 即可将文档生成到该工程。覆盖 `--root` 不会改变 `--output` 的默认值;使用其他仓库或发布目录时请分别指定。 + +在 Server 工程目录运行时也支持 `dotnet llms-docs --output=bin\Release\publish\`,省略的 `--root` 使用 `.`,相对输出路径也基于当前 Server 目录解析。MSBuild 中可使用 `dotnet llms-docs --output=$(PublishDir)`(路径不含空格时)。 | 选项 | 必需 | 说明 | |------|------|------| -| `--root` | 是 | 项目根目录。组件源码从 `/../BootstrapBlazor` 读取,因此通常指向 `BootstrapBlazor.Server` 项目目录。 | -| `--output` | 是 | 发布目录。文档生成到 `/wwwroot/llms/`。 | +| `--root` | 否 | 项目根目录,默认为 `.`。组件源码从 `/../BootstrapBlazor` 读取。 | +| `--output` | 否 | 发布目录,默认为 `.`。文档生成到 `/wwwroot/llms/`。 | + +当 `BootstrapBlazor.Extensions` 仓库与核心 `BootstrapBlazor` 仓库并列存在时,生成器也会分析扩展仓库源码。需在 `src/BootstrapBlazor.Server/docs.json` 的 `extensions` 节中配置要生成文档的扩展包和类型;这是唯一配置位置,文件不存在时生成失败。扩展文档会写入 `extensions/` 目录。 + +仅扫描 `BootstrapBlazor.Extensions.slnx` 中列出的源码工程,按工程文件名而非目录名匹配扩展包。配置中不属于解决方案的包会输出提示并跳过;解决方案引用了不存在的工程时会报错。磁盘上残留的其他目录不会参与分析。核心库中仅有 `.razor` 文件、通过 `@inherits` 指定基类的组件也支持生成文档。重新生成时会清理带有 `` 标记的过期 `.txt` 文档,保留其他文件。 + +配置了扩展包但缺少同级扩展源码仓库时,生成会失败且不改动已有文档。配置中的组件或服务类型无法解析时,也会在写入文件、清理过期文档之前报错。多个包共用的服务接口仍使用一份文档,按配置的包分别列出注册方法,使用时选择对应包的注册方式。 + +源码工程 `.csproj` 中显式声明的 ``(包括别名和静态导入)会应用到该工程的 C# 文件,不会在合并分析时泄漏到其他工程。 + +此工具进行源码分析,不执行完整的 MSBuild 求值:不处理条件、属性展开、导入的 `.props`/`.targets` 及 `Using Remove`。纯 Razor 支持仅合成继承声明,不编译 Razor,也不提取 `@code` 块中的成员。 + +扩展服务应配置实际用于依赖注入的注册类型。例如,两个语音包均使用 `"services": ["IRecognizerProvider", "ISynthesizerProvider"]`,而不是 Provider 实现类。 + +生成错误或显式传入空路径时会返回非零进程退出码,便于 MSBuild 和 CI 中止。 + +使用 PowerShell 执行集成检查: + +```powershell +.\tools\BootstrapBlazor.LLMsDocsGenerator\tests\Verify-Generation.ps1 +``` ### 示例 -在 BootstrapBlazor 仓库根目录下执行,两个选项都指向 Server 项目: +将 `BootstrapBlazor` 与 `BootstrapBlazor.Extensions` 仓库放在同级目录后,在 BootstrapBlazor 仓库根目录执行,两个选项都指向 Server 项目: ```bash llms-docs --root src/BootstrapBlazor.Server --output src/BootstrapBlazor.Server @@ -131,26 +159,43 @@ llms-docs --root src/BootstrapBlazor.Server --output src/BootstrapBlazor.Server ``` src/BootstrapBlazor.Server/wwwroot/llms/llms.txt src/BootstrapBlazor.Server/wwwroot/llms/components/*.txt +src/BootstrapBlazor.Server/wwwroot/llms/extensions/*.txt ``` -通过 `dotnet run` 执行同样的命令: +索引会按扩展包分组,例如 `BootstrapBlazor.Region`;`SelectCity` 文档生成到 `extensions/SelectCity.txt`。 -```bash -dotnet run --project tools/BootstrapBlazor.LLMsDocsGenerator -- --root src/BootstrapBlazor.Server --output src/BootstrapBlazor.Server +仍在 `BootstrapBlazor` 仓库根目录,通过同级仓库的源码工程执行: + +```powershell +dotnet run --project "..\BootstrapBlazor.Extensions\tools\BootstrapBlazor.LLMsDocsGenerator" --root "src\BootstrapBlazor.Server" --output "src\BootstrapBlazor.Server" ``` ## CI/CD 集成 -在发布文档站点的工作流中重新生成文档: +将两个仓库检出到同级目录,使用与生成器工程匹配的 SDK。以下步骤假定工作流位于 `dotnetcore/BootstrapBlazor` 仓库;构建发布版本时请固定兼容的 ref: ```yaml +- uses: actions/checkout@v4 + with: + path: BootstrapBlazor +- uses: actions/checkout@v4 + with: + repository: BootstrapBlazor/BootstrapBlazor.Extensions + path: BootstrapBlazor.Extensions +- uses: actions/setup-dotnet@v4 + with: + dotnet-version: '11.0.x' + dotnet-quality: 'preview' - name: Generate LLM Documentation + working-directory: BootstrapBlazor run: > - dotnet run --project tools/BootstrapBlazor.LLMsDocsGenerator -- + dotnet run --project ../BootstrapBlazor.Extensions/tools/BootstrapBlazor.LLMsDocsGenerator --root src/BootstrapBlazor.Server --output src/BootstrapBlazor.Server ``` +BB Server 的发布 target 则还原并运行 BB 的 `dotnet-tools.json` 固定版本的本地 `llms-docs` 工具。执行目录固定为 Server 工程目录(`$(MSBuildThisFileDirectory)`),使用默认 `--root`,仅传入实际发布目录的绝对路径 `--output`。修改生成器源码不会自动更新该工具包;使用简化后的 target 前,需发布支持默认参数的新版本并更新工具清单。 + ## 输出格式 文档统一生成中文版本,从 XML 注释的双语 `` 块中提取。每个组件文件(`components/<组件名>.txt`)的 Markdown 内容形如: diff --git a/tools/BootstrapBlazor.LLMsDocsGenerator/Runner.cs b/tools/BootstrapBlazor.LLMsDocsGenerator/Runner.cs index b41f89c1..ab6855f5 100644 --- a/tools/BootstrapBlazor.LLMsDocsGenerator/Runner.cs +++ b/tools/BootstrapBlazor.LLMsDocsGenerator/Runner.cs @@ -6,10 +6,10 @@ namespace BootstrapBlazor.LLMsDocsGenerator; internal static class Runner { - public static async Task Run(string[] args) + public static async Task Run(string[] args) { var result = ArgumentsHelper.Parse(args); - await result.InvokeAsync(); + return await result.InvokeAsync(); } } From 2a6b629094ea4e3bc94d43a90c5898efedba5243 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 30 Oct 2026 11:51:24 +0800 Subject: [PATCH 2/7] chore: bump version 10.0.5 --- .../BootstrapBlazor.LLMsDocsGenerator.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/BootstrapBlazor.LLMsDocsGenerator/BootstrapBlazor.LLMsDocsGenerator.csproj b/tools/BootstrapBlazor.LLMsDocsGenerator/BootstrapBlazor.LLMsDocsGenerator.csproj index 80d3b28f..ba5dbc40 100644 --- a/tools/BootstrapBlazor.LLMsDocsGenerator/BootstrapBlazor.LLMsDocsGenerator.csproj +++ b/tools/BootstrapBlazor.LLMsDocsGenerator/BootstrapBlazor.LLMsDocsGenerator.csproj @@ -1,9 +1,9 @@  - 10.0.4 + 10.0.5 Exe - net10.0;net11.0 + net11.0 enable enable false @@ -20,8 +20,8 @@ - - + + From 8e149f953907e7601e0e63fe1db51a5112dbe52d Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 30 Oct 2026 11:57:43 +0800 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CommandFactory.cs | 4 ++-- tools/BootstrapBlazor.LLMsDocs.Cli/Models.cs | 7 +++++- .../Services/LlmsDocsClient.cs | 22 ++++++++++++++----- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tools/BootstrapBlazor.LLMsDocs.Cli/CommandFactory.cs b/tools/BootstrapBlazor.LLMsDocs.Cli/CommandFactory.cs index d5acf308..891e95e2 100644 --- a/tools/BootstrapBlazor.LLMsDocs.Cli/CommandFactory.cs +++ b/tools/BootstrapBlazor.LLMsDocs.Cli/CommandFactory.cs @@ -129,9 +129,9 @@ private static Command BuildGetCommand() Console.Out.Write(await client.GetComponentAsync(name, ct)); return 0; } - catch (FileNotFoundException) + catch (FileNotFoundException ex) { - Console.Error.WriteLine($"error: component '{name}' not found. Try: bb-llms search "); + Console.Error.WriteLine($"error: {ex.Message} Try: bb-llms search "); return 1; } catch (Exception ex) diff --git a/tools/BootstrapBlazor.LLMsDocs.Cli/Models.cs b/tools/BootstrapBlazor.LLMsDocs.Cli/Models.cs index 49b1cf75..8ac19b76 100644 --- a/tools/BootstrapBlazor.LLMsDocs.Cli/Models.cs +++ b/tools/BootstrapBlazor.LLMsDocs.Cli/Models.cs @@ -4,4 +4,9 @@ namespace BootstrapBlazor.LLMsDocs.Cli; /// A single component entry parsed from the llms.txt index, used by the /// list and search commands for structured (JSON) output. /// -public sealed record ComponentEntry(string Name, string? Description, string Category); +public sealed record ComponentEntry(string Name, string? Description, string Category) +{ + public bool IsExtension { get; init; } + + internal string DocumentPath { get; init; } = ""; +} diff --git a/tools/BootstrapBlazor.LLMsDocs.Cli/Services/LlmsDocsClient.cs b/tools/BootstrapBlazor.LLMsDocs.Cli/Services/LlmsDocsClient.cs index c14060d4..40e6127d 100644 --- a/tools/BootstrapBlazor.LLMsDocs.Cli/Services/LlmsDocsClient.cs +++ b/tools/BootstrapBlazor.LLMsDocs.Cli/Services/LlmsDocsClient.cs @@ -63,14 +63,21 @@ public LlmsDocsClient(string? baseOverride, bool refresh = false, bool noCache = public Task GetIndexAsync(CancellationToken ct = default) => FetchAsync("llms.txt", ct); - public Task GetComponentAsync(string name, CancellationToken ct = default) + public async Task GetComponentAsync(string name, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("Component name is required.", nameof(name)); } - return FetchAsync($"components/{name}.txt", ct); + var entry = ParseIndex(await GetIndexAsync(ct)) + .FirstOrDefault(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase)); + if (entry is null) + { + throw new FileNotFoundException($"Component not found in index: {name}"); + } + + return await FetchAsync(entry.DocumentPath, ct); } public async Task> ListAsync(string? query, CancellationToken ct = default) @@ -96,6 +103,7 @@ public async Task> SearchAsync(string query, int l var matches = await ListAsync(query, ct); return matches + .DistinctBy(e => e.DocumentPath, StringComparer.Ordinal) .OrderBy(e => e.Name.StartsWith(query, StringComparison.OrdinalIgnoreCase) ? 0 : 1) .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase) .Take(Math.Clamp(limit, 1, 100)) @@ -157,8 +165,8 @@ private async Task FetchAsync(string relativePath, CancellationToken ct) // ---- index parsing ---------------------------------------------------- - // Matches: "- [Button](components/Button.txt) - optional description" - [GeneratedRegex(@"^- \[(?[^\]]+)\]\(components/[^)]+\.txt\)(?:\s*-\s*(?.*))?$")] + // Matches component and extension links in llms.txt. + [GeneratedRegex(@"^- \[(?[^\]]+)\]\((?(?:components|extensions)/[^)]+\.txt)\)(?:\s*-\s*(?.*))?$")] private static partial Regex IndexLineRegex(); // Matches a category heading: "### Buttons" @@ -189,7 +197,11 @@ private static List ParseIndex(string indexText) var name = lineMatch.Groups["name"].Value.Trim(); var desc = lineMatch.Groups["desc"].Success ? lineMatch.Groups["desc"].Value.Trim() : null; - entries.Add(new ComponentEntry(name, string.IsNullOrEmpty(desc) ? null : desc, category)); + entries.Add(new ComponentEntry(name, string.IsNullOrEmpty(desc) ? null : desc, category) + { + IsExtension = lineMatch.Groups["path"].Value.StartsWith("extensions/", StringComparison.Ordinal), + DocumentPath = lineMatch.Groups["path"].Value + }); } return entries; From 7d44f8d2440f135885994c68da3cf91cb622150c Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 30 Oct 2026 11:58:16 +0800 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20=E7=A8=B3=E5=AE=9A=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapBlazor.LLMsDocs.Cli.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/BootstrapBlazor.LLMsDocs.Cli/BootstrapBlazor.LLMsDocs.Cli.csproj b/tools/BootstrapBlazor.LLMsDocs.Cli/BootstrapBlazor.LLMsDocs.Cli.csproj index cb277cf9..e41c07c3 100644 --- a/tools/BootstrapBlazor.LLMsDocs.Cli/BootstrapBlazor.LLMsDocs.Cli.csproj +++ b/tools/BootstrapBlazor.LLMsDocs.Cli/BootstrapBlazor.LLMsDocs.Cli.csproj @@ -3,7 +3,7 @@ 10.0.4 Exe - net10.0;net11.0 + net10.0 false @@ -17,7 +17,7 @@ - + From 9d1685e2089e4b4f8b9bfed756c0d643359d0a39 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 30 Oct 2026 11:58:29 +0800 Subject: [PATCH 5/7] chore: bump version 10.0.5 --- .../BootstrapBlazor.LLMsDocs.Cli.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/BootstrapBlazor.LLMsDocs.Cli/BootstrapBlazor.LLMsDocs.Cli.csproj b/tools/BootstrapBlazor.LLMsDocs.Cli/BootstrapBlazor.LLMsDocs.Cli.csproj index e41c07c3..2947a815 100644 --- a/tools/BootstrapBlazor.LLMsDocs.Cli/BootstrapBlazor.LLMsDocs.Cli.csproj +++ b/tools/BootstrapBlazor.LLMsDocs.Cli/BootstrapBlazor.LLMsDocs.Cli.csproj @@ -1,7 +1,7 @@ - 10.0.4 + 10.0.5 Exe net10.0 false From 9d62bf43893dfba184ac55f01750fa9750cd4722 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 30 Oct 2026 11:58:50 +0800 Subject: [PATCH 6/7] =?UTF-8?q?doc:=20=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/BootstrapBlazor.LLMsDocs.Cli/README.md | 23 ++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tools/BootstrapBlazor.LLMsDocs.Cli/README.md b/tools/BootstrapBlazor.LLMsDocs.Cli/README.md index bef71b0b..5865e216 100644 --- a/tools/BootstrapBlazor.LLMsDocs.Cli/README.md +++ b/tools/BootstrapBlazor.LLMsDocs.Cli/README.md @@ -1,7 +1,7 @@ # BootstrapBlazor LLMs Docs CLI(`bb-llms`) 一个轻量命令行工具,用于从 [www.blazor.zone/llms](https://www.blazor.zone/llms) -拉取 BootstrapBlazor 组件的**官方 API 文档**(参数、事件回调、公开方法、源码链接), +拉取 BootstrapBlazor 及 BootstrapBlazor.Extensions 组件的**官方 API 文档**(参数、事件回调、公开方法、源码链接), 供 AI agent(Claude Code / Cursor / Trae / Codex 等经 Bash 调用)和开发者查询。 组件文档由官网的 `BootstrapBlazor.LLMsDocsGenerator`(生产者,CI 侧用 Roslyn 生成并发布)产出, @@ -49,6 +49,7 @@ dotnet tool uninstall -g BootstrapBlazor.LLMsDocs.Cli ```bash bb-llms search table # 按关键词搜索组件,定位准确名称 bb-llms get Table # 拉取 Table 组件的完整 API 文档(核心命令) +bb-llms get SelectCity # 拉取 Region 扩展包中 SelectCity 的 API 文档 bb-llms list # 列出全部组件 bb-llms index # 打印原始 llms.txt 索引 ``` @@ -68,10 +69,12 @@ bb-llms index # 打印原始 llms.txt 索引 要点: -- `get` / `index` 为**原样透传**,不解析格式,输出即远程 `.txt` 原文。 -- `list` / `search` 默认输出 **JSON**;加 `--format text` 输出纯文本(`Name - 描述` 每行一条)。 +- `get` / `index` 的输出保留远程 `.txt` 原文;`get` 会先解析 `llms.txt`,按名称找到文档路径,再直接读取核心或扩展文档,不通过请求失败来尝试其他目录。本地源也必须包含该索引。 +- `list` / `search` 包括组件和服务,默认输出 **JSON**;`isExtension` 表示文档位于 `extensions/`,不表示接口源码一定声明在扩展仓库。加 `--format text` 输出纯文本(`Name - 描述` 每行一条)。 - `search --limit` 默认 `20`,取值范围会被限制在 `1~100`。 +- `search` 按文档路径去重后应用结果数量限制,避免多个包共用的服务占满结果;`list` 保留各包的索引项。 - 找不到组件时 `get` 退出码非 0,并提示用 `bb-llms search <关键词>` 重新定位。 +- 索引缺失、索引中没有对应名称、目标文档缺失均返回非零退出码,并保留具体失败原因;不会把缺少 `llms.txt` 误报为组件不存在。 示例: @@ -80,8 +83,12 @@ bb-llms search button --limit 5 # 最多 5 条,Button 类优先 bb-llms list --query table # 仅名称/描述含 "table" 的组件 bb-llms list --format text # 纯文本清单 bb-llms get Table # Table 的参数表 / 事件 / 方法 / GitHub 源码链接 +bb-llms get SelectCity # BootstrapBlazor.Region 扩展包的 SelectCity 文档 +bb-llms get IRecognizerProvider # 共享服务接口,按包列出 Azure/Baidu 注册方式 ``` +多个扩展包共用一个接口时,各包的索引项指向同一份服务文档;按实际使用的包选择其中一种注册方式,不要全部复制执行。 + ### 源与缓存选项(数据命令通用) ``` @@ -120,6 +127,8 @@ bb-llms get Button --base-url ./wwwroot/llms > 含义:官网发版后,最长 24 小时本地才会感知到文档更新(用 `--refresh` 可立即刷新)。 +索引同样缓存。新增组件或扩展服务已发布但提示索引中不存在时,使用 `bb-llms get SelectCity --refresh` 同时刷新索引及目标文档。 + --- ## 给 AI agent 使用(发现层) @@ -181,12 +190,18 @@ bb-llms install --client claude # 2. 装发现层(写入 ## 开发 / 本地运行 +在 Extentions 仓库根目录执行 CLI 回归检查: + +```powershell +.\tools\BootstrapBlazor.LLMsDocs.Cli\tests\Verify-Client.ps1 +``` + ```bash cd tools/BootstrapBlazor.LLMsDocs.Cli dotnet build dotnet run -- get Button dotnet run -- search table --limit 5 -dotnet run -- get Button --base-url ../BootstrapBlazor.LLMsDocsGenerator/<生成目录>/llms +dotnet run -- get Button --base-url "<生成目录>/wwwroot/llms" ``` 打包冒烟: From 13cc82c5f54b68227caf8e86c60ce8c481a8ed27 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 30 Oct 2026 12:04:33 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/BootstrapBlazor.LLMsDocsGenerator/MarkdownBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/BootstrapBlazor.LLMsDocsGenerator/MarkdownBuilder.cs b/tools/BootstrapBlazor.LLMsDocsGenerator/MarkdownBuilder.cs index d58ff39d..b065e347 100644 --- a/tools/BootstrapBlazor.LLMsDocsGenerator/MarkdownBuilder.cs +++ b/tools/BootstrapBlazor.LLMsDocsGenerator/MarkdownBuilder.cs @@ -15,7 +15,7 @@ internal static partial class MarkdownBuilder { private const string GitHubRawBaseUrl = "https://raw.githubusercontent.com/dotnetcore/BootstrapBlazor/refs/heads/main/"; private const string GitHubRepositoryUrl = "https://github.com/dotnetcore/BootstrapBlazor"; - private const string GitHubExtensionsRawUrl = "https://raw.githubusercontent.com/BootstrapBlazor/BootstrapBlazor.Extensions/refs/heads/main/"; + private const string GitHubExtensionsRawUrl = "https://raw.githubusercontent.com/BootstrapBlazor/BootstrapBlazor.Extensions/refs/heads/master/"; private const string GitHubExtensionsRepositoryUrl = "https://github.com/BootstrapBlazor/BootstrapBlazor.Extensions"; ///