diff --git a/docs/_docset.yml b/docs/_docset.yml index 9c732d4802..c46346047b 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -44,7 +44,17 @@ features: 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 @@ -117,6 +127,7 @@ toc: - file: navigation.md - file: extensions.md - file: api-explorer.md + - file: cta.md - file: page.md - file: content-sources.md - folder: syntax diff --git a/docs/configure/content-set/cta.md b/docs/configure/content-set/cta.md new file mode 100644 index 0000000000..37bff5b229 --- /dev/null +++ b/docs/configure/content-set/cta.md @@ -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. diff --git a/docs/configure/content-set/index.md b/docs/configure/content-set/index.md index b22768e236..f8432d538c 100644 --- a/docs/configure/content-set/index.md +++ b/docs/configure/content-set/index.md @@ -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). \ No newline at end of file +* [API Explorer](./api-explorer.md). +* [CTA](./cta.md). \ No newline at end of file diff --git a/docs/configure/content-set/navigation.md b/docs/configure/content-set/navigation.md index e39933e732..8d271cb1fc 100644 --- a/docs/configure/content-set/navigation.md +++ b/docs/configure/content-set/navigation.md @@ -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 diff --git a/docs/syntax/frontmatter.md b/docs/syntax/frontmatter.md index 88b5d7537a..944f433834 100644 --- a/docs/syntax/frontmatter.md +++ b/docs/syntax/frontmatter.md @@ -17,6 +17,8 @@ products: <4> - id: edot-sdk sub: <5> key: value +cta: <6> + id: beta --- ``` @@ -25,6 +27,7 @@ sub: <5> 3. [`applies_to`](#applies-to) 4. [`products`](#products) 5. [`sub`](#subs) +6. [`cta`](#cta) ## Navigation Title @@ -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. \ No newline at end of file +Use the `sub` field to define local substitutions. Refer to [Substitutions](substitutions.md) for more information. + +## CTA + +See [CTA](../configure/content-set/cta.md). \ No newline at end of file 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..34db8d3ea0 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -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; @@ -90,6 +91,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 +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) @@ -330,6 +346,63 @@ 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. + /// + /// Set when is unknown, so the caller can report it. + 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 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 AllowedImageExtensions = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]; 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..a150497052 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). 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], ]) }) @@ -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..5b6335b19b 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -108,6 +108,13 @@ 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); + if (ctaWarning is not null) + DocumentationSet.Context.Collector.EmitWarning(markdown.FilePath, ctaWarning); + // Use DocumentInferrerService to get merged products and versioning info var inference = DocumentInferrerService.InferForMarkdown( DocumentationSet.Context.Git.RepositoryName, @@ -197,7 +204,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 diff --git a/src/Elastic.Markdown/Layout/_TableOfContents.cshtml b/src/Elastic.Markdown/Layout/_TableOfContents.cshtml index d19dcf92b5..603f99b067 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.Cta.Name != Cta.DefaultName || (Model.BuildType == BuildType.Assembler && Model.Branding is null))) {