Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion docs/configure/content-set/cta.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ public record ConfigurationFile

private readonly Dictionary<string, Cta> _ctas = new(StringComparer.OrdinalIgnoreCase) { [Cta.DefaultName] = Cta.Default };

// Path scopes declared via `cta.<name>.paths`, as (normalized prefix, template name) pairs ordered
// longest-prefix-first so the most specific scope wins during resolution.
private readonly List<KeyValuePair<string, string>> _ctaPathScopes = [];

/// <summary>
/// Named right-gutter CTA templates declared under <c>docset.yml</c>'s <c>cta</c> map, keyed by name.
/// Always contains at least the built-in <see cref="Cta.DefaultName"/> entry.
Expand Down Expand Up @@ -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<string, string>(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)];
Expand Down Expand Up @@ -347,20 +355,58 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte
}

/// <summary>
/// Resolves a page's <c>cta</c> frontmatter id to a template, falling back to <see cref="Cta.DefaultName"/>
/// when <paramref name="id"/> is omitted or doesn't match a configured template.
/// Resolves the right-gutter CTA for a page. An explicit, known <c>cta</c> frontmatter <paramref name="id"/>
/// always wins. Otherwise the template whose <c>paths</c> scope matches <paramref name="relativePath"/>
/// applies (most specific prefix first), falling back to <see cref="Cta.DefaultName"/>.
/// </summary>
/// <param name="id">The page's <c>cta.id</c> frontmatter value, if any.</param>
/// <param name="relativePath">The page's docset-root-relative source path, used for path-scope matching.</param>
/// <param name="warning">Set when <paramref name="id"/> is unknown, so the caller can report it.</param>
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<string> paths, Dictionary<string, string> 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<string> knownCtaNames)
{
var known = knownCtaNames.ToHashSet();
Expand All @@ -372,7 +418,7 @@ private static string UnknownCtaWarning(string ctaName, IEnumerable<string> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,14 @@ public class CtaDefinition

[YamlMember(Alias = "benefits")]
public List<string> Benefits { get; set; } = [];

/// <summary>
/// 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 <c>cta</c> frontmatter.
/// When scopes overlap, the most specific (longest) prefix wins.
/// </summary>
[YamlMember(Alias = "paths")]
public List<string> Paths { get; set; } = [];
}

/// <summary>
Expand Down
8 changes: 4 additions & 4 deletions src/Elastic.Markdown/HtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,10 @@ private async Task<RenderResult> 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.<name>.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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, CtaDefinition>();
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<Diagnostic> 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<string, MockFileData>
{
{ 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<VersioningSystemId, VersioningSystem>()
};
var productsConfig = new ProductsConfiguration
{
Products = new Dictionary<string, Product>().ToFrozenDictionary(),
PublicReferenceProducts = new Dictionary<string, Product>().ToFrozenDictionary(),
ProductDisplayNames = new Dictionary<string, string>().ToFrozenDictionary()
};

return new ConfigurationFile(docSet, context, versionsConfig, productsConfig);
}

private sealed class RecordingDiagnosticsOutput : IDiagnosticsOutput
{
public List<Diagnostic> 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;
}
}
Loading