From 74ae533e50549e107b346d44dda458d6ba4c733a Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 30 Oct 2026 11:51:08 +0800 Subject: [PATCH 1/3] =?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/3] 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 6191c4d369ffe0b13844ce6491b041d10679ffa0 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 30 Oct 2026 12:04:33 +0800 Subject: [PATCH 3/3] =?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"; ///