From 72fe3da5eb2465f3d2c7a9065d94db90ed2b250c Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 1 Jul 2026 12:24:48 +0200 Subject: [PATCH 1/9] Add configurable right-gutter CTA templates Docsets can now define named CTA templates under docset.yml's `cta` map (label, url, benefits) and select one per-page via frontmatter `cta: `, instead of the previously hardcoded trial card. Clicks and impressions are tracked via OpenTelemetry. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Codex/Page/Index.cshtml | 1 + .../Builder/ConfigurationFile.cs | 30 ++++++++++ .../Toc/DocumentationSetFile.cs | 60 +++++++++++++++++++ src/Elastic.Documentation.Site/Assets/main.ts | 60 ++++++++++++++++++- .../Assets/telemetry/semconv.ts | 32 ++++++++++ src/Elastic.Markdown/HtmlWriter.cs | 30 +++++++++- .../Layout/_TableOfContents.cshtml | 26 ++++---- .../MarkdownLayoutViewModel.cs | 4 ++ .../Myst/FrontMatter/FrontMatterParser.cs | 7 +++ src/Elastic.Markdown/Page/Index.cshtml | 1 + src/Elastic.Markdown/Page/IndexViewModel.cs | 3 + 11 files changed, 237 insertions(+), 17 deletions(-) diff --git a/src/Elastic.Codex/Page/Index.cshtml b/src/Elastic.Codex/Page/Index.cshtml index a7f1f08d7e..e1dcc21d00 100644 --- a/src/Elastic.Codex/Page/Index.cshtml +++ b/src/Elastic.Codex/Page/Index.cshtml @@ -79,6 +79,7 @@ GitRepository = Model.GitRepository, GitHubDocsUrl = Model.GitHubDocsUrl, GitHubRef = Model.GitHubRef, + Cta = Model.Cta, }; 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 0eb44b29c5..afc5eafbea 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -90,6 +90,14 @@ public record ConfigurationFile /// public BrandingConfiguration? Branding { get; private set; } + private readonly Dictionary _ctas = new(StringComparer.OrdinalIgnoreCase) { [Cta.DefaultName] = Cta.Default }; + + /// + /// Named right-gutter CTA templates declared under docset.yml's cta map, keyed by name. + /// Always contains at least the built-in entry. + /// + public IReadOnlyDictionary 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; } @@ -291,6 +299,28 @@ 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 (string.IsNullOrWhiteSpace(definition.Button?.Label) || string.IsNullOrWhiteSpace(definition.Button?.Url)) + { + context.EmitError(context.ConfigurationPath, $"'cta.{name}' must define both 'button.label' and 'button.url'."); + continue; + } + 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."); + continue; + } + _ctas[name] = new Cta + { + Name = name, + Label = definition.Button.Label, + Url = definition.Button.Url, + Benefits = definition.Benefits + }; + } + // Process features _features = [with(StringComparer.OrdinalIgnoreCase)]; if (docSetFile.Features.PrimaryNav.HasValue) diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index c4008495ed..5ea875eb28 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -84,6 +84,13 @@ public class DocumentationSetFile : TableOfContentsFile [YamlMember(Alias = "storybook")] public DocumentationSetStorybook? Storybook { get; set; } + /// + /// Named, reusable right-gutter CTA templates. Selected per-page via frontmatter cta: <name>; + /// pages that omit it fall back to the built-in trial default. + /// + [YamlMember(Alias = "cta")] + public Dictionary Cta { get; set; } = []; + public static FileRef[] GetFileRefs(ITableOfContentsItem item) { if (item is FileRef fileRef) @@ -811,3 +818,56 @@ public class BrandingConfiguration [YamlMember(Alias = "apple-touch-icon", ApplyNamingConventions = false)] public string? AppleTouchIcon { get; set; } } + +/// +/// A single named right-gutter CTA template, as declared under docset.yml's cta map. +/// +[YamlSerializable] +public class CtaDefinition +{ + [YamlMember(Alias = "button")] + public CtaButton? Button { get; set; } + + [YamlMember(Alias = "benefits")] + public List Benefits { get; set; } = []; +} + +/// +/// The clickable button portion of a . +/// +[YamlSerializable] +public class CtaButton +{ + [YamlMember(Alias = "label")] + public string? Label { get; set; } + + [YamlMember(Alias = "url")] + public string? Url { get; set; } +} + +/// +/// A resolved, validated right-gutter CTA, ready to render. See for the raw +/// docset.yml shape this is parsed from. +/// +public record Cta +{ + /// Name of the template this was resolved from; for the built-in default. + public required string Name { get; init; } + public required string Label { get; init; } + public required string Url { get; init; } + public IReadOnlyList Benefits { get; init; } = []; + + /// The built-in default, reproducing the CTA that renders when no cta: config exists. + public const string DefaultName = "trial"; + + /// 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() + { + 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"] + }; +} diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 41c8effed6..0736abc3b0 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -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' @@ -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). Recreated on every load/swap so +// htmx-replaced CTAs get (re-)observed against their current page path. +function initCtaImpressions() { + ctaImpressionObserver?.disconnect() + 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], ]) }) @@ -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( + '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', diff --git a/src/Elastic.Documentation.Site/Assets/telemetry/semconv.ts b/src/Elastic.Documentation.Site/Assets/telemetry/semconv.ts index ae1c20cf77..00078816b6 100644 --- a/src/Elastic.Documentation.Site/Assets/telemetry/semconv.ts +++ b/src/Elastic.Documentation.Site/Assets/telemetry/semconv.ts @@ -16,6 +16,7 @@ export { ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_ERROR_TYPE, ATTR_EXCEPTION_MESSAGE, + ATTR_URL_PATH, } from '@opentelemetry/semantic-conventions' // ============================================================================ @@ -199,6 +200,37 @@ export const ATTR_NAVIGATION_SEARCH_RESULT_SCORE = export const ATTR_NAVIGATION_SEARCH_RETRY_AFTER = 'navigation_search.retry_after' +// ============================================================================ +// CTA ATTRIBUTES (Custom) +// ============================================================================ + +/** + * Name of the right-gutter CTA template that was clicked + * @example "trial" | "mp" + */ +export const ATTR_CTA_NAME = 'cta.name' + +/** + * Destination URL of the clicked CTA button + * @example "https://cloud.elastic.co/registration?page=docs&placement=docs-siderail" + */ +export const ATTR_CTA_URL = 'cta.url' + +/** + * Button label shown on the clicked CTA — lets click counts be grouped by exact wording, + * so copy changes to the same cta.name can still be told apart. + * @example "Get started free" + */ +export const ATTR_CTA_LABEL = 'cta.label' + +/** + * Where on the page the CTA is placed. Lets performance be compared across placements + * (e.g. an always-visible sidebar card vs. a CTA embedded partway through the article body), + * which otherwise have very different exposure and aren't comparable by click count alone. + * @example "sidebar" | "inline" + */ +export const ATTR_CTA_LOCATION = 'cta.location' + // ============================================================================ // EVENT ATTRIBUTES (Custom) // ============================================================================ diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index dc693b98d5..b03b10af45 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -8,6 +8,8 @@ using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.Suggestions; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Extensions; using Elastic.Documentation.Navigation; @@ -108,6 +110,17 @@ 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 name 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 ctaName = markdown.YamlFrontMatter?.Cta; + if (ctaName is null || !DocumentationSet.Configuration.Ctas.TryGetValue(ctaName, out var cta)) + { + if (ctaName is not null) + DocumentationSet.Context.Collector.EmitWarning(markdown.FilePath, UnknownCtaWarning(ctaName, DocumentationSet.Configuration.Ctas.Keys)); + cta = DocumentationSet.Configuration.Ctas[Cta.DefaultName]; + } + // Use DocumentInferrerService to get merged products and versioning info var inference = DocumentInferrerService.InferForMarkdown( DocumentationSet.Context.Git.RepositoryName, @@ -197,7 +210,8 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc GitHubDocsUrl = gitHubDocsUrl, GitHubRef = DocumentationSet.Context.Git.GitHubRef, Branding = DocumentationSet.Configuration.Branding, - RedirectUrl = markdown.RedirectUrl + RedirectUrl = markdown.RedirectUrl, + Cta = cta }); return new RenderResult @@ -207,6 +221,20 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc } + private static string UnknownCtaWarning(string ctaName, IEnumerable 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 BreadcrumbsList CreateStructuredBreadcrumbsData(MarkdownFile markdown, INavigationItem[] crumbs) { List breadcrumbItems = []; diff --git a/src/Elastic.Markdown/Layout/_TableOfContents.cshtml b/src/Elastic.Markdown/Layout/_TableOfContents.cshtml index d19dcf92b5..bbe1098944 100644 --- a/src/Elastic.Markdown/Layout/_TableOfContents.cshtml +++ b/src/Elastic.Markdown/Layout/_TableOfContents.cshtml @@ -1,3 +1,4 @@ +@using Elastic.Documentation.Configuration.Toc @using Elastic.Documentation.Svg @using Microsoft.AspNetCore.Html @inherits RazorSlice @@ -71,27 +72,22 @@ } - @if (Model.BuildType == BuildType.Assembler) + @if (Model.BuildType != BuildType.Codex && (Model.BuildType == BuildType.Assembler || Model.Cta.Name != Cta.DefaultName)) {