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
6 changes: 6 additions & 0 deletions docs/documentation/isolated/configure/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion docs/documentation/isolated/cta.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,6 +92,9 @@ public record ConfigurationFile

private readonly Dictionary<string, Cta> _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<string, string> _tocDefaultCtas;

/// <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 @@ -123,6 +127,7 @@ ProductsConfiguration productsConfig
{
_context = context;
ScopeDirectory = context.ConfigurationPath.Directory!;
_tocDefaultCtas = FrozenDictionary<string, string>.Empty;
if (!context.ConfigurationPath.Exists)
{
Project = "unknown";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -273,17 +292,29 @@ ProductsConfiguration productsConfig
}

/// <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 registered via <c>default_cta</c> on the page's navigation file
/// applies, 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 toc default lookup.</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 })
{
var normalizedPath = DocumentationSetFile.NormalizeDocsetRelativePath(relativePath);
if (_tocDefaultCtas.TryGetValue(normalizedPath, out var tocDefault) && Ctas.TryGetValue(tocDefault, out var scoped))
return scoped;
}
return Ctas[Cta.DefaultName];
}

Expand All @@ -298,7 +329,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 @@ -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;
Expand Down Expand Up @@ -149,6 +150,13 @@ public static DocumentationSetFile LoadAndResolve(
[YamlIgnore]
public HashSet<string> FolderExcludedFiles { get; private set; } = [];

/// <summary>
/// Pages registered with a default CTA via <c>default_cta</c> on <c>docset.yml</c> or nested <c>toc.yml</c> files.
/// Keys are docset-root-relative markdown paths; values are template names from the docset's <c>cta</c> map.
/// </summary>
[YamlIgnore]
public IReadOnlyDictionary<string, string> TocDefaultCtas { get; private set; } = FrozenDictionary<string, string>.Empty;

/// <summary>
/// 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.
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -379,7 +388,8 @@ private static TableOfContents ResolveTableOfContents(
tocPathRelativeToContainer,
resolvedChildren,
parentContext,
tocRef.Island || nestedTocFile.Island
tocRef.Island || nestedTocFile.Island,
nestedTocFile.DefaultCta
);
}

Expand Down Expand Up @@ -846,6 +856,96 @@ private static TableOfContents AutoDiscoverFolderFiles(
return ResolveTableOfContents(collector, children, baseDirectory, fileSystem, folderPath, containerPath, context);
}

/// <summary>
/// Traverses the resolved TOC and collects pages registered with a <c>default_cta</c> from
/// <c>docset.yml</c> or nested <c>toc.yml</c> files.
/// </summary>
private static FrozenDictionary<string, string> CollectTocDefaultCtas(
IDiagnosticsCollector collector,
IReadOnlyCollection<ITableOfContentsItem> items,
string? inheritedDefault
)
{
var defaults = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
CollectTocDefaultCtas(collector, items, inheritedDefault, defaults);
return defaults.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}

private static void CollectTocDefaultCtas(
IDiagnosticsCollector collector,
IReadOnlyCollection<ITableOfContentsItem> items,
string? inheritedDefault,
Dictionary<string, string> 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<string, string> 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<string>(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);
}

/// <summary>
/// Traverses the resolved TOC and collects relative paths of files excluded via folder-level <c>exclude</c>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ public class TableOfContentsFile
[YamlMember(Alias = "suppress")]
public HashSet<HintType> SuppressDiagnostics { get; set; } = [];

/// <summary>
/// Optional name of a <c>cta</c> template (declared in <c>docset.yml</c>) applied to every page
/// listed in this navigation file unless the page selects one explicitly via frontmatter.
/// Nested <c>toc.yml</c> files may override the value inherited from a parent navigation file.
/// </summary>
[YamlMember(Alias = "default_cta")]
public string? DefaultCta { get; set; }

public static TableOfContentsFile Deserialize(string json) =>
ConfigurationFileProvider.Deserializer.Deserialize<TableOfContentsFile>(json);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ public record IsolatedTableOfContentsRef(
string PathRelativeToContainer,
IReadOnlyCollection<ITableOfContentsItem> Children,
string Context,
bool Island = false
bool Island = false,
string? DefaultCta = null
) : ITableOfContentsItem;

/// <summary>Controls how much of the listing appears in the rendered navigation tree.</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 @@ -119,10 +119,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 `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);

Expand Down
Loading
Loading