diff --git a/src/DocsTool/BuildUi.cs b/src/DocsTool/BuildUi.cs index a0e09a2..74d5738 100644 --- a/src/DocsTool/BuildUi.cs +++ b/src/DocsTool/BuildUi.cs @@ -1,4 +1,5 @@ -using Tanka.DocsTool.UI; +using Tanka.DocsTool.Pipelines; +using Tanka.DocsTool.UI; internal class BuildUi : IMiddleware { @@ -13,20 +14,28 @@ public BuildUi(IAnsiConsole console) public async Task Invoke(PipelineStep next, BuildContext context) { - await _console.Progress() - .Columns( - new TaskDescriptionColumn(), - new ProgressBarColumn(), - new ItemCountColumn(), - new ElapsedTimeColumn(), - new RemainingTimeColumn(), - new SpinnerColumn() - ) - .StartAsync(async progress => - { - var ui = new UiBuilder(context.PageCache, context.OutputFs, _console); - await ui.BuildSite(context.Site ?? throw new InvalidOperationException(), progress); - }); + try + { + await _console.Progress() + .Columns( + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new ItemCountColumn(), + new ElapsedTimeColumn(), + new RemainingTimeColumn(), + new SpinnerColumn() + ) + .StartAsync(async progress => + { + var ui = new UiBuilder(context.PageCache, context.OutputFs, _console); + await ui.BuildSite(context.Site ?? throw new InvalidOperationException(), progress, context); + }); + } + catch (Exception ex) + { + context.Add(new Error($"UI build failed: {ex.Message}")); + _console.MarkupLine($"[red]UI build error:[/] {ex.Message}"); + } await next(context); } diff --git a/src/DocsTool/UI/SectionComposer.cs b/src/DocsTool/UI/SectionComposer.cs index 850bfa3..cfd3b55 100644 --- a/src/DocsTool/UI/SectionComposer.cs +++ b/src/DocsTool/UI/SectionComposer.cs @@ -35,16 +35,23 @@ public SectionComposer(Site site, IFileSystem cache, IFileSystem output, IUiBund _logger = Infra.LoggerFactory.CreateLogger(); } - public async Task ComposeSection(Section section) + public async Task ComposeSection(Section section, BuildContext buildContext) { - var preprocessorPipe = BuildPreProcessors(section); - var router = new DocsSiteRouter(_site, section); - var renderer = await BuildMarkdownService(section, router); - - var menu = await ComposeMenu(section); - - await ComposeAssets(section, router); - await ComposePages(section, menu, router, renderer, preprocessorPipe); + try + { + var preprocessorPipe = BuildPreProcessors(section); + var router = new DocsSiteRouter(_site, section); + var renderer = await BuildMarkdownService(section, router); + + var menu = await ComposeMenu(section, buildContext); + + await ComposeAssets(section, router, buildContext); + await ComposePages(section, menu, router, renderer, preprocessorPipe, buildContext); + } + catch (Exception ex) + { + buildContext.Add(new Error($"Failed to compose section '{section}': {ex.Message}")); + } } private Func> BuildPreProcessors(Section section) @@ -64,25 +71,36 @@ private Task BuildMarkdownService(Section section, DocsSite return Task.FromResult(new DocsMarkdownService(builder)); } - private async Task ComposeAssets(Section section, DocsSiteRouter router) + private async Task ComposeAssets(Section section, DocsSiteRouter router, BuildContext buildContext) { // compose assets from sections foreach (var (relativePath, assetItem) in section.ContentItems.Where(ci => IsAsset(ci.Key, ci.Value))) { - // open file streams - await using var inputStream = await assetItem.File.OpenRead(); + try + { + // open file streams + await using var inputStream = await assetItem.File.OpenRead(); - // create output dir for page - FileSystemPath outputPath = router.GenerateRoute(new Xref(assetItem.Version, section.Id, relativePath)) - ?? throw new InvalidOperationException($"Could not generate output path for '{outputPath}'."); + // create output dir for page + var outputPath = router.GenerateRoute(new Xref(assetItem.Version, section.Id, relativePath)); + if (outputPath == null) + { + buildContext.Add(new Error($"Could not generate output path for asset '{relativePath}'.", assetItem)); + continue; + } - await _output.GetOrCreateDirectory(outputPath.GetDirectoryPath()); + await _output.GetOrCreateDirectory(Path.GetDirectoryName(outputPath)); - // create output file - var outputFile = await _output.GetOrCreateFile(outputPath); - await using var outputStream = await outputFile.OpenWrite(); + // create output file + var outputFile = await _output.GetOrCreateFile(outputPath); + await using var outputStream = await outputFile.OpenWrite(); - await inputStream.CopyToAsync(outputStream); + await inputStream.CopyToAsync(outputStream); + } + catch (Exception ex) + { + buildContext.Add(new Error($"Failed to compose asset '{relativePath}': {ex.Message}", assetItem)); + } } } @@ -112,7 +130,8 @@ private async Task ComposePages( IReadOnlyCollection menu, DocsSiteRouter router, DocsMarkdownService renderer, - Func> preprocessorPipe) + Func> preprocessorPipe, + BuildContext buildContext) { var pageComposer = new PageComposer(_site, section, _cache, _output, _uiBundle, renderer); @@ -127,7 +146,10 @@ private async Task ComposePages( } catch (Exception e) { - throw new InvalidOperationException($"Failed to compose page '{pageItem.Key}'.", e); + lock (buildContext) + { + buildContext.Add(new Error($"Failed to compose page '{pageItem.Key}': {e.Message}", pageItem.Value)); + } } })); } @@ -149,11 +171,15 @@ private async Task ComposePages( } catch (Exception e) { - throw new InvalidOperationException($"Failed to compose redirect page 'index.html'.", e); + lock (buildContext) + { + buildContext.Add(new Error($"Failed to compose redirect page 'index.html': {e.Message}")); + } } })); } + // Wait for all tasks to complete, regardless of whether some fail await Task.WhenAll(tasks); } @@ -162,42 +188,58 @@ private bool IsPage(FileSystemPath relativePath, ContentItem contentItem) return relativePath.GetExtension() == ".md" && relativePath.GetFileName() != "nav.md"; } - private async Task> ComposeMenu(Section section) + private async Task> ComposeMenu(Section section, BuildContext buildContext) { var items = new List(); foreach (var naviFileLink in section.Definition.Nav) { - if (naviFileLink.Xref == null) - throw new NotSupportedException("External navigation file links are not supported"); - - var xref = naviFileLink.Xref.Value; - - var targetSection = _site.GetSectionByXref(xref, section); + try + { + if (naviFileLink.Xref == null) + { + buildContext.Add(new Error("External navigation file links are not supported")); + continue; + } - if (targetSection == null) - throw new InvalidOperationException($"Invalid navigation file link {naviFileLink}. Section not found."); + var xref = naviFileLink.Xref.Value; - var navigationFileItem = targetSection.GetContentItem(xref.Path); + var targetSection = _site.GetSectionByXref(xref, section); - if (navigationFileItem == null) - throw new InvalidOperationException($"Invalid navigation file link {naviFileLink}. Path not found."); + if (targetSection == null) + { + buildContext.Add(new Error($"Invalid navigation file link {naviFileLink}. Section not found.")); + continue; + } - await using var fileStream = await navigationFileItem.File.OpenRead(); - using var reader = new StreamReader(fileStream); - var text = await reader.ReadToEndAsync(); + var navigationFileItem = targetSection.GetContentItem(xref.Path); - // override context so each navigation file is rendered in the context of the owning section - var router = new DocsSiteRouter(_site, targetSection); - var renderer = new DocsMarkdownService(new DocsMarkdownRenderingContext(_site, targetSection, router)); - var builder = new NavigationBuilder(renderer, router); - var fileItems = builder.Add(new string[] + if (navigationFileItem == null) { - text - }) - .Build(); + buildContext.Add(new Error($"Invalid navigation file link {naviFileLink}. Path not found.", navigationFileItem)); + continue; + } - items.AddRange(fileItems); + await using var fileStream = await navigationFileItem.File.OpenRead(); + using var reader = new StreamReader(fileStream); + var text = await reader.ReadToEndAsync(); + + // override context so each navigation file is rendered in the context of the owning section + var router = new DocsSiteRouter(_site, targetSection); + var renderer = new DocsMarkdownService(new DocsMarkdownRenderingContext(_site, targetSection, router)); + var builder = new NavigationBuilder(renderer, router); + var fileItems = builder.Add(new string[] + { + text + }) + .Build(); + + items.AddRange(fileItems); + } + catch (Exception ex) + { + buildContext.Add(new Error($"Failed to compose navigation for '{naviFileLink}': {ex.Message}")); + } } return items; diff --git a/src/DocsTool/UI/UiBuilder.cs b/src/DocsTool/UI/UiBuilder.cs index 333662a..4000a22 100644 --- a/src/DocsTool/UI/UiBuilder.cs +++ b/src/DocsTool/UI/UiBuilder.cs @@ -17,7 +17,7 @@ public UiBuilder(IFileSystem cache, IFileSystem output, IAnsiConsole console) _console = console; } - public async Task BuildSite(Site site, ProgressContext progress) + public async Task BuildSite(Site site, ProgressContext progress, BuildContext buildContext) { var tasks = site.Versions .OrderBy(v => v) @@ -32,74 +32,100 @@ public async Task BuildSite(Site site, ProgressContext progress) // compose doc sections foreach (var section in sections) { - _console.LogInformation($"Building: {section}"); - var uiBundleRef = LinkParser.Parse("xref://ui-bundle:tanka-docs-section.yml").Xref!.Value; - var uiContent = site.GetSectionByXref(uiBundleRef, section); - - if (uiContent == null) - throw new InvalidOperationException($"Could not resolve ui-bundle. Xref '{uiBundleRef}' could not be resolved.'"); - - var uiBundle = new HandlebarsUiBundle(site, uiContent, _output); - await uiBundle.Initialize(CancellationToken.None); - - var composer = new SectionComposer(site, _cache, _output, uiBundle); - await composer.ComposeSection(section); - - _console.LogInformation($"Built: {section}"); + try + { + _console.LogInformation($"Building: {section}"); + var uiBundleRef = LinkParser.Parse("xref://ui-bundle:tanka-docs-section.yml").Xref!.Value; + var uiContent = site.GetSectionByXref(uiBundleRef, section); + + if (uiContent == null) + { + buildContext.Add(new Error($"Could not resolve ui-bundle. Xref '{uiBundleRef}' could not be resolved.'")); + task.Increment(1); + continue; + } + + var uiBundle = new HandlebarsUiBundle(site, uiContent, _output); + await uiBundle.Initialize(CancellationToken.None); + + var composer = new SectionComposer(site, _cache, _output, uiBundle); + await composer.ComposeSection(section, buildContext); + + _console.LogInformation($"Built: {section}"); + } + catch (Exception ex) + { + buildContext.Add(new Error($"Failed to build section '{section}': {ex.Message}")); + _console.LogError($"Error building section '{section}': {ex.Message}"); + } + task.Increment(1); } task.StopTask(); } - await ComposeIndexPage(site); + await ComposeIndexPage(site, buildContext); } - private async Task ComposeIndexPage(Site site) + private async Task ComposeIndexPage(Site site, BuildContext buildContext) { - var redirectoToPage = site.Definition.IndexPage; - - string? target; - if (redirectoToPage.IsXref) + try { - var xref = redirectoToPage.Xref.Value; - var targetSection = site.GetSectionByXref(xref); + var redirectoToPage = site.Definition.IndexPage; - if (targetSection == null) - throw new InvalidOperationException( - $"Cannot generate site index page. " + - $"Target section of '{redirectoToPage}' not found."); - - var router = new DocsSiteRouter(site, targetSection); - target = router.GenerateRoute(xref) ?? string.Empty; - } - else - { - target = redirectoToPage.Uri ?? string.Empty; - } + string? target; + if (redirectoToPage.IsXref) + { + var xref = redirectoToPage.Xref.Value; + var targetSection = site.GetSectionByXref(xref); + + if (targetSection == null) + { + buildContext.Add(new Error( + $"Cannot generate site index page. " + + $"Target section of '{redirectoToPage}' not found.")); + return; + } + + var router = new DocsSiteRouter(site, targetSection); + target = router.GenerateRoute(xref) ?? string.Empty; + } + else + { + target = redirectoToPage.Uri ?? string.Empty; + } - var generatedHtml = string.Format( - PageComposer.RedirectPageHtml, - site.BasePath, - target); + var generatedHtml = string.Format( + PageComposer.RedirectPageHtml, + site.BasePath, + target); - // create output dir for page - FileSystemPath targetFilePath = "index.html"; + // create output dir for page + FileSystemPath targetFilePath = "index.html"; - if (targetFilePath == new FileSystemPath(target)) - throw new InvalidOperationException( - $"Cannot generate a index.html redirect page '{targetFilePath}'. " + - $"Redirect would point to same file as the generated file and would" + - "end in a endless loop"); + if (targetFilePath == new FileSystemPath(target)) + { + buildContext.Add(new Error( + $"Cannot generate a index.html redirect page '{targetFilePath}'. " + + $"Redirect would point to same file as the generated file and would" + + "end in an endless loop")); + return; + } - await _output.GetOrCreateDirectory(targetFilePath.GetDirectoryPath()); + await _output.GetOrCreateDirectory(targetFilePath.GetDirectoryPath()); - // create output file - var outputFile = await _output.GetOrCreateFile(targetFilePath); - await using var outputStream = await outputFile.OpenWrite(); - await using var writer = new StreamWriter(outputStream); - await writer.WriteAsync(generatedHtml); + // create output file + var outputFile = await _output.GetOrCreateFile(targetFilePath); + await using var outputStream = await outputFile.OpenWrite(); + await using var writer = new StreamWriter(outputStream); + await writer.WriteAsync(generatedHtml); + } + catch (Exception ex) + { + buildContext.Add(new Error($"Failed to compose index page: {ex.Message}")); + } } } } \ No newline at end of file