diff --git a/docs/documentation/isolated/configure/index.md b/docs/documentation/isolated/configure/index.md index 524a416034..985075f8e8 100644 --- a/docs/documentation/isolated/configure/index.md +++ b/docs/documentation/isolated/configure/index.md @@ -168,6 +168,12 @@ See [API Explorer](/data/openapi/api-explorer.md) for full details. Defines named call-to-action templates for the right-hand sidebar. See [CTA](../cta.md). +## `default_cta` + +Registers a named CTA template as the default for every page listed in this navigation file. Available on both `docset.yml` and nested `toc.yml` files. The template must be declared under the `cta` map in `docset.yml`. + +See [CTA](../cta.md). + ## `suppress` Suppresses specific diagnostic hints: diff --git a/docs/documentation/isolated/cta.md b/docs/documentation/isolated/cta.md index 37bff5b229..af1340d711 100644 --- a/docs/documentation/isolated/cta.md +++ b/docs/documentation/isolated/cta.md @@ -40,7 +40,35 @@ cta: --- ``` -If a page omits `cta`, or its `id` doesn't match a template defined in `docset.yml`, it falls back to the built-in `trial` CTA. An unknown `id` also emits a build warning. +If a page omits `cta`, the template registered as the default for its navigation file (if any) applies; otherwise it falls back to the built-in `trial` CTA. An unknown `id` emits a build warning and is ignored. + +## Register a default CTA on a navigation file + +To apply a template to every page listed in a `docset.yml` or nested `toc.yml` without editing each file, set `default_cta` to a template name declared in `docset.yml`: + +```yaml +# solutions/observability/toc.yml +default_cta: observability +toc: + - file: index.md + - file: apps/apm.md +``` + +```yaml +# docset.yml +cta: + observability: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=observability + benefits: + - "14-day free trial" +``` + +- `default_cta` is available on both `docset.yml` and nested `toc.yml` files. +- Pages inherit the nearest `default_cta` from their navigation file. A nested `toc.yml` can override the value from a parent navigation file. +- A page's `cta` frontmatter always takes precedence over a navigation default. +- Each page can only be registered with one default CTA; listing the same page twice with different defaults is a build error. ## Click and impression tracking diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index 9618625573..3237618525 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Collections.Frozen; using System.Diagnostics.CodeAnalysis; using System.IO.Abstractions; using DotNet.Globbing; @@ -91,6 +92,9 @@ public record ConfigurationFile private readonly Dictionary _ctas = new(StringComparer.OrdinalIgnoreCase) { [Cta.DefaultName] = Cta.Default }; + // Pages registered with a default CTA via `default_cta` on docset.yml or nested toc.yml files. + private readonly IReadOnlyDictionary _tocDefaultCtas; + /// /// Named right-gutter CTA templates declared under docset.yml's cta map, keyed by name. /// Always contains at least the built-in entry. @@ -123,6 +127,7 @@ ProductsConfiguration productsConfig { _context = context; ScopeDirectory = context.ConfigurationPath.Directory!; + _tocDefaultCtas = FrozenDictionary.Empty; if (!context.ConfigurationPath.Exists) { Project = "unknown"; @@ -224,10 +229,24 @@ ProductsConfiguration productsConfig // Process CTA templates - overlays onto (and may override) the built-in 'trial' default foreach (var (name, definition) in docSetFile.Cta) { - if (ValidateCta(name, definition, context) is { } cta) - _ctas[name] = cta; + if (ValidateCta(name, definition, context) is not { } cta) + continue; + _ctas[name] = cta; + } + + foreach (var (pagePath, ctaName) in docSetFile.TocDefaultCtas) + { + if (!_ctas.ContainsKey(ctaName)) + { + context.EmitError( + context.ConfigurationPath, + $"'default_cta: {ctaName}' on page '{pagePath}' does not match any 'cta' template in docset.yml." + ); + } } + _tocDefaultCtas = docSetFile.TocDefaultCtas; + // Process features _features = [with(StringComparer.OrdinalIgnoreCase)]; if (docSetFile.Features.PrimaryNav.HasValue) @@ -273,17 +292,29 @@ ProductsConfiguration productsConfig } /// - /// Resolves a page's cta frontmatter id to a template, falling back to - /// when is omitted or doesn't match a configured template. + /// Resolves the right-gutter CTA for a page. An explicit, known cta frontmatter + /// always wins. Otherwise the template registered via default_cta on the page's navigation file + /// applies, falling back to . /// + /// The page's cta.id frontmatter value, if any. + /// The page's docset-root-relative source path, used for toc default lookup. /// Set when is unknown, so the caller can report it. - public Cta ResolveCta(string? id, out string? warning) + public Cta ResolveCta(string? id, string? relativePath, out string? warning) { warning = null; - if (id is not null && Ctas.TryGetValue(id, out var cta)) - return cta; if (id is not null) + { + if (Ctas.TryGetValue(id, out var selected)) + return selected; + // Unknown id: warn, then resolve as if the page had no `cta` frontmatter. warning = UnknownCtaWarning(id, Ctas.Keys); + } + if (relativePath is { Length: > 0 }) + { + var normalizedPath = DocumentationSetFile.NormalizeDocsetRelativePath(relativePath); + if (_tocDefaultCtas.TryGetValue(normalizedPath, out var tocDefault) && Ctas.TryGetValue(tocDefault, out var scoped)) + return scoped; + } return Ctas[Cta.DefaultName]; } @@ -298,7 +329,7 @@ private static string UnknownCtaWarning(string ctaName, IEnumerable know : "No 'cta' templates are defined in this docset.yml yet. Add one under a top-level 'cta:' map, e.g.:\n" + "cta:\n mp:\n button:\n label: Get started on MP\n url: https://example.com\n benefits:\n - \"Some benefit\""; } - return $"'cta: {ctaName}' does not match any 'cta' template in docset.yml. Falling back to '{Cta.DefaultName}'. {hint}"; + return $"'cta: {ctaName}' does not match any 'cta' template in docset.yml and is ignored. {hint}"; } private static Cta? ValidateCta(string name, CtaDefinition definition, IDocumentationSetContext context) diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index ca075a7df4..7139eef290 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Collections.Frozen; using System.IO.Abstractions; using DotNet.Globbing; using Elastic.Documentation.Configuration.Products; @@ -149,6 +150,13 @@ public static DocumentationSetFile LoadAndResolve( [YamlIgnore] public HashSet FolderExcludedFiles { get; private set; } = []; + /// + /// Pages registered with a default CTA via default_cta on docset.yml or nested toc.yml files. + /// Keys are docset-root-relative markdown paths; values are template names from the docset's cta map. + /// + [YamlIgnore] + public IReadOnlyDictionary TocDefaultCtas { get; private set; } = FrozenDictionary.Empty; + /// /// Loads a DocumentationSetFile from YAML string and recursively resolves all IsolatedTableOfContentsRef items, /// replacing them with their resolved children and ensuring file paths carry over parent paths. @@ -178,6 +186,7 @@ public static DocumentationSetFile LoadAndResolve( ); // Collect excluded paths so they can be skipped during file processing (not just navigation) docSet.FolderExcludedFiles = CollectFolderExcludedFiles(docSet.TableOfContents); + docSet.TocDefaultCtas = CollectTocDefaultCtas(collector, docSet.TableOfContents, docSet.DefaultCta); return docSet; } @@ -379,7 +388,8 @@ private static TableOfContents ResolveTableOfContents( tocPathRelativeToContainer, resolvedChildren, parentContext, - tocRef.Island || nestedTocFile.Island + tocRef.Island || nestedTocFile.Island, + nestedTocFile.DefaultCta ); } @@ -846,6 +856,96 @@ private static TableOfContents AutoDiscoverFolderFiles( return ResolveTableOfContents(collector, children, baseDirectory, fileSystem, folderPath, containerPath, context); } + /// + /// Traverses the resolved TOC and collects pages registered with a default_cta from + /// docset.yml or nested toc.yml files. + /// + private static FrozenDictionary CollectTocDefaultCtas( + IDiagnosticsCollector collector, + IReadOnlyCollection items, + string? inheritedDefault + ) + { + var defaults = new Dictionary(StringComparer.OrdinalIgnoreCase); + CollectTocDefaultCtas(collector, items, inheritedDefault, defaults); + return defaults.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + + private static void CollectTocDefaultCtas( + IDiagnosticsCollector collector, + IReadOnlyCollection items, + string? inheritedDefault, + Dictionary defaults + ) + { + foreach (var item in items) + { + switch (item) + { + case FileRef file: + RegisterTocDefaultCta(collector, file.PathRelativeToDocumentationSet, inheritedDefault, file.Context, defaults); + if (file.Children.Count > 0) + CollectTocDefaultCtas(collector, file.Children, inheritedDefault, defaults); + break; + case IsolatedTableOfContentsRef toc: + var activeDefault = toc.DefaultCta ?? inheritedDefault; + CollectTocDefaultCtas(collector, toc.Children, activeDefault, defaults); + break; + case FolderRef folder: + CollectTocDefaultCtas(collector, folder.Children, inheritedDefault, defaults); + break; + case CrossLinkRef crossLink when crossLink.Children.Count > 0: + CollectTocDefaultCtas(collector, crossLink.Children, inheritedDefault, defaults); + break; + } + } + } + + private static void RegisterTocDefaultCta( + IDiagnosticsCollector collector, + string relativePath, + string? defaultCta, + string context, + Dictionary defaults + ) + { + if (string.IsNullOrWhiteSpace(defaultCta)) + return; + + var normalizedPath = NormalizeDocsetRelativePath(relativePath); + if (defaults.TryGetValue(normalizedPath, out var existing) && !existing.Equals(defaultCta, StringComparison.OrdinalIgnoreCase)) + { + collector.EmitError( + context, + $"'{normalizedPath}' is registered with default CTA '{existing}' and '{defaultCta}'. Each page can only have one default CTA." + ); + return; + } + + defaults[normalizedPath] = defaultCta; + } + + internal static string NormalizeDocsetRelativePath(string relativePath) + { + var segments = relativePath.Replace('\\', '/').TrimStart('/').Split('/'); + var stack = new List(segments.Length); + foreach (var segment in segments) + { + if (segment is "" or ".") + continue; + if (segment == "..") + { + if (stack.Count > 0) + stack.RemoveAt(stack.Count - 1); + continue; + } + + stack.Add(segment); + } + + return string.Join('/', stack); + } + /// /// Traverses the resolved TOC and collects relative paths of files excluded via folder-level exclude. /// diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs index 4ea646348b..c2aa87a4d6 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs @@ -31,6 +31,14 @@ public class TableOfContentsFile [YamlMember(Alias = "suppress")] public HashSet SuppressDiagnostics { get; set; } = []; + /// + /// Optional name of a cta template (declared in docset.yml) applied to every page + /// listed in this navigation file unless the page selects one explicitly via frontmatter. + /// Nested toc.yml files may override the value inherited from a parent navigation file. + /// + [YamlMember(Alias = "default_cta")] + public string? DefaultCta { get; set; } + public static TableOfContentsFile Deserialize(string json) => ConfigurationFileProvider.Deserializer.Deserialize(json); } diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs index e5f0e837b3..d3677723ba 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs @@ -195,7 +195,8 @@ public record IsolatedTableOfContentsRef( string PathRelativeToContainer, IReadOnlyCollection Children, string Context, - bool Island = false + bool Island = false, + string? DefaultCta = null ) : ITableOfContentsItem; /// Controls how much of the listing appears in the rendered navigation tree. diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index feb24e9e41..14b831d848 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -119,10 +119,10 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc var siteName = DocumentationSet.Navigation.NavigationTitle; var legacyPages = LegacyUrlMapper.MapLegacyUrl(markdown.YamlFrontMatter?.MappedPages); - // Resolve the right-gutter CTA: an explicit, known frontmatter id is 'custom' and renders in - // isolated builds too (so authors can preview it); otherwise fall back to the built-in default, - // which stays assembler-only to preserve today's behavior. - var cta = DocumentationSet.Configuration.ResolveCta(markdown.YamlFrontMatter?.Cta?.Id, out var ctaWarning); + // Resolve the right-gutter CTA: an explicit, known frontmatter id wins, then any `default_cta` + // registered on the page's navigation file. Both are 'custom' and render in isolated builds too + // (so authors can preview them); the built-in default stays assembler-only to preserve today's behavior. + var cta = DocumentationSet.Configuration.ResolveCta(markdown.YamlFrontMatter?.Cta?.Id, markdown.RelativePath, out var ctaWarning); if (ctaWarning is not null) DocumentationSet.Context.Collector.EmitWarning(markdown.FilePath, ctaWarning); diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileCtaTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileCtaTests.cs new file mode 100644 index 0000000000..b2ee08226a --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileCtaTests.cs @@ -0,0 +1,364 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Collections.Frozen; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.Configuration.Tests; + +public class ConfigurationFileCtaTests +{ + [Fact] + public void ResolveCta_FrontmatterId_TakesPrecedenceOverTocDefault() + { + var docSet = LoadDocSet( + """ + project: test + cta: + observability: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=observability + monitor-kubernetes: + button: + label: Monitor Kubernetes + url: https://example.com/kubernetes + toc: + - toc: solutions/observability + """, + (""" + default_cta: observability + toc: + - file: get-started/quickstart.md + """, "solutions/observability/toc.yml"), + ("# Quickstart", "solutions/observability/get-started/quickstart.md") + ); + + var config = CreateConfiguration(docSet); + var cta = config.ResolveCta("monitor-kubernetes", "solutions/observability/get-started/quickstart.md", out var warning); + + cta.Name.Should().Be("monitor-kubernetes"); + warning.Should().BeNull(); + } + + [Fact] + public void ResolveCta_NoFrontmatter_UsesTocDefault() + { + var docSet = LoadDocSet( + """ + project: test + cta: + observability: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=observability + toc: + - toc: solutions/observability + """, + (""" + default_cta: observability + toc: + - file: apps/apm.md + """, "solutions/observability/toc.yml"), + ("# APM", "solutions/observability/apps/apm.md") + ); + + var config = CreateConfiguration(docSet); + var cta = config.ResolveCta(null, "solutions/observability/apps/apm.md", out var warning); + + cta.Name.Should().Be("observability"); + warning.Should().BeNull(); + } + + [Fact] + public void ResolveCta_NoFrontmatterAndNoTocDefault_FallsBackToDefault() + { + var docSet = LoadDocSet( + """ + project: test + cta: + observability: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=observability + toc: + - file: reference/query-languages/esql.md + """, + ("# ES|QL", "reference/query-languages/esql.md") + ); + + var config = CreateConfiguration(docSet); + var cta = config.ResolveCta(null, "reference/query-languages/esql.md", out var warning); + + cta.Name.Should().Be(Cta.DefaultName); + warning.Should().BeNull(); + } + + [Fact] + public void ResolveCta_NestedTocDefault_OverridesParentDefault() + { + var docSet = LoadDocSet( + """ + project: test + cta: + observability: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=observability + monitor-kubernetes: + button: + label: Monitor Kubernetes + url: https://example.com/kubernetes + toc: + - toc: solutions/observability + """, + (""" + default_cta: observability + toc: + - file: apps/apm.md + - toc: get-started + """, "solutions/observability/toc.yml"), + (""" + default_cta: monitor-kubernetes + toc: + - file: quickstart.md + """, "solutions/observability/get-started/toc.yml"), + ("# APM", "solutions/observability/apps/apm.md"), + ("# Quickstart", "solutions/observability/get-started/quickstart.md") + ); + + var config = CreateConfiguration(docSet); + + config.ResolveCta(null, "solutions/observability/get-started/quickstart.md", out _).Name.Should().Be("monitor-kubernetes"); + config.ResolveCta(null, "solutions/observability/apps/apm.md", out _).Name.Should().Be("observability"); + } + + [Fact] + public void ResolveCta_UnknownFrontmatterId_WarnsAndFallsBackToTocDefault() + { + var docSet = LoadDocSet( + """ + project: test + cta: + observability: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=observability + toc: + - toc: solutions/observability + """, + (""" + default_cta: observability + toc: + - file: apps/apm.md + """, "solutions/observability/toc.yml"), + ("# APM", "solutions/observability/apps/apm.md") + ); + + var config = CreateConfiguration(docSet); + var cta = config.ResolveCta("does-not-exist", "solutions/observability/apps/apm.md", out var warning); + + cta.Name.Should().Be("observability"); + warning.Should().Contain("does-not-exist").And.Contain("ignored"); + } + + [Fact] + public void ResolveCta_DocsetDefaultCta_AppliesToRootLevelPages() + { + var docSet = LoadDocSet( + """ + project: test + default_cta: observability + cta: + observability: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=observability + toc: + - file: index.md + """, + ("# Home", "index.md") + ); + + var config = CreateConfiguration(docSet); + var cta = config.ResolveCta(null, "index.md", out _); + + cta.Name.Should().Be("observability"); + } + + [Fact] + public async Task LoadAndResolve_PageClaimedByTwoDefaults_EmitsError() + { + var recorder = new RecordingDiagnosticsOutput(); + var collector = new DiagnosticsCollector([recorder]); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + + _ = LoadDocSet( + collector, + """ + project: test + cta: + observability: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=observability + security: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=security + toc: + - toc: section-a + - toc: section-b + """, + (""" + default_cta: observability + toc: + - file: ../shared/page.md + """, "section-a/toc.yml"), + (""" + default_cta: security + toc: + - file: ../shared/page.md + """, "section-b/toc.yml"), + ("# Shared", "shared/page.md") + ); + + await collector.StopAsync(TestContext.Current.CancellationToken); + + recorder + .Diagnostics + .Should() + .Contain(d => d.Severity == Severity.Error && d.Message.Contains("observability") && d.Message.Contains("security")); + } + + [Fact] + public async Task Constructor_UnknownTocDefaultCta_EmitsError() + { + var docSet = LoadDocSet( + """ + project: test + toc: + - toc: solutions/observability + """, + (""" + default_cta: does-not-exist + toc: + - file: apps/apm.md + """, "solutions/observability/toc.yml"), + ("# APM", "solutions/observability/apps/apm.md") + ); + + var (_, diagnostics) = await CreateConfigurationWithDiagnostics(docSet); + + diagnostics.Should().ContainSingle(d => d.Severity == Severity.Error).Which.Message.Should().Contain("does-not-exist"); + } + + private static DocumentationSetFile LoadDocSet(string docsetYaml, params (string Content, string Path)[] files) + { + var collector = new DiagnosticsCollector([]); + return LoadDocSet(collector, docsetYaml, files); + } + + private static DocumentationSetFile LoadDocSet( + DiagnosticsCollector collector, + string docsetYaml, + params (string Content, string Path)[] files + ) + { + var fileSystem = new MockFileSystem(new Dictionary(), "/docs"); + fileSystem.AddFile("/docs/docset.yml", new MockFileData(docsetYaml)); + + foreach (var (content, path) in files) + { + var fullPath = $"/docs/{path}"; + var directory = fileSystem.Path.GetDirectoryName(fullPath); + if (directory is not null) + fileSystem.AddDirectory(directory); + fileSystem.AddFile(fullPath, new MockFileData(content)); + } + + return DocumentationSetFile.LoadAndResolve( + collector, + docsetYaml, + fileSystem.DirectoryInfo.New("/docs"), + new ScopedFileSystem(fileSystem, "/docs") + ); + } + + private static ConfigurationFile CreateConfiguration(DocumentationSetFile docSet) + { + var collector = new DiagnosticsCollector([]); + return CreateConfiguration(docSet, collector); + } + + private static async Task<(ConfigurationFile Config, IReadOnlyList Diagnostics)> CreateConfigurationWithDiagnostics( + DocumentationSetFile docSet + ) + { + var recorder = new RecordingDiagnosticsOutput(); + var collector = new DiagnosticsCollector([recorder]); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + var config = CreateConfiguration(docSet, collector); + await collector.StopAsync(TestContext.Current.CancellationToken); + return (config, recorder.Diagnostics); + } + + private static ConfigurationFile CreateConfiguration(DocumentationSetFile docSet, DiagnosticsCollector collector) + { + var fileSystem = new MockFileSystem(new Dictionary { { "/docs/docset.yml", new MockFileData("") } }, "/docs"); + + var configPath = fileSystem.FileInfo.New("/docs/docset.yml"); + var docsDir = fileSystem.DirectoryInfo.New("/docs"); + + var context = new MockDocumentationSetContext(collector, fileSystem, configPath, docsDir); + var versionsConfig = new VersionsConfiguration { VersioningSystems = new Dictionary() }; + var productsConfig = new ProductsConfiguration + { + Products = new Dictionary().ToFrozenDictionary(), + PublicReferenceProducts = new Dictionary().ToFrozenDictionary(), + ProductDisplayNames = new Dictionary().ToFrozenDictionary() + }; + + return new ConfigurationFile(docSet, context, versionsConfig, productsConfig); + } + + private sealed class RecordingDiagnosticsOutput : IDiagnosticsOutput + { + public List Diagnostics { get; } = []; + public void Write(Diagnostic diagnostic) => Diagnostics.Add(diagnostic); + } + + private sealed class MockDocumentationSetContext( + IDiagnosticsCollector collector, + IFileSystem fileSystem, + IFileInfo configurationPath, + IDirectoryInfo documentationSourceDirectory + ) : IDocumentationSetContext + { + public IDiagnosticsCollector Collector => collector; + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve( + documentationSourceDirectory, + new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configurationPath.FullName } + ); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: fileSystem + ); + public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); + public IFileInfo ConfigurationPath => configurationPath; + public BuildType BuildType => BuildType.Isolated; + public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory; + public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem); + public IEnvironmentVariables Environment => SystemEnvironmentVariables.Instance; + } +}