From aa2ebfda8dc301d9b11d97394c3810ee7c4b29a2 Mon Sep 17 00:00:00 2001 From: bmorelli25 Date: Thu, 23 Jul 2026 14:32:10 -0700 Subject: [PATCH 1/4] Allow scoping CTA templates to path prefixes via cta..paths Docsets can now apply a right-gutter CTA template to an entire path subtree by listing docset-relative prefixes under a template's 'paths' key, instead of adding 'cta' frontmatter to every page. Resolution order: explicit frontmatter id, then the longest matching path prefix, then the built-in trial default. An unknown frontmatter id warns and is ignored so path scopes still apply. A path claimed by two templates is a build error. Co-authored-by: Cursor --- docs/configure/content-set/cta.md | 23 +- .../Builder/ConfigurationFile.cs | 62 +++++- .../Toc/DocumentationSetFile.cs | 8 + src/Elastic.Markdown/HtmlWriter.cs | 8 +- .../ConfigurationFileCtaTests.cs | 208 ++++++++++++++++++ 5 files changed, 296 insertions(+), 13 deletions(-) create mode 100644 tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileCtaTests.cs 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; + } +} From e685a09fcc21b9f4d41064188bbfa4e990e7eeb3 Mon Sep 17 00:00:00 2001 From: bmorelli25 Date: Thu, 6 Aug 2026 14:34:00 -0700 Subject: [PATCH 2/4] Pivot CTA scoping to default_cta on navigation files. Replace cta..paths in docset.yml with default_cta on docset.yml and nested toc.yml so section owners register defaults alongside their nav trees. Co-authored-by: Cursor --- docs/configure/content-set/cta.md | 25 +- docs/configure/content-set/navigation.md | 6 + .../Builder/ConfigurationFile.cs | 63 ++--- .../Toc/DocumentationSetFile.cs | 105 ++++++- .../Toc/TableOfContentsFile.cs | 8 + .../Toc/TableOfContentsItems.cs | 2 +- src/Elastic.Markdown/HtmlWriter.cs | 6 +- .../ConfigurationFileCtaTests.cs | 256 +++++++++++++----- 8 files changed, 346 insertions(+), 125 deletions(-) diff --git a/docs/configure/content-set/cta.md b/docs/configure/content-set/cta.md index 3d25ea4599..af1340d711 100644 --- a/docs/configure/content-set/cta.md +++ b/docs/configure/content-set/cta.md @@ -40,13 +40,22 @@ cta: --- ``` -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. +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. -## Scope a CTA to a path +## Register a default CTA on a navigation file -To apply a template to every page under a directory without editing each file, list path prefixes under `paths`: +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: @@ -54,14 +63,12 @@ cta: 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. +- `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/docs/configure/content-set/navigation.md b/docs/configure/content-set/navigation.md index e1703f7826..3e40775a16 100644 --- a/docs/configure/content-set/navigation.md +++ b/docs/configure/content-set/navigation.md @@ -361,6 +361,12 @@ Defines named right-gutter call-to-action templates that pages can opt into via See [CTA](cta.md) for full configuration details and examples. +### `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) for full configuration details and examples. + ## Navigation configuration patterns ### Single file reference diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index 5d9b27330a..5f56ebce9d 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; @@ -93,9 +94,8 @@ 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 = []; + // 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. @@ -124,6 +124,7 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte { _context = context; ScopeDirectory = context.ConfigurationPath.Directory!; + _tocDefaultCtas = FrozenDictionary.Empty; if (!context.ConfigurationPath.Exists) { Project = "unknown"; @@ -305,15 +306,23 @@ 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 not { } cta) continue; _ctas[name] = cta; - CollectCtaPathScopes(name, definition.Paths, ctaPathScopes, context); } - _ctaPathScopes = [.. ctaPathScopes.OrderByDescending(kv => kv.Key.Length)]; + + 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)]; @@ -356,11 +365,11 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte /// /// 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 . + /// 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 path-scope matching. + /// 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, string? relativePath, out string? warning) { @@ -372,39 +381,13 @@ public Cta ResolveCta(string? id, string? relativePath, out string? warning) // 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) + if (relativePath is { Length: > 0 }) { - // 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."); + var normalizedPath = DocumentationSetFile.NormalizeDocsetRelativePath(relativePath); + if (_tocDefaultCtas.TryGetValue(normalizedPath, out var tocDefault) && Ctas.TryGetValue(tocDefault, out var scoped)) + return scoped; } + return Ctas[Cta.DefaultName]; } private static string UnknownCtaWarning(string ctaName, IEnumerable knownCtaNames) diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 3cdd90020e..add384b95a 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 Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Toc.CliReference; @@ -141,6 +142,13 @@ public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collecto [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. @@ -155,6 +163,7 @@ public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collecto docSet.TableOfContents = ResolveTableOfContents(collector, docSet.TableOfContents, sourceDirectory, fileSystem, parentPath: "", containerPath: "", context: docsetPath, docSet.SuppressDiagnostics); // 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; } @@ -303,7 +312,7 @@ private static TableOfContents ResolveTableOfContents( // Return TOC ref with FULL path and resolved children // The context remains the parent context (where this TOC was referenced) - return new IsolatedTableOfContentsRef(fullTocPath, tocPathRelativeToContainer, resolvedChildren, parentContext); + return new IsolatedTableOfContentsRef(fullTocPath, tocPathRelativeToContainer, resolvedChildren, parentContext, nestedTocFile.DefaultCta); } /// @@ -695,6 +704,92 @@ 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. /// @@ -830,14 +925,6 @@ 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.Documentation.Configuration/Toc/TableOfContentsFile.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs index faa7f5865a..fe42cf7007 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsFile.cs @@ -23,6 +23,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 d7cbd9a347..181e142c63 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsItems.cs @@ -147,5 +147,5 @@ public record CrossLinkRef(Uri CrossLinkUri, string? Title, bool Hidden, IReadOn public record FolderRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, IReadOnlyCollection Children, string Context, string? Sort = null, IReadOnlyCollection? Exclude = null) : ITableOfContentsItem; -public record IsolatedTableOfContentsRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, IReadOnlyCollection Children, string Context) +public record IsolatedTableOfContentsRef(string PathRelativeToDocumentationSet, string PathRelativeToContainer, IReadOnlyCollection Children, string Context, string? DefaultCta = null) : ITableOfContentsItem; diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 3f78ecc129..539dc85a82 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -108,9 +108,9 @@ 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 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. + // 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 index 41472dcb6b..86e184528c 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileCtaTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileCtaTests.cs @@ -18,12 +18,30 @@ namespace Elastic.Documentation.Configuration.Tests; public class ConfigurationFileCtaTests { [Fact] - public void ResolveCta_FrontmatterId_TakesPrecedenceOverPathScope() + public void ResolveCta_FrontmatterId_TakesPrecedenceOverTocDefault() { - var config = CreateConfiguration(DocSetWith( - ("observability", "solutions/observability"), - ("monitor-kubernetes", null))); - + 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"); @@ -31,10 +49,26 @@ public void ResolveCta_FrontmatterId_TakesPrecedenceOverPathScope() } [Fact] - public void ResolveCta_NoFrontmatter_UsesPathScope() + public void ResolveCta_NoFrontmatter_UsesTocDefault() { - var config = CreateConfiguration(DocSetWith(("observability", "solutions/observability"))); - + 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"); @@ -42,10 +76,21 @@ public void ResolveCta_NoFrontmatter_UsesPathScope() } [Fact] - public void ResolveCta_NoFrontmatterAndNoScopeMatch_FallsBackToDefault() + public void ResolveCta_NoFrontmatterAndNoTocDefault_FallsBackToDefault() { - var config = CreateConfiguration(DocSetWith(("observability", "solutions/observability"))); - + 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); @@ -53,21 +98,37 @@ public void ResolveCta_NoFrontmatterAndNoScopeMatch_FallsBackToDefault() } [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() + public void ResolveCta_NestedTocDefault_OverridesParentDefault() { - var config = CreateConfiguration(DocSetWith( - ("observability", "solutions/observability"), - ("monitor-kubernetes", "solutions/observability/get-started"))); + 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"); @@ -76,10 +137,26 @@ public void ResolveCta_OverlappingScopes_MostSpecificPrefixWins() } [Fact] - public void ResolveCta_UnknownFrontmatterId_WarnsAndFallsBackToPathScope() + public void ResolveCta_UnknownFrontmatterId_WarnsAndFallsBackToTocDefault() { - var config = CreateConfiguration(DocSetWith(("observability", "solutions/observability"))); - + 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"); @@ -87,56 +164,111 @@ public void ResolveCta_UnknownFrontmatterId_WarnsAndFallsBackToPathScope() } [Fact] - public void ResolveCta_PathScope_NormalizesSeparatorsAndSlashes() + public void ResolveCta_DocsetDefaultCta_AppliesToRootLevelPages() { - var config = CreateConfiguration(DocSetWith(("observability", "/solutions/observability/"))); - - var cta = config.ResolveCta(null, @"solutions\observability\apps\apm.md", out _); + 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 Constructor_PathClaimedByTwoTemplates_EmitsError() + public async Task LoadAndResolve_PageClaimedByTwoDefaults_EmitsError() { - var docSet = DocSetWith( - ("observability", "solutions/observability"), - ("security", "solutions/observability")); + var recorder = new RecordingDiagnosticsOutput(); + var collector = new DiagnosticsCollector([recorder]); + _ = collector.StartAsync(TestContext.Current.CancellationToken); - var (_, diagnostics) = await CreateConfigurationWithDiagnostics(docSet); + _ = 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")); - diagnostics.Should().ContainSingle(d => d.Severity == Severity.Error) - .Which.Message.Should().Contain("already claimed by 'cta.observability'"); + 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_EmptyPath_EmitsError() + public async Task Constructor_UnknownTocDefaultCta_EmitsError() { - var docSet = DocSetWith(("observability", " ")); + 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("empty path"); + .Which.Message.Should().Contain("does-not-exist"); } - private static DocumentationSetFile DocSetWith(params (string Name, string? Path)[] templates) + private static DocumentationSetFile LoadDocSet(string docsetYaml, params (string Content, string Path)[] files) { - var cta = new Dictionary(); - foreach (var (name, path) in templates) + 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) { - 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] - }; + 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 new DocumentationSetFile - { - Project = "test", - TableOfContents = [], - Cta = cta - }; + + return DocumentationSetFile.LoadAndResolve(collector, docsetYaml, fileSystem.DirectoryInfo.New("/docs"), new ScopedFileSystem(fileSystem, "/docs")); } private static ConfigurationFile CreateConfiguration(DocumentationSetFile docSet) @@ -157,15 +289,13 @@ private static ConfigurationFile CreateConfiguration(DocumentationSetFile docSet 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); + { "/docs/docset.yml", new MockFileData("") } + }, "/docs"); - var configPath = fileSystem.FileInfo.New(configFilePath); - var docsDir = fileSystem.DirectoryInfo.New(Path.Join(root, "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 @@ -198,7 +328,7 @@ private sealed class MockDocumentationSetContext( 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 IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New("/docs/.artifacts"); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory; From 9a61c0071f8613869f722ef02612b26dde9080bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:15:53 +0000 Subject: [PATCH 3/4] Rename Cta to CallToAction in C# code Co-authored-by: Mpdreamz <245275+Mpdreamz@users.noreply.github.com> --- src/Elastic.Codex/Page/Index.cshtml | 2 +- .../Builder/ConfigurationFile.cs | 28 +++++++++---------- .../Toc/DocumentationSetFile.cs | 4 +-- src/Elastic.Markdown/HtmlWriter.cs | 4 +-- .../Layout/_TableOfContents.cshtml | 8 +++--- .../MarkdownLayoutViewModel.cs | 2 +- src/Elastic.Markdown/Page/Index.cshtml | 2 +- src/Elastic.Markdown/Page/IndexViewModel.cs | 2 +- .../ConfigurationFileCtaTests.cs | 16 +++++------ 9 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/Elastic.Codex/Page/Index.cshtml b/src/Elastic.Codex/Page/Index.cshtml index 4529b9b85b..bfcb509146 100644 --- a/src/Elastic.Codex/Page/Index.cshtml +++ b/src/Elastic.Codex/Page/Index.cshtml @@ -68,7 +68,7 @@ GitRepository = Model.GitRepository, GitHubDocsUrl = Model.GitHubDocsUrl, GitHubRef = Model.GitHubRef, - Cta = Model.Cta, + CallToAction = Model.CallToAction, }; protected override Task ExecuteSectionAsync(string name) { diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index 5f56ebce9d..f2e1d695d3 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -92,16 +92,16 @@ public record ConfigurationFile /// public BrandingConfiguration? Branding { get; private set; } - private readonly Dictionary _ctas = new(StringComparer.OrdinalIgnoreCase) { [Cta.DefaultName] = Cta.Default }; + private readonly Dictionary _ctas = new(StringComparer.OrdinalIgnoreCase) { [CallToAction.DefaultName] = CallToAction.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. + /// Always contains at least the built-in entry. /// - public IReadOnlyDictionary Ctas => _ctas; + public IReadOnlyDictionary CallToActions => _ctas; /// This is a documentation set not linked to by assembler. /// Setting this to true relaxes a few restrictions such as mixing toc references with file and folder reference @@ -308,7 +308,7 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte // 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 not { } cta) + if (ValidateCallToAction(name, definition, context) is not { } cta) continue; _ctas[name] = cta; } @@ -366,28 +366,28 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte /// /// 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 . + /// 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, string? relativePath, out string? warning) + public CallToAction ResolveCallToAction(string? id, string? relativePath, out string? warning) { warning = null; if (id is not null) { - if (Ctas.TryGetValue(id, out var selected)) + if (CallToActions.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); + warning = UnknownCtaWarning(id, CallToActions.Keys); } if (relativePath is { Length: > 0 }) { var normalizedPath = DocumentationSetFile.NormalizeDocsetRelativePath(relativePath); - if (_tocDefaultCtas.TryGetValue(normalizedPath, out var tocDefault) && Ctas.TryGetValue(tocDefault, out var scoped)) + if (_tocDefaultCtas.TryGetValue(normalizedPath, out var tocDefault) && CallToActions.TryGetValue(tocDefault, out var scoped)) return scoped; } - return Ctas[Cta.DefaultName]; + return CallToActions[CallToAction.DefaultName]; } private static string UnknownCtaWarning(string ctaName, IEnumerable knownCtaNames) @@ -404,7 +404,7 @@ private static string UnknownCtaWarning(string ctaName, IEnumerable know 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) + private static CallToAction? ValidateCallToAction(string name, CtaDefinition definition, IDocumentationSetContext context) { if (string.IsNullOrWhiteSpace(definition.Button?.Label) || string.IsNullOrWhiteSpace(definition.Button?.Url)) { @@ -418,12 +418,12 @@ private static string UnknownCtaWarning(string ctaName, IEnumerable know context.EmitError(context.ConfigurationPath, $"'cta.{name}.button.url' must use http/https or a relative URL."); return null; } - if (definition.Benefits.Count > Cta.MaxBenefits) + if (definition.Benefits.Count > CallToAction.MaxBenefits) { - context.EmitError(context.ConfigurationPath, $"'cta.{name}.benefits' has {definition.Benefits.Count} entries; a maximum of {Cta.MaxBenefits} is allowed."); + context.EmitError(context.ConfigurationPath, $"'cta.{name}.benefits' has {definition.Benefits.Count} entries; a maximum of {CallToAction.MaxBenefits} is allowed."); return null; } - return new Cta + return new CallToAction { Name = name, Label = definition.Button.Label, diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index add384b95a..137a28c518 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -944,7 +944,7 @@ public class CtaButton /// A resolved, validated right-gutter CTA, ready to render. See for the raw /// docset.yml shape this is parsed from. /// -public record Cta +public record CallToAction { /// Name of the template this was resolved from; for the built-in default. public required string Name { get; init; } @@ -958,7 +958,7 @@ public record Cta /// Right-gutter card space is limited; benefit bullet lists are capped at this many entries. public const int MaxBenefits = 3; - public static Cta Default { get; } = new() + public static CallToAction Default { get; } = new() { Name = DefaultName, Label = "Get started free", diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 539dc85a82..f065979e78 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -111,7 +111,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc // 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); + var cta = DocumentationSet.Configuration.ResolveCallToAction(markdown.YamlFrontMatter?.Cta?.Id, markdown.RelativePath, out var ctaWarning); if (ctaWarning is not null) DocumentationSet.Context.Collector.EmitWarning(markdown.FilePath, ctaWarning); @@ -204,7 +204,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc GitHubRef = DocumentationSet.Context.Git.GitHubRef, Branding = DocumentationSet.Configuration.Branding, RedirectUrl = markdown.RedirectUrl, - Cta = cta + CallToAction = cta }); return new RenderResult diff --git a/src/Elastic.Markdown/Layout/_TableOfContents.cshtml b/src/Elastic.Markdown/Layout/_TableOfContents.cshtml index 250a8733df..d3e5ce8462 100644 --- a/src/Elastic.Markdown/Layout/_TableOfContents.cshtml +++ b/src/Elastic.Markdown/Layout/_TableOfContents.cshtml @@ -72,16 +72,16 @@ } - @if (Model.BuildType != BuildType.Codex && (Model.Cta.Name != Cta.DefaultName || (Model.BuildType == BuildType.Assembler && Model.Branding is null))) + @if (Model.BuildType != BuildType.Codex && (Model.CallToAction.Name != CallToAction.DefaultName || (Model.BuildType == BuildType.Assembler && Model.Branding is null))) {