Skip to content
Merged
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
13 changes: 12 additions & 1 deletion docs/_docset.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
project: 'doc-builder'

Check notice on line 1 in docs/_docset.yml

View workflow job for this annotation

GitHub Actions / build

Irregular space detected. Run 'docs-builder format --write' to automatically fix all instances.
max_toc_depth: 2
# indicates this documentation set is not linkable by assembler.
# relaxes a few restrictions around toc building and file placement
Expand Down Expand Up @@ -44,7 +44,17 @@

storybook:
registry: https://ci-artifacts.kibana.dev/storybooks/pr-272388/storybook-docs/docs_registry.json


cta:
docs-builder:
button:
label: Star docs-builder on GitHub
url: https://github.com/elastic/docs-builder
benefits:
- "Open source"
- "Built with .NET"
- "Contributions welcome"

suppress:
- AutolinkElasticCoDocs
- DeepLinkingVirtualFile
Expand Down Expand Up @@ -117,6 +127,7 @@
- file: navigation.md
- file: extensions.md
- file: api-explorer.md
- file: cta.md
- file: page.md
- file: content-sources.md
- folder: syntax
Expand Down
47 changes: 47 additions & 0 deletions docs/configure/content-set/cta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
navigation_title: CTA
cta:
id: docs-builder
---

# CTA

The CTA (call-to-action) feature renders a card in the right-hand sidebar of a page, with a button and a short list of benefits. By default, every page shows the built-in `trial` card. Docsets can define their own named CTA templates and have individual pages opt into them.

## Define CTA templates

Add a `cta` map to your `docset.yml` file. Each key is a template name; the value defines the button and benefits.

```yaml
cta:
beta:
button:
label: Join the private beta
url: https://example.com/beta-signup
benefits:
- "Early access to new features"
- "Direct line to the team"
- "Free for beta participants"
```

- `button.label` and `button.url` are required.
- `benefits` is optional and limited to 3 entries.

You can also override the built-in default by defining your own `trial` entry — it replaces the default card sitewide for this docset.

## Select a CTA on a page

Use the `cta` frontmatter field to select a template by `id`:

```yaml
---
cta:
id: beta
---
```

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.

## Click and impression tracking

CTA buttons are tracked via OpenTelemetry: a `cta_viewed` event fires the first time a card becomes visible, and a `cta_clicked` event fires on click. Both events carry the CTA's name, URL, label, and placement, so click-through rate can be compared across templates.
3 changes: 2 additions & 1 deletion docs/configure/content-set/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ A content set in `docs-builder` is equivalent to an AsciiDoc book. At this level
* [Navigation](./navigation.md).
* [Attributes](./attributes.md).
* [Extensions](./extensions.md).
* [API Explorer](./api-explorer.md).
* [API Explorer](./api-explorer.md).
* [CTA](./cta.md).
6 changes: 6 additions & 0 deletions docs/configure/content-set/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,12 @@ See https://www.elastic.co/docs/solutions/search for more details.
See [docs](docs-content://index.md) for more details.
```

### `cta`

Defines named right-gutter call-to-action templates that pages can opt into via frontmatter.

See [CTA](cta.md) for full configuration details and examples.

## Navigation configuration patterns

### Single file reference
Expand Down
9 changes: 8 additions & 1 deletion docs/syntax/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ products: <4>
- id: edot-sdk
sub: <5>
key: value
cta: <6>
id: beta
---
```

Expand All @@ -25,6 +27,7 @@ sub: <5>
3. [`applies_to`](#applies-to)
4. [`products`](#products)
5. [`sub`](#subs)
6. [`cta`](#cta)

## Navigation Title

Expand Down Expand Up @@ -90,4 +93,8 @@ Only products with the `public-reference` feature enabled in [`products.yml`](ht

## Subs

Use the `sub` field to define local substitutions. Refer to [Substitutions](substitutions.md) for more information.
Use the `sub` field to define local substitutions. Refer to [Substitutions](substitutions.md) for more information.

## CTA

See [CTA](../configure/content-set/cta.md).
1 change: 1 addition & 0 deletions src/Elastic.Codex/Page/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
GitRepository = Model.GitRepository,
GitHubDocsUrl = Model.GitHubDocsUrl,
GitHubRef = Model.GitHubRef,
Cta = Model.Cta,
};
protected override Task ExecuteSectionAsync(string name)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.IO.Abstractions;
using DotNet.Globbing;
using Elastic.Documentation.Configuration.Products;
using Elastic.Documentation.Configuration.Suggestions;
using Elastic.Documentation.Configuration.Toc;
using Elastic.Documentation.Configuration.Versions;
using Elastic.Documentation.Diagnostics;
Expand Down Expand Up @@ -90,6 +91,14 @@ public record ConfigurationFile
/// </summary>
public BrandingConfiguration? Branding { get; private set; }

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

/// <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.
/// </summary>
public IReadOnlyDictionary<string, Cta> Ctas => _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
public bool DevelopmentDocs { get; }
Expand Down Expand Up @@ -291,6 +300,13 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte
if (docSetFile.Branding is not null)
Branding = ValidateBranding(docSetFile.Branding, context);

// 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;
}

// Process features
_features = [with(StringComparer.OrdinalIgnoreCase)];
if (docSetFile.Features.PrimaryNav.HasValue)
Expand Down Expand Up @@ -330,6 +346,63 @@ 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.
/// </summary>
/// <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)
{
warning = null;
if (id is not null && Ctas.TryGetValue(id, out var cta))
return cta;
if (id is not null)
warning = UnknownCtaWarning(id, Ctas.Keys);
return Ctas[Cta.DefaultName];
}

private static string UnknownCtaWarning(string ctaName, IEnumerable<string> knownCtaNames)
{
var known = knownCtaNames.ToHashSet();
var hint = new Suggestion(known, ctaName).GetSuggestionQuestion();
if (string.IsNullOrEmpty(hint))
{
hint = known.Count > 1
? $"Available: {string.Join(", ", known.Order())}."
: "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}";
}

private static Cta? ValidateCta(string name, CtaDefinition definition, IDocumentationSetContext context)
{
if (string.IsNullOrWhiteSpace(definition.Button?.Label) || string.IsNullOrWhiteSpace(definition.Button?.Url))
{
context.EmitError(context.ConfigurationPath, $"'cta.{name}' must define both 'button.label' and 'button.url'.");
return null;
}
var url = definition.Button.Url.Trim();
if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) && uri.IsAbsoluteUri &&
uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
{
context.EmitError(context.ConfigurationPath, $"'cta.{name}.button.url' must use http/https or a relative URL.");
return null;
}
if (definition.Benefits.Count > Cta.MaxBenefits)
{
context.EmitError(context.ConfigurationPath, $"'cta.{name}.benefits' has {definition.Benefits.Count} entries; a maximum of {Cta.MaxBenefits} is allowed.");
return null;
}
return new Cta
{
Name = name,
Label = definition.Button.Label,
Url = url,
Benefits = definition.Benefits
};
}

private static readonly HashSet<string> AllowedImageExtensions =
[".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ public class DocumentationSetFile : TableOfContentsFile
[YamlMember(Alias = "storybook")]
public DocumentationSetStorybook? Storybook { get; set; }

/// <summary>
/// Named, reusable right-gutter CTA templates. Selected per-page via frontmatter <c>cta: &lt;name&gt;</c>;
/// pages that omit it fall back to the built-in <c>trial</c> default.
/// </summary>
[YamlMember(Alias = "cta")]
public Dictionary<string, CtaDefinition> Cta { get; set; } = [];

public static FileRef[] GetFileRefs(ITableOfContentsItem item)
{
if (item is FileRef fileRef)
Expand Down Expand Up @@ -811,3 +818,56 @@ public class BrandingConfiguration
[YamlMember(Alias = "apple-touch-icon", ApplyNamingConventions = false)]
public string? AppleTouchIcon { get; set; }
}

/// <summary>
/// A single named right-gutter CTA template, as declared under <c>docset.yml</c>'s <c>cta</c> map.
/// </summary>
[YamlSerializable]
public class CtaDefinition
{
[YamlMember(Alias = "button")]
public CtaButton? Button { get; set; }

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

/// <summary>
/// The clickable button portion of a <see cref="CtaDefinition"/>.
/// </summary>
[YamlSerializable]
public class CtaButton
{
[YamlMember(Alias = "label")]
public string? Label { get; set; }

[YamlMember(Alias = "url")]
public string? Url { get; set; }
}

/// <summary>
/// A resolved, validated right-gutter CTA, ready to render. See <see cref="CtaDefinition"/> for the raw
/// <c>docset.yml</c> shape this is parsed from.
/// </summary>
public record Cta
{
/// <summary>Name of the template this was resolved from; <see cref="DefaultName"/> for the built-in default.</summary>
public required string Name { get; init; }
public required string Label { get; init; }
public required string Url { get; init; }
public IReadOnlyList<string> Benefits { get; init; } = [];

/// <summary>The built-in default, reproducing the CTA that renders when no <c>cta:</c> config exists.</summary>
public const string DefaultName = "trial";

/// <summary>Right-gutter card space is limited; benefit bullet lists are capped at this many entries.</summary>
public const int MaxBenefits = 3;

public static Cta Default { get; } = new()
{
Name = DefaultName,
Label = "Get started free",
Url = "https://cloud.elastic.co/registration?page=docs&placement=docs-siderail",
Benefits = ["14-day free trial", "All features included", "No setup required"]
};
}
60 changes: 59 additions & 1 deletion src/Elastic.Documentation.Site/Assets/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import { initNav } from './pages-nav'
import { initSmoothScroll } from './smooth-scroll'
import { initTabs } from './tabs'
import { initializeOtel } from './telemetry/instrumentation'
import { logError } from './telemetry/logging'
import { logError, logInfo } from './telemetry/logging'
import {
ATTR_CTA_NAME,
ATTR_CTA_URL,
ATTR_CTA_LABEL,
ATTR_CTA_LOCATION,
ATTR_URL_PATH,
} from './telemetry/semconv'
import { initTocNav } from './toc-nav'
import 'htmx-ext-head-support'
import 'htmx-ext-preload'
Expand Down Expand Up @@ -128,11 +135,48 @@ function initMath() {
})
}

// Attributes shared by cta_viewed and cta_clicked so the two are directly comparable (CTR).
function ctaAttributes(cta: HTMLAnchorElement) {
return {
[ATTR_CTA_NAME]: cta.dataset.cta ?? '',
[ATTR_CTA_URL]: cta.dataset.ctaUrl ?? '',
[ATTR_CTA_LABEL]: cta.dataset.ctaLabel ?? '',
[ATTR_CTA_LOCATION]: cta.dataset.ctaLocation ?? '',
[ATTR_URL_PATH]: window.location.pathname,
}
}

let ctaImpressionObserver: IntersectionObserver | null = null

// Fires 'cta_viewed' the first time a CTA becomes at least half visible, then stops
// observing it (one impression per page view). The observer is created once and
// reused across htmx swaps; re-observing an already-observed element is a no-op per
// spec, so this only needs to (re-)register CTAs that are new since the last swap.
function initCtaImpressions() {
ctaImpressionObserver ??= new IntersectionObserver(
(entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
logInfo(
'cta_viewed',
ctaAttributes(entry.target as HTMLAnchorElement)
)
observer.unobserve(entry.target)
}
},
{ threshold: 0.5 }
)
$$optional('a[data-cta]').forEach((cta) =>
ctaImpressionObserver?.observe(cta)
)
}

// Initialize on initial page load
document.addEventListener('DOMContentLoaded', function () {
runInitSteps([
['initMath', initMath],
['initMermaid', initMermaid],
['initCtaImpressions', initCtaImpressions],
])
})

Expand All @@ -152,9 +196,23 @@ document.addEventListener('htmx:load', function () {
['initImageCarousel', initImageCarousel],
['initApiDocs', initApiDocs],
['applyEditParam', applyEditParam],
['initCtaImpressions', initCtaImpressions],
])
})

// Delegated listener: survives htmx swaps without needing re-init, unlike the
// runInitSteps above which bind directly to elements that get replaced.
// logInfo's export survives this same-tab navigation: the batch processor flushes on
// 'pagehide' (registered in initializeOtel) via a keepalive fetch, which browsers keep
// alive past unload.
document.addEventListener('click', function (event: MouseEvent) {
const cta = (event.target as HTMLElement)?.closest<HTMLAnchorElement>(
'a[data-cta]'
)
if (!cta) return
logInfo('cta_clicked', ctaAttributes(cta))
})

// Don't remove style tags because they are used by the elastic global nav.
document.addEventListener(
'htmx:removingHeadElement',
Expand Down
Loading
Loading