diff --git a/docs/configure/content-set/cta.md b/docs/configure/content-set/cta.md index 37bff5b229..3d25ea4599 100644 --- a/docs/configure/content-set/cta.md +++ b/docs/configure/content-set/cta.md @@ -40,7 +40,28 @@ 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 scoped to its path (if any) applies; otherwise it falls back to the built-in `trial` CTA. An unknown `id` emits a build warning and is ignored. + +## Scope a CTA to a path + +To apply a template to every page under a directory without editing each file, list path prefixes under `paths`: + +```yaml +cta: + observability: + button: + label: Get started free + url: https://cloud.elastic.co/serverless-registration?onboarding_token=observability + benefits: + - "14-day free trial" + paths: + - solutions/observability +``` + +- Paths are relative to the docset root (the directory containing `docset.yml`) and match whole path segments: `solutions/observability` covers `solutions/observability/apps/apm.md` but not `solutions/observability-labs/index.md`. +- When a page falls under more than one scoped path, the most specific (longest) prefix wins. +- A page's `cta` frontmatter always takes precedence over a path scope. +- Each path can only be claimed by one template; declaring the same path in two templates 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 34db8d3ea0..5d9b27330a 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -93,6 +93,10 @@ public record ConfigurationFile private readonly Dictionary _ctas = new(StringComparer.OrdinalIgnoreCase) { [Cta.DefaultName] = Cta.Default }; + // Path scopes declared via `cta..paths`, as (normalized prefix, template name) pairs ordered + // longest-prefix-first so the most specific scope wins during resolution. + private readonly List> _ctaPathScopes = []; + /// /// Named right-gutter CTA templates declared under docset.yml's cta map, keyed by name. /// Always contains at least the built-in entry. @@ -301,11 +305,15 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte Branding = ValidateBranding(docSetFile.Branding, context); // Process CTA templates - overlays onto (and may override) the built-in 'trial' default + var ctaPathScopes = new Dictionary(StringComparer.OrdinalIgnoreCase); 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; + CollectCtaPathScopes(name, definition.Paths, ctaPathScopes, context); } + _ctaPathScopes = [.. ctaPathScopes.OrderByDescending(kv => kv.Key.Length)]; // Process features _features = [with(StringComparer.OrdinalIgnoreCase)]; @@ -347,20 +355,58 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte } /// - /// 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 whose paths scope matches + /// applies (most specific prefix first), falling back to . /// + /// The page's cta.id frontmatter value, if any. + /// The page's docset-root-relative source path, used for path-scope matching. /// 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 } && MatchCtaPathScope(relativePath) is { } scoped) + return scoped; return Ctas[Cta.DefaultName]; } + private Cta? MatchCtaPathScope(string relativePath) + { + if (_ctaPathScopes.Count == 0) + return null; + var normalized = relativePath.Replace('\\', '/').TrimStart('/'); + foreach (var (prefix, name) in _ctaPathScopes) + { + // Whole-segment prefix match: "solutions/observability" must not match "solutions/observability-labs/...". + if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && (normalized.Length == prefix.Length || normalized[prefix.Length] == '/')) + return Ctas[name]; + } + return null; + } + + private static void CollectCtaPathScopes(string name, List paths, Dictionary scopes, IDocumentationSetContext context) + { + foreach (var path in paths) + { + var prefix = path.Trim().Replace('\\', '/').Trim('/'); + if (string.IsNullOrEmpty(prefix)) + { + context.EmitError(context.ConfigurationPath, $"'cta.{name}.paths' contains an empty path."); + continue; + } + if (!scopes.TryAdd(prefix, name)) + context.EmitError(context.ConfigurationPath, $"'cta.{name}.paths' declares '{prefix}' which is already claimed by 'cta.{scopes[prefix]}'. Each path can only map to one CTA template."); + } + } + private static string UnknownCtaWarning(string ctaName, IEnumerable knownCtaNames) { var known = knownCtaNames.ToHashSet(); @@ -372,7 +418,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 5ea875eb28..3cdd90020e 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -830,6 +830,14 @@ public class CtaDefinition [YamlMember(Alias = "benefits")] public List Benefits { get; set; } = []; + + /// + /// Optional docset-root-relative path prefixes this template applies to. Every page under a listed + /// prefix uses this template unless it selects one explicitly via its cta frontmatter. + /// When scopes overlap, the most specific (longest) prefix wins. + /// + [YamlMember(Alias = "paths")] + public List Paths { get; set; } = []; } /// diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index be506878f7..3f78ecc129 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -108,10 +108,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 `cta..paths` + // scope covering this page. 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..41472dcb6b --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileCtaTests.cs @@ -0,0 +1,208 @@ +// 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 Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.Configuration.Tests; + +public class ConfigurationFileCtaTests +{ + [Fact] + public void ResolveCta_FrontmatterId_TakesPrecedenceOverPathScope() + { + var config = CreateConfiguration(DocSetWith( + ("observability", "solutions/observability"), + ("monitor-kubernetes", null))); + + 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_UsesPathScope() + { + var config = CreateConfiguration(DocSetWith(("observability", "solutions/observability"))); + + 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_NoFrontmatterAndNoScopeMatch_FallsBackToDefault() + { + var config = CreateConfiguration(DocSetWith(("observability", "solutions/observability"))); + + 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_PathScope_MatchesWholeSegmentsOnly() + { + var config = CreateConfiguration(DocSetWith(("observability", "solutions/observability"))); + + var cta = config.ResolveCta(null, "solutions/observability-labs/index.md", out _); + + cta.Name.Should().Be(Cta.DefaultName); + } + + [Fact] + public void ResolveCta_OverlappingScopes_MostSpecificPrefixWins() + { + var config = CreateConfiguration(DocSetWith( + ("observability", "solutions/observability"), + ("monitor-kubernetes", "solutions/observability/get-started"))); + + 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_WarnsAndFallsBackToPathScope() + { + var config = CreateConfiguration(DocSetWith(("observability", "solutions/observability"))); + + 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_PathScope_NormalizesSeparatorsAndSlashes() + { + var config = CreateConfiguration(DocSetWith(("observability", "/solutions/observability/"))); + + var cta = config.ResolveCta(null, @"solutions\observability\apps\apm.md", out _); + + cta.Name.Should().Be("observability"); + } + + [Fact] + public async Task Constructor_PathClaimedByTwoTemplates_EmitsError() + { + var docSet = DocSetWith( + ("observability", "solutions/observability"), + ("security", "solutions/observability")); + + var (_, diagnostics) = await CreateConfigurationWithDiagnostics(docSet); + + diagnostics.Should().ContainSingle(d => d.Severity == Severity.Error) + .Which.Message.Should().Contain("already claimed by 'cta.observability'"); + } + + [Fact] + public async Task Constructor_EmptyPath_EmitsError() + { + var docSet = DocSetWith(("observability", " ")); + + var (_, diagnostics) = await CreateConfigurationWithDiagnostics(docSet); + + diagnostics.Should().ContainSingle(d => d.Severity == Severity.Error) + .Which.Message.Should().Contain("empty path"); + } + + private static DocumentationSetFile DocSetWith(params (string Name, string? Path)[] templates) + { + var cta = new Dictionary(); + foreach (var (name, path) in templates) + { + cta[name] = new CtaDefinition + { + Button = new CtaButton { Label = "Get started free", Url = $"https://cloud.elastic.co/serverless-registration?onboarding_token={name}" }, + Paths = path is null ? [] : [path] + }; + } + return new DocumentationSetFile + { + Project = "test", + TableOfContents = [], + Cta = cta + }; + } + + 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 root = Paths.WorkingDirectoryRoot.FullName; + var configFilePath = Path.Join(root, "docs", "_docset.yml"); + var fileSystem = new MockFileSystem(new Dictionary + { + { configFilePath, new MockFileData("") } + }, root); + + var configPath = fileSystem.FileInfo.New(configFilePath); + var docsDir = fileSystem.DirectoryInfo.New(Path.Join(root, "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 ScopedFileSystem ReadFileSystem => WriteFileSystem; + public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(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; + } +}