From dae9fc0854b0a1c4c00d7f13f34db16dbe6eecad Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 18:01:48 +0200 Subject: [PATCH 01/56] feat(hubs): add Product.Hub field for product hub URL mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps each product to an optional hub page URL slug, used by the upcoming product badge renderer to link page-level product metadata to a 360° landing page. Elasticsearch and Kibana wired to /hubs/{product}/9.0 for the prototype; other products leave the field null and render no badge. Co-Authored-By: Claude Sonnet 4.6 --- config/products.yml | 2 ++ .../Products/Product.cs | 7 +++++++ .../Products/ProductExtensions.cs | 6 +++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/config/products.yml b/config/products.yml index d96d39e83e..b5471b8912 100644 --- a/config/products.yml +++ b/config/products.yml @@ -147,6 +147,7 @@ products: elasticsearch: display: 'Elasticsearch' versioning: 'stack' + hub: 'hubs/elasticsearch/9.0' elasticsearch-client: display: 'Elasticsearch Client' versioning: 'stack' @@ -207,6 +208,7 @@ products: kibana: display: 'Kibana' versioning: 'stack' + hub: 'hubs/kibana/9.0' logstash: display: 'Logstash' versioning: 'stack' diff --git a/src/Elastic.Documentation.Configuration/Products/Product.cs b/src/Elastic.Documentation.Configuration/Products/Product.cs index a1f6366468..3f4e851551 100644 --- a/src/Elastic.Documentation.Configuration/Products/Product.cs +++ b/src/Elastic.Documentation.Configuration/Products/Product.cs @@ -84,5 +84,12 @@ public record Product public VersioningSystem? VersioningSystem { get; init; } public string? Repository { get; init; } public ProductFeatures Features { get; init; } = ProductFeatures.All; + + /// + /// Slug of the product hub page, relative to the site root (no leading or trailing slash). + /// When set, regular pages that declare this product in frontmatter render a clickable + /// badge linking to /{Hub}/. When null, no badge is rendered for the product. + /// + public string? Hub { get; init; } } diff --git a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs index 0c825afb2d..3e12fa8498 100644 --- a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs +++ b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs @@ -34,7 +34,8 @@ public static ProductsConfiguration CreateProducts(this ConfigurationFileProvide DisplayName = kvp.Value.Display, VersioningSystem = versioningSystem, Repository = kvp.Value.Repository ?? kvp.Key, - Features = features + Features = features, + Hub = kvp.Value.Hub }; }); @@ -103,4 +104,7 @@ internal sealed record ProductDto [YamlMember(Alias = "features")] public Dictionary? Features { get; set; } + + [YamlMember(Alias = "hub")] + public string? Hub { get; set; } } From dbda3a4290c0c4e16cd3ecf1281601e7c9add6b8 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 18:09:18 +0200 Subject: [PATCH 02/56] feat(hubs): add 'hub' page layout with full-width chrome Adds a new MarkdownPageLayout.Hub value selected by frontmatter 'layout: hub'. The hub layout shares the section sidebar with regular pages but drops the right-hand TOC and prev/next nav, and renders the markdown body in a full-width
wrapper so hero and card-group directives can manage their own widths. Two placeholder hub pages are wired in at /hubs/elasticsearch/9.0/ and /hubs/kibana/9.0/, listed as hidden entries in docs/_docset.yml. They are reached only via the upcoming Product hubs nav section and the product-badge links; their bodies will be filled by hero/card-group directives in subsequent commits. Co-Authored-By: Claude Sonnet 4.6 --- docs/_docset.yml | 2 ++ docs/hubs/elasticsearch/9.0.md | 7 +++++++ docs/hubs/kibana/9.0.md | 7 +++++++ src/Elastic.Markdown/MarkdownPageLayout.cs | 3 ++- src/Elastic.Markdown/_Layout.cshtml | 22 +++++++++++++++++++++- 5 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 docs/hubs/elasticsearch/9.0.md create mode 100644 docs/hubs/kibana/9.0.md diff --git a/docs/_docset.yml b/docs/_docset.yml index 16e604fe9a..c43759d2fb 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -60,6 +60,8 @@ toc: - hidden: 404.md - hidden: _search.md - hidden: developer-notes.md + - hidden: hubs/elasticsearch/9.0.md + - hidden: hubs/kibana/9.0.md - hidden: contribute/cumulative-docs/index.md - hidden: contribute/cumulative-docs/guidelines.md - hidden: contribute/cumulative-docs/badge-placement.md diff --git a/docs/hubs/elasticsearch/9.0.md b/docs/hubs/elasticsearch/9.0.md new file mode 100644 index 0000000000..8346214f32 --- /dev/null +++ b/docs/hubs/elasticsearch/9.0.md @@ -0,0 +1,7 @@ +--- +layout: hub +--- + +# Elasticsearch + +Elasticsearch hub page placeholder. Hero and section directives land here in step 3. diff --git a/docs/hubs/kibana/9.0.md b/docs/hubs/kibana/9.0.md new file mode 100644 index 0000000000..7595573dbe --- /dev/null +++ b/docs/hubs/kibana/9.0.md @@ -0,0 +1,7 @@ +--- +layout: hub +--- + +# Kibana + +Kibana hub page placeholder. Hero and section directives land here in step 3. diff --git a/src/Elastic.Markdown/MarkdownPageLayout.cs b/src/Elastic.Markdown/MarkdownPageLayout.cs index 6563b2a0fc..7f80d07134 100644 --- a/src/Elastic.Markdown/MarkdownPageLayout.cs +++ b/src/Elastic.Markdown/MarkdownPageLayout.cs @@ -11,5 +11,6 @@ public enum MarkdownPageLayout [EnumMember(Value = "landing-page")] LandingPage, [EnumMember(Value = "not-found")] NotFound, [EnumMember(Value = "archive")] Archive, - [EnumMember(Value = "full-search")] FullSearch + [EnumMember(Value = "full-search")] FullSearch, + [EnumMember(Value = "hub")] Hub } diff --git a/src/Elastic.Markdown/_Layout.cshtml b/src/Elastic.Markdown/_Layout.cshtml index b88596eddd..9e1208d25e 100644 --- a/src/Elastic.Markdown/_Layout.cshtml +++ b/src/Elastic.Markdown/_Layout.cshtml @@ -22,7 +22,7 @@ private async Task RenderDefault() {
- +
+
+
+ @await RenderPartialAsync(_PagesNav.Create(Model)) +
+ +
+ @await RenderBodyAsync() +
+
+
+
+
+ } } @if (RenderHeaderAndFooter) @@ -75,6 +92,9 @@ case MarkdownPageLayout.FullSearch: await RenderPartialAsync(_FullSearch.Create(Model)); break; + case MarkdownPageLayout.Hub: + await RenderHub(); + break; default: await RenderDefault(); break; From e9676c67d2b0abdbd56a2cc164b2de44a4a90358 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 18:41:27 +0200 Subject: [PATCH 03/56] feat(hubs): add {hero} directive for landing-page heroes Generic, reusable directive that renders a full-bleed hero region with optional icon, version badge, search box, and quick links. Body content (typically an H1 plus a short description paragraph) renders inside the hero, so the page's H1 stays the canonical title. Title detection in MarkdownFile falls back to descendant H1s when no top-level H1 is present, so a {hero}-wrapped H1 is still picked up as the page title without spurious 'no title' warnings. The wired-up Elasticsearch placeholder hub now drives the hero. Cards and card-group come in subsequent commits. Co-Authored-By: Claude Sonnet 4.6 --- docs/hubs/elasticsearch/9.0.md | 10 ++- src/Elastic.Markdown/IO/MarkdownFile.cs | 10 +++ .../Myst/Directives/DirectiveBlockParser.cs | 4 ++ .../Myst/Directives/DirectiveHtmlRenderer.cs | 17 +++++ .../Myst/Directives/Hub/HeroBlock.cs | 66 +++++++++++++++++++ .../Myst/Directives/Hub/HeroView.cshtml | 34 ++++++++++ .../Myst/Directives/Hub/HeroViewModel.cs | 13 ++++ 7 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs diff --git a/docs/hubs/elasticsearch/9.0.md b/docs/hubs/elasticsearch/9.0.md index 8346214f32..88a5fadf0a 100644 --- a/docs/hubs/elasticsearch/9.0.md +++ b/docs/hubs/elasticsearch/9.0.md @@ -2,6 +2,14 @@ layout: hub --- +:::{hero} +:icon: E +:version: 9.0 +:quick-links: What's new=#whats-new,Get started=#get-started,All versions=/ + # Elasticsearch -Elasticsearch hub page placeholder. Hero and section directives land here in step 3. +The distributed search and analytics engine, ready for production at scale. +::: + +Card sections will land here in step 4. diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index ee548e9bba..8fdb7835ce 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -156,6 +156,16 @@ protected void ReadDocumentInstructions(MarkdownDocument document, Func block is HeadingBlock { Level: 1 })? .GetData("header") as string ?? Title; + // Fall back to any H1 nested in directive containers (e.g. {hero}) so layouts + // that wrap the page title in a directive still get title detection. + if (Title == RelativePath) + { + Title = document + .Descendants() + .FirstOrDefault(h => h.Level == 1)? + .GetData("header") as string ?? Title; + } + var yamlFrontMatter = ProcessYamlFrontMatter(document); YamlFrontMatter = yamlFrontMatter; if (yamlFrontMatter.NavigationTitle is not null) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index 301822195b..a479e4180c 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -9,6 +9,7 @@ using Elastic.Markdown.Myst.Directives.Button; using Elastic.Markdown.Myst.Directives.Changelog; using Elastic.Markdown.Myst.Directives.CsvInclude; +using Elastic.Markdown.Myst.Directives.Hub; using Elastic.Markdown.Myst.Directives.Image; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Myst.Directives.Math; @@ -136,6 +137,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{agent-skill}") > 0) return new AgentSkillBlock(this, context); + if (info.IndexOf("{hero}") > 0) + return new HeroBlock(this, context); + foreach (var admonition in Admonitions) { if (info.IndexOf(admonition) > 0) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index d731bf1b45..7c912068ea 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -15,6 +15,7 @@ using Elastic.Markdown.Myst.Directives.Changelog; using Elastic.Markdown.Myst.Directives.CsvInclude; using Elastic.Markdown.Myst.Directives.Dropdown; +using Elastic.Markdown.Myst.Directives.Hub; using Elastic.Markdown.Myst.Directives.Image; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Myst.Directives.Math; @@ -121,6 +122,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case AgentSkillBlock agentSkillBlock: WriteAgentSkill(renderer, agentSkillBlock); return; + case HeroBlock heroBlock: + WriteHero(renderer, heroBlock); + return; default: // if (!string.IsNullOrEmpty(directiveBlock.Info) && !directiveBlock.Info.StartsWith('{')) // WriteCode(renderer, directiveBlock); @@ -332,6 +336,19 @@ private static void WriteTabSet(HtmlRenderer renderer, TabSetBlock block) RenderRazorSlice(slice, renderer); } + private static void WriteHero(HtmlRenderer renderer, HeroBlock block) + { + var slice = HeroView.Create(new HeroViewModel + { + DirectiveBlock = block, + Icon = block.Icon, + Version = block.Version, + ShowSearch = block.ShowSearch, + QuickLinks = block.QuickLinks + }); + RenderRazorSlice(slice, renderer); + } + private static void WriteTabItem(HtmlRenderer renderer, TabItemBlock block) { var slice = TabItemView.Create(new TabItemViewModel diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs new file mode 100644 index 0000000000..c28ea58412 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs @@ -0,0 +1,66 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Renders a full-bleed page hero (dark background, icon, title, description, optional +/// version badge, optional quick links, optional search box). Designed for landing-style +/// pages such as product hubs, but the directive is generic and reusable. +/// +/// +/// +/// :::{hero} +/// :icon: elasticsearch +/// :version: 9.0 +/// :quick-links: What's new=#whats-new,Get started=/get-started +/// +/// # Elasticsearch +/// +/// The distributed search and analytics engine. +/// ::: +/// +/// +public class HeroBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + public override string Directive => "hero"; + + public string? Icon { get; private set; } + public string? Version { get; private set; } + public bool ShowSearch { get; private set; } + public IReadOnlyList QuickLinks { get; private set; } = []; + + public override void FinalizeAndValidate(ParserContext context) + { + Icon = Prop("icon"); + Version = Prop("version"); + // search defaults to true; explicit ":search: false" hides it + ShowSearch = TryPropBool("search") ?? true; + QuickLinks = ParseQuickLinks(Prop("quick-links")); + } + + private static IReadOnlyList ParseQuickLinks(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return []; + + var entries = new List(); + foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separator = part.IndexOf('='); + if (separator <= 0 || separator == part.Length - 1) + continue; + + var label = part[..separator].Trim(); + var url = part[(separator + 1)..].Trim(); + if (label.Length > 0 && url.Length > 0) + entries.Add(new HeroQuickLink(label, url)); + } + + return entries; + } +} + +public readonly record struct HeroQuickLink(string Label, string Url); diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml new file mode 100644 index 0000000000..cbd2d794e0 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml @@ -0,0 +1,34 @@ +@inherits RazorSlice + +
+
+
+ @if (!string.IsNullOrWhiteSpace(Model.Icon)) + { + + } + @if (!string.IsNullOrWhiteSpace(Model.Version)) + { + @Model.Version + } +
+
+ @Model.RenderBlock() +
+ @if (Model.ShowSearch) + { + + } + @if (Model.QuickLinks.Count > 0) + { + + } +
+
diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs new file mode 100644 index 0000000000..1809af3d30 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs @@ -0,0 +1,13 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class HeroViewModel : DirectiveViewModel +{ + public required string? Icon { get; init; } + public required string? Version { get; init; } + public required bool ShowSearch { get; init; } + public required IReadOnlyList QuickLinks { get; init; } +} From ec6ff071d4e0aeb29e1d65763e8e18e3adb3a601 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 18:44:46 +0200 Subject: [PATCH 04/56] feat(hubs): add {card-group} and {link-card} directives {card-group} is a container with optional :title:, :intro:, :id:, and :variant: options that wraps a grid of {link-card} children. {link-card} is a leaf with a required title argument and :link: option, plus an optional :badge: pill, and renders the directive body as the card description. Both are generic enough to be used outside hub pages -- any landing or section page can render a titled card grid with these. The Elasticsearch placeholder hub is wired up with three card groups (What's new in news variant, Install and deploy, Index and ingest data) to exercise the rendering end to end. Co-Authored-By: Claude Sonnet 4.6 --- docs/hubs/elasticsearch/9.0.md | 64 ++++++++++++++++++- .../Myst/Directives/DirectiveBlockParser.cs | 6 ++ .../Myst/Directives/DirectiveHtmlRenderer.cs | 31 +++++++++ .../Myst/Directives/Hub/CardGroupBlock.cs | 46 +++++++++++++ .../Myst/Directives/Hub/CardGroupView.cshtml | 23 +++++++ .../Myst/Directives/Hub/CardGroupViewModel.cs | 13 ++++ .../Myst/Directives/Hub/LinkCardBlock.cs | 45 +++++++++++++ .../Myst/Directives/Hub/LinkCardView.cshtml | 16 +++++ .../Myst/Directives/Hub/LinkCardViewModel.cs | 12 ++++ 9 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs diff --git a/docs/hubs/elasticsearch/9.0.md b/docs/hubs/elasticsearch/9.0.md index 88a5fadf0a..1069f120a3 100644 --- a/docs/hubs/elasticsearch/9.0.md +++ b/docs/hubs/elasticsearch/9.0.md @@ -5,11 +5,71 @@ layout: hub :::{hero} :icon: E :version: 9.0 -:quick-links: What's new=#whats-new,Get started=#get-started,All versions=/ +:quick-links: What's new=#whats-new,Get started=#install,All versions=/ # Elasticsearch The distributed search and analytics engine, ready for production at scale. ::: -Card sections will land here in step 4. +::::{card-group} +:title: What's new +:variant: news +:id: whats-new + +:::{link-card} ES|QL JOIN syntax now GA +:link: / +:badge: 9.0 +Run cross-index joins in ES|QL queries with full GA stability and performance. +::: + +:::{link-card} Vector quantization improvements +:link: / +:badge: 9.0 +30% smaller indices and faster search with the new BBQ encoding. +::: + +:::: + +::::{card-group} +:title: Install and deploy +:intro: Get Elasticsearch running on the platform of your choice. +:id: install + +:::{link-card} Self-managed +:link: / +Run Elasticsearch on your own infrastructure for full control over hardware and configuration. +::: + +:::{link-card} Elastic Cloud Hosted +:link: / +Managed deployments on AWS, GCP, or Azure with one-click upgrades and zero downtime. +::: + +:::{link-card} Elastic Cloud Serverless +:link: / +Pay-per-use Elasticsearch with no clusters to manage and elastic auto-scaling. +::: + +:::: + +::::{card-group} +:title: Index and ingest data +:intro: Move data into Elasticsearch from any source. + +:::{link-card} Elastic integrations +:link: / +Pre-built integrations for hundreds of services and platforms. +::: + +:::{link-card} Logstash +:link: / +Ingest, transform, and enrich data from any source. +::: + +:::{link-card} Beats +:link: / +Lightweight shippers for logs, metrics, and network data. +::: + +:::: diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index a479e4180c..d38bfa1921 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -140,6 +140,12 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{hero}") > 0) return new HeroBlock(this, context); + if (info.IndexOf("{card-group}") > 0) + return new CardGroupBlock(this, context); + + if (info.IndexOf("{link-card}") > 0) + return new LinkCardBlock(this, context); + foreach (var admonition in Admonitions) { if (info.IndexOf(admonition) > 0) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 7c912068ea..df6f26b8a2 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -125,6 +125,12 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case HeroBlock heroBlock: WriteHero(renderer, heroBlock); return; + case CardGroupBlock cardGroupBlock: + WriteCardGroup(renderer, cardGroupBlock); + return; + case LinkCardBlock linkCardBlock: + WriteLinkCard(renderer, linkCardBlock); + return; default: // if (!string.IsNullOrEmpty(directiveBlock.Info) && !directiveBlock.Info.StartsWith('{')) // WriteCode(renderer, directiveBlock); @@ -349,6 +355,31 @@ private static void WriteHero(HtmlRenderer renderer, HeroBlock block) RenderRazorSlice(slice, renderer); } + private static void WriteCardGroup(HtmlRenderer renderer, CardGroupBlock block) + { + var slice = CardGroupView.Create(new CardGroupViewModel + { + DirectiveBlock = block, + Title = block.Title, + Intro = block.Intro, + Anchor = block.Anchor, + Variant = block.Variant + }); + RenderRazorSlice(slice, renderer); + } + + private static void WriteLinkCard(HtmlRenderer renderer, LinkCardBlock block) + { + var slice = LinkCardView.Create(new LinkCardViewModel + { + DirectiveBlock = block, + Title = block.Title, + Link = block.Link, + Badge = block.Badge + }); + RenderRazorSlice(slice, renderer); + } + private static void WriteTabItem(HtmlRenderer renderer, TabItemBlock block) { var slice = TabItemView.Create(new TabItemViewModel diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs new file mode 100644 index 0000000000..ed6a11d43a --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs @@ -0,0 +1,46 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Container directive that renders a titled section housing a grid of +/// children. Generic and reusable wherever a +/// linked-card grid is appropriate. +/// +/// +/// +/// ::::{card-group} +/// :title: Install and deploy +/// :intro: Set up Elasticsearch on your platform of choice. +/// :id: install +/// +/// :::{link-card} Self-managed +/// :link: /deploy-manage/deploy/self-managed +/// Run on your own infrastructure. +/// ::: +/// :::: +/// +/// +public class CardGroupBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + public override string Directive => "card-group"; + + public string? Title { get; private set; } + public string? Intro { get; private set; } + public string? Anchor { get; private set; } + public string? Variant { get; private set; } + + public override void FinalizeAndValidate(ParserContext context) + { + Title = Prop("title"); + Intro = Prop("intro"); + Anchor = Prop("id"); + Variant = Prop("variant"); + } + + public override IEnumerable GeneratedAnchors => + string.IsNullOrWhiteSpace(Anchor) ? [] : [Anchor]; +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml new file mode 100644 index 0000000000..979065318e --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml @@ -0,0 +1,23 @@ +@inherits RazorSlice + +@{ + var sectionClass = string.IsNullOrWhiteSpace(Model.Variant) + ? "hub-card-group" + : $"hub-card-group hub-card-group-{Model.Variant}"; +} + +
+ @if (!string.IsNullOrWhiteSpace(Model.Title)) + { +
+

@Model.Title

+ @if (!string.IsNullOrWhiteSpace(Model.Intro)) + { +

@Model.Intro

+ } +
+ } +
    + @Model.RenderBlock() +
+
diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs new file mode 100644 index 0000000000..d75d8716ac --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs @@ -0,0 +1,13 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class CardGroupViewModel : DirectiveViewModel +{ + public required string? Title { get; init; } + public required string? Intro { get; init; } + public required string? Anchor { get; init; } + public required string? Variant { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs new file mode 100644 index 0000000000..8c4431a8a4 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs @@ -0,0 +1,45 @@ +// 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 Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Helpers; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// A single card with a required title and link, plus an optional badge and +/// description body. Designed to live inside a +/// but rendered standalone if used outside one. +/// +/// +/// +/// :::{link-card} Self-managed +/// :link: /deploy-manage/deploy/self-managed +/// :badge: 9.0 +/// Run Elasticsearch on your own infrastructure. +/// ::: +/// +/// +public class LinkCardBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context), IBlockTitle +{ + public override string Directive => "link-card"; + + public string Title { get; private set; } = default!; + public string? Link { get; private set; } + public string? Badge { get; private set; } + + public override void FinalizeAndValidate(ParserContext context) + { + if (string.IsNullOrWhiteSpace(Arguments)) + this.EmitError("{link-card} requires a title argument, e.g. `:::{link-card} My title`."); + + Title = (Arguments ?? "{undefined}").ReplaceSubstitutions(context); + Link = Prop("link"); + Badge = Prop("badge"); + + if (string.IsNullOrWhiteSpace(Link)) + this.EmitError("{link-card} requires a `:link:` option."); + } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml new file mode 100644 index 0000000000..d9bb7d3d60 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml @@ -0,0 +1,16 @@ +@inherits RazorSlice + +
  • + +
    + @Model.Title + @if (!string.IsNullOrWhiteSpace(Model.Badge)) + { + @Model.Badge + } +
    +
    + @Model.RenderBlock() +
    +
    +
  • diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs new file mode 100644 index 0000000000..7d91af642c --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs @@ -0,0 +1,12 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class LinkCardViewModel : DirectiveViewModel +{ + public required string Title { get; init; } + public required string? Link { get; init; } + public required string? Badge { get; init; } +} From 8817c8ce2899cdb827e48c23305e747083cf74ba Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 18:47:19 +0200 Subject: [PATCH 05/56] feat(hubs): add 'Product hubs' top-bar section to nav-v2 New section in navigation-v2.yml that surfaces the prototype hub pages in the V2 secondary nav. The Elasticsearch and Kibana hubs are wired up as page references; Observability, Security, and the three deployment runbooks are placeholder title leaves until their hub markdown lands. Co-Authored-By: Claude Sonnet 4.6 --- config/navigation-v2.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index a212064cd7..a3f262d205 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -2871,6 +2871,21 @@ nav: children: - toc: docs-content://release-notes/intro + - section: Product hubs + url: /hubs/ + children: + - label: Stack products + children: + - page: docs-builder://hubs/elasticsearch/9.0.md + - page: docs-builder://hubs/kibana/9.0.md + - title: Observability + - title: Security + - label: Deployment runbooks + children: + - title: Elastic Cloud Hosted + - title: Elastic Cloud Serverless + - title: Self-managed + - section: Extension points url: /extend/ isolated: true From e3d365f655bd9bfc9cfe410f16df1e3e4ea4e3f7 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 19:03:48 +0200 Subject: [PATCH 06/56] feat(hubs): render product badges linking to hub pages Adds a ProductBadges partial that renders a clickable pill for each frontmatter product whose Product.Hub field is set, linking to that product's hub page. Skipped on layouts that own their own chrome (hub, landing, archive, full-search). For products without a hub configured, no badge is rendered, so the feature lights up incrementally as more products get hub URLs in products.yml. The mapping is centralized: products.yml is the single source of truth for which products have hubs and where they live. Co-Authored-By: Claude Sonnet 4.6 --- src/Elastic.Markdown/Page/Index.cshtml | 2 ++ .../Page/ProductBadges.cshtml | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/Elastic.Markdown/Page/ProductBadges.cshtml diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index f820b165a6..e31eb35864 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -6,6 +6,7 @@ @using Elastic.Markdown @using Elastic.Markdown.Myst.Components @using Markdig +@using Elastic.Markdown.Page @using ViewModelSerializerContext = Elastic.Markdown.Page.ViewModelSerializerContext @inherits RazorSliceHttpResult @implements IUsesLayout @@ -91,6 +92,7 @@
    @* This way it's correctly rendered as

    text

    instead of

    text

    *@ @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) + @await RenderPartialAsync(ProductBadges.Create(Model)) @if (Model.AppliesTo is not null) {

    diff --git a/src/Elastic.Markdown/Page/ProductBadges.cshtml b/src/Elastic.Markdown/Page/ProductBadges.cshtml new file mode 100644 index 0000000000..bc3066f2f1 --- /dev/null +++ b/src/Elastic.Markdown/Page/ProductBadges.cshtml @@ -0,0 +1,24 @@ +@inherits RazorSlice +@{ + // Hide on layouts that own the page chrome themselves (hub, landing, archive, search). + var layout = Model.CurrentDocument.YamlFrontMatter?.Layout; + var skip = layout is not null; + var withHubs = skip + ? [] + : Model.Products + .Where(p => !string.IsNullOrWhiteSpace(p.Hub)) + .OrderBy(p => p.DisplayName, StringComparer.Ordinal) + .ToList(); + var prefix = Model.UrlPathPrefix?.TrimEnd('/') ?? string.Empty; +} +@if (withHubs.Count > 0) +{ +

    +} From 28cbacacc316a4600424b1c957e9d8d6cc0d04d4 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 19:11:46 +0200 Subject: [PATCH 07/56] feat(hubs): style hub pages, cards, and product badges + port Kibana hub Adds hub.css with Tailwind component layer rules for hero, card-group, link-card, and product-badges. The hero is full-bleed dark navy with icon and version chips, content rail constrained to the standard text width. Cards are a responsive 1/2/3-column grid; the news variant collapses to a tighter 1/2-column list. Product badges are subtle grey pills that hover blue. The Kibana hub markdown is filled in with seven sections (What's new, Install and administer, Explore visualize and analyze, Track and respond, AI and automation, Management and developer tools) mirroring the gist prototype. Co-Authored-By: Claude Sonnet 4.6 --- docs/hubs/kibana/9.0.md | 137 +++++++++++++++++- .../Assets/markdown/hub.css | 136 +++++++++++++++++ .../Assets/styles.css | 1 + 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/Elastic.Documentation.Site/Assets/markdown/hub.css diff --git a/docs/hubs/kibana/9.0.md b/docs/hubs/kibana/9.0.md index 7595573dbe..2bc63dd2c4 100644 --- a/docs/hubs/kibana/9.0.md +++ b/docs/hubs/kibana/9.0.md @@ -2,6 +2,141 @@ layout: hub --- +:::{hero} +:icon: K +:version: 9.0 +:quick-links: What's new=#whats-new,Install=#install,All versions=/ + # Kibana -Kibana hub page placeholder. Hero and section directives land here in step 3. +The window into the Elastic Stack. Visualize, analyze, and explore your data with dashboards, queries, and machine learning. +::: + +::::{card-group} +:title: What's new +:variant: news +:id: whats-new + +:::{link-card} Refreshed dashboard layout engine +:link: / +:badge: 9.0 +Smoother resizing, faster panel rendering, and improved touch behavior. +::: + +:::{link-card} Lens chart suggestions +:link: / +:badge: 9.0 +Lens now suggests visualizations based on your data shape automatically. +::: + +:::: + +::::{card-group} +:title: Install and administer +:intro: Get Kibana running and configure it for your environment. +:id: install + +:::{link-card} Self-managed +:link: / +Install Kibana on your own infrastructure with full configuration control. +::: + +:::{link-card} Elastic Cloud Hosted +:link: / +Kibana included in every Elastic Cloud deployment, fully managed. +::: + +:::{link-card} Configure Kibana +:link: / +Set up authentication, spaces, monitoring, alerts, and more. +::: + +:::: + +::::{card-group} +:title: Explore, visualize, and analyze +:intro: The core Kibana surfaces for working with your data. +:id: explore + +:::{link-card} Discover +:link: / +Search and explore raw documents in any Elasticsearch index. +::: + +:::{link-card} Dashboards +:link: / +Build and share interactive dashboards with charts, maps, and metrics. +::: + +:::{link-card} Lens +:link: / +Drag-and-drop visualization builder with smart suggestions. +::: + +:::: + +::::{card-group} +:title: Track and respond +:intro: Alerting, watchers, and case management. +:id: track + +:::{link-card} Alerting +:link: / +Define rules and trigger actions when conditions are met. +::: + +:::{link-card} Cases +:link: / +Collect, track, and resolve issues across teams. +::: + +:::{link-card} Maps +:link: / +Geospatial analysis and live tracking on layered maps. +::: + +:::: + +::::{card-group} +:title: AI and automation +:intro: Machine learning and AI-assisted workflows in Kibana. +:id: ai + +:::{link-card} Machine learning +:link: / +Detect anomalies, classify data, and forecast trends. +::: + +:::{link-card} AI Assistant +:link: / +A conversational assistant that helps you query and analyze data. +::: + +:::{link-card} Automatic insights +:link: / +Surface notable changes in your data without writing rules. +::: + +:::: + +::::{card-group} +:title: Management and developer tools +:intro: Stack management, Dev Tools, and APIs. +:id: manage + +:::{link-card} Stack Management +:link: / +Manage spaces, roles, index lifecycle, snapshots, and more. +::: + +:::{link-card} Dev Tools +:link: / +Console, Painless Lab, Search Profiler, Grok Debugger. +::: + +:::{link-card} Kibana APIs +:link: / +Automate Kibana with the public REST APIs. +::: + +:::: diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css new file mode 100644 index 0000000000..c959e1cc42 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -0,0 +1,136 @@ +@layer components { + .hub-page { + .hub-content { + @apply mx-auto mb-12 max-w-(--max-text-width) py-0; + } + } + + /* Full-bleed hero, breaks out of the article container width. */ + .hub-hero { + @apply bg-ink-navy mx-auto -mt-4 mb-10 w-full max-w-none px-6 py-14 text-white; + + .hub-hero-inner { + @apply mx-auto max-w-(--max-text-width); + } + + .hub-hero-top { + @apply mb-3 flex items-center gap-4; + } + + .hub-hero-icon { + @apply inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-white/8 text-xl font-bold text-white; + } + + .hub-hero-version { + @apply inline-flex items-center rounded-full border border-white/15 bg-white/10 px-2 py-0.5 text-xs font-semibold tracking-wide; + } + + .hub-hero-content { + h1 { + @apply mb-3 text-4xl font-bold tracking-tight text-white; + } + h1 a.headerlink { + @apply text-white no-underline; + } + p { + @apply max-w-3xl text-base leading-relaxed text-white/70; + } + } + + .hub-hero-search { + @apply mt-6 max-w-md; + } + + .hub-hero-links { + @apply mt-5 flex list-none flex-wrap gap-x-5 gap-y-2 p-0; + + li { + @apply m-0 p-0; + } + + a { + @apply border-b border-transparent text-sm font-semibold text-white/85 no-underline; + } + a:hover { + @apply border-white/40 text-white; + } + } + } + + .hub-card-group { + @apply mt-10 px-6; + + .hub-card-group-header { + @apply mx-auto mb-5 max-w-(--max-text-width); + + h2 { + @apply text-ink-dark m-0 text-2xl font-bold; + } + } + + .hub-card-group-intro { + @apply text-ink-light mt-1; + } + + .hub-card-grid { + @apply mx-auto grid max-w-(--max-text-width) list-none grid-cols-1 gap-4 p-0 md:grid-cols-2 lg:grid-cols-3; + } + } + + /* News variant: vertical list, version chip leading. */ + .hub-card-group-news { + .hub-card-grid { + @apply grid-cols-1 gap-3 lg:grid-cols-2; + } + } + + .hub-card { + @apply m-0 p-0; + } + + .hub-card-link { + @apply border-grey-20 block h-full rounded-xl border bg-white p-5 no-underline transition-colors; + } + .hub-card-link:hover { + @apply border-blue-elastic no-underline; + } + + .hub-card-head { + @apply mb-2 flex items-start justify-between gap-3; + } + + .hub-card-title { + @apply text-ink-dark text-base font-semibold; + } + + .hub-card-badge { + @apply bg-grey-10 text-ink-light border-grey-20 inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-semibold tracking-wide; + } + + .hub-card-body { + @apply text-ink-light text-sm leading-relaxed; + + & > *:first-child { + @apply mt-0; + } + & > *:last-child { + @apply mb-0; + } + } + + /* Product badges shown above the H1 of a regular page. */ + .product-badges { + @apply mb-3 flex list-none flex-wrap gap-2 p-0; + + li { + @apply m-0 p-0; + } + } + + .product-badge { + @apply bg-grey-10 text-ink-light border-grey-20 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold tracking-wide no-underline; + } + .product-badge:hover { + @apply bg-blue-elastic-10 text-blue-elastic-100 border-blue-elastic no-underline; + } +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 3a45bfa2c5..6ce8ef6141 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -27,6 +27,7 @@ @import './markdown/stepper.css'; @import './markdown/button.css'; @import './markdown/agent-skill.css'; +@import './markdown/hub.css'; @import './markdown/contributors.css'; @import './api-docs.css'; @import 'tippy.js/dist/tippy.css'; From a7ff2208682febc3e993f6dd38cbb32ed1e7026c Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 20:58:01 +0200 Subject: [PATCH 08/56] fix(hubs): suppress page preamble on hub layout, widen hub content - Index.cshtml skips its auto-rendered H1, product-badges, and applies-to block when the page declares 'layout: hub' so the hero owns the page header and there is no duplicate H1. - hub.css drops the article-level max-width and lets hero/card-group manage their own inner rails. Hero goes edge-to-edge of the content area; card grids open up to a 5xl rail so 3 columns render. Co-Authored-By: Claude Sonnet 4.6 --- .../Assets/markdown/hub.css | 49 +++++++++++-------- src/Elastic.Markdown/Page/Index.cshtml | 25 +++++----- 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index c959e1cc42..3578c6ff0a 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -1,16 +1,20 @@ @layer components { - .hub-page { - .hub-content { - @apply mx-auto mb-12 max-w-(--max-text-width) py-0; - } + /* Full-width content area; hero and card-group manage their own inner rails. */ + .hub-page .hub-content { + @apply mb-12 w-full max-w-none p-0; + } + + /* Section-content rail used by hero/group internals. */ + .hub-page :where(.hub-rail) { + @apply mx-auto w-full max-w-5xl px-6; } - /* Full-bleed hero, breaks out of the article container width. */ + /* Full-bleed hero with dark background and inner content rail. */ .hub-hero { - @apply bg-ink-navy mx-auto -mt-4 mb-10 w-full max-w-none px-6 py-14 text-white; + @apply bg-ink-navy mb-10 w-full px-0 py-14 text-white; .hub-hero-inner { - @apply mx-auto max-w-(--max-text-width); + @apply mx-auto w-full max-w-5xl px-6; } .hub-hero-top { @@ -18,11 +22,14 @@ } .hub-hero-icon { - @apply inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-white/8 text-xl font-bold text-white; + @apply inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-xl font-bold text-white; + background-color: rgb(255 255 255 / 0.08); } .hub-hero-version { - @apply inline-flex items-center rounded-full border border-white/15 bg-white/10 px-2 py-0.5 text-xs font-semibold tracking-wide; + @apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold tracking-wide; + background-color: rgb(255 255 255 / 0.1); + border: 1px solid rgb(255 255 255 / 0.15); } .hub-hero-content { @@ -33,7 +40,8 @@ @apply text-white no-underline; } p { - @apply max-w-3xl text-base leading-relaxed text-white/70; + @apply max-w-3xl text-base leading-relaxed; + color: rgb(255 255 255 / 0.7); } } @@ -49,19 +57,22 @@ } a { - @apply border-b border-transparent text-sm font-semibold text-white/85 no-underline; + @apply border-b border-transparent text-sm font-semibold no-underline; + color: rgb(255 255 255 / 0.85); } a:hover { - @apply border-white/40 text-white; + @apply text-white; + border-bottom-color: rgb(255 255 255 / 0.4); } } } + /* Card group: section title rail, then card grid rail. */ .hub-card-group { - @apply mt-10 px-6; + @apply mt-10; .hub-card-group-header { - @apply mx-auto mb-5 max-w-(--max-text-width); + @apply mx-auto mb-5 w-full max-w-5xl px-6; h2 { @apply text-ink-dark m-0 text-2xl font-bold; @@ -73,15 +84,13 @@ } .hub-card-grid { - @apply mx-auto grid max-w-(--max-text-width) list-none grid-cols-1 gap-4 p-0 md:grid-cols-2 lg:grid-cols-3; + @apply mx-auto grid w-full max-w-5xl list-none grid-cols-1 gap-4 p-0 px-6 md:grid-cols-2 lg:grid-cols-3; } } - /* News variant: vertical list, version chip leading. */ - .hub-card-group-news { - .hub-card-grid { - @apply grid-cols-1 gap-3 lg:grid-cols-2; - } + /* News variant: tighter 2-column list. */ + .hub-card-group-news .hub-card-grid { + @apply grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-2; } .hub-card { diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index e31eb35864..d1ad409c34 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -90,19 +90,22 @@ } }
    - @* This way it's correctly rendered as

    text

    instead of

    text

    *@ - @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) - @await RenderPartialAsync(ProductBadges.Create(Model)) - @if (Model.AppliesTo is not null) + @if (Model.CurrentDocument.YamlFrontMatter?.Layout != MarkdownPageLayout.Hub) { -

    - @await RenderPartialAsync(ApplicableToComponent.Create(new ApplicableToViewModel + @* This way it's correctly rendered as

    text

    instead of

    text

    *@ + @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) + @await RenderPartialAsync(ProductBadges.Create(Model)) + @if (Model.AppliesTo is not null) { - AppliesTo = Model.AppliesTo, - Inline = false, - VersionsConfig = Model.VersionsConfig - })) -

    +

    + @await RenderPartialAsync(ApplicableToComponent.Create(new ApplicableToViewModel + { + AppliesTo = Model.AppliesTo, + Inline = false, + VersionsConfig = Model.VersionsConfig + })) +

    + } } @(new HtmlString(Model.MarkdownHtml))
    From 26b4886478e43779a8966c55b7987115d3088674 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 21:13:21 +0200 Subject: [PATCH 09/56] feat(hubs): rewrite hub directives to match gist prototypes Aligns the hub-page directives with the HTML gist prototypes' DOM and visual structure. The card / what's new / hero shapes were too thin to reproduce the gists; this commit replaces them with richer directives: - {hero}: adds inline product-icon SVGs (kibana/elasticsearch/observability/ security via ProductIcons lookup; first-letter fallback otherwise), a version pill with status dot, quick-action pill bar, and a release status line rendered as inline markdown. - {link-card}: now consumes a YAML body with title/link/description plus a primary link list and an optional aside (label + inline link list with middle-dot separators). Adds optional :icon: / :variant: for solution cards (es / obs / sec accent borders). - {whats-new}: new directive for the gist's "New" panel -- header with pink badge, section title, optional release-notes link list on the right, and items rendered as link rows with title / description / right-side meta pill. - {intro}: new directive for the teal-bar tip panel below the hero. - {on-this-page}: new directive that auto-collects every {card-group} and {whats-new} on the page (by id+title) and renders a single inline-link TOC chip. Both YAML-bodied directives use the existing YamlSerialization static context (AOT-safe) with new types registered. The two hub markdown files (Elasticsearch and Kibana) are rewritten to exercise the full directive set, mirroring the gists' content and shape. hub.css is rewritten to model the gists' rules with the project's design tokens. Co-Authored-By: Claude Sonnet 4.6 --- docs/hubs/elasticsearch/9.0.md | 136 +++-- docs/hubs/kibana/9.0.md | 332 +++++++---- .../Assets/markdown/hub.css | 546 ++++++++++++++---- .../Myst/Directives/DirectiveBlockParser.cs | 9 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 61 +- .../Myst/Directives/Hub/CardGroupView.cshtml | 32 +- .../Myst/Directives/Hub/HeroBlock.cs | 21 +- .../Myst/Directives/Hub/HeroView.cshtml | 46 +- .../Myst/Directives/Hub/HeroViewModel.cs | 4 +- .../Myst/Directives/Hub/HubYamlBody.cs | 96 +++ .../Myst/Directives/Hub/IntroBlock.cs | 26 + .../Myst/Directives/Hub/IntroView.cshtml | 5 + .../Myst/Directives/Hub/IntroViewModel.cs | 7 + .../Myst/Directives/Hub/LinkCardBlock.cs | 106 +++- .../Myst/Directives/Hub/LinkCardView.cshtml | 71 ++- .../Myst/Directives/Hub/LinkCardViewModel.cs | 5 +- .../Myst/Directives/Hub/OnThisPageBlock.cs | 54 ++ .../Myst/Directives/Hub/OnThisPageView.cshtml | 17 + .../Directives/Hub/OnThisPageViewModel.cs | 10 + .../Myst/Directives/Hub/ProductIcons.cs | 68 +++ .../Myst/Directives/Hub/WhatsNewBlock.cs | 99 ++++ .../Myst/Directives/Hub/WhatsNewView.cshtml | 75 +++ .../Myst/Directives/Hub/WhatsNewViewModel.cs | 10 + .../Myst/YamlSerialization.cs | 5 + 24 files changed, 1521 insertions(+), 320 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HubYamlBody.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/IntroBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/IntroView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/IntroViewModel.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageViewModel.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs diff --git a/docs/hubs/elasticsearch/9.0.md b/docs/hubs/elasticsearch/9.0.md index 1069f120a3..005007f1a0 100644 --- a/docs/hubs/elasticsearch/9.0.md +++ b/docs/hubs/elasticsearch/9.0.md @@ -3,73 +3,125 @@ layout: hub --- :::{hero} -:icon: E -:version: 9.0 -:quick-links: What's new=#whats-new,Get started=#install,All versions=/ +:icon: elasticsearch +:version: v9 / Serverless (current) +:quick-links: Install=/install,Tutorial=/tutorial,API reference=/api,Release notes=/release-notes +:releases: Latest: [Stack 9.4.1](/release-notes/elasticsearch) (Mar 28, 2026) · [Serverless deployed](/release-notes/serverless) Apr 1, 2026 # Elasticsearch -The distributed search and analytics engine, ready for production at scale. +The distributed search and analytics engine. Store, search, and analyze data at any scale, with vector search, AI toolkits, and a high-performance retrieval engine. ::: -::::{card-group} -:title: What's new -:variant: news -:id: whats-new - -:::{link-card} ES|QL JOIN syntax now GA -:link: / -:badge: 9.0 -Run cross-index joins in ES|QL queries with full GA stability and performance. +:::{on-this-page} ::: -:::{link-card} Vector quantization improvements -:link: / -:badge: 9.0 -30% smaller indices and faster search with the new BBQ encoding. +:::{intro} +**New to Elasticsearch?** [Start with Elastic Fundamentals](https://www.elastic.co/docs/get-started) — a hands-on walk-through of indexing, querying, and search relevance. ::: -:::: +:::{whats-new} +title: What's new in Elasticsearch +id: whats-new +release-links: + - label: 9.4 + url: /release-notes/elasticsearch + - label: Serverless + url: /release-notes/serverless +items: + - title: ES|QL JOIN syntax + description: Run cross-index joins in ES|QL queries with full GA stability and performance + link: /reference/esql/joins + meta: 9.0 GA + - title: BBQ vector quantization + description: 30% smaller indices and faster search with the new Better Binary Quantization encoding + link: /reference/vector-quantization + meta: 9.0 GA +::: ::::{card-group} :title: Install and deploy -:intro: Get Elasticsearch running on the platform of your choice. :id: install -:::{link-card} Self-managed -:link: / -Run Elasticsearch on your own infrastructure for full control over hardware and configuration. +:::{link-card} +title: Self-managed +link: /deploy-manage/deploy/self-managed +description: Run Elasticsearch on your own infrastructure for full control over hardware and configuration. +links: + - label: Install on Linux / macOS + url: /deploy-manage/deploy/self-managed/install-elasticsearch + - label: Install with Docker + url: /deploy-manage/deploy/self-managed/install-elasticsearch-with-docker + - label: Configure (elasticsearch.yml) + url: /deploy-manage/deploy/self-managed/configure-elasticsearch ::: -:::{link-card} Elastic Cloud Hosted -:link: / -Managed deployments on AWS, GCP, or Azure with one-click upgrades and zero downtime. +:::{link-card} +title: Elastic Cloud Hosted +link: /deploy-manage/deploy/elastic-cloud +description: Managed deployments on AWS, GCP, or Azure with one-click upgrades and zero downtime. +links: + - label: Sign up + url: /deploy-manage/deploy/elastic-cloud/sign-up + - label: Manage deployments + url: /deploy-manage/deploy/elastic-cloud/manage-deployments ::: -:::{link-card} Elastic Cloud Serverless -:link: / -Pay-per-use Elasticsearch with no clusters to manage and elastic auto-scaling. +:::{link-card} +title: Elastic Cloud Serverless +link: /deploy-manage/deploy/elastic-cloud-serverless +description: Pay-per-use Elasticsearch with no clusters to manage and elastic auto-scaling. +links: + - label: Get started + url: /deploy-manage/deploy/elastic-cloud-serverless/get-started ::: - :::: ::::{card-group} -:title: Index and ingest data -:intro: Move data into Elasticsearch from any source. - -:::{link-card} Elastic integrations -:link: / -Pre-built integrations for hundreds of services and platforms. +:title: Search and query +:id: search + +:::{link-card} +title: ES|QL +link: /reference/esql +description: Pipe-friendly query language for searching, filtering, and aggregating data. +links: + - label: Get started + url: /reference/esql/get-started + - label: Functions reference + url: /reference/esql/functions ::: -:::{link-card} Logstash -:link: / -Ingest, transform, and enrich data from any source. +:::{link-card} +title: Query DSL +link: /reference/query-dsl +description: JSON-based query language for full-text, structured, geo, and vector search. +links: + - label: Match query + url: /reference/query-dsl/query-dsl-match-query + - label: Bool query + url: /reference/query-dsl/query-dsl-bool-query +aside: + label: Query types + links: + - label: Term + url: /reference/query-dsl/term-level-queries + - label: Full text + url: /reference/query-dsl/full-text-queries + - label: Geo + url: /reference/query-dsl/geo-queries + - label: Vector + url: /reference/query-dsl/vector-queries ::: -:::{link-card} Beats -:link: / -Lightweight shippers for logs, metrics, and network data. +:::{link-card} +title: Vector search +link: /solutions/search/vector +description: Dense and sparse vector retrieval for AI applications and semantic search. +links: + - label: kNN search + url: /reference/search-types/knn-search + - label: ELSER + url: /reference/elasticsearch-clients/elser ::: - :::: diff --git a/docs/hubs/kibana/9.0.md b/docs/hubs/kibana/9.0.md index 2bc63dd2c4..11ee62040c 100644 --- a/docs/hubs/kibana/9.0.md +++ b/docs/hubs/kibana/9.0.md @@ -3,140 +3,278 @@ layout: hub --- :::{hero} -:icon: K -:version: 9.0 -:quick-links: What's new=#whats-new,Install=#install,All versions=/ +:icon: kibana +:version: v9 / Serverless (current) +:quick-links: Install=/install,Tutorial=/tutorial,API reference=/api,Release notes=/release-notes +:releases: Latest: [Stack 9.4.1](/release-notes/kibana) (Mar 28, 2026) · [Serverless deployed](/release-notes/serverless) Apr 1, 2026 # Kibana -The window into the Elastic Stack. Visualize, analyze, and explore your data with dashboards, queries, and machine learning. +The UI for the Elasticsearch platform. Explore and visualize your data, build dashboards, set up alerts, automate tasks with AI, and use purpose-built solutions for Search, Observability, and Security. ::: -::::{card-group} -:title: What's new -:variant: news -:id: whats-new - -:::{link-card} Refreshed dashboard layout engine -:link: / -:badge: 9.0 -Smoother resizing, faster panel rendering, and improved touch behavior. +:::{on-this-page} ::: -:::{link-card} Lens chart suggestions -:link: / -:badge: 9.0 -Lens now suggests visualizations based on your data shape automatically. +:::{intro} +**New to Kibana?** [Learn data exploration and visualization](https://www.elastic.co/docs/explore-analyze/kibana-data-exploration-learning-tutorial) — a hands-on walk-through of Discover, ES|QL, visualizations, and dashboards. ::: -:::: +:::{whats-new} +title: What's new in Kibana +id: whats-new +release-links: + - label: 9.4 + url: /release-notes/kibana + - label: Serverless + url: /release-notes/serverless +items: + - title: Dashboards and visualizations APIs + description: Programmatically create and manage dashboards and visualizations + link: /api/dashboards + meta: 9.4 preview + - title: AI agent skills + description: Teach AI coding agents to work with the Elastic stack + link: /ai/agent-skills + meta: Mar 2026 + - title: Agent Builder + description: Build custom AI agents that reason over your data + link: /ai/agent-builder + meta: 9.3 GA + - title: Workflows + description: Automate tasks with Kibana actions, HTTP calls, and AI agents + link: /ai/workflows + meta: 9.3 preview +::: ::::{card-group} :title: Install and administer -:intro: Get Kibana running and configure it for your environment. :id: install -:::{link-card} Self-managed -:link: / -Install Kibana on your own infrastructure with full configuration control. +:::{link-card} +title: Self-managed +link: /deploy-manage/deploy/self-managed/install-kibana +description: Install and run Kibana on your own infrastructure. +links: + - label: Docker + url: /deploy-manage/deploy/self-managed/install-kibana-with-docker + - label: Debian / Ubuntu + url: /deploy-manage/deploy/self-managed/install-kibana-with-debian-package + - label: RPM + url: /deploy-manage/deploy/self-managed/install-kibana-with-rpm + - label: Windows + url: /deploy-manage/deploy/self-managed/install-kibana-on-windows + - label: Configure (kibana.yml) + url: /deploy-manage/deploy/self-managed/configure-kibana ::: -:::{link-card} Elastic Cloud Hosted -:link: / -Kibana included in every Elastic Cloud deployment, fully managed. +:::{link-card} +title: Managed deployments +link: /deploy-manage/deploy/elastic-cloud/access-kibana +description: Run Kibana on Elastic Cloud, Kubernetes, or ECE. +links: + - label: Elastic Cloud Hosted + url: /deploy-manage/deploy/elastic-cloud/access-kibana + - label: Elastic Cloud on Kubernetes (ECK) + url: /deploy-manage/deploy/cloud-on-k8s/kibana-configuration + - label: Elastic Cloud Enterprise + url: /deploy-manage/deploy/cloud-enterprise/access-kibana ::: -:::{link-card} Configure Kibana -:link: / -Set up authentication, spaces, monitoring, alerts, and more. +:::{link-card} +title: Configure and secure +link: /deploy-manage/users-roles/cluster-or-deployment-auth/kibana-authentication +description: Authentication, roles, spaces, and encryption. +links: + - label: Authentication (SSO, SAML, OIDC...) + url: /deploy-manage/users-roles/cluster-or-deployment-auth/kibana-authentication + - label: Kibana privileges + url: /deploy-manage/users-roles/cluster-or-deployment-auth/kibana-privileges + - label: Spaces + url: /deploy-manage/manage-spaces + - label: Secure saved objects + url: /deploy-manage/security/secure-saved-objects ::: +:::{link-card} +title: Maintain and monitor +link: /deploy-manage/production-guidance/kibana-in-production-environments +description: Production guidance, upgrades, reporting, monitoring, and logging. +links: + - label: Run in production + url: /deploy-manage/production-guidance/kibana-in-production-environments + - label: Upgrade Kibana + url: /deploy-manage/upgrade/deployment-or-cluster/kibana + - label: Monitoring + url: /deploy-manage/monitor/stack-monitoring/kibana-monitoring-data + - label: Logging + url: /deploy-manage/monitor/logging-configuration/kibana-logging +::: :::: ::::{card-group} :title: Explore, visualize, and analyze -:intro: The core Kibana surfaces for working with your data. :id: explore -:::{link-card} Discover -:link: / -Search and explore raw documents in any Elasticsearch index. +:::{link-card} +title: Discover +link: /explore-analyze/discover/discover-get-started +description: Browse documents, filter, and query your indices in real time. +links: + - label: Get started with Discover + url: /explore-analyze/discover/discover-get-started + - label: Use ES|QL in Kibana + url: /explore-analyze/query-filter/languages/esql-kibana ::: -:::{link-card} Dashboards -:link: / -Build and share interactive dashboards with charts, maps, and metrics. +:::{link-card} +title: Dashboards +link: /explore-analyze/dashboards +description: Build interactive dashboards that combine visualizations, controls, and context. +links: + - label: Create a dashboard + url: /explore-analyze/dashboards/create-dashboard + - label: Build and customize + url: /explore-analyze/dashboards/building + - label: Organize panels and sections + url: /explore-analyze/dashboards/arrange-panels + - label: Share and export + url: /explore-analyze/dashboards/sharing +aside: + label: Panel types + links: + - label: Visualizations + url: /explore-analyze/visualize/lens + - label: Maps + url: /explore-analyze/visualize/maps + - label: Text + url: /explore-analyze/visualize/text-panels + - label: Images + url: /explore-analyze/visualize/image-panels + - label: Links + url: /explore-analyze/visualize/link-panels + - label: Alerts + url: /explore-analyze/visualize/alert-panels ::: -:::{link-card} Lens -:link: / -Drag-and-drop visualization builder with smart suggestions. +:::{link-card} +title: Build visualizations +link: /explore-analyze/visualize/lens +description: Create charts and visual panels using the drag-and-drop editor, query mode (ES|QL), Vega, or Maps. +links: + - label: Visualization editor (drag-and-drop) + url: /explore-analyze/visualize/lens + - label: Query mode (ES|QL) + url: /explore-analyze/visualize/esorql + - label: Vega + url: /explore-analyze/visualize/custom-visualizations-with-vega + - label: Maps + url: /explore-analyze/visualize/maps +aside: + label: Chart types + links: + - label: Area + url: /explore-analyze/visualize/charts/area-charts + - label: Line + url: /explore-analyze/visualize/charts/line-charts + - label: Pie + url: /explore-analyze/visualize/charts/pie-charts + - label: Table + url: /explore-analyze/visualize/charts/tables + - label: Metric + url: /explore-analyze/visualize/charts/metric-charts + - label: Heat map + url: /explore-analyze/visualize/charts/heat-map-charts + - label: Treemap + url: /explore-analyze/visualize/charts/treemap-charts + - label: Mosaic + url: /explore-analyze/visualize/charts/mosaic-charts ::: - :::: ::::{card-group} -:title: Track and respond -:intro: Alerting, watchers, and case management. -:id: track - -:::{link-card} Alerting -:link: / -Define rules and trigger actions when conditions are met. -::: - -:::{link-card} Cases -:link: / -Collect, track, and resolve issues across teams. -::: - -:::{link-card} Maps -:link: / -Geospatial analysis and live tracking on layered maps. +:title: Solutions +:id: solutions + +:::{link-card} +title: Elasticsearch +link: /solutions/elasticsearch-solution-project +icon: elasticsearch +variant: es +description: Build powerful search and RAG applications using vector database, AI toolkit, and advanced retrieval capabilities. +links: + - label: Solution overview + url: /solutions/elasticsearch-solution-project + - label: Get started + url: /solutions/elasticsearch-solution-project/get-started + - label: Agent Builder + url: /explore-analyze/ai-features/elastic-agent-builder +aside: + label: Key features + links: + - label: Playground + url: /explore-analyze/query-filter/tools/playground + - label: Vector search + url: /solutions/search/vector + - label: Content connectors + url: /reference/search-connectors + - label: Semantic search + url: /solutions/search/semantic-search/semantic-search-elser-ingest-pipelines ::: -:::: - -::::{card-group} -:title: AI and automation -:intro: Machine learning and AI-assisted workflows in Kibana. -:id: ai - -:::{link-card} Machine learning -:link: / -Detect anomalies, classify data, and forecast trends. +:::{link-card} +title: Observability +link: /solutions/observability +icon: observability +variant: obs +description: Resolve problems with open, flexible, and unified observability powered by advanced machine learning and analytics. +links: + - label: Solution overview + url: /solutions/observability + - label: Get started + url: /solutions/observability/get-started + - label: Agent Builder + url: /solutions/observability/ai/agent-builder-observability +aside: + label: Key features + links: + - label: APM + url: /solutions/observability/apm + - label: Logs + url: /solutions/observability/logs + - label: Infrastructure + url: /solutions/observability/infra-and-hosts + - label: Synthetics + url: /solutions/observability/synthetics/get-started + - label: SLOs + url: /solutions/observability/incident-management/service-level-objectives-slos ::: -:::{link-card} AI Assistant -:link: / -A conversational assistant that helps you query and analyze data. +:::{link-card} +title: Security +link: /solutions/security +icon: security +variant: sec +description: Detect, investigate, and respond to threats with AI-driven security analytics to protect your organization. +links: + - label: Solution overview + url: /solutions/security + - label: Get started + url: /solutions/security/get-started + - label: Agent Builder + url: /solutions/security/ai/agent-builder/agent-builder +aside: + label: Key features + links: + - label: SIEM + url: /solutions/security/get-started/get-started-detect-with-siem + - label: Detection rules + url: /solutions/security/detect-and-alert/manage-detection-rules + - label: Elastic Defend + url: /solutions/security/configure-elastic-defend + - label: Cloud security + url: /solutions/security/cloud + - label: Cases + url: /solutions/security/investigate/security-cases ::: - -:::{link-card} Automatic insights -:link: / -Surface notable changes in your data without writing rules. -::: - -:::: - -::::{card-group} -:title: Management and developer tools -:intro: Stack management, Dev Tools, and APIs. -:id: manage - -:::{link-card} Stack Management -:link: / -Manage spaces, roles, index lifecycle, snapshots, and more. -::: - -:::{link-card} Dev Tools -:link: / -Console, Painless Lab, Search Profiler, Grok Debugger. -::: - -:::{link-card} Kibana APIs -:link: / -Automate Kibana with the public REST APIs. -::: - :::: diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 3578c6ff0a..bf2f300672 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -1,145 +1,487 @@ +/* + * Hub-page styling. Models the gist prototypes in docs/hubs/. Class names + * mirror the directive output: + * + * .hub-page page wrapper (set by Layout/_Layout.cshtml's RenderHub) + * .hub-content article wrapper inside the content column + * .hub-hero hero section (full-bleed dark background) + * .hub-on-this-page inline TOC chip + * .hub-intro getting-started callout panel + * .hub-whats-new What's new panel + * .hub-zone section heading + * .hub-card-grid card grid container + * .hub-card card item + * + * The hero / on-this-page / intro / whats-new / card-grid containers all + * use a shared inner rail of max-w-5xl + 24px horizontal padding. + */ + @layer components { - /* Full-width content area; hero and card-group manage their own inner rails. */ - .hub-page .hub-content { - @apply mb-12 w-full max-w-none p-0; + /* Page wrapper: dotted background like the gist, full-width content. */ + .hub-page { + background-color: var(--color-grey-10); + background-image: radial-gradient( + circle, + var(--color-grey-20) 0.75px, + transparent 0.75px + ); + background-size: 24px 24px; } - - /* Section-content rail used by hero/group internals. */ - .hub-page :where(.hub-rail) { - @apply mx-auto w-full max-w-5xl px-6; + .hub-page .hub-content { + @apply mb-20 w-full max-w-none p-0; } - /* Full-bleed hero with dark background and inner content rail. */ + /* Hero ------------------------------------------------------------- */ .hub-hero { - @apply bg-ink-navy mb-10 w-full px-0 py-14 text-white; - - .hub-hero-inner { - @apply mx-auto w-full max-w-5xl px-6; - } - - .hub-hero-top { - @apply mb-3 flex items-center gap-4; - } + background-color: var(--color-ink-navy); + color: var(--color-white); + padding: 56px 24px 48px; + } + .hub-hero .hub-hero-inner { + @apply mx-auto w-full max-w-5xl; + } + .hub-hero .hub-hero-top { + @apply mb-3 flex items-center gap-4; + } + .hub-hero .hub-hero-icon { + @apply inline-flex h-13 w-13 shrink-0 items-center justify-center rounded-xl text-2xl font-bold; + width: 52px; + height: 52px; + background-color: rgb(255 255 255 / 0.08); + color: var(--color-white); + } + .hub-hero .hub-hero-icon-svg { + background-color: transparent; + } + .hub-hero .hub-hero-icon-svg svg { + width: 32px; + height: 32px; + } + .hub-hero .hub-hero-title h1 { + font-size: 2.571rem; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.5px; + color: var(--color-white); + margin: 0; + } + .hub-hero .hub-hero-title h1 a.headerlink { + color: var(--color-white); + text-decoration: none; + } + .hub-hero .hub-hero-title p { + font-size: 1.143rem; + color: rgb(255 255 255 / 0.7); + max-width: 720px; + line-height: 1.6; + margin-top: 12px; + } - .hub-hero-icon { - @apply inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-xl font-bold text-white; - background-color: rgb(255 255 255 / 0.08); - } + .hub-hero .hub-hero-search { + margin-top: 20px; + margin-bottom: 20px; + max-width: 480px; + } - .hub-hero-version { - @apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold tracking-wide; - background-color: rgb(255 255 255 / 0.1); - border: 1px solid rgb(255 255 255 / 0.15); - } + .hub-hero .hub-hero-meta { + @apply mt-2 flex flex-wrap items-center gap-3; + } - .hub-hero-content { - h1 { - @apply mb-3 text-4xl font-bold tracking-tight text-white; - } - h1 a.headerlink { - @apply text-white no-underline; - } - p { - @apply max-w-3xl text-base leading-relaxed; - color: rgb(255 255 255 / 0.7); - } - } + .hub-hero .hub-hero-version { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgb(255 255 255 / 0.08); + border: 1px solid rgb(255 255 255 / 0.15); + border-radius: 20px; + padding: 5px 14px; + font-size: 13px; + color: rgb(255 255 255 / 0.85); + } + .hub-hero .hub-hero-version-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #9adc30; + } - .hub-hero-search { - @apply mt-6 max-w-md; - } + .hub-hero .hub-hero-pills { + @apply m-0 flex list-none flex-wrap gap-2 p-0; + } + .hub-hero .hub-hero-pills li { + @apply m-0 p-0; + } + .hub-hero .hub-hero-pills a { + font-size: 13px; + padding: 5px 14px; + border-radius: 20px; + background: rgb(255 255 255 / 0.06); + border: 1px solid rgb(255 255 255 / 0.12); + color: rgb(255 255 255 / 0.85); + text-decoration: none; + transition: background 0.15s; + } + .hub-hero .hub-hero-pills a:hover { + background: rgb(255 255 255 / 0.12); + color: var(--color-white); + text-decoration: none; + } - .hub-hero-links { - @apply mt-5 flex list-none flex-wrap gap-x-5 gap-y-2 p-0; - - li { - @apply m-0 p-0; - } - - a { - @apply border-b border-transparent text-sm font-semibold no-underline; - color: rgb(255 255 255 / 0.85); - } - a:hover { - @apply text-white; - border-bottom-color: rgb(255 255 255 / 0.4); - } - } + .hub-hero .hub-hero-releases { + @apply mt-4; + font-size: 12px; + color: rgb(255 255 255 / 0.45); + } + .hub-hero .hub-hero-releases a { + color: rgb(255 255 255 / 0.6); + text-decoration: none; + } + .hub-hero .hub-hero-releases a:hover { + color: rgb(255 255 255 / 0.85); + text-decoration: underline; } - /* Card group: section title rail, then card grid rail. */ - .hub-card-group { - @apply mt-10; + /* On this page ----------------------------------------------------- */ + .hub-on-this-page { + @apply mx-auto w-full max-w-5xl; + margin-top: 40px; + margin-bottom: 36px; + padding: 20px 28px; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 16px; + font-size: 14px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 0; + line-height: 1.9; + } + .hub-on-this-page .hub-on-this-page-label { + font-weight: 700; + color: var(--color-ink-dark); + margin-right: 14px; + } + .hub-on-this-page a { + font-weight: 500; + color: var(--color-blue-elastic-100); + text-decoration: none; + } + .hub-on-this-page a:hover { + text-decoration: underline; + } + .hub-on-this-page .hub-on-this-page-sep { + color: var(--color-grey-30); + margin: 0 10px; + font-size: 12px; + } - .hub-card-group-header { - @apply mx-auto mb-5 w-full max-w-5xl px-6; + /* Intro callout ---------------------------------------------------- */ + .hub-intro { + @apply mx-auto w-full max-w-5xl; + margin-bottom: 36px; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-left: 3px solid var(--color-teal-40, #66ccc8); + border-radius: 16px; + padding: 18px 24px; + font-size: 14px; + color: var(--color-ink); + line-height: 1.5; + } + .hub-intro p { + margin: 0; + } + .hub-intro a { + color: var(--color-blue-elastic-100); + text-decoration: underline; + } - h2 { - @apply text-ink-dark m-0 text-2xl font-bold; - } - } + /* What's new ------------------------------------------------------- */ + .hub-whats-new { + @apply mx-auto w-full max-w-5xl; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 16px; + padding: 24px 28px; + margin-bottom: 36px; + scroll-margin-top: 120px; + } + .hub-whats-new .hub-wn-header { + @apply mb-4 flex items-center gap-2; + } + .hub-whats-new .hub-wn-badge { + font-size: 12px; + font-weight: 700; + color: var(--color-white); + background: #f04e98; + border-radius: 6px; + padding: 3px 10px; + line-height: 1.3; + } + .hub-whats-new .hub-wn-title { + font-size: 15px; + font-weight: 700; + color: var(--color-ink-dark); + } + .hub-whats-new .hub-wn-rn-links { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + flex-shrink: 0; + color: var(--color-ink-light); + } + .hub-whats-new .hub-wn-rn-label { + color: var(--color-ink-light); + } + .hub-whats-new .hub-wn-rn-links a { + font-weight: 500; + color: var(--color-blue-elastic-100); + text-decoration: none; + } + .hub-whats-new .hub-wn-rn-links a:hover { + text-decoration: underline; + } + .hub-whats-new .hub-wn-rn-sep { + color: var(--color-grey-30); + } - .hub-card-group-intro { - @apply text-ink-light mt-1; - } + .hub-whats-new .hub-wn-items { + @apply m-0 flex list-none flex-col gap-2 p-0; + } + .hub-whats-new .hub-wn-item { + @apply m-0 p-0; + } + .hub-whats-new .hub-wn-item-link { + display: flex; + align-items: baseline; + gap: 10px; + padding: 10px 14px; + border-radius: 10px; + background: var(--color-grey-10); + text-decoration: none; + font-size: 14px; + color: var(--color-body, #515151); + line-height: 1.4; + border: 1px solid transparent; + transition: background 0.12s; + } + .hub-whats-new a.hub-wn-item-link:hover { + background: var(--color-blue-elastic-10); + border-color: var(--color-grey-20); + text-decoration: none; + } + .hub-whats-new .hub-wn-item-title { + color: var(--color-ink-dark); + white-space: nowrap; + } + .hub-whats-new .hub-wn-item-desc { + color: var(--color-ink-light); + } + .hub-whats-new .hub-wn-item-meta { + margin-left: auto; + white-space: nowrap; + flex-shrink: 0; + font-size: 12px; + color: var(--color-grey-80); + font-weight: 500; + } - .hub-card-grid { - @apply mx-auto grid w-full max-w-5xl list-none grid-cols-1 gap-4 p-0 px-6 md:grid-cols-2 lg:grid-cols-3; - } + /* Zone (section heading) ------------------------------------------ */ + .hub-zone { + @apply mx-auto w-full max-w-5xl; + margin-top: 56px; + margin-bottom: 24px; + scroll-margin-top: 120px; + } + .hub-zone:first-of-type { + margin-top: 0; + } + .hub-zone .hub-zone-title { + font-size: 1.571rem; + font-weight: 700; + color: var(--color-ink-dark); + line-height: 1.2; + margin: 0; + } + .hub-zone .hub-zone-intro { + color: var(--color-ink-light); + margin-top: 8px; } - /* News variant: tighter 2-column list. */ - .hub-card-group-news .hub-card-grid { - @apply grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-2; + /* Card grid -------------------------------------------------------- */ + .hub-card-grid { + @apply m-0 mx-auto w-full max-w-5xl list-none p-0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); + gap: 24px; } + /* Card ------------------------------------------------------------- */ .hub-card { - @apply m-0 p-0; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 16px; + padding: 28px 28px 24px; + transition: + box-shadow 0.15s, + border-color 0.15s; + margin: 0; + list-style: none; + } + .hub-card:hover { + border-color: var(--color-grey-30); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.05); } - .hub-card-link { - @apply border-grey-20 block h-full rounded-xl border bg-white p-5 no-underline transition-colors; + .hub-card .hub-card-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; } - .hub-card-link:hover { - @apply border-blue-elastic no-underline; + .hub-card .hub-card-icon svg { + width: 28px; + height: 28px; + display: block; } - - .hub-card-head { - @apply mb-2 flex items-start justify-between gap-3; + .hub-card .hub-card-title { + font-size: 1.214rem; + font-weight: 700; + color: var(--color-ink-dark); + margin: 0; + } + .hub-card .hub-card-title a { + color: var(--color-ink-dark); + text-decoration: none; + } + .hub-card .hub-card-title a:hover { + color: var(--color-blue-elastic); + text-decoration: none; } - .hub-card-title { - @apply text-ink-dark text-base font-semibold; + .hub-card .hub-card-desc { + font-size: 14px; + color: var(--color-ink-light); + margin-bottom: 14px; + line-height: 1.5; } - .hub-card-badge { - @apply bg-grey-10 text-ink-light border-grey-20 inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-semibold tracking-wide; + .hub-card .hub-card-links { + list-style: none; + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + } + .hub-card .hub-card-links li { + margin: 0; + padding: 0; + } + .hub-card .hub-card-links li a { + font-size: 14px; + font-weight: 600; + color: var(--color-blue-elastic-100); + padding: 3px 0; + display: inline-flex; + align-items: baseline; + gap: 6px; + text-decoration: none; + } + .hub-card .hub-card-links li a:hover { + color: var(--color-blue-elastic-110); + text-decoration: underline; + } + .hub-card .hub-card-links li a::before { + content: '\203A'; + color: var(--color-grey-40); + font-weight: 700; + font-size: 15px; } - .hub-card-body { - @apply text-ink-light text-sm leading-relaxed; + /* Card aside (Panel types, Chart types, Key features...) */ + .hub-card .hub-card-aside { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--color-grey-20); + } + .hub-card .hub-card-aside-label { + font-size: 12px; + color: var(--color-grey-80); + margin-bottom: 6px; + font-weight: 600; + letter-spacing: 0.3px; + } + .hub-card .hub-card-aside-links { + display: flex; + flex-wrap: wrap; + gap: 0; + font-size: 13px; + line-height: 1.9; + } + .hub-card .hub-card-aside-links a { + color: var(--color-blue-elastic-100); + font-weight: 500; + text-decoration: none; + } + .hub-card .hub-card-aside-links a:hover { + text-decoration: underline; + } + .hub-card .hub-card-aside-sep { + color: var(--color-grey-30); + margin: 0 6px; + } - & > *:first-child { - @apply mt-0; - } - & > *:last-child { - @apply mb-0; - } + /* Solution accent borders */ + .hub-card-sol { + border-left: 3px solid var(--color-blue-elastic); + } + .hub-card-sol-es { + border-left-color: #fec514; + } + .hub-card-sol-obs { + border-left-color: #f04e98; + } + .hub-card-sol-sec { + border-left-color: #00bfb3; } /* Product badges shown above the H1 of a regular page. */ .product-badges { @apply mb-3 flex list-none flex-wrap gap-2 p-0; - - li { - @apply m-0 p-0; - } } - + .product-badges li { + @apply m-0 p-0; + } .product-badge { - @apply bg-grey-10 text-ink-light border-grey-20 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold tracking-wide no-underline; + @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold tracking-wide no-underline; + background: var(--color-grey-10); + color: var(--color-ink-light); + border: 1px solid var(--color-grey-20); } .product-badge:hover { - @apply bg-blue-elastic-10 text-blue-elastic-100 border-blue-elastic no-underline; + background: var(--color-blue-elastic-10); + color: var(--color-blue-elastic-100); + border-color: var(--color-blue-elastic); + text-decoration: none; + } + + @media (max-width: 768px) { + .hub-hero h1 { + font-size: 2rem; + } + .hub-card-grid { + grid-template-columns: 1fr; + } + .hub-on-this-page { + flex-direction: column; + } + .hub-card { + padding: 20px; + } } } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index d38bfa1921..fe1e022fad 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -146,6 +146,15 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{link-card}") > 0) return new LinkCardBlock(this, context); + if (info.IndexOf("{whats-new}") > 0) + return new WhatsNewBlock(this, context); + + if (info.IndexOf("{intro}") > 0) + return new IntroBlock(this, context); + + if (info.IndexOf("{on-this-page}") > 0) + return new OnThisPageBlock(this, context); + foreach (var admonition in Admonitions) { if (info.IndexOf(admonition) > 0) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index df6f26b8a2..92db50c08d 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -131,6 +131,15 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case LinkCardBlock linkCardBlock: WriteLinkCard(renderer, linkCardBlock); return; + case WhatsNewBlock whatsNewBlock: + WriteWhatsNew(renderer, whatsNewBlock); + return; + case IntroBlock introBlock: + WriteIntro(renderer, introBlock); + return; + case OnThisPageBlock onThisPageBlock: + WriteOnThisPage(renderer, onThisPageBlock); + return; default: // if (!string.IsNullOrEmpty(directiveBlock.Info) && !directiveBlock.Info.StartsWith('{')) // WriteCode(renderer, directiveBlock); @@ -344,13 +353,19 @@ private static void WriteTabSet(HtmlRenderer renderer, TabSetBlock block) private static void WriteHero(HtmlRenderer renderer, HeroBlock block) { + var releasesHtml = string.IsNullOrWhiteSpace(block.Releases) + ? null + : RenderInlineMarkdown(block.Releases!); + var slice = HeroView.Create(new HeroViewModel { DirectiveBlock = block, - Icon = block.Icon, + IconKey = block.Icon, + IconSvg = block.IconSvg, Version = block.Version, ShowSearch = block.ShowSearch, - QuickLinks = block.QuickLinks + QuickLinks = block.QuickLinks, + ReleasesHtml = releasesHtml }); RenderRazorSlice(slice, renderer); } @@ -373,13 +388,49 @@ private static void WriteLinkCard(HtmlRenderer renderer, LinkCardBlock block) var slice = LinkCardView.Create(new LinkCardViewModel { DirectiveBlock = block, - Title = block.Title, - Link = block.Link, - Badge = block.Badge + Data = block.Data, + IconSvg = ProductIcons.Get(block.Data.Icon) + }); + RenderRazorSlice(slice, renderer); + } + + private static void WriteWhatsNew(HtmlRenderer renderer, WhatsNewBlock block) + { + var slice = WhatsNewView.Create(new WhatsNewViewModel + { + DirectiveBlock = block, + Data = block.Data + }); + RenderRazorSlice(slice, renderer); + } + + private static void WriteIntro(HtmlRenderer renderer, IntroBlock block) + { + var slice = IntroView.Create(new IntroViewModel { DirectiveBlock = block }); + RenderRazorSlice(slice, renderer); + } + + private static void WriteOnThisPage(HtmlRenderer renderer, OnThisPageBlock block) + { + var slice = OnThisPageView.Create(new OnThisPageViewModel + { + DirectiveBlock = block, + Items = block.CollectItems() }); RenderRazorSlice(slice, renderer); } + private static string RenderInlineMarkdown(string source) + { + var html = Markdig.Markdown.ToHtml(source).Trim(); + // Strip a single wrapping

    ...

    so the result can drop into a span/p directly. + const string open = "

    "; + const string close = "

    "; + if (html.StartsWith(open, StringComparison.Ordinal) && html.EndsWith(close, StringComparison.Ordinal)) + html = html[open.Length..^close.Length]; + return html; + } + private static void WriteTabItem(HtmlRenderer renderer, TabItemBlock block) { var slice = TabItemView.Create(new TabItemViewModel diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml index 979065318e..28f9d6cc86 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml @@ -1,23 +1,15 @@ @inherits RazorSlice -@{ - var sectionClass = string.IsNullOrWhiteSpace(Model.Variant) - ? "hub-card-group" - : $"hub-card-group hub-card-group-{Model.Variant}"; +@if (!string.IsNullOrWhiteSpace(Model.Title)) +{ +
    +

    @Model.Title

    + @if (!string.IsNullOrWhiteSpace(Model.Intro)) + { +

    @Model.Intro

    + } +
    } - -
    - @if (!string.IsNullOrWhiteSpace(Model.Title)) - { -
    -

    @Model.Title

    - @if (!string.IsNullOrWhiteSpace(Model.Intro)) - { -

    @Model.Intro

    - } -
    - } -
      - @Model.RenderBlock() -
    -
    +
      + @Model.RenderBlock() +
    diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs index c28ea58412..2797255c13 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs @@ -5,20 +5,21 @@ namespace Elastic.Markdown.Myst.Directives.Hub; /// -/// Renders a full-bleed page hero (dark background, icon, title, description, optional -/// version badge, optional quick links, optional search box). Designed for landing-style -/// pages such as product hubs, but the directive is generic and reusable. +/// Renders a full-bleed page hero with product icon, title (from inner H1), +/// description, search box, version chip, quick-link pills, and an optional +/// release-status line. Designed for landing-style pages such as product hubs. /// /// /// /// :::{hero} -/// :icon: elasticsearch -/// :version: 9.0 -/// :quick-links: What's new=#whats-new,Get started=/get-started +/// :icon: kibana +/// :version: v9 / Serverless (current) +/// :quick-links: Install=/install,Tutorial=/tutorial,API reference=/api,Release notes=/release-notes +/// :releases: Latest: [Stack 9.4.1](/rn) (Mar 28, 2026) · [Serverless deployed](/srn) Apr 1, 2026 /// -/// # Elasticsearch +/// # Kibana /// -/// The distributed search and analytics engine. +/// The UI for the Elasticsearch platform. /// ::: /// /// @@ -28,17 +29,21 @@ public class HeroBlock(DirectiveBlockParser parser, ParserContext context) public override string Directive => "hero"; public string? Icon { get; private set; } + public string? IconSvg { get; private set; } public string? Version { get; private set; } public bool ShowSearch { get; private set; } public IReadOnlyList QuickLinks { get; private set; } = []; + public string? Releases { get; private set; } public override void FinalizeAndValidate(ParserContext context) { Icon = Prop("icon"); + IconSvg = ProductIcons.Get(Icon); Version = Prop("version"); // search defaults to true; explicit ":search: false" hides it ShowSearch = TryPropBool("search") ?? true; QuickLinks = ParseQuickLinks(Prop("quick-links")); + Releases = Prop("releases"); } private static IReadOnlyList ParseQuickLinks(string? raw) diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml index cbd2d794e0..d124143244 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml @@ -3,32 +3,50 @@
    - @if (!string.IsNullOrWhiteSpace(Model.Icon)) + @if (!string.IsNullOrWhiteSpace(Model.IconSvg)) { - + + @(new HtmlString(Model.IconSvg)) + } - @if (!string.IsNullOrWhiteSpace(Model.Version)) + else if (!string.IsNullOrWhiteSpace(Model.IconKey)) { - @Model.Version + } +
    + @Model.RenderBlock() +
    -
    - @Model.RenderBlock() -
    + @if (Model.ShowSearch) { } - @if (Model.QuickLinks.Count > 0) + +
    + @if (!string.IsNullOrWhiteSpace(Model.Version)) + { + + + @Model.Version + + } + @if (Model.QuickLinks.Count > 0) + { +
      + @foreach (var link in Model.QuickLinks) + { +
    • @link.Label
    • + } +
    + } +
    + + @if (!string.IsNullOrWhiteSpace(Model.ReleasesHtml)) { - +

    @(new HtmlString(Model.ReleasesHtml))

    }
    diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs index 1809af3d30..19373fd0f2 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs @@ -6,8 +6,10 @@ namespace Elastic.Markdown.Myst.Directives.Hub; public class HeroViewModel : DirectiveViewModel { - public required string? Icon { get; init; } + public required string? IconKey { get; init; } + public required string? IconSvg { get; init; } public required string? Version { get; init; } public required bool ShowSearch { get; init; } public required IReadOnlyList QuickLinks { get; init; } + public required string? ReleasesHtml { get; init; } } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HubYamlBody.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HubYamlBody.cs new file mode 100644 index 0000000000..fe885e3ce0 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HubYamlBody.cs @@ -0,0 +1,96 @@ +// 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 Markdig.Syntax; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Helpers for directives that read their body as raw YAML directly from the +/// source file. The directive's children remain markdown blocks (so renderers +/// that don't recognize the directive still produce something), but the +/// canonical structured data comes from the YAML. +/// +internal static class HubYamlBody +{ + /// + /// Returns the raw text between the opening and closing fences of a directive + /// block, or null when no fenced body could be located. + /// + public static string? Extract(IBlockExtension block, IFileSystemFileReader reader) + { + if (block is not Block markdig) + return null; + + string source; + try + { + source = reader.ReadAllText(block.CurrentFile.FullName); + } + catch + { + return null; + } + + var lines = source.Split('\n'); + var openingLine = markdig.Line; + if (openingLine < 0 || openingLine >= lines.Length) + return null; + + var fence = ExtractFenceMarker(lines[openingLine]); + if (fence is null) + return null; + + var closingLine = -1; + for (var i = openingLine + 1; i < lines.Length; i++) + { + var trimmed = lines[i].TrimStart(); + if (trimmed.StartsWith(fence, StringComparison.Ordinal) && IsClosingFence(trimmed, fence)) + { + closingLine = i; + break; + } + } + if (closingLine < 0) + return null; + + var body = string.Join('\n', lines, openingLine + 1, closingLine - openingLine - 1); + return string.IsNullOrWhiteSpace(body) ? null : body; + } + + private static string? ExtractFenceMarker(string openingLine) + { + var trimmed = openingLine.TrimStart(); + var count = 0; + while (count < trimmed.Length && trimmed[count] == ':') + count++; + return count >= 3 ? new string(':', count) : null; + } + + private static bool IsClosingFence(string trimmed, string fence) + { + if (!trimmed.StartsWith(fence, StringComparison.Ordinal)) + return false; + for (var i = fence.Length; i < trimmed.Length; i++) + { + if (!char.IsWhiteSpace(trimmed[i])) + return false; + } + return true; + } +} + +/// +/// Minimal abstraction over file reading so HubYamlBody can be tested without a +/// full BuildContext. +/// +public interface IFileSystemFileReader +{ + string ReadAllText(string path); +} + +internal sealed class BuildContextFileReader(System.IO.Abstractions.IFileSystem fileSystem) : IFileSystemFileReader +{ + public string ReadAllText(string path) => fileSystem.File.ReadAllText(path); +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/IntroBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/IntroBlock.cs new file mode 100644 index 0000000000..e6f350dfd6 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/IntroBlock.cs @@ -0,0 +1,26 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// A small "getting started" callout panel shown between the hero and the first +/// section. The body is rendered as inline markdown so authors can use bold, +/// links, and emphasis. Visually a white rounded panel with a teal accent bar. +/// +/// +/// +/// :::{intro} +/// **New to Kibana?** [Learn data exploration and visualization](/learn), a hands-on +/// walk-through of Discover, ES|QL, visualizations, and dashboards. +/// ::: +/// +/// +public class IntroBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + public override string Directive => "intro"; + + public override void FinalizeAndValidate(ParserContext context) { } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/IntroView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/IntroView.cshtml new file mode 100644 index 0000000000..8f47ae9ee9 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/IntroView.cshtml @@ -0,0 +1,5 @@ +@inherits RazorSlice + + diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/IntroViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/IntroViewModel.cs new file mode 100644 index 0000000000..f639218cea --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/IntroViewModel.cs @@ -0,0 +1,7 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class IntroViewModel : DirectiveViewModel; diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs index 8c4431a8a4..a116fd6e81 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs @@ -3,21 +3,34 @@ // See the LICENSE file in the project root for more information using Elastic.Markdown.Diagnostics; -using Elastic.Markdown.Helpers; +using YamlDotNet.Core; +using YamlDotNet.Serialization; namespace Elastic.Markdown.Myst.Directives.Hub; /// -/// A single card with a required title and link, plus an optional badge and -/// description body. Designed to live inside a -/// but rendered standalone if used outside one. +/// A rich card with title, link, description, primary-link list, and an optional +/// aside (e.g. "Panel types: A · B · C"). The card schema is YAML-formatted in the +/// directive body for predictable structure. /// /// /// -/// :::{link-card} Self-managed -/// :link: /deploy-manage/deploy/self-managed -/// :badge: 9.0 -/// Run Elasticsearch on your own infrastructure. +/// :::{link-card} +/// title: Discover +/// link: /discover/ +/// description: Browse documents, filter, and query your indices in real time. +/// links: +/// - label: Get started with Discover +/// url: /discover/get-started +/// - label: Use ES|QL in Kibana +/// url: /esql +/// aside: +/// label: Panel types +/// links: +/// - label: Visualizations +/// url: /viz +/// - label: Maps +/// url: /maps /// ::: /// /// @@ -26,20 +39,77 @@ public class LinkCardBlock(DirectiveBlockParser parser, ParserContext context) { public override string Directive => "link-card"; - public string Title { get; private set; } = default!; - public string? Link { get; private set; } - public string? Badge { get; private set; } + public LinkCardData Data { get; private set; } = LinkCardData.Empty; + + public string Title => Data.Title ?? string.Empty; public override void FinalizeAndValidate(ParserContext context) { - if (string.IsNullOrWhiteSpace(Arguments)) - this.EmitError("{link-card} requires a title argument, e.g. `:::{link-card} My title`."); + var yaml = HubYamlBody.Extract(this, new BuildContextFileReader(Build.ReadFileSystem)); + if (yaml is null) + { + this.EmitError("{link-card} requires a YAML body. See the link-card directive docs."); + return; + } - Title = (Arguments ?? "{undefined}").ReplaceSubstitutions(context); - Link = Prop("link"); - Badge = Prop("badge"); + try + { + Data = YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration) ?? LinkCardData.Empty; + } + catch (YamlException ex) + { + this.EmitError($"{{link-card}} YAML parse error: {ex.Message}"); + return; + } - if (string.IsNullOrWhiteSpace(Link)) - this.EmitError("{link-card} requires a `:link:` option."); + if (string.IsNullOrWhiteSpace(Data.Title)) + this.EmitError("{link-card} requires a `title` field in its YAML body."); } } + +[YamlSerializable] +public record LinkCardData +{ + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "link")] + public string? Link { get; set; } + + [YamlMember(Alias = "description")] + public string? Description { get; set; } + + [YamlMember(Alias = "icon")] + public string? Icon { get; set; } + + [YamlMember(Alias = "variant")] + public string? Variant { get; set; } + + [YamlMember(Alias = "links")] + public LinkCardLink[] Links { get; set; } = []; + + [YamlMember(Alias = "aside")] + public LinkCardAside? Aside { get; set; } + + public static LinkCardData Empty { get; } = new(); +} + +[YamlSerializable] +public record LinkCardLink +{ + [YamlMember(Alias = "label")] + public string? Label { get; set; } + + [YamlMember(Alias = "url")] + public string? Url { get; set; } +} + +[YamlSerializable] +public record LinkCardAside +{ + [YamlMember(Alias = "label")] + public string? Label { get; set; } + + [YamlMember(Alias = "links")] + public LinkCardLink[] Links { get; set; } = []; +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml index d9bb7d3d60..2017da0d05 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml @@ -1,16 +1,67 @@ @inherits RazorSlice -
  • - -
    - @Model.Title - @if (!string.IsNullOrWhiteSpace(Model.Badge)) +@{ + var d = Model.Data; + var classes = "hub-card"; + if (!string.IsNullOrWhiteSpace(d.Variant)) + classes += " hub-card-sol hub-card-sol-" + d.Variant; +} + +
  • + + + @if (!string.IsNullOrWhiteSpace(d.Description)) + { +

    @d.Description

    + } + + @if (d.Links.Length > 0) + { + + } + + @if (d.Aside is { } aside && aside.Links.Length > 0) + { +
    + @if (!string.IsNullOrWhiteSpace(aside.Label)) + { +
    @aside.Label
    + } +
    -
    - @Model.RenderBlock() -
    - + }
  • diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs index 7d91af642c..ff75e4821f 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs @@ -6,7 +6,6 @@ namespace Elastic.Markdown.Myst.Directives.Hub; public class LinkCardViewModel : DirectiveViewModel { - public required string Title { get; init; } - public required string? Link { get; init; } - public required string? Badge { get; init; } + public required LinkCardData Data { get; init; } + public required string? IconSvg { get; init; } } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageBlock.cs new file mode 100644 index 0000000000..adb9fc9e04 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageBlock.cs @@ -0,0 +1,54 @@ +// 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 Markdig.Syntax; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Inline table-of-contents chip listing every {card-group} and {whats-new} +/// section on the page that has a non-empty id and title. Auto-generated; no +/// options or body required. +/// +/// +/// +/// :::{on-this-page} +/// ::: +/// +/// +public class OnThisPageBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + public override string Directive => "on-this-page"; + + public override void FinalizeAndValidate(ParserContext context) { } + + /// + /// Resolved at render time, since later sibling card-groups may not be + /// validated yet at parse time. + /// + public IReadOnlyList CollectItems() + { + var current = (ContainerBlock)this; + while (current.Parent is not null) + current = current.Parent; + + var items = new List(); + foreach (var descendant in current.Descendants()) + { + switch (descendant) + { + case CardGroupBlock g when !string.IsNullOrWhiteSpace(g.Anchor) && !string.IsNullOrWhiteSpace(g.Title): + items.Add(new OnThisPageItem(g.Title!, g.Anchor!)); + break; + case WhatsNewBlock w when !string.IsNullOrWhiteSpace(w.Data.Id) && !string.IsNullOrWhiteSpace(w.Data.Title): + items.Add(new OnThisPageItem(w.Data.Title!, w.Data.Id!)); + break; + } + } + return items; + } +} + +public readonly record struct OnThisPageItem(string Title, string Anchor); diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageView.cshtml new file mode 100644 index 0000000000..69b9231df0 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageView.cshtml @@ -0,0 +1,17 @@ +@inherits RazorSlice + +@if (Model.Items.Count > 0) +{ + +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageViewModel.cs new file mode 100644 index 0000000000..e4f190c7a9 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageViewModel.cs @@ -0,0 +1,10 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class OnThisPageViewModel : DirectiveViewModel +{ + public required IReadOnlyList Items { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs b/src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs new file mode 100644 index 0000000000..3cd39b84ec --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs @@ -0,0 +1,68 @@ +// 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; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Looks up an inline SVG by product key. Used by the {hero} directive (hero-icon +/// chip) and {link-card} (solution-card icon). Keys are kept lowercase and match +/// the product ids in products.yml wherever possible. +/// +public static class ProductIcons +{ + private static readonly FrozenDictionary Icons = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["elasticsearch"] = """ + + """, + ["kibana"] = """ + + """, + ["observability"] = """ + + """, + ["security"] = """ + + """ + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Returns the inline SVG markup for , or null when no icon + /// is registered for that product. + /// + public static string? Get(string? key) + { + if (string.IsNullOrWhiteSpace(key)) + return null; + return Icons.TryGetValue(key, out var svg) ? svg : null; + } + + /// + /// Initials fallback: the first character of the key, uppercased. Returns a + /// single-letter string, suitable for the standard hero-icon chip when no + /// SVG is registered. + /// + public static string Initials(string? key) => + string.IsNullOrWhiteSpace(key) ? "?" : char.ToUpperInvariant(key[0]).ToString(); +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs new file mode 100644 index 0000000000..f009c3f0b8 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs @@ -0,0 +1,99 @@ +// 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 Elastic.Markdown.Diagnostics; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Renders the "What's new" panel: a header with a New badge, a section title, +/// optional release-notes links, and a list of items with title / description / +/// per-item meta pill (e.g. "9.4 preview", "Mar 2026"). +/// +/// +/// +/// :::{whats-new} +/// title: What's new in Kibana +/// id: whats-new +/// release-links: +/// - label: 9.4 +/// url: /release-notes/kibana +/// - label: Serverless +/// url: /release-notes/serverless +/// items: +/// - title: Dashboards APIs +/// description: Programmatically create dashboards +/// link: /api/dashboards +/// meta: 9.4 preview +/// ::: +/// +/// +public class WhatsNewBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + public override string Directive => "whats-new"; + + public WhatsNewData Data { get; private set; } = WhatsNewData.Empty; + + public override void FinalizeAndValidate(ParserContext context) + { + var yaml = HubYamlBody.Extract(this, new BuildContextFileReader(Build.ReadFileSystem)); + if (yaml is null) + { + this.EmitError("{whats-new} requires a YAML body. See the whats-new directive docs."); + return; + } + + try + { + Data = YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration) ?? WhatsNewData.Empty; + } + catch (YamlException ex) + { + this.EmitError($"{{whats-new}} YAML parse error: {ex.Message}"); + } + } + + public override IEnumerable GeneratedAnchors => + string.IsNullOrWhiteSpace(Data.Id) ? [] : [Data.Id!]; +} + +[YamlSerializable] +public record WhatsNewData +{ + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "id")] + public string? Id { get; set; } + + [YamlMember(Alias = "badge")] + public string? Badge { get; set; } = "New"; + + [YamlMember(Alias = "release-links")] + public LinkCardLink[] ReleaseLinks { get; set; } = []; + + [YamlMember(Alias = "items")] + public WhatsNewItem[] Items { get; set; } = []; + + public static WhatsNewData Empty { get; } = new(); +} + +[YamlSerializable] +public record WhatsNewItem +{ + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "description")] + public string? Description { get; set; } + + [YamlMember(Alias = "link")] + public string? Link { get; set; } + + [YamlMember(Alias = "meta")] + public string? Meta { get; set; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml new file mode 100644 index 0000000000..ea449797ec --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml @@ -0,0 +1,75 @@ +@inherits RazorSlice + +@{ + var d = Model.Data; +} + +
    +
    + @if (!string.IsNullOrWhiteSpace(d.Badge)) + { + @d.Badge + } + @if (!string.IsNullOrWhiteSpace(d.Title)) + { + @d.Title + } + @if (d.ReleaseLinks.Length > 0) + { + + Release notes: + @for (var i = 0; i < d.ReleaseLinks.Length; i++) + { + var link = d.ReleaseLinks[i]; + if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) + continue; + @link.Label + @if (i < d.ReleaseLinks.Length - 1) + { + + } + } + + } +
    + @if (d.Items.Length > 0) + { + + } +
    diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs new file mode 100644 index 0000000000..678e31f650 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs @@ -0,0 +1,10 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class WhatsNewViewModel : DirectiveViewModel +{ + public required WhatsNewData Data { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 4f8502e75f..9b9309e1cc 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -42,4 +42,9 @@ public static T Deserialize(string yaml, ProductsConfiguration products) [YamlSerializable(typeof(ContributorEntry))] [YamlSerializable(typeof(ChangelogDirectiveConfigYaml))] [YamlSerializable(typeof(ChangelogDirectiveBundleConfigYaml))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardData))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardLink))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardAside))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewData))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewItem))] public partial class DocsBuilderYamlStaticContext; From 705bd159abda5185566d61de115fe8adf5e6863b Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 21:25:12 +0200 Subject: [PATCH 10/56] feat(hubs): restore full section coverage matching the gist prototypes Adds the sections that were dropped during the directive rewrite so the hub markdown matches the gist HTML one-to-one: - Elasticsearch: Index and ingest data, Aggregations, Data modeling, Manage data, AI and vector search, Security, Clients and integrations, Reference (8 sections previously missing). - Kibana: Track and respond, AI and automation, Management and developer tools, Reference (4 sections previously missing). Co-Authored-By: Claude Sonnet 4.6 --- docs/hubs/elasticsearch/9.0.md | 631 +++++++++++++++++++++++++++++---- docs/hubs/kibana/9.0.md | 217 ++++++++++++ 2 files changed, 785 insertions(+), 63 deletions(-) diff --git a/docs/hubs/elasticsearch/9.0.md b/docs/hubs/elasticsearch/9.0.md index 005007f1a0..6ef52436c4 100644 --- a/docs/hubs/elasticsearch/9.0.md +++ b/docs/hubs/elasticsearch/9.0.md @@ -4,9 +4,9 @@ layout: hub :::{hero} :icon: elasticsearch -:version: v9 / Serverless (current) -:quick-links: Install=/install,Tutorial=/tutorial,API reference=/api,Release notes=/release-notes -:releases: Latest: [Stack 9.4.1](/release-notes/elasticsearch) (Mar 28, 2026) · [Serverless deployed](/release-notes/serverless) Apr 1, 2026 +:version: v9 (current) +:quick-links: Install=https://www.elastic.co/docs/deploy-manage/deploy/self-managed/installing-elasticsearch,Quick start=https://www.elastic.co/docs/reference/elasticsearch/rest-apis/api-examples,API reference=https://www.elastic.co/docs/api/doc/elasticsearch,Release notes=https://www.elastic.co/docs/release-notes/elasticsearch +:releases: Latest: [Stack 9.0.0](https://www.elastic.co/docs/release-notes/elasticsearch) (Apr 2025) # Elasticsearch @@ -17,26 +17,32 @@ The distributed search and analytics engine. Store, search, and analyze data at ::: :::{intro} -**New to Elasticsearch?** [Start with Elastic Fundamentals](https://www.elastic.co/docs/get-started) — a hands-on walk-through of indexing, querying, and search relevance. +**New to Elasticsearch?** [Follow the quick start](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/api-examples) to run your first queries, or explore [how to bring your data in](https://www.elastic.co/docs/manage-data/ingest) and start searching in minutes. ::: :::{whats-new} title: What's new in Elasticsearch id: whats-new release-links: - - label: 9.4 - url: /release-notes/elasticsearch - - label: Serverless - url: /release-notes/serverless + - label: 9.0 + url: https://www.elastic.co/docs/release-notes/elasticsearch items: - - title: ES|QL JOIN syntax - description: Run cross-index joins in ES|QL queries with full GA stability and performance - link: /reference/esql/joins - meta: 9.0 GA - - title: BBQ vector quantization - description: 30% smaller indices and faster search with the new Better Binary Quantization encoding - link: /reference/vector-quantization + - title: semantic_text field type + description: Simplify semantic search — no pipeline setup needed, just define the field and start querying + link: https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text meta: 9.0 GA + - title: ES|QL improvements + description: New functions, cross-cluster support, and dense vector operations directly in ES|QL queries + link: https://www.elastic.co/docs/reference/query-languages/esql/esql-getting-started + meta: '9.0' + - title: Inference API expansion + description: Connect to external AI models (OpenAI, Cohere, Anthropic, Hugging Face, Azure, Google) for embeddings and reranking + link: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put + meta: '9.0' + - title: Breaking changes in 9.0 + description: Review removed settings, deprecated APIs, and behavioral changes before upgrading from 8.x + link: https://www.elastic.co/docs/release-notes/elasticsearch/breaking-changes + meta: Upgrade guide ::: ::::{card-group} @@ -45,83 +51,582 @@ items: :::{link-card} title: Self-managed -link: /deploy-manage/deploy/self-managed -description: Run Elasticsearch on your own infrastructure for full control over hardware and configuration. +link: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/installing-elasticsearch +description: Install and run Elasticsearch on your own infrastructure. links: - - label: Install on Linux / macOS - url: /deploy-manage/deploy/self-managed/install-elasticsearch - - label: Install with Docker - url: /deploy-manage/deploy/self-managed/install-elasticsearch-with-docker - - label: Configure (elasticsearch.yml) - url: /deploy-manage/deploy/self-managed/configure-elasticsearch + - label: Docker + url: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-with-docker + - label: Debian / Ubuntu + url: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-with-debian-package + - label: RPM + url: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-with-rpm + - label: Windows + url: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-with-zip-on-windows + - label: Linux / macOS archive + url: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-from-archive-on-linux-macos + - label: Configure Elasticsearch + url: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/configure-elasticsearch + - label: Secure a self-managed cluster + url: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/tutorial-self-managed-secure ::: :::{link-card} -title: Elastic Cloud Hosted -link: /deploy-manage/deploy/elastic-cloud -description: Managed deployments on AWS, GCP, or Azure with one-click upgrades and zero downtime. +title: Managed deployments +link: https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud +description: Run Elasticsearch on Elastic Cloud, Kubernetes, or ECE. links: - - label: Sign up - url: /deploy-manage/deploy/elastic-cloud/sign-up - - label: Manage deployments - url: /deploy-manage/deploy/elastic-cloud/manage-deployments + - label: Elastic Cloud Hosted + url: https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud + - label: Elastic Cloud on Kubernetes (ECK) + url: https://www.elastic.co/docs/deploy-manage/deploy/cloud-on-k8s + - label: Elastic Cloud Enterprise (ECE) + url: https://www.elastic.co/docs/deploy-manage/deploy/cloud-enterprise ::: :::{link-card} -title: Elastic Cloud Serverless -link: /deploy-manage/deploy/elastic-cloud-serverless -description: Pay-per-use Elasticsearch with no clusters to manage and elastic auto-scaling. +title: Maintain and monitor +link: https://www.elastic.co/docs/deploy-manage/maintenance/start-stop-services/start-stop-elasticsearch +description: Production guidance, upgrades, cluster health, and diagnostics. links: - - label: Get started - url: /deploy-manage/deploy/elastic-cloud-serverless/get-started + - label: Start and stop Elasticsearch + url: https://www.elastic.co/docs/deploy-manage/maintenance/start-stop-services/start-stop-elasticsearch + - label: Stack monitoring + url: https://www.elastic.co/docs/deploy-manage/monitor/stack-monitoring/elasticsearch-monitoring-self-managed + - label: Capture diagnostics + url: https://www.elastic.co/docs/troubleshoot/elasticsearch/diagnostic + - label: Troubleshoot + url: https://www.elastic.co/docs/troubleshoot/elasticsearch + - label: Snapshot and restore + url: https://www.elastic.co/docs/deploy-manage/tools/snapshot-and-restore ::: :::: ::::{card-group} -:title: Search and query -:id: search +:title: Index and ingest data +:id: ingest :::{link-card} -title: ES|QL -link: /reference/esql -description: Pipe-friendly query language for searching, filtering, and aggregating data. +title: Ingest pipelines +link: https://www.elastic.co/docs/manage-data/ingest/transform-enrich/ingest-pipelines +description: Pre-process documents before indexing with processors that parse, transform, and enrich your data. +links: + - label: Overview and setup + url: https://www.elastic.co/docs/manage-data/ingest/transform-enrich/ingest-pipelines + - label: Simulate ingestion (API) + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-simulate-ingest + - label: All ingest methods + url: https://www.elastic.co/docs/manage-data/ingest +::: + +:::{link-card} +title: Data streams +link: https://www.elastic.co/docs/manage-data/data-store/data-streams +description: Manage time-series data with a single resource that spans multiple backing indices. +links: + - label: Data streams overview + url: https://www.elastic.co/docs/manage-data/data-store/data-streams + - label: Manage data streams + url: https://www.elastic.co/docs/manage-data/data-store/data-streams/manage-data-stream + - label: Logs data stream (logsdb) + url: https://www.elastic.co/docs/manage-data/data-store/data-streams/logs-data-stream +::: + +:::{link-card} +title: Document APIs +link: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-index +description: Index, update, delete, and retrieve documents with the core document APIs. links: - - label: Get started - url: /reference/esql/get-started - - label: Functions reference - url: /reference/esql/functions + - label: Index a document + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-index + - label: Bulk API + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk + - label: Reindex API + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-reindex + - label: Reindex examples + url: https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reindex-indices + - label: Migrate data between clusters + url: https://www.elastic.co/docs/manage-data/migrate/migrate-data-between-elasticsearch-clusters-with-minimal-downtime ::: +:::: + +::::{card-group} +:title: Search and query +:id: search :::{link-card} title: Query DSL -link: /reference/query-dsl -description: JSON-based query language for full-text, structured, geo, and vector search. +link: https://www.elastic.co/docs/reference/query-languages/query-dsl/full-text-queries +description: Write expressive queries using the JSON-based Domain Specific Language — from simple term filters to compound boolean queries. links: - - label: Match query - url: /reference/query-dsl/query-dsl-match-query - - label: Bool query - url: /reference/query-dsl/query-dsl-bool-query + - label: Full-text search + url: https://www.elastic.co/docs/solutions/search/full-text + - label: Full-text queries + url: https://www.elastic.co/docs/reference/query-languages/query-dsl/full-text-queries + - label: Search API + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-search + - label: Cross-cluster search + url: https://www.elastic.co/docs/explore-analyze/cross-cluster-search aside: label: Query types links: - - label: Term - url: /reference/query-dsl/term-level-queries - - label: Full text - url: /reference/query-dsl/full-text-queries + - label: Full-text + url: https://www.elastic.co/docs/reference/query-languages/query-dsl/full-text-queries + - label: Term-level + url: https://www.elastic.co/docs/reference/query-languages/query-dsl - label: Geo - url: /reference/query-dsl/geo-queries - - label: Vector - url: /reference/query-dsl/vector-queries + url: https://www.elastic.co/docs/reference/query-languages/query-dsl + - label: Shape + url: https://www.elastic.co/docs/reference/query-languages/query-dsl + - label: Joining + url: https://www.elastic.co/docs/reference/query-languages/query-dsl + - label: Span + url: https://www.elastic.co/docs/reference/query-languages/query-dsl +::: + +:::{link-card} +title: ES|QL +link: https://www.elastic.co/docs/reference/query-languages/esql/esql-getting-started +description: A pipe-based query language purpose-built for filtering, transforming, and analyzing data — usable from the API, Kibana Discover, or directly in search. +links: + - label: Get started with ES|QL + url: https://www.elastic.co/docs/reference/query-languages/esql/esql-getting-started + - label: Syntax reference + url: https://www.elastic.co/docs/reference/query-languages/esql/esql-syntax-reference + - label: Commands + url: https://www.elastic.co/docs/reference/query-languages/esql/esql-commands + - label: ES|QL for search + url: https://www.elastic.co/docs/solutions/search/esql-for-search + - label: Cross-cluster ES|QL + url: https://www.elastic.co/docs/reference/query-languages/esql/esql-cross-clusters +::: + +:::{link-card} +title: Other query languages +link: https://www.elastic.co/docs/reference/query-languages +description: Use EQL for event sequences, SQL for familiar syntax, or Painless for custom scoring and scripted queries. +links: + - label: EQL (Event Query Language) + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-eql-search + - label: SQL + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-sql-query + - label: Translate SQL to Query DSL + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-sql-translate + - label: Painless scripting + url: https://www.elastic.co/docs/explore-analyze/scripting/modules-scripting-painless +::: +:::: + +::::{card-group} +:title: Aggregations +:id: aggs + +:::{link-card} +title: Aggregations overview +link: https://www.elastic.co/docs/explore-analyze/query-filter/aggregations +description: Summarize and compute statistics over your data — counts, averages, histograms, date ranges, and more. +links: + - label: Introduction + url: https://www.elastic.co/docs/explore-analyze/query-filter/aggregations + - label: 'Tutorial: eCommerce data analysis' + url: https://www.elastic.co/docs/explore-analyze/query-filter/aggregations/tutorial-analyze-ecommerce-data-with-aggregations-using-query-dsl +aside: + label: Aggregation families + links: + - label: Metric + url: https://www.elastic.co/docs/reference/aggregations + - label: Bucket + url: https://www.elastic.co/docs/reference/aggregations + - label: Pipeline + url: https://www.elastic.co/docs/reference/aggregations + - label: Matrix + url: https://www.elastic.co/docs/reference/aggregations ::: :::{link-card} -title: Vector search -link: /solutions/search/vector -description: Dense and sparse vector retrieval for AI applications and semantic search. +title: Metric aggregations +link: https://www.elastic.co/docs/reference/aggregations +description: Compute single numeric values from field data — min, max, avg, sum, percentiles, stats, and more. +links: + - label: Top hits + url: https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-top-hits-aggregation + - label: Top metrics + url: https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-top-metrics + - label: Cardinality, percentiles, stats + url: https://www.elastic.co/docs/reference/aggregations +::: + +:::{link-card} +title: Bucket and pipeline aggregations +link: https://www.elastic.co/docs/reference/aggregations +description: Group documents into buckets, then chain pipeline aggregations to compute on those buckets. +links: + - label: Filters aggregation + url: https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-filters-aggregation + - label: Multi-terms aggregation + url: https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-multi-terms-aggregation + - label: Avg bucket (pipeline) + url: https://www.elastic.co/docs/reference/aggregations/search-aggregations-pipeline-avg-bucket-aggregation + - label: Bucket script (pipeline) + url: https://www.elastic.co/docs/reference/aggregations/search-aggregations-pipeline-bucket-script-aggregation +::: +:::: + +::::{card-group} +:title: Data modeling +:id: modeling + +:::{link-card} +title: Mapping +link: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference +description: Define how documents and their fields are stored, indexed, and analyzed. +links: + - label: Field data types + url: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/field-data-types + - label: Dynamic field mapping + url: https://www.elastic.co/docs/manage-data/data-store/mapping/dynamic-field-mapping + - label: Runtime fields + url: https://www.elastic.co/docs/manage-data/data-store/mapping/define-runtime-fields-in-search-request + - label: Update mappings (examples) + url: https://www.elastic.co/docs/manage-data/data-store/mapping/update-mappings-examples + - label: Put mapping API + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-put-mapping +aside: + label: Common field types + links: + - label: keyword + url: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/field-data-types + - label: text + url: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/field-data-types + - label: date + url: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/field-data-types + - label: dense_vector + url: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/field-data-types + - label: semantic_text + url: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/field-data-types + - label: geo_point + url: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/field-data-types +::: + +:::{link-card} +title: Text analysis +link: https://www.elastic.co/docs/manage-data/data-store/text-analysis/configure-text-analysis +description: Control how text is tokenized and indexed to optimize relevance and search recall. +links: + - label: Configure text analysis + url: https://www.elastic.co/docs/manage-data/data-store/text-analysis/configure-text-analysis + - label: Create a custom analyzer + url: https://www.elastic.co/docs/manage-data/data-store/text-analysis/create-custom-analyzer + - label: Specify an analyzer + url: https://www.elastic.co/docs/manage-data/data-store/text-analysis/specify-an-analyzer + - label: Analyze API + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-analyze + - label: Analyzers, tokenizers, and filters reference + url: https://www.elastic.co/docs/reference/text-analysis +::: + +:::{link-card} +title: Configuration reference +link: https://www.elastic.co/docs/reference/elasticsearch/configuration-reference +description: All Elasticsearch node settings — network, thread pools, index defaults, security, and more. +links: + - label: Node settings + url: https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/node-settings + - label: Security settings + url: https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/security-settings + - label: All settings reference + url: https://www.elastic.co/docs/reference/elasticsearch/configuration-reference +::: +:::: + +::::{card-group} +:title: Manage data +:id: manage + +:::{link-card} +title: Index lifecycle management (ILM) +link: https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management +description: Automate index policies to move data through hot, warm, cold, and frozen tiers — and eventually delete it. +links: + - label: Create an ILM policy + url: https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management/configure-lifecycle-policy + - label: Tutorials + url: https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management/ilm-tutorials + - label: Check ILM status + url: https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management/policy-view-status + - label: Start and stop ILM + url: https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management/start-stop-index-lifecycle-management + - label: Explain lifecycle (API) + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-ilm-explain-lifecycle +::: + +:::{link-card} +title: Data stream lifecycle +link: https://www.elastic.co/docs/manage-data/lifecycle/data-stream +description: Set built-in retention and rollover directly on data streams, without a separate ILM policy. +links: + - label: Data stream lifecycle overview + url: https://www.elastic.co/docs/manage-data/lifecycle/data-stream + - label: Update an existing data stream + url: https://www.elastic.co/docs/manage-data/lifecycle/data-stream/tutorial-update-existing-data-stream + - label: Manage data tiers + url: https://www.elastic.co/docs/manage-data/lifecycle/data-tiers/manage-data-tiers-self-managed-eck +::: + +:::{link-card} +title: Snapshot and restore +link: https://www.elastic.co/docs/deploy-manage/tools/snapshot-and-restore +description: Back up indices and data streams to a remote repository and restore them when needed. +links: + - label: Overview + url: https://www.elastic.co/docs/deploy-manage/tools/snapshot-and-restore + - label: Restore a snapshot + url: https://www.elastic.co/docs/deploy-manage/tools/snapshot-and-restore/restore-snapshot + - label: Create a snapshot repository (API) + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-snapshot-create-repository + - label: Create a snapshot (API) + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-snapshot-create + - label: Troubleshoot snapshots + url: https://www.elastic.co/docs/troubleshoot/elasticsearch/snapshot-and-restore +::: +:::: + +::::{card-group} +:title: AI and vector search +:id: ai + +:::{link-card} +title: Semantic search +link: https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text +description: Search by meaning rather than exact keywords using dense or sparse vector embeddings. +links: + - label: Semantic search with semantic_text + url: https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text + - label: Semantic search with ELSER + url: https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-elser-ingest-pipelines + - label: Get started with semantic search + url: https://www.elastic.co/docs/solutions/search/get-started/semantic-search + - label: Dense vs. sparse ingest pipelines + url: https://www.elastic.co/docs/solutions/search/vector/dense-versus-sparse-ingest-pipelines +::: + +:::{link-card} +title: Vector search (kNN) +link: https://www.elastic.co/docs/solutions/search/vector/knn +description: Find the nearest neighbors to a query vector for similarity-based retrieval and hybrid search. links: - label: kNN search - url: /reference/search-types/knn-search - - label: ELSER - url: /reference/elasticsearch-clients/elser + url: https://www.elastic.co/docs/solutions/search/vector/knn + - label: Sparse vector search + url: https://www.elastic.co/docs/solutions/search/vector/sparse-vector + - label: Bring your own vectors + url: https://www.elastic.co/docs/solutions/search/vector/bring-own-vectors + - label: kNN retriever + url: https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/knn-retriever + - label: kNN in ES|QL + url: https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/dense-vector-functions/knn +::: + +:::{link-card} +title: Inference API +link: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put +description: Connect Elasticsearch to external AI model providers for embeddings, reranking, and completions. +links: + - label: Create an inference endpoint + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put + - label: ELSER inference endpoint + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-elser +aside: + label: Supported providers + links: + - label: OpenAI + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put + - label: Cohere + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put + - label: Anthropic + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put + - label: Azure + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put + - label: Google + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put + - label: Hugging Face + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put + - label: Mistral + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put +::: +:::: + +::::{card-group} +:title: Security +:id: security + +:::{link-card} +title: Encryption and TLS +link: https://www.elastic.co/docs/deploy-manage/security/secure-cluster-communications +description: Encrypt communications between nodes and between clients and the cluster. +links: + - label: Set up basic security (TLS) + url: https://www.elastic.co/docs/deploy-manage/security/set-up-basic-security + - label: Secure cluster communications + url: https://www.elastic.co/docs/deploy-manage/security/secure-cluster-communications + - label: Secure HTTP clients + url: https://www.elastic.co/docs/deploy-manage/security/httprest-clients-security + - label: Security settings reference + url: https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/security-settings +::: + +:::{link-card} +title: Authentication +link: https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/ldap +description: Verify user identity with native realms, LDAP, Active Directory, SAML, OIDC, PKI, and more. +links: + - label: LDAP authentication + url: https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/ldap + - label: SAML authentication (API) + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-security-saml-authenticate + - label: All authentication methods + url: https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth +::: + +:::{link-card} +title: Authorization and RBAC +link: https://www.elastic.co/docs/deploy-manage/users-roles +description: Control what users and applications can access using roles, privileges, and field- and document-level security. +links: + - label: Users and roles overview + url: https://www.elastic.co/docs/deploy-manage/users-roles + - label: Create or update a role (API) + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-security-put-role + - label: Create or update a user (API) + url: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-security-put-user +::: +:::: + +::::{card-group} +:title: Clients and integrations +:id: clients + +:::{link-card} +title: Language clients +link: https://www.elastic.co/docs/reference/elasticsearch/clients +description: Official Elasticsearch clients for your preferred programming language. +links: + - label: Python + url: https://www.elastic.co/docs/reference/elasticsearch/clients/python + - label: Java + url: https://www.elastic.co/docs/reference/elasticsearch/clients/java/getting-started + - label: Go + url: https://www.elastic.co/docs/reference/elasticsearch/clients/go/using-the-api/dense-vectors + - label: Rust + url: https://www.elastic.co/docs/reference/elasticsearch/clients/rust + - label: PHP + url: https://www.elastic.co/docs/reference/elasticsearch/clients/php + - label: Ruby + url: https://www.elastic.co/docs/reference/elasticsearch/clients/ruby/api + - label: Community clients + url: https://www.elastic.co/docs/reference/elasticsearch/clients/community +::: + +:::{link-card} +title: Python DSL and helpers +link: https://www.elastic.co/docs/reference/elasticsearch/clients/python/elasticsearch-dsl +description: High-level Python libraries that simplify building queries, mappings, and bulk operations. +links: + - label: Elasticsearch DSL (Python) + url: https://www.elastic.co/docs/reference/elasticsearch/clients/python/elasticsearch-dsl + - label: Java bulk indexing + url: https://www.elastic.co/docs/reference/elasticsearch/clients/java/usage/indexing-bulk + - label: Troubleshoot clients + url: https://www.elastic.co/docs/troubleshoot/elasticsearch/clients +::: + +:::{link-card} +title: Ingest tools +link: https://www.elastic.co/docs/manage-data/ingest +description: Get data into Elasticsearch using Fleet, Logstash, Beats, or search connectors. +links: + - label: Fleet and Elastic Agent + url: https://www.elastic.co/docs/reference/fleet + - label: Content connectors + url: https://www.elastic.co/docs/reference/search-connectors + - label: All ingest options + url: https://www.elastic.co/docs/manage-data/ingest +::: +:::: + +::::{card-group} +:title: Reference +:id: ref + +:::{link-card} +title: REST APIs +link: https://www.elastic.co/docs/reference/elasticsearch/rest-apis +description: Conventions, common options, compatibility guarantees, and the full interactive API specification. +links: + - label: API conventions + url: https://www.elastic.co/docs/reference/elasticsearch/rest-apis/api-conventions + - label: Common options + url: https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options + - label: API compatibility + url: https://www.elastic.co/docs/reference/elasticsearch/rest-apis/compatibility + - label: Examples and quick start + url: https://www.elastic.co/docs/reference/elasticsearch/rest-apis/api-examples + - label: Full API reference + url: https://www.elastic.co/docs/api/doc/elasticsearch +::: + +:::{link-card} +title: Painless scripting +link: https://www.elastic.co/docs/explore-analyze/scripting/modules-scripting-painless +description: Write custom scripts in Painless for queries, aggregations, ingest processors, and runtime fields. +links: + - label: Introduction to Painless + url: https://www.elastic.co/docs/explore-analyze/scripting/modules-scripting-painless + - label: Language specification + url: https://www.elastic.co/docs/reference/scripting-languages/painless/painless-language-specification + - label: Use Painless in runtime fields + url: https://www.elastic.co/docs/reference/scripting-languages/painless/use-painless-scripts-in-runtime-fields + - label: Scripts reference + url: https://www.elastic.co/docs/reference/scripting-languages/painless/painless-scripts +::: + +:::{link-card} +title: Release notes +link: https://www.elastic.co/docs/release-notes/elasticsearch +description: What's new, deprecated, and fixed in each Elasticsearch release. +links: + - label: Elasticsearch + url: https://www.elastic.co/docs/release-notes/elasticsearch + - label: Breaking changes + url: https://www.elastic.co/docs/release-notes/elasticsearch/breaking-changes + - label: Deprecations + url: https://www.elastic.co/docs/release-notes/elasticsearch/deprecations +aside: + label: Client release notes + links: + - label: Python + url: https://www.elastic.co/docs/release-notes/elasticsearch/clients/python + - label: Java + url: https://www.elastic.co/docs/release-notes/elasticsearch/clients/java + - label: .NET + url: https://www.elastic.co/docs/release-notes/elasticsearch/clients/dotnet + - label: PHP + url: https://www.elastic.co/docs/release-notes/elasticsearch/clients/php + - label: Ruby + url: https://www.elastic.co/docs/release-notes/elasticsearch/clients/ruby +::: + +:::{link-card} +title: Troubleshooting +link: https://www.elastic.co/docs/troubleshoot/elasticsearch +description: Diagnose and fix common Elasticsearch cluster, performance, and operational issues. +links: + - label: Cluster issues + url: https://www.elastic.co/docs/troubleshoot/elasticsearch/clusters + - label: Snapshot and restore + url: https://www.elastic.co/docs/troubleshoot/elasticsearch/snapshot-and-restore + - label: Clients + url: https://www.elastic.co/docs/troubleshoot/elasticsearch/clients + - label: Capture diagnostics + url: https://www.elastic.co/docs/troubleshoot/elasticsearch/diagnostic ::: :::: diff --git a/docs/hubs/kibana/9.0.md b/docs/hubs/kibana/9.0.md index 11ee62040c..4c703b0eb8 100644 --- a/docs/hubs/kibana/9.0.md +++ b/docs/hubs/kibana/9.0.md @@ -192,6 +192,98 @@ aside: ::: :::: +::::{card-group} +:title: Track and respond +:id: track + +:::{link-card} +title: Alerting and rules +link: https://www.elastic.co/docs/explore-analyze/alerting/alerts/alerting-getting-started +description: Detect important changes and notify people or trigger actions. +links: + - label: Getting started + url: https://www.elastic.co/docs/explore-analyze/alerting/alerts/alerting-getting-started + - label: Create and manage rules + url: https://www.elastic.co/docs/explore-analyze/alerting/alerts/create-manage-rules + - label: Performance and scaling + url: https://www.elastic.co/docs/deploy-manage/production-guidance/kibana-alerting-production-considerations +::: + +:::{link-card} +title: Connectors +link: https://www.elastic.co/docs/reference/kibana/connectors-kibana +description: Integrate Kibana with external services for AI, notifications, and case management. +links: + - label: GenAI (OpenAI, Bedrock, Gemini...) + url: https://www.elastic.co/docs/reference/kibana/connectors-kibana/gen-ai-connectors + - label: Alerting & cases (Jira, ServiceNow, PagerDuty...) + url: https://www.elastic.co/docs/reference/kibana/connectors-kibana/alerting-cases-connectors + - label: Elastic Stack + url: https://www.elastic.co/docs/reference/kibana/connectors-kibana + - label: Data & context sources + url: https://www.elastic.co/docs/reference/kibana/connectors-kibana +::: + +:::{link-card} +title: Reporting and sharing +link: https://www.elastic.co/docs/deploy-manage/kibana-reporting-configuration +description: Generate PDF, PNG, and CSV exports. Share dashboards with links or embeds. +links: + - label: Configure reporting + url: https://www.elastic.co/docs/deploy-manage/kibana-reporting-configuration + - label: Share dashboards + url: https://www.elastic.co/docs/explore-analyze/dashboards/sharing + - label: Saved objects + url: https://www.elastic.co/docs/explore-analyze/find-and-organize/saved-objects +::: +:::: + +::::{card-group} +:title: AI and automation +:id: ai + +:::{link-card} +title: Agent Builder +link: https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder +description: Build custom AI agents that reason over your Elasticsearch data using LLMs and tools. +links: + - label: Overview + url: https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder + - label: Agents + url: https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agent-builder-agents + - label: Custom tools + url: https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools/custom-tools + - label: Programmatic access (MCP, A2A, API) + url: https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/programmatic-access + - label: 'Tutorial: build a custom agent' + url: https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agent-builder-api-tutorial +::: + +:::{link-card} +title: Workflows +link: https://www.elastic.co/docs/explore-analyze/workflows +description: Automate tasks with sequences that chain Kibana actions, HTTP calls, and AI agents. +links: + - label: Get started + url: https://www.elastic.co/docs/explore-analyze/workflows/get-started + - label: Kibana action steps + url: https://www.elastic.co/docs/explore-analyze/workflows/steps/kibana + - label: Call agents from workflows + url: https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agents-and-workflows +::: + +:::{link-card} +title: Machine learning +link: https://www.elastic.co/docs/explore-analyze/machine-learning/machine-learning-in-kibana +description: Detect anomalies, forecast trends, and run data frame analytics. +links: + - label: Machine learning in Kibana + url: https://www.elastic.co/docs/explore-analyze/machine-learning/machine-learning-in-kibana + - label: Anomaly detection job wizards + url: https://www.elastic.co/docs/reference/machine-learning/supplied-anomaly-detection-configurations +::: +:::: + ::::{card-group} :title: Solutions :id: solutions @@ -278,3 +370,128 @@ aside: url: /solutions/security/investigate/security-cases ::: :::: + +::::{card-group} +:title: Management and developer tools +:id: manage + +:::{link-card} +title: Data and indices +description: Manage Elasticsearch indices, data streams, ingest pipelines, and transforms from Kibana. +links: + - label: Index management + url: https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management/index-management-in-kibana + - label: Data streams + url: https://www.elastic.co/docs/manage-data/data-store/data-streams/manage-data-stream + - label: Transforms + url: https://www.elastic.co/docs/explore-analyze/transforms/transform-setup + - label: Task management + url: https://www.elastic.co/docs/deploy-manage/distributed-architecture/kibana-tasks-management +::: + +:::{link-card} +title: Integrations and Fleet +description: Deploy and manage Elastic Agents, integrations, and Osquery. +links: + - label: Fleet + url: https://www.elastic.co/docs/reference/fleet + - label: Manage agents + url: https://www.elastic.co/docs/reference/fleet/manage-elastic-agents-in-fleet + - label: Agent policies + url: https://www.elastic.co/docs/reference/fleet/agent-policy +::: + +:::{link-card} +title: Developer tools +description: Run API requests, profile queries, test grok patterns, and debug Painless scripts. +links: + - label: Console + url: https://www.elastic.co/docs/reference/cloud/cloud-hosted/ec-api-console + - label: Search Profiler + url: https://www.elastic.co/docs/explore-analyze/query-filter/tools/search-profiler + - label: Grok Debugger + url: https://www.elastic.co/docs/explore-analyze/query-filter/tools/grok-debugger +::: +:::: + +::::{card-group} +:title: Reference +:id: ref + +:::{link-card} +title: Configuration and settings +link: https://www.elastic.co/docs/reference/kibana/configuration-reference +description: All kibana.yml settings and UI-configurable advanced settings. +links: + - label: General + url: https://www.elastic.co/docs/reference/kibana/configuration-reference/general-settings + - label: Alerting + url: https://www.elastic.co/docs/reference/kibana/configuration-reference/alerting-settings + - label: Security + url: https://www.elastic.co/docs/reference/kibana/configuration-reference/security-settings + - label: Reporting + url: https://www.elastic.co/docs/reference/kibana/configuration-reference/reporting-settings + - label: Monitoring + url: https://www.elastic.co/docs/reference/kibana/configuration-reference/monitoring-settings + - label: Advanced settings (UI) + url: https://www.elastic.co/docs/reference/kibana/advanced-settings +::: + +:::{link-card} +title: APIs and plugins +link: https://www.elastic.co/docs/api/doc/kibana +description: REST API, plugins, and CLI commands. +links: + - label: Kibana REST API + url: https://www.elastic.co/docs/api/doc/kibana + - label: Kibana serverless API + url: https://www.elastic.co/docs/api/doc/serverless-kibana + - label: Plugins + url: https://www.elastic.co/docs/reference/kibana/kibana-plugins + - label: kibana-setup command + url: https://www.elastic.co/docs/reference/kibana/commands/kibana-setup +::: + +:::{link-card} +title: Release notes +link: https://www.elastic.co/docs/release-notes/kibana +description: What's new, deprecated, and fixed in each Kibana release. +links: + - label: Kibana + url: https://www.elastic.co/docs/release-notes/kibana + - label: Known issues + url: https://www.elastic.co/docs/release-notes/kibana/known-issues + - label: Breaking changes + url: https://www.elastic.co/docs/release-notes/kibana/breaking-changes + - label: Deprecations + url: https://www.elastic.co/docs/release-notes/kibana/deprecations +aside: + label: Related release notes + links: + - label: Elasticsearch + url: https://www.elastic.co/docs/release-notes/elasticsearch + - label: Security + url: https://www.elastic.co/docs/release-notes/security + - label: Observability + url: https://www.elastic.co/docs/release-notes/observability + - label: Serverless + url: https://www.elastic.co/docs/release-notes/serverless +::: + +:::{link-card} +title: Troubleshooting +link: https://www.elastic.co/docs/troubleshoot/kibana +description: Diagnose and fix common Kibana issues. +links: + - label: General + url: https://www.elastic.co/docs/troubleshoot/kibana + - label: Alerts + url: https://www.elastic.co/docs/troubleshoot/kibana/alerts + - label: Maps + url: https://www.elastic.co/docs/troubleshoot/kibana/maps + - label: Reporting + url: https://www.elastic.co/docs/troubleshoot/kibana/reporting + - label: Capture diagnostics + url: https://www.elastic.co/docs/troubleshoot/kibana/capturing-diagnostics +::: +:::: From 7cc21db461fb9cdc7c7a2d5e0fcdbfafeb1902df Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 21:27:51 +0200 Subject: [PATCH 11/56] feat(hubs): render fake search box in hero Replaces the inert navigation-search web component placeholder with a styled fake search box that matches the gist prototypes (white pill with magnifying-glass icon, contextual placeholder, disabled input). The input is disabled until search is wired up, but visually it's now indistinguishable from the gist. Placeholder text uses the hero :icon: key (e.g. 'Search Kibana docs...'). Co-Authored-By: Claude Sonnet 4.6 --- .../Assets/markdown/hub.css | 27 +++++++++++++++++++ .../Myst/Directives/Hub/HeroView.cshtml | 10 ++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index bf2f300672..b559711f75 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -82,6 +82,33 @@ margin-bottom: 20px; max-width: 480px; } + .hub-hero .hub-hero-search-box { + display: flex; + align-items: center; + gap: 10px; + background: var(--color-white); + border-radius: 10px; + padding: 10px 16px; + box-shadow: 0 2px 12px rgb(0 0 0 / 0.15); + color: var(--color-grey-80); + cursor: text; + } + .hub-hero .hub-hero-search-icon { + flex-shrink: 0; + } + .hub-hero .hub-hero-search-box input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-size: 15px; + font-family: inherit; + color: var(--color-ink-dark); + min-width: 0; + } + .hub-hero .hub-hero-search-box input::placeholder { + color: var(--color-grey-40); + } .hub-hero .hub-hero-meta { @apply mt-2 flex flex-wrap items-center gap-3; diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml index d124143244..8206116af4 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml @@ -20,8 +20,16 @@ @if (Model.ShowSearch) { + var placeholder = string.IsNullOrWhiteSpace(Model.IconKey) + ? "Search docs..." + : $"Search {char.ToUpperInvariant(Model.IconKey![0])}{Model.IconKey.AsSpan(1).ToString()} docs..."; } From 91795999e9ddaf218113d3b540b09bb7c117c9dc Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 21:31:27 +0200 Subject: [PATCH 12/56] fix(hubs): align hero icon with H1, push description to its own row Switches .hub-hero-top from flex to a 2-col grid and uses display:contents on .hub-hero-title so its children become direct grid items. The icon vertically centers with the H1 (row 1), and the description spans both columns in row 2 -- matching the gist layout where the description starts at the left edge below the icon row, not hanging-indented next to the H1. Co-Authored-By: Claude Sonnet 4.6 --- .../Assets/markdown/hub.css | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index b559711f75..82e6726a12 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -41,7 +41,24 @@ @apply mx-auto w-full max-w-5xl; } .hub-hero .hub-hero-top { - @apply mb-3 flex items-center gap-4; + display: grid; + grid-template-columns: auto 1fr; + column-gap: 16px; + align-items: center; + margin-bottom: 12px; + } + .hub-hero .hub-hero-title { + display: contents; + } + .hub-hero .hub-hero-title .heading-wrapper { + grid-column: 2; + grid-row: 1; + margin: 0; + } + .hub-hero .hub-hero-title p { + grid-column: 1 / -1; + grid-row: 2; + margin-top: 16px; } .hub-hero .hub-hero-icon { @apply inline-flex h-13 w-13 shrink-0 items-center justify-center rounded-xl text-2xl font-bold; @@ -74,7 +91,6 @@ color: rgb(255 255 255 / 0.7); max-width: 720px; line-height: 1.6; - margin-top: 12px; } .hub-hero .hub-hero-search { From 7940d99c608b2ae0e4acfb22a81afb4643c0ed02 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 21:36:02 +0200 Subject: [PATCH 13/56] feat(hubs): turn hero version pill into a real dropdown Adds a :versions: option to {hero} taking comma-separated 'Label[=URL]' entries. When non-empty, the version chip renders as a
    dropdown -- click to reveal a menu listing the current version (with a checkmark) plus each entry. Entries with a URL are clickable links; entries without a URL render as greyed-out 'soon' rows. ES 9.4 / Kibana 9.4 hubs are wired with v8 and v7 as disabled entries so reviewers can see the dropdown shape without yet-built hub pages. Also bumps Elasticsearch examples to 9.4 and adds a Serverless release-notes link in the What's new header (matching the Kibana hub). Co-Authored-By: Claude Sonnet 4.6 --- docs/hubs/elasticsearch/9.0.md | 17 ++- docs/hubs/kibana/9.0.md | 1 + .../Assets/markdown/hub.css | 131 ++++++++++++++++++ .../Myst/Directives/DirectiveHtmlRenderer.cs | 1 + .../Myst/Directives/Hub/HeroBlock.cs | 41 ++++-- .../Myst/Directives/Hub/HeroView.cshtml | 41 +++++- .../Myst/Directives/Hub/HeroViewModel.cs | 1 + 7 files changed, 212 insertions(+), 21 deletions(-) diff --git a/docs/hubs/elasticsearch/9.0.md b/docs/hubs/elasticsearch/9.0.md index 6ef52436c4..4c03a55cdf 100644 --- a/docs/hubs/elasticsearch/9.0.md +++ b/docs/hubs/elasticsearch/9.0.md @@ -4,9 +4,10 @@ layout: hub :::{hero} :icon: elasticsearch -:version: v9 (current) +:version: v9 / Serverless (current) +:versions: v8,v7 :quick-links: Install=https://www.elastic.co/docs/deploy-manage/deploy/self-managed/installing-elasticsearch,Quick start=https://www.elastic.co/docs/reference/elasticsearch/rest-apis/api-examples,API reference=https://www.elastic.co/docs/api/doc/elasticsearch,Release notes=https://www.elastic.co/docs/release-notes/elasticsearch -:releases: Latest: [Stack 9.0.0](https://www.elastic.co/docs/release-notes/elasticsearch) (Apr 2025) +:releases: Latest: [Stack 9.4.1](https://www.elastic.co/docs/release-notes/elasticsearch) (Mar 28, 2026) · [Serverless deployed](https://www.elastic.co/docs/release-notes/serverless) Apr 1, 2026 # Elasticsearch @@ -24,22 +25,24 @@ The distributed search and analytics engine. Store, search, and analyze data at title: What's new in Elasticsearch id: whats-new release-links: - - label: 9.0 + - label: '9.4' url: https://www.elastic.co/docs/release-notes/elasticsearch + - label: Serverless + url: https://www.elastic.co/docs/release-notes/serverless items: - title: semantic_text field type description: Simplify semantic search — no pipeline setup needed, just define the field and start querying link: https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text - meta: 9.0 GA + meta: 9.4 GA - title: ES|QL improvements description: New functions, cross-cluster support, and dense vector operations directly in ES|QL queries link: https://www.elastic.co/docs/reference/query-languages/esql/esql-getting-started - meta: '9.0' + meta: '9.4' - title: Inference API expansion description: Connect to external AI models (OpenAI, Cohere, Anthropic, Hugging Face, Azure, Google) for embeddings and reranking link: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put - meta: '9.0' - - title: Breaking changes in 9.0 + meta: '9.4' + - title: Breaking changes in 9.x description: Review removed settings, deprecated APIs, and behavioral changes before upgrading from 8.x link: https://www.elastic.co/docs/release-notes/elasticsearch/breaking-changes meta: Upgrade guide diff --git a/docs/hubs/kibana/9.0.md b/docs/hubs/kibana/9.0.md index 4c703b0eb8..9c9f84ff18 100644 --- a/docs/hubs/kibana/9.0.md +++ b/docs/hubs/kibana/9.0.md @@ -5,6 +5,7 @@ layout: hub :::{hero} :icon: kibana :version: v9 / Serverless (current) +:versions: v8,v7 :quick-links: Install=/install,Tutorial=/tutorial,API reference=/api,Release notes=/release-notes :releases: Latest: [Stack 9.4.1](/release-notes/kibana) (Mar 28, 2026) · [Serverless deployed](/release-notes/serverless) Apr 1, 2026 diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 82e6726a12..607b3117df 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -149,6 +149,137 @@ background: #9adc30; } + /* Version dropdown ------------------------------------------------- */ + .hub-hero details.hub-hero-version-dropdown { + position: relative; + display: inline-block; + padding: 0; + border: 0; + background: transparent; + } + .hub-hero details.hub-hero-version-dropdown summary { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgb(255 255 255 / 0.08); + border: 1px solid rgb(255 255 255 / 0.15); + border-radius: 20px; + padding: 5px 14px; + font-size: 13px; + color: rgb(255 255 255 / 0.85); + cursor: pointer; + list-style: none; + user-select: none; + transition: background 0.15s; + } + .hub-hero + details.hub-hero-version-dropdown + summary::-webkit-details-marker { + display: none; + } + .hub-hero details.hub-hero-version-dropdown summary:hover { + background: rgb(255 255 255 / 0.14); + } + .hub-hero .hub-hero-version-caret { + font-size: 10px; + opacity: 0.6; + transition: transform 0.15s; + } + .hub-hero details[open] .hub-hero-version-caret { + transform: rotate(180deg); + } + + .hub-hero .hub-hero-version-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 10px; + padding: 4px; + margin: 0; + list-style: none; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.15); + font-size: 13px; + color: var(--color-ink-dark); + z-index: 20; + } + .hub-hero .hub-hero-version-menu li { + margin: 0; + padding: 0; + border-radius: 6px; + } + .hub-hero .hub-hero-version-menu li a, + .hub-hero + .hub-hero-version-menu + li + > span:not(.hub-hero-version-tick):not(.hub-hero-version-soon) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + text-decoration: none; + color: inherit; + white-space: nowrap; + } + .hub-hero + .hub-hero-version-menu + li + > span:not(.hub-hero-version-tick):not(.hub-hero-version-soon) { + display: flex; + } + .hub-hero .hub-hero-version-menu li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + white-space: nowrap; + } + .hub-hero .hub-hero-version-menu li a, + .hub-hero .hub-hero-version-menu li > span { + padding: 0; + } + .hub-hero + .hub-hero-version-menu + li:hover:not(.hub-hero-version-current):not( + .hub-hero-version-disabled + ) { + background: var(--color-grey-10); + } + .hub-hero .hub-hero-version-menu li a { + color: inherit; + text-decoration: none; + flex: 1; + } + .hub-hero .hub-hero-version-menu li a:hover { + color: var(--color-blue-elastic-100); + text-decoration: none; + } + .hub-hero .hub-hero-version-current { + font-weight: 600; + color: var(--color-ink-dark); + } + .hub-hero .hub-hero-version-tick { + color: var(--color-blue-elastic-100); + font-weight: 700; + } + .hub-hero .hub-hero-version-disabled { + color: var(--color-grey-40); + cursor: not-allowed; + } + .hub-hero .hub-hero-version-soon { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-grey-40); + background: var(--color-grey-10); + padding: 2px 6px; + border-radius: 4px; + } + .hub-hero .hub-hero-pills { @apply m-0 flex list-none flex-wrap gap-2 p-0; } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 92db50c08d..3e234c9072 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -365,6 +365,7 @@ private static void WriteHero(HtmlRenderer renderer, HeroBlock block) Version = block.Version, ShowSearch = block.ShowSearch, QuickLinks = block.QuickLinks, + OtherVersions = block.OtherVersions, ReleasesHtml = releasesHtml }); RenderRazorSlice(slice, renderer); diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs index 2797255c13..529f8d8f4a 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs @@ -33,6 +33,7 @@ public class HeroBlock(DirectiveBlockParser parser, ParserContext context) public string? Version { get; private set; } public bool ShowSearch { get; private set; } public IReadOnlyList QuickLinks { get; private set; } = []; + public IReadOnlyList OtherVersions { get; private set; } = []; public string? Releases { get; private set; } public override void FinalizeAndValidate(ParserContext context) @@ -42,26 +43,44 @@ public override void FinalizeAndValidate(ParserContext context) Version = Prop("version"); // search defaults to true; explicit ":search: false" hides it ShowSearch = TryPropBool("search") ?? true; - QuickLinks = ParseQuickLinks(Prop("quick-links")); + QuickLinks = ParsePairs(Prop("quick-links"), allowEmptyUrl: false) + .Select(p => new HeroQuickLink(p.Label, p.Url!)).ToList(); + OtherVersions = ParsePairs(Prop("versions"), allowEmptyUrl: true) + .Select(p => new HeroVersion(p.Label, p.Url)).ToList(); Releases = Prop("releases"); } - private static IReadOnlyList ParseQuickLinks(string? raw) + private static IReadOnlyList<(string Label, string? Url)> ParsePairs(string? raw, bool allowEmptyUrl) { if (string.IsNullOrWhiteSpace(raw)) return []; - var entries = new List(); + var entries = new List<(string, string?)>(); foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var separator = part.IndexOf('='); - if (separator <= 0 || separator == part.Length - 1) - continue; - - var label = part[..separator].Trim(); - var url = part[(separator + 1)..].Trim(); - if (label.Length > 0 && url.Length > 0) - entries.Add(new HeroQuickLink(label, url)); + string label; + string? url; + if (separator < 0) + { + if (!allowEmptyUrl) + continue; + label = part.Trim(); + url = null; + } + else + { + label = part[..separator].Trim(); + url = part[(separator + 1)..].Trim(); + if (string.IsNullOrEmpty(url)) + { + if (!allowEmptyUrl) + continue; + url = null; + } + } + if (label.Length > 0) + entries.Add((label, url)); } return entries; @@ -69,3 +88,5 @@ private static IReadOnlyList ParseQuickLinks(string? raw) } public readonly record struct HeroQuickLink(string Label, string Url); + +public readonly record struct HeroVersion(string Label, string? Url); diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml index 8206116af4..7190cfaa05 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml @@ -36,10 +36,43 @@
    @if (!string.IsNullOrWhiteSpace(Model.Version)) { - - - @Model.Version - + if (Model.OtherVersions.Count > 0) + { +
    + + + @Model.Version + + + +
    + } + else + { + + + @Model.Version + + } } @if (Model.QuickLinks.Count > 0) { diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs index 19373fd0f2..6f69430990 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs @@ -11,5 +11,6 @@ public class HeroViewModel : DirectiveViewModel public required string? Version { get; init; } public required bool ShowSearch { get; init; } public required IReadOnlyList QuickLinks { get; init; } + public required IReadOnlyList OtherVersions { get; init; } public required string? ReleasesHtml { get; init; } } From 1908fee724f83eb4de2e27ac8ef77e61346641bb Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 21:47:26 +0200 Subject: [PATCH 14/56] feat(hubs): centralize What's new content in config/whats-new.yml The {whats-new} directive now accepts a :product: option that looks the content up in a single source-of-truth file. Authors edit one YAML in config/, and any page can render that product's panel with a one-line directive: :::{whats-new} :product: kibana ::: Inline YAML body still works as a fallback / override for one-offs. The new file is loaded through ConfigurationFileProvider (with embedded- resource fallback so the binary always has it) and made available to the directive via BuildContext. Configuration is cached per-build so multiple {whats-new} blocks share one parse pass. Both hub markdowns are slimmed down to the one-line shape; their previous inline data is now in config/whats-new.yml. Also: - Switches the What's new item layout to grid with a fixed-width meta column (130px), so version/date pills line up across rows and descriptions don't run into them. - Makes the hero version chip read more clearly as a dropdown: caret is bigger and lives in a translucent circle, the chip itself has a brighter idle / hover / open state. Co-Authored-By: Claude Sonnet 4.6 --- config/whats-new.yml | 72 +++++++++++++++++ docs/hubs/elasticsearch/9.0.md | 25 +----- docs/hubs/kibana/9.0.md | 25 +----- .../ConfigurationFileProvider.cs | 4 + ...Elastic.Documentation.Configuration.csproj | 1 + .../Assets/markdown/hub.css | 56 ++++++++++--- .../Myst/Directives/Hub/WhatsNewBlock.cs | 81 ++++++++++++++----- .../Myst/YamlSerialization.cs | 1 + 8 files changed, 185 insertions(+), 80 deletions(-) create mode 100644 config/whats-new.yml diff --git a/config/whats-new.yml b/config/whats-new.yml new file mode 100644 index 0000000000..2cfb4f0105 --- /dev/null +++ b/config/whats-new.yml @@ -0,0 +1,72 @@ +### +# Centralized "What's new" feed used by the {whats-new} directive. +# +# Any page can render a product's recent highlights with: +# +# :::{whats-new} +# :product: kibana +# ::: +# +# The {whats-new} directive looks the product up in this file and renders the +# full panel (header + items). Authors only edit this file; the directive +# stays trivial in markdown. +# +# An optional `id` and `badge` per product control the section anchor and the +# small badge shown to the left of the title; default badge is "New". +### + +products: + + kibana: + title: What's new in Kibana + id: whats-new + badge: New + release-links: + - label: '9.4' + url: https://www.elastic.co/docs/release-notes/kibana + - label: Serverless + url: https://www.elastic.co/docs/release-notes/serverless + items: + - title: Dashboards and visualizations APIs + description: Programmatically create and manage dashboards and visualizations + link: https://www.elastic.co/docs/api/doc/kibana + meta: 9.4 preview + - title: AI agent skills + description: Teach AI coding agents to work with the Elastic stack + link: https://www.elastic.co/docs/explore-analyze/ai-features/agent-skills + meta: Mar 2026 + - title: Agent Builder + description: Build custom AI agents that reason over your data + link: https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder + meta: 9.3 GA + - title: Workflows + description: Automate tasks with Kibana actions, HTTP calls, and AI agents + link: https://www.elastic.co/docs/explore-analyze/workflows + meta: 9.3 preview + + elasticsearch: + title: What's new in Elasticsearch + id: whats-new + badge: New + release-links: + - label: '9.4' + url: https://www.elastic.co/docs/release-notes/elasticsearch + - label: Serverless + url: https://www.elastic.co/docs/release-notes/serverless + items: + - title: semantic_text field type + description: Simplify semantic search — no pipeline setup needed, just define the field and start querying + link: https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text + meta: 9.4 GA + - title: ES|QL improvements + description: New functions, cross-cluster support, and dense vector operations directly in ES|QL queries + link: https://www.elastic.co/docs/reference/query-languages/esql/esql-getting-started + meta: '9.4' + - title: Inference API expansion + description: Connect to external AI models (OpenAI, Cohere, Anthropic, Hugging Face, Azure, Google) for embeddings and reranking + link: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put + meta: '9.4' + - title: Breaking changes in 9.x + description: Review removed settings, deprecated APIs, and behavioral changes before upgrading from 8.x + link: https://www.elastic.co/docs/release-notes/elasticsearch/breaking-changes + meta: Upgrade guide diff --git a/docs/hubs/elasticsearch/9.0.md b/docs/hubs/elasticsearch/9.0.md index 4c03a55cdf..ecf192ba34 100644 --- a/docs/hubs/elasticsearch/9.0.md +++ b/docs/hubs/elasticsearch/9.0.md @@ -22,30 +22,7 @@ The distributed search and analytics engine. Store, search, and analyze data at ::: :::{whats-new} -title: What's new in Elasticsearch -id: whats-new -release-links: - - label: '9.4' - url: https://www.elastic.co/docs/release-notes/elasticsearch - - label: Serverless - url: https://www.elastic.co/docs/release-notes/serverless -items: - - title: semantic_text field type - description: Simplify semantic search — no pipeline setup needed, just define the field and start querying - link: https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text - meta: 9.4 GA - - title: ES|QL improvements - description: New functions, cross-cluster support, and dense vector operations directly in ES|QL queries - link: https://www.elastic.co/docs/reference/query-languages/esql/esql-getting-started - meta: '9.4' - - title: Inference API expansion - description: Connect to external AI models (OpenAI, Cohere, Anthropic, Hugging Face, Azure, Google) for embeddings and reranking - link: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put - meta: '9.4' - - title: Breaking changes in 9.x - description: Review removed settings, deprecated APIs, and behavioral changes before upgrading from 8.x - link: https://www.elastic.co/docs/release-notes/elasticsearch/breaking-changes - meta: Upgrade guide +:product: elasticsearch ::: ::::{card-group} diff --git a/docs/hubs/kibana/9.0.md b/docs/hubs/kibana/9.0.md index 9c9f84ff18..8d16f4d737 100644 --- a/docs/hubs/kibana/9.0.md +++ b/docs/hubs/kibana/9.0.md @@ -22,30 +22,7 @@ The UI for the Elasticsearch platform. Explore and visualize your data, build da ::: :::{whats-new} -title: What's new in Kibana -id: whats-new -release-links: - - label: 9.4 - url: /release-notes/kibana - - label: Serverless - url: /release-notes/serverless -items: - - title: Dashboards and visualizations APIs - description: Programmatically create and manage dashboards and visualizations - link: /api/dashboards - meta: 9.4 preview - - title: AI agent skills - description: Teach AI coding agents to work with the Elastic stack - link: /ai/agent-skills - meta: Mar 2026 - - title: Agent Builder - description: Build custom AI agents that reason over your data - link: /ai/agent-builder - meta: 9.3 GA - - title: Workflows - description: Automate tasks with Kibana actions, HTTP calls, and AI agents - link: /ai/workflows - meta: 9.3 preview +:product: kibana ::: ::::{card-group} diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index ba59191eb7..3fca252636 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -112,6 +112,7 @@ public ConfigurationFileProvider( LegacyUrlMappingsFile = CreateTemporaryConfigurationFile("legacy-url-mappings.yml"); // reading from synonyms.yml is temporary. If you spot this again as a future reader, feel free to remove it. SearchFile = CreateTemporaryConfigurationFile("search.yml", "synonyms.yml"); + WhatsNewFile = TryCreateTemporaryConfigurationFile("whats-new.yml"); } public bool SkipPrivateRepositories { get; } @@ -127,6 +128,9 @@ public ConfigurationFileProvider( public IFileInfo ProductsFile { get; } + /// Optional — present only when config/whats-new.yml exists. + public IFileInfo? WhatsNewFile { get; } + public IFileInfo AssemblerFile { get; } public IFileInfo LegacyUrlMappingsFile { get; } diff --git a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj index 0f96c00f34..5712b6a1d9 100644 --- a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj +++ b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj @@ -31,5 +31,6 @@ + diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 607b3117df..8005538897 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -160,17 +160,19 @@ .hub-hero details.hub-hero-version-dropdown summary { display: inline-flex; align-items: center; - gap: 8px; - background: rgb(255 255 255 / 0.08); - border: 1px solid rgb(255 255 255 / 0.15); + gap: 10px; + background: rgb(255 255 255 / 0.12); + border: 1px solid rgb(255 255 255 / 0.25); border-radius: 20px; - padding: 5px 14px; + padding: 5px 6px 5px 14px; font-size: 13px; - color: rgb(255 255 255 / 0.85); + color: var(--color-white); cursor: pointer; list-style: none; user-select: none; - transition: background 0.15s; + transition: + background 0.15s, + border-color 0.15s; } .hub-hero details.hub-hero-version-dropdown @@ -178,12 +180,33 @@ display: none; } .hub-hero details.hub-hero-version-dropdown summary:hover { - background: rgb(255 255 255 / 0.14); + background: rgb(255 255 255 / 0.2); + border-color: rgb(255 255 255 / 0.4); + } + .hub-hero details[open].hub-hero-version-dropdown summary { + background: rgb(255 255 255 / 0.2); + border-color: rgb(255 255 255 / 0.4); } .hub-hero .hub-hero-version-caret { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: rgb(255 255 255 / 0.18); font-size: 10px; - opacity: 0.6; - transition: transform 0.15s; + line-height: 1; + color: var(--color-white); + transition: + transform 0.18s ease, + background 0.15s; + } + .hub-hero + details.hub-hero-version-dropdown + summary:hover + .hub-hero-version-caret { + background: rgb(255 255 255 / 0.3); } .hub-hero details[open] .hub-hero-version-caret { transform: rotate(180deg); @@ -430,9 +453,10 @@ @apply m-0 p-0; } .hub-whats-new .hub-wn-item-link { - display: flex; + display: grid; + grid-template-columns: auto minmax(0, 1fr) 130px; align-items: baseline; - gap: 10px; + column-gap: 12px; padding: 10px 14px; border-radius: 10px; background: var(--color-grey-10); @@ -456,13 +480,19 @@ color: var(--color-ink-light); } .hub-whats-new .hub-wn-item-meta { - margin-left: auto; + justify-self: end; + text-align: right; white-space: nowrap; - flex-shrink: 0; font-size: 12px; color: var(--color-grey-80); font-weight: 500; } + .hub-whats-new .hub-wn-item-desc { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } /* Zone (section heading) ------------------------------------------ */ .hub-zone { diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs index f009c3f0b8..59202757cc 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs @@ -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.Concurrent; using Elastic.Markdown.Diagnostics; using YamlDotNet.Core; using YamlDotNet.Serialization; @@ -9,41 +10,52 @@ namespace Elastic.Markdown.Myst.Directives.Hub; /// -/// Renders the "What's new" panel: a header with a New badge, a section title, -/// optional release-notes links, and a list of items with title / description / -/// per-item meta pill (e.g. "9.4 preview", "Mar 2026"). -/// -/// +/// Renders the "What's new" panel for a product. Two usage shapes: +/// +/// Centralized lookup (preferred): /// /// :::{whats-new} -/// title: What's new in Kibana -/// id: whats-new -/// release-links: -/// - label: 9.4 -/// url: /release-notes/kibana -/// - label: Serverless -/// url: /release-notes/serverless -/// items: -/// - title: Dashboards APIs -/// description: Programmatically create dashboards -/// link: /api/dashboards -/// meta: 9.4 preview +/// :product: kibana /// ::: /// -/// +/// The directive looks the product key up in config/whats-new.yml +/// and renders the data declared there. Authors edit one file; any page can +/// surface the panel. +/// +/// Inline override: if no :product: option is provided, +/// the directive expects a YAML body declaring title, items, +/// etc. directly. Useful for one-offs that don't belong in the central +/// feed. +/// public class WhatsNewBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { + private static readonly ConcurrentDictionary CentralConfigCache = new(); + public override string Directive => "whats-new"; public WhatsNewData Data { get; private set; } = WhatsNewData.Empty; public override void FinalizeAndValidate(ParserContext context) { + var product = Prop("product"); + + if (!string.IsNullOrWhiteSpace(product)) + { + var resolved = LoadFromCentralConfig(product!); + if (resolved is null) + { + this.EmitError($"{{whats-new}} :product: '{product}' was not found in config/whats-new.yml."); + return; + } + Data = resolved; + return; + } + var yaml = HubYamlBody.Extract(this, new BuildContextFileReader(Build.ReadFileSystem)); if (yaml is null) { - this.EmitError("{whats-new} requires a YAML body. See the whats-new directive docs."); + this.EmitError("{whats-new} requires either a `:product:` option or a YAML body."); return; } @@ -57,10 +69,41 @@ public override void FinalizeAndValidate(ParserContext context) } } + private WhatsNewData? LoadFromCentralConfig(string productKey) + { + var configFile = Build.ConfigurationFileProvider.WhatsNewFile; + if (configFile is null || !configFile.Exists) + return null; + + var config = CentralConfigCache.GetOrAdd(configFile.FullName, path => + { + try + { + var yaml = Build.ReadFileSystem.File.ReadAllText(path); + return YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration); + } + catch + { + return null; + } + }); + + if (config?.Products is null) + return null; + return config.Products.TryGetValue(productKey, out var data) ? data : null; + } + public override IEnumerable GeneratedAnchors => string.IsNullOrWhiteSpace(Data.Id) ? [] : [Data.Id!]; } +[YamlSerializable] +public record WhatsNewConfig +{ + [YamlMember(Alias = "products")] + public Dictionary Products { get; set; } = []; +} + [YamlSerializable] public record WhatsNewData { diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 9b9309e1cc..af1a36d2a1 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -47,4 +47,5 @@ public static T Deserialize(string yaml, ProductsConfiguration products) [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardAside))] [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewData))] [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewItem))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewConfig))] public partial class DocsBuilderYamlStaticContext; From f6ce8b8339366f5db7fc1bca1b6d2b1292c50fc2 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 30 Apr 2026 21:54:28 +0200 Subject: [PATCH 15/56] feat(hubs): wire hub pages under testing/hubs in docs-builder nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moves docs/hubs/{es,kibana}/9.0.md to docs/testing/hubs/{es,kibana}/9.0.md so they appear in the docs-builder docs sidebar under the Testing group, while still rendering with layout: hub. - Adds matching folder children in _docset.yml. - Updates products.yml hub slugs to the new paths so the product badges link to /testing/hubs/{product}/9.0/. Also: - Drops the description truncation in What's new items so long descriptions wrap onto a second line cleanly under the title (meta pill stays right-aligned with align-items: baseline). - Bigger hero version dropdown caret: 28x28 puck with a 16px SVG chevron (replacing the small ▾ character) so it's clearly a click target. Co-Authored-By: Claude Sonnet 4.6 --- config/products.yml | 4 ++-- docs/_docset.yml | 10 ++++++++-- docs/{ => testing}/hubs/elasticsearch/9.0.md | 0 docs/{ => testing}/hubs/kibana/9.0.md | 0 .../Assets/markdown/hub.css | 20 +++++++++---------- .../Myst/Directives/Hub/HeroView.cshtml | 6 +++++- 6 files changed, 25 insertions(+), 15 deletions(-) rename docs/{ => testing}/hubs/elasticsearch/9.0.md (100%) rename docs/{ => testing}/hubs/kibana/9.0.md (100%) diff --git a/config/products.yml b/config/products.yml index b5471b8912..160d8a2006 100644 --- a/config/products.yml +++ b/config/products.yml @@ -147,7 +147,7 @@ products: elasticsearch: display: 'Elasticsearch' versioning: 'stack' - hub: 'hubs/elasticsearch/9.0' + hub: 'testing/hubs/elasticsearch/9.0' elasticsearch-client: display: 'Elasticsearch Client' versioning: 'stack' @@ -208,7 +208,7 @@ products: kibana: display: 'Kibana' versioning: 'stack' - hub: 'hubs/kibana/9.0' + hub: 'testing/hubs/kibana/9.0' logstash: display: 'Logstash' versioning: 'stack' diff --git a/docs/_docset.yml b/docs/_docset.yml index c43759d2fb..189227f00c 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -60,8 +60,6 @@ toc: - hidden: 404.md - hidden: _search.md - hidden: developer-notes.md - - hidden: hubs/elasticsearch/9.0.md - - hidden: hubs/kibana/9.0.md - hidden: contribute/cumulative-docs/index.md - hidden: contribute/cumulative-docs/guidelines.md - hidden: contribute/cumulative-docs/badge-placement.md @@ -234,6 +232,14 @@ toc: - folder: testing children: - file: index.md + - folder: hubs + children: + - folder: elasticsearch + children: + - file: 9.0.md + - folder: kibana + children: + - file: 9.0.md - file: kibana-settings-yaml-samples.md - file: security-settings.md - file: req.md diff --git a/docs/hubs/elasticsearch/9.0.md b/docs/testing/hubs/elasticsearch/9.0.md similarity index 100% rename from docs/hubs/elasticsearch/9.0.md rename to docs/testing/hubs/elasticsearch/9.0.md diff --git a/docs/hubs/kibana/9.0.md b/docs/testing/hubs/kibana/9.0.md similarity index 100% rename from docs/hubs/kibana/9.0.md rename to docs/testing/hubs/kibana/9.0.md diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 8005538897..b2c65e46c5 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -163,8 +163,8 @@ gap: 10px; background: rgb(255 255 255 / 0.12); border: 1px solid rgb(255 255 255 / 0.25); - border-radius: 20px; - padding: 5px 6px 5px 14px; + border-radius: 999px; + padding: 4px 4px 4px 14px; font-size: 13px; color: var(--color-white); cursor: pointer; @@ -191,17 +191,20 @@ display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: 28px; + height: 28px; border-radius: 50%; - background: rgb(255 255 255 / 0.18); - font-size: 10px; - line-height: 1; + background: rgb(255 255 255 / 0.22); color: var(--color-white); transition: transform 0.18s ease, background 0.15s; } + .hub-hero .hub-hero-version-caret svg { + display: block; + width: 16px; + height: 16px; + } .hub-hero details.hub-hero-version-dropdown summary:hover @@ -489,9 +492,6 @@ } .hub-whats-new .hub-wn-item-desc { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } /* Zone (section heading) ------------------------------------------ */ diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml index 7190cfaa05..3080d703fa 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml @@ -42,7 +42,11 @@ @Model.Version - +
    diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs index 6f69430990..7c971bdd02 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs @@ -8,6 +8,8 @@ public class HeroViewModel : DirectiveViewModel { public required string? IconKey { get; init; } public required string? IconSvg { get; init; } + public required string? Title { get; init; } + public required string? DescriptionHtml { get; init; } public required string? Version { get; init; } public required bool ShowSearch { get; init; } public required IReadOnlyList QuickLinks { get; init; } From 9979af74ae52c724f009616d744ac5be390f8564 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 1 May 2026 10:46:37 +0200 Subject: [PATCH 19/56] fix(hubs): make Product hubs section an external link to the gist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assembler can't yet route through the V2 nav for our hub pages (known limitation -- AssembleSources still wires file output via the V1 toc chain), so the previous in-repo page: references caused 'Could not find ... in navigation' errors during assembler builds. Replace the section's internal /products/ URL + nested page references with a single external URL pointing at the Kibana gist preview. The secondary nav already renders any section whose URL starts with http as an external link with the ↗ icon (see _SecondaryNav.cshtml). Effect: - Assembler build: 0 errors. Top-bar now shows 'Product hubs ↗'. - Isolated build: unchanged. The real hub pages still render at /testing/products/{elasticsearch,kibana}/9.0/ in docs-builder's own meta-docs, which is how reviewers preview them today. Once the V2 nav-wiring lands upstream, this will revert to internal section + page references. Co-Authored-By: Claude Sonnet 4.6 --- config/navigation-v2.yml | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index 8570c6c1fd..cd6b18f288 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -2871,20 +2871,13 @@ nav: children: - toc: docs-content://release-notes/intro + # Until hub pages are produced through the V1 navigation/toc chain so the + # assembler can resolve them, this section is an external link to the + # Kibana gist preview -- the visual reference for the prototype, with its + # own island-style nav and stubs already wired up. + # https://gist.github.com/florent-leborgne/c9075416b50ec111a09897b7be4462cc - section: Product hubs - url: /products/ - children: - - label: Stack products - children: - - page: docs-builder://testing/products/elasticsearch/9.0.md - - page: docs-builder://testing/products/kibana/9.0.md - - title: Observability - - title: Security - - label: Deployment runbooks - children: - - title: Elastic Cloud Hosted - - title: Elastic Cloud Serverless - - title: Self-managed + url: https://htmlpreview.github.io/?https://gist.github.com/florent-leborgne/c9075416b50ec111a09897b7be4462cc/raw/kibana-hub.html - section: Extension points url: /extend/ From 35aa23b60f3011a27b63f3c457ceeb098e9fd6a0 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 1 May 2026 11:35:28 +0200 Subject: [PATCH 20/56] feat(hubs): redesign product badges as inline-with-title pills Old badges were small underlined pills above the H1 with default
  • bullets leaking through and ambiguous semantics ('Applies to' clashed with the existing applies_to lifecycle/version system). New design: - Sit inline with the page H1 (.page-title-row flex container, align baseline + translateY(-0.45em) to land at the H1 x-height). - Per pill: brand-color accent dot, product name, right-arrow chevron that slides on hover. - Hover state: border picks up the brand color, faint background tint, arrow color and position shift -- reads clearly as 'click to navigate there'. - No more SVG product icons in the chip (Elasticsearch tricolor read like a tiny flag at 14px). The accent dot carries the identity at any size. Per-product accents reuse the solution-card brand colors: elasticsearch=#FEC514, kibana=#F04E98, observability=#0077CC, security=#00BFB3. The hub-pages syntax doc now declares products: [elasticsearch, kibana] in its frontmatter so the new rendering is visible in the docs-builder docs build. Co-Authored-By: Claude Sonnet 4.6 --- docs/syntax/hub-pages.md | 6 + .../Assets/markdown/hub.css | 120 ++++++++++++++++-- src/Elastic.Markdown/Page/Index.cshtml | 8 +- .../Page/ProductBadges.cshtml | 14 +- 4 files changed, 129 insertions(+), 19 deletions(-) diff --git a/docs/syntax/hub-pages.md b/docs/syntax/hub-pages.md index f4a405b5f9..71d3a9aaee 100644 --- a/docs/syntax/hub-pages.md +++ b/docs/syntax/hub-pages.md @@ -1,3 +1,9 @@ +--- +products: + - id: elasticsearch + - id: kibana +--- + # Hub pages Hub pages are product-scoped landing pages — a 360° overview of one product across versions, deployment types, and surfaces. They're enabled by setting `layout: hub` in the page's frontmatter and composed from a small set of dedicated directives. diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 453f5a09bc..eadafc1468 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -653,24 +653,122 @@ border-left-color: #00bfb3; } - /* Product badges shown above the H1 of a regular page. */ - .product-badges { - @apply mb-3 flex list-none flex-wrap gap-2 p-0; + /* Page title row -- wraps the H1 and the product badges so they sit + on the same baseline when there's room and stack on narrow screens. */ + .page-title-row { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px 14px; + margin-bottom: 0.5em; } - .product-badges li { - @apply m-0 p-0; + .page-title-row > .heading-wrapper, + .page-title-row > h1 { + margin-bottom: 0; + } + + /* Product badges -- the inline-with-title pills that point at the + matching product hub. Each pill has the product's accent dot, the + product name, and a right-arrow chevron to read as clickable. + The translateY offset lifts the pill so it sits at the H1's + x-height rather than below its baseline. */ + .product-badges { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + margin: 0; + padding: 0; + transform: translateY(-0.45em); } + .product-badge { - @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold tracking-wide no-underline; - background: var(--color-grey-10); - color: var(--color-ink-light); + display: inline-flex; + align-items: center; + gap: 7px; + padding: 3px 10px; + border-radius: 999px; + background: var(--color-white); border: 1px solid var(--color-grey-20); + color: var(--color-ink-dark); + font-size: 12px; + font-weight: 500; + text-decoration: none; + line-height: 1.2; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease, + transform 0.15s ease; } .product-badge:hover { - background: var(--color-blue-elastic-10); - color: var(--color-blue-elastic-100); - border-color: var(--color-blue-elastic); text-decoration: none; + color: var(--color-ink-dark); + box-shadow: 0 1px 3px rgb(0 0 0 / 0.08); + transform: translateY(-1px); + } + .product-badge:focus-visible { + outline: 2px solid var(--color-blue-elastic); + outline-offset: 2px; + } + + /* Brand-color dot leading the pill. Every product gets its accent + here -- recognizable at any size, no logo distortion. */ + .product-badge-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--color-grey-40); + flex-shrink: 0; + } + .product-badge-label { + display: inline-block; + white-space: nowrap; + } + .product-badge-arrow { + flex-shrink: 0; + color: var(--color-grey-40); + transition: + transform 0.15s ease, + color 0.15s ease; + margin-left: 1px; + } + .product-badge:hover .product-badge-arrow { + color: var(--color-ink-light); + transform: translateX(2px); + } + + /* Per-product accents -- dot color, hover border, hover background tint. + Brand colors mirror the solution-card border-left accents. */ + .product-badge-elasticsearch .product-badge-dot { + background: #fec514; + } + .product-badge-elasticsearch:hover { + border-color: #fec514; + background: rgb(254 197 20 / 0.06); + } + .product-badge-kibana .product-badge-dot { + background: #f04e98; + } + .product-badge-kibana:hover { + border-color: #f04e98; + background: rgb(240 78 152 / 0.05); + } + .product-badge-observability .product-badge-dot { + background: #0077cc; + } + .product-badge-observability:hover { + border-color: #0077cc; + background: rgb(0 119 204 / 0.05); + } + .product-badge-security .product-badge-dot { + background: #00bfb3; + } + .product-badge-security:hover { + border-color: #00bfb3; + background: rgb(0 191 179 / 0.06); } @media (max-width: 768px) { diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index d1ad409c34..ecb424eb54 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -92,9 +92,11 @@
    @if (Model.CurrentDocument.YamlFrontMatter?.Layout != MarkdownPageLayout.Hub) { - @* This way it's correctly rendered as

    text

    instead of

    text

    *@ - @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) - @await RenderPartialAsync(ProductBadges.Create(Model)) +
    + @* This way it's correctly rendered as

    text

    instead of

    text

    *@ + @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) + @await RenderPartialAsync(ProductBadges.Create(Model)) +
    @if (Model.AppliesTo is not null) {

    diff --git a/src/Elastic.Markdown/Page/ProductBadges.cshtml b/src/Elastic.Markdown/Page/ProductBadges.cshtml index bc3066f2f1..0068712950 100644 --- a/src/Elastic.Markdown/Page/ProductBadges.cshtml +++ b/src/Elastic.Markdown/Page/ProductBadges.cshtml @@ -13,12 +13,16 @@ } @if (withHubs.Count > 0) { -

    +
  • } From 44810515918174866563db300eca0636be2d2791 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 1 May 2026 12:44:57 +0200 Subject: [PATCH 21/56] feat(hubs): point product badge URLs at the gist previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until hub pages render through the assembler, product badges now link to the rendered gist previews (htmlpreview.github.io). The gists are the visual reference for the prototype anyway, so badges land on a representative page even when the assembler can't yet wire in our own hub markdown. ProductBadges.cshtml detects absolute URLs (http(s)://) and: - Opens them in a new tab (target=_blank rel=noopener) - Renders an external-link arrow (↗) instead of the chevron - Otherwise keeps the existing internal-pill rendering Co-Authored-By: Claude Sonnet 4.6 --- config/products.yml | 4 +-- .../Assets/markdown/hub.css | 27 ++++--------------- src/Elastic.Markdown/Page/Index.cshtml | 8 +++--- .../Page/ProductBadges.cshtml | 24 ++++++++++++++--- 4 files changed, 30 insertions(+), 33 deletions(-) diff --git a/config/products.yml b/config/products.yml index 7c0068ca81..da1fcb230a 100644 --- a/config/products.yml +++ b/config/products.yml @@ -147,7 +147,7 @@ products: elasticsearch: display: 'Elasticsearch' versioning: 'stack' - hub: 'testing/products/elasticsearch/9.0' + hub: 'https://htmlpreview.github.io/?https://gist.github.com/florent-leborgne/b2ddb83d7b849980d1668aef600d0139/raw/elasticsearch-hub.html' elasticsearch-client: display: 'Elasticsearch Client' versioning: 'stack' @@ -208,7 +208,7 @@ products: kibana: display: 'Kibana' versioning: 'stack' - hub: 'testing/products/kibana/9.0' + hub: 'https://htmlpreview.github.io/?https://gist.github.com/florent-leborgne/c9075416b50ec111a09897b7be4462cc/raw/kibana-hub.html' logstash: display: 'Logstash' versioning: 'stack' diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index eadafc1468..fad8a53880 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -653,33 +653,16 @@ border-left-color: #00bfb3; } - /* Page title row -- wraps the H1 and the product badges so they sit - on the same baseline when there's room and stack on narrow screens. */ - .page-title-row { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 8px 14px; - margin-bottom: 0.5em; - } - .page-title-row > .heading-wrapper, - .page-title-row > h1 { - margin-bottom: 0; - } - - /* Product badges -- the inline-with-title pills that point at the - matching product hub. Each pill has the product's accent dot, the - product name, and a right-arrow chevron to read as clickable. - The translateY offset lifts the pill so it sits at the H1's - x-height rather than below its baseline. */ + /* Product badges -- shown as a small meta strip between the + breadcrumb and the H1. The negative top margin pulls the strip + up tight against the breadcrumb's bottom padding (py-6 there). */ .product-badges { - display: inline-flex; + display: flex; flex-wrap: wrap; gap: 6px; align-items: center; - margin: 0; + margin: -16px 0 14px 0; padding: 0; - transform: translateY(-0.45em); } .product-badge { diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index ecb424eb54..0f9a650c4f 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -92,11 +92,9 @@
    @if (Model.CurrentDocument.YamlFrontMatter?.Layout != MarkdownPageLayout.Hub) { -
    - @* This way it's correctly rendered as

    text

    instead of

    text

    *@ - @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) - @await RenderPartialAsync(ProductBadges.Create(Model)) -
    + @await RenderPartialAsync(ProductBadges.Create(Model)) + @* This way it's correctly rendered as

    text

    instead of

    text

    *@ + @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) @if (Model.AppliesTo is not null) {

    diff --git a/src/Elastic.Markdown/Page/ProductBadges.cshtml b/src/Elastic.Markdown/Page/ProductBadges.cshtml index 0068712950..1e0c972f16 100644 --- a/src/Elastic.Markdown/Page/ProductBadges.cshtml +++ b/src/Elastic.Markdown/Page/ProductBadges.cshtml @@ -16,12 +16,28 @@

    From 39fb863387b2c43f875dbf8b492a1f61379c6b2f Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 1 May 2026 15:06:58 +0200 Subject: [PATCH 22/56] =?UTF-8?q?Rename=20hub=20markdown=209.0.md=20?= =?UTF-8?q?=E2=86=92=20v9.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns with the v9/v8 URL convention (v9+ is cumulative for v9 minors and Serverless; older majors are only worth a single 8.19 / 7.17 entry). Co-Authored-By: Claude Sonnet 4.6 --- docs/_docset.yml | 4 ++-- docs/testing/products/elasticsearch/{9.0.md => v9.md} | 0 docs/testing/products/kibana/{9.0.md => v9.md} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/testing/products/elasticsearch/{9.0.md => v9.md} (100%) rename docs/testing/products/kibana/{9.0.md => v9.md} (100%) diff --git a/docs/_docset.yml b/docs/_docset.yml index bd2d6c9980..d3a1f2db56 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -243,10 +243,10 @@ toc: children: - folder: elasticsearch children: - - file: 9.0.md + - file: v9.md - folder: kibana children: - - file: 9.0.md + - file: v9.md - file: kibana-settings-yaml-samples.md - file: security-settings.md - file: req.md diff --git a/docs/testing/products/elasticsearch/9.0.md b/docs/testing/products/elasticsearch/v9.md similarity index 100% rename from docs/testing/products/elasticsearch/9.0.md rename to docs/testing/products/elasticsearch/v9.md diff --git a/docs/testing/products/kibana/9.0.md b/docs/testing/products/kibana/v9.md similarity index 100% rename from docs/testing/products/kibana/9.0.md rename to docs/testing/products/kibana/v9.md From 57ea08ed9827dc08b539b0e48c52fd361dbd9f6d Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 1 May 2026 15:21:17 +0200 Subject: [PATCH 23/56] Replace page-top product badges + applies-block with a metadata box A single white card with a thin grey outline sits below the H1 and renders up to four labeled rows, hidden when empty: - Stack products: hub pills for products with `hub:` set in products.yml. - Stack versions: applies_to.stack badges, plus applies_to.serverless when no deployment is specified. - Deployments: applies_to.deployment badges, plus applies_to.serverless when a deployment is specified (so serverless lands in only one row). - Other products: applies_to.product and per-component applicabilities (EDOT, APM agents, ECCTL, etc.). Three new ApplicabilityBadgePlacement values drive the row-specific collection logic; the existing renderer handles the rest. The hub layout opts out via the Layout frontmatter check, same as before. Co-Authored-By: Claude Sonnet 4.6 --- .../Assets/markdown/hub.css | 101 ------------- .../Assets/markdown/metadata-box.css | 134 ++++++++++++++++++ .../Assets/styles.css | 1 + .../Myst/Components/ApplicableToViewModel.cs | 60 +++++++- src/Elastic.Markdown/Page/Index.cshtml | 13 +- src/Elastic.Markdown/Page/MetadataBox.cshtml | 97 +++++++++++++ .../Page/ProductBadges.cshtml | 44 ------ 7 files changed, 292 insertions(+), 158 deletions(-) create mode 100644 src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css create mode 100644 src/Elastic.Markdown/Page/MetadataBox.cshtml delete mode 100644 src/Elastic.Markdown/Page/ProductBadges.cshtml diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index fad8a53880..8ea07978b8 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -653,107 +653,6 @@ border-left-color: #00bfb3; } - /* Product badges -- shown as a small meta strip between the - breadcrumb and the H1. The negative top margin pulls the strip - up tight against the breadcrumb's bottom padding (py-6 there). */ - .product-badges { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; - margin: -16px 0 14px 0; - padding: 0; - } - - .product-badge { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 3px 10px; - border-radius: 999px; - background: var(--color-white); - border: 1px solid var(--color-grey-20); - color: var(--color-ink-dark); - font-size: 12px; - font-weight: 500; - text-decoration: none; - line-height: 1.2; - transition: - border-color 0.15s ease, - background-color 0.15s ease, - color 0.15s ease, - box-shadow 0.15s ease, - transform 0.15s ease; - } - .product-badge:hover { - text-decoration: none; - color: var(--color-ink-dark); - box-shadow: 0 1px 3px rgb(0 0 0 / 0.08); - transform: translateY(-1px); - } - .product-badge:focus-visible { - outline: 2px solid var(--color-blue-elastic); - outline-offset: 2px; - } - - /* Brand-color dot leading the pill. Every product gets its accent - here -- recognizable at any size, no logo distortion. */ - .product-badge-dot { - display: inline-block; - width: 7px; - height: 7px; - border-radius: 999px; - background: var(--color-grey-40); - flex-shrink: 0; - } - .product-badge-label { - display: inline-block; - white-space: nowrap; - } - .product-badge-arrow { - flex-shrink: 0; - color: var(--color-grey-40); - transition: - transform 0.15s ease, - color 0.15s ease; - margin-left: 1px; - } - .product-badge:hover .product-badge-arrow { - color: var(--color-ink-light); - transform: translateX(2px); - } - - /* Per-product accents -- dot color, hover border, hover background tint. - Brand colors mirror the solution-card border-left accents. */ - .product-badge-elasticsearch .product-badge-dot { - background: #fec514; - } - .product-badge-elasticsearch:hover { - border-color: #fec514; - background: rgb(254 197 20 / 0.06); - } - .product-badge-kibana .product-badge-dot { - background: #f04e98; - } - .product-badge-kibana:hover { - border-color: #f04e98; - background: rgb(240 78 152 / 0.05); - } - .product-badge-observability .product-badge-dot { - background: #0077cc; - } - .product-badge-observability:hover { - border-color: #0077cc; - background: rgb(0 119 204 / 0.05); - } - .product-badge-security .product-badge-dot { - background: #00bfb3; - } - .product-badge-security:hover { - border-color: #00bfb3; - background: rgb(0 191 179 / 0.06); - } - @media (max-width: 768px) { .hub-hero h1 { font-size: 2rem; diff --git a/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css b/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css new file mode 100644 index 0000000000..c460c4926c --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css @@ -0,0 +1,134 @@ +/* + * Page-top metadata box. Sits below the H1 and above the body content. + * Up to four labeled rows: Stack products / Stack versions / Deployments / + * Other products. Hidden when no row has content. Hub layout opts out + * (the partial returns early when YamlFrontMatter.Layout is set). + */ + +@layer components { + .metadata-box { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0 0 24px 0; + padding: 10px 14px; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 6px; + font-size: 13px; + line-height: 1.4; + } + + .metadata-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + min-height: 24px; + } + + .metadata-label { + flex: 0 0 auto; + min-width: 110px; + font-weight: 600; + color: var(--color-ink-dark); + } + + .metadata-values { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-width: 0; + } + + @media (max-width: 640px) { + .metadata-row { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + .metadata-label { + min-width: 0; + } + } + + /* + * Product hub pills (used in the "Stack products" row of the metadata + * box, and reusable elsewhere). Each pill is a brand-color dot + the + * product display name. + */ + .product-badge { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 2px 10px; + border-radius: 999px; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + color: var(--color-ink-dark); + font-size: 12px; + font-weight: 500; + text-decoration: none; + line-height: 1.3; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease, + transform 0.15s ease; + } + .product-badge:hover { + text-decoration: none; + color: var(--color-ink-dark); + box-shadow: 0 1px 3px rgb(0 0 0 / 0.08); + transform: translateY(-1px); + } + .product-badge:focus-visible { + outline: 2px solid var(--color-blue-elastic); + outline-offset: 2px; + } + + .product-badge-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--color-grey-40); + flex-shrink: 0; + } + .product-badge-label { + display: inline-block; + white-space: nowrap; + } + + /* Per-product accents -- mirrors hub solution-card border colors. */ + .product-badge-elasticsearch .product-badge-dot { + background: #fec514; + } + .product-badge-elasticsearch:hover { + border-color: #fec514; + background: rgb(254 197 20 / 0.06); + } + .product-badge-kibana .product-badge-dot { + background: #f04e98; + } + .product-badge-kibana:hover { + border-color: #f04e98; + background: rgb(240 78 152 / 0.05); + } + .product-badge-observability .product-badge-dot { + background: #0077cc; + } + .product-badge-observability:hover { + border-color: #0077cc; + background: rgb(0 119 204 / 0.05); + } + .product-badge-security .product-badge-dot { + background: #00bfb3; + } + .product-badge-security:hover { + border-color: #00bfb3; + background: rgb(0 191 179 / 0.06); + } +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 6ce8ef6141..e3f115e1d8 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -28,6 +28,7 @@ @import './markdown/button.css'; @import './markdown/agent-skill.css'; @import './markdown/hub.css'; +@import './markdown/metadata-box.css'; @import './markdown/contributors.css'; @import './api-docs.css'; @import 'tippy.js/dist/tippy.css'; diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs index 82afa2ce5f..7dffae80ea 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs @@ -16,7 +16,13 @@ public enum ApplicabilityBadgePlacement /// Elastic Stack (and generic product) availability next to the setting name. StackRow, /// Deployment, serverless, and product-specific agents on a "Supported on" line. - SupportedOnRow + SupportedOnRow, + /// Stack versions row of the page-top metadata box: applies_to.stack + applies_to.serverless when no deployment is specified. + StackVersionsRow, + /// Deployments row of the page-top metadata box: applies_to.deployment + applies_to.serverless when a deployment is specified. + DeploymentsRow, + /// Other products row of the page-top metadata box: applies_to.product and per-product applicabilities (agents, EDOT, etc.). + OtherProductsRow } public class ApplicableToViewModel @@ -79,6 +85,9 @@ public IReadOnlyCollection GetApplicabilityItems() { ApplicabilityBadgePlacement.StackRow => CollectStackRowRaw(), ApplicabilityBadgePlacement.SupportedOnRow => CollectSupportedOnRaw(), + ApplicabilityBadgePlacement.StackVersionsRow => CollectStackVersionsRowRaw(), + ApplicabilityBadgePlacement.DeploymentsRow => CollectDeploymentsRowRaw(), + ApplicabilityBadgePlacement.OtherProductsRow => CollectOtherProductsRowRaw(), _ => CollectCombinedRaw() }; @@ -154,6 +163,55 @@ AppliesTo.Serverless is null && .ToList(); } + private List CollectStackVersionsRowRaw() + { + var rawItems = new List(); + + if (AppliesTo.Stack is not null) + rawItems.AddRange(CollectFromCollection(AppliesTo.Stack, ApplicabilityMappings.Stack)); + + if (AppliesTo.Deployment is null && AppliesTo.Serverless is not null) + { + rawItems.AddRange(AppliesTo.Serverless.AllProjects is not null + ? CollectFromCollection(AppliesTo.Serverless.AllProjects, ApplicabilityMappings.Serverless) + : CollectFromMappings(AppliesTo.Serverless, ServerlessMappings)); + } + + return rawItems; + } + + private List CollectDeploymentsRowRaw() + { + var rawItems = new List(); + + if (AppliesTo.Deployment is not null) + { + rawItems.AddRange(CollectFromMappings(AppliesTo.Deployment, DeploymentMappings)); + + if (AppliesTo.Serverless is not null) + { + rawItems.AddRange(AppliesTo.Serverless.AllProjects is not null + ? CollectFromCollection(AppliesTo.Serverless.AllProjects, ApplicabilityMappings.Serverless) + : CollectFromMappings(AppliesTo.Serverless, ServerlessMappings)); + } + } + + return rawItems; + } + + private List CollectOtherProductsRowRaw() + { + var rawItems = new List(); + + if (AppliesTo.ProductApplicability is not null) + rawItems.AddRange(CollectFromMappings(AppliesTo.ProductApplicability, ProductMappings)); + + if (AppliesTo.Product is not null) + rawItems.AddRange(CollectFromCollection(AppliesTo.Product, ApplicabilityMappings.Product)); + + return rawItems; + } + private static bool IsGenericGa(AppliesCollection collection) { if (collection.Count != 1) diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index 0f9a650c4f..70ca017a50 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -92,20 +92,9 @@
    @if (Model.CurrentDocument.YamlFrontMatter?.Layout != MarkdownPageLayout.Hub) { - @await RenderPartialAsync(ProductBadges.Create(Model)) @* This way it's correctly rendered as

    text

    instead of

    text

    *@ @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) - @if (Model.AppliesTo is not null) - { -

    - @await RenderPartialAsync(ApplicableToComponent.Create(new ApplicableToViewModel - { - AppliesTo = Model.AppliesTo, - Inline = false, - VersionsConfig = Model.VersionsConfig - })) -

    - } + @await RenderPartialAsync(MetadataBox.Create(Model)) } @(new HtmlString(Model.MarkdownHtml))
    diff --git a/src/Elastic.Markdown/Page/MetadataBox.cshtml b/src/Elastic.Markdown/Page/MetadataBox.cshtml new file mode 100644 index 0000000000..02b5b3107e --- /dev/null +++ b/src/Elastic.Markdown/Page/MetadataBox.cshtml @@ -0,0 +1,97 @@ +@inherits RazorSlice +@using Elastic.Markdown.Myst.Components +@{ + var layout = Model.CurrentDocument.YamlFrontMatter?.Layout; + if (layout is not null) + return; + + var prefix = Model.UrlPathPrefix?.TrimEnd('/') ?? string.Empty; + var productHubs = Model.Products + .Where(p => !string.IsNullOrWhiteSpace(p.Hub)) + .OrderBy(p => p.DisplayName, StringComparer.Ordinal) + .ToList(); + + var hasStackVersions = false; + var hasDeployments = false; + var hasOtherProducts = false; + if (Model.AppliesTo is not null) + { + hasStackVersions = Model.AppliesTo.Stack is not null + || (Model.AppliesTo.Deployment is null && Model.AppliesTo.Serverless is not null); + hasDeployments = Model.AppliesTo.Deployment is not null; + hasOtherProducts = Model.AppliesTo.ProductApplicability is not null + || Model.AppliesTo.Product is not null; + } + + var hasProducts = productHubs.Count > 0; + if (!hasProducts && !hasStackVersions && !hasDeployments && !hasOtherProducts) + return; +} + diff --git a/src/Elastic.Markdown/Page/ProductBadges.cshtml b/src/Elastic.Markdown/Page/ProductBadges.cshtml deleted file mode 100644 index 1e0c972f16..0000000000 --- a/src/Elastic.Markdown/Page/ProductBadges.cshtml +++ /dev/null @@ -1,44 +0,0 @@ -@inherits RazorSlice -@{ - // Hide on layouts that own the page chrome themselves (hub, landing, archive, search). - var layout = Model.CurrentDocument.YamlFrontMatter?.Layout; - var skip = layout is not null; - var withHubs = skip - ? [] - : Model.Products - .Where(p => !string.IsNullOrWhiteSpace(p.Hub)) - .OrderBy(p => p.DisplayName, StringComparer.Ordinal) - .ToList(); - var prefix = Model.UrlPathPrefix?.TrimEnd('/') ?? string.Empty; -} -@if (withHubs.Count > 0) -{ - -} From d0c449715648d2c98186a0adbcbed88962f2fe3c Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 1 May 2026 16:44:41 +0200 Subject: [PATCH 24/56] Restructure page-top metadata: product pills above H1, Requirements box below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous metadata box mixed product pills (navigation) with applies_to badges (metadata) in one container, which conflated two distinct concerns. Splitting them gives each a clear role: - ProductPills.cshtml renders the brand-dotted hub pills above the H1, styled as nav affordances (dark-grey label, → arrow, no link underline). - MetadataBox.cshtml renders applies_to data below the H1 in a collapsible "Requirements" box (Versions / Deployments / Subscription rows). The Subscription row is a placeholder ("Enterprise") until that metadata exists. Multiple values within a row are separated by an italic "or". - A thin divider sits between the H1 and the box to mark the page-top chrome boundary. Final styling and placement are a working baseline; refinement is for the design pass. The structural split is the part that matters and unblocks adding more page-level metadata systems (e.g. licensing) without piling loose badges at the page top. Co-Authored-By: Claude Sonnet 4.6 --- .../Assets/markdown/metadata-box.css | 228 +++++++++++++----- src/Elastic.Markdown/Page/Index.cshtml | 2 + src/Elastic.Markdown/Page/MetadataBox.cshtml | 120 ++++----- src/Elastic.Markdown/Page/ProductPills.cshtml | 29 +++ 4 files changed, 249 insertions(+), 130 deletions(-) create mode 100644 src/Elastic.Markdown/Page/ProductPills.cshtml diff --git a/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css b/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css index c460c4926c..08e6357a16 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css @@ -1,64 +1,41 @@ /* - * Page-top metadata box. Sits below the H1 and above the body content. - * Up to four labeled rows: Stack products / Stack versions / Deployments / - * Other products. Hidden when no row has content. Hub layout opts out - * (the partial returns early when YamlFrontMatter.Layout is set). + * Page-top product pills + Requirements box. The pills sit just below the + * H1 (one per `products:` frontmatter entry whose product has `hub:` set); + * the box sits below the pills and surfaces applies_to-driven metadata in + * a labeled two-column layout. Hub layout opts out (the partials return + * early when YamlFrontMatter.Layout is set). */ @layer components { - .metadata-box { - display: flex; - flex-direction: column; - gap: 6px; - margin: 0 0 24px 0; - padding: 10px 14px; - background: var(--color-white); - border: 1px solid var(--color-grey-20); - border-radius: 6px; - font-size: 13px; - line-height: 1.4; + /* + * Product hub pills below the H1. Brand-color dot + display name. + */ + /* Thin separator that sits between the H1 and the Requirements box, + carrying the visual weight the old applies-to summary line used to + provide. Keeps the page-top chrome anchored. */ + .page-meta-divider { + height: 0; + margin: 18px 0; + border: none; + border-top: 1px solid var(--color-grey-20); } - .metadata-row { + .product-pills { display: flex; flex-wrap: wrap; - align-items: center; - gap: 6px 10px; - min-height: 24px; - } - - .metadata-label { - flex: 0 0 auto; - min-width: 110px; - font-weight: 600; - color: var(--color-ink-dark); - } - - .metadata-values { - display: inline-flex; - flex-wrap: wrap; - align-items: center; gap: 6px; - min-width: 0; - } - - @media (max-width: 640px) { - .metadata-row { - flex-direction: column; - align-items: flex-start; - gap: 4px; - } - .metadata-label { - min-width: 0; - } + align-items: center; + /* Pull tight against the breadcrumb's bottom padding so the pills + read as part of the breadcrumb strip, not a separate row. */ + margin: -16px 0 14px 0; } - /* - * Product hub pills (used in the "Stack products" row of the metadata - * box, and reusable elsewhere). Each pill is a brand-color dot + the - * product display name. - */ - .product-badge { + /* Override `.markdown-content a` (blue + underline) with a more + specific selector. Pills are nav affordances, not body links. + `!important` on color + decoration is a safety net against any + utility-layer rules that beat us on cascade order. */ + .markdown-content a.product-badge, + a.product-badge { display: inline-flex; align-items: center; gap: 7px; @@ -66,10 +43,10 @@ border-radius: 999px; background: var(--color-white); border: 1px solid var(--color-grey-20); - color: var(--color-ink-dark); + color: var(--color-ink-dark) !important; font-size: 12px; - font-weight: 500; - text-decoration: none; + font-weight: 600; + text-decoration: none !important; line-height: 1.3; transition: border-color 0.15s ease, @@ -78,12 +55,28 @@ box-shadow 0.15s ease, transform 0.15s ease; } - .product-badge:hover { - text-decoration: none; - color: var(--color-ink-dark); + .markdown-content a.product-badge:hover, + a.product-badge:hover { + text-decoration: none !important; + color: var(--color-ink-dark) !important; box-shadow: 0 1px 3px rgb(0 0 0 / 0.08); transform: translateY(-1px); } + + .product-badge-arrow { + flex-shrink: 0; + color: var(--color-grey-50); + font-size: 13px; + line-height: 1; + margin-left: 1px; + transition: + transform 0.15s ease, + color 0.15s ease; + } + .product-badge:hover .product-badge-arrow { + color: var(--color-ink-light); + transform: translateX(2px); + } .product-badge:focus-visible { outline: 2px solid var(--color-blue-elastic); outline-offset: 2px; @@ -131,4 +124,127 @@ border-color: #00bfb3; background: rgb(0 191 179 / 0.06); } + + /* + * Requirements box. Two columns: a heading on the left that titles the + * whole box, and a stack of labeled rows on the right. Hidden when no + * applies_to-driven row would render. + */ + .metadata-box { + margin: 16px 0 24px 0; + padding: 8px 14px; + background: var(--color-grey-10); + border: 1px solid var(--color-grey-20); + border-radius: 6px; + font-size: 13px; + line-height: 1.4; + } + + .metadata-box-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; + list-style: none; + padding: 4px 0; + font-size: 14px; + font-weight: 600; + color: var(--color-ink-dark); + user-select: none; + } + .metadata-box-heading::-webkit-details-marker { + display: none; + } + .metadata-box-heading:focus-visible { + outline: 2px solid var(--color-blue-elastic); + outline-offset: 2px; + border-radius: 3px; + } + + .metadata-box-chevron { + flex-shrink: 0; + color: var(--color-grey-60); + transition: transform 0.15s ease; + } + .metadata-box[open] .metadata-box-chevron { + transform: rotate(180deg); + } + + .metadata-box-rows { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; + padding-top: 10px; + border-top: 1px solid var(--color-grey-20); + } + + .metadata-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + min-height: 24px; + } + + .metadata-label { + flex: 0 0 auto; + min-width: 100px; + font-weight: 600; + color: var(--color-ink-dark); + } + + .metadata-values { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + row-gap: 4px; + min-width: 0; + } + + /* + * When the row has multiple entries, separate them with "or". Override + * the global `.applies applies-to-popover { display: contents }` so the + * popover's ::before generates a real separator inline. + */ + .metadata-values.applies applies-to-popover { + display: inline-flex; + align-items: center; + } + .metadata-values > * + *::before { + content: 'or'; + margin: 0 8px; + color: var(--color-ink-light); + font-size: 12px; + font-style: italic; + } + + /* + * Plain pill for placeholder values like the Subscription row, until + * real metadata replaces it. Visually quieter than the applies_to + * popover badges so it reads as informational, not interactive. + */ + .metadata-pill { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + color: var(--color-ink-dark); + font-size: 12px; + line-height: 1.3; + } + + @media (max-width: 640px) { + .metadata-row { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + .metadata-label { + min-width: 0; + } + } } diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index 70ca017a50..4f89660c1c 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -92,8 +92,10 @@
    @if (Model.CurrentDocument.YamlFrontMatter?.Layout != MarkdownPageLayout.Hub) { + @await RenderPartialAsync(ProductPills.Create(Model)) @* This way it's correctly rendered as

    text

    instead of

    text

    *@ @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) +
    @await RenderPartialAsync(MetadataBox.Create(Model)) } @(new HtmlString(Model.MarkdownHtml)) diff --git a/src/Elastic.Markdown/Page/MetadataBox.cshtml b/src/Elastic.Markdown/Page/MetadataBox.cshtml index 02b5b3107e..0b49d8dffd 100644 --- a/src/Elastic.Markdown/Page/MetadataBox.cshtml +++ b/src/Elastic.Markdown/Page/MetadataBox.cshtml @@ -5,93 +5,65 @@ if (layout is not null) return; - var prefix = Model.UrlPathPrefix?.TrimEnd('/') ?? string.Empty; - var productHubs = Model.Products - .Where(p => !string.IsNullOrWhiteSpace(p.Hub)) - .OrderBy(p => p.DisplayName, StringComparer.Ordinal) - .ToList(); - var hasStackVersions = false; var hasDeployments = false; - var hasOtherProducts = false; if (Model.AppliesTo is not null) { hasStackVersions = Model.AppliesTo.Stack is not null || (Model.AppliesTo.Deployment is null && Model.AppliesTo.Serverless is not null); hasDeployments = Model.AppliesTo.Deployment is not null; - hasOtherProducts = Model.AppliesTo.ProductApplicability is not null - || Model.AppliesTo.Product is not null; } - var hasProducts = productHubs.Count > 0; - if (!hasProducts && !hasStackVersions && !hasDeployments && !hasOtherProducts) + // Subscription is a placeholder until the metadata exists. Hardcoded so the + // row is always visible alongside the real applies_to data. + const string subscriptionPlaceholder = "Enterprise"; + + if (!hasStackVersions && !hasDeployments) return; } - + + diff --git a/src/Elastic.Markdown/Page/ProductPills.cshtml b/src/Elastic.Markdown/Page/ProductPills.cshtml new file mode 100644 index 0000000000..f601189ff6 --- /dev/null +++ b/src/Elastic.Markdown/Page/ProductPills.cshtml @@ -0,0 +1,29 @@ +@inherits RazorSlice +@{ + var layout = Model.CurrentDocument.YamlFrontMatter?.Layout; + if (layout is not null) + return; + + var prefix = Model.UrlPathPrefix?.TrimEnd('/') ?? string.Empty; + var productHubs = Model.Products + .Where(p => !string.IsNullOrWhiteSpace(p.Hub)) + .OrderBy(p => p.DisplayName, StringComparer.Ordinal) + .ToList(); + + if (productHubs.Count == 0) + return; +} + From f5fea776f24cbd9c49f96c9a1415751ec4369cd4 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 4 May 2026 14:03:46 +0200 Subject: [PATCH 25/56] fix: remove unnecessary null-forgiving operators flagged as IDE0370 The compiler already proves non-null via the IsNullOrWhiteSpace guards, so the ! suppressions are redundant and treated as errors by the analyzer. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Markdown/IO/MarkdownFile.cs | 2 +- src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs | 4 ++-- src/Elastic.Markdown/Myst/Directives/Hub/OnThisPageBlock.cs | 4 ++-- src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index 84edc11c79..42500c9123 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -175,7 +175,7 @@ protected void ReadDocumentInstructions(MarkdownDocument document, Func CollectItems() switch (descendant) { case CardGroupBlock g when !string.IsNullOrWhiteSpace(g.Anchor) && !string.IsNullOrWhiteSpace(g.Title): - items.Add(new OnThisPageItem(g.Title!, g.Anchor!)); + items.Add(new OnThisPageItem(g.Title, g.Anchor)); break; case WhatsNewBlock w when !string.IsNullOrWhiteSpace(w.Data.Id) && !string.IsNullOrWhiteSpace(w.Data.Title): - items.Add(new OnThisPageItem(w.Data.Title!, w.Data.Id!)); + items.Add(new OnThisPageItem(w.Data.Title, w.Data.Id)); break; } } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs index 59202757cc..f61250d815 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs @@ -42,7 +42,7 @@ public override void FinalizeAndValidate(ParserContext context) if (!string.IsNullOrWhiteSpace(product)) { - var resolved = LoadFromCentralConfig(product!); + var resolved = LoadFromCentralConfig(product); if (resolved is null) { this.EmitError($"{{whats-new}} :product: '{product}' was not found in config/whats-new.yml."); @@ -94,7 +94,7 @@ public override void FinalizeAndValidate(ParserContext context) } public override IEnumerable GeneratedAnchors => - string.IsNullOrWhiteSpace(Data.Id) ? [] : [Data.Id!]; + string.IsNullOrWhiteSpace(Data.Id) ? [] : [Data.Id]; } [YamlSerializable] From 04f59da5b7c97688ed4b3009fcbc3f1e96b02322 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 4 May 2026 14:14:22 +0200 Subject: [PATCH 26/56] fix(tests): filter page-title-row div from HTML assertions hub-pages moved the H1 title into a `div.page-title-row` wrapper (alongside product badges). The test framework's prettyHtml filter only stripped bare H1 as the first section child; update it to also strip any element with class page-title-row so tests don't need to include the title wrapper in their expected HTML snippets. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- tests/authoring/Framework/HtmlAssertions.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/authoring/Framework/HtmlAssertions.fs b/tests/authoring/Framework/HtmlAssertions.fs index eaa648d322..0f025cfe01 100644 --- a/tests/authoring/Framework/HtmlAssertions.fs +++ b/tests/authoring/Framework/HtmlAssertions.fs @@ -105,7 +105,7 @@ actual: {actual} let formatter = PrettyMarkupFormatter() element.Children |> Seq.indexed - |> Seq.filter (fun (i, c) -> (not <| (i = 0 && c.TagName = "H1"))) + |> Seq.filter (fun (i, c) -> not (c.ClassList.Contains("page-title-row")) && not (i = 0 && c.TagName = "H1")) |> Seq.map(fun (_, c) -> c) |> Seq.iter _.ToHtml(sw, formatter) sw.ToString().TrimStart('\n') From 20a3966d5f1dec3ee907b75faee549b12c268b50 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 4 May 2026 16:12:02 +0200 Subject: [PATCH 27/56] feat(hub-pages): wire hub pages through assembler preview - assembler.yml: point narrative (docs-content) at hub-pages branch so the preview build checks out the branch containing the hub page files - navigation.yml: add toc: products so the assembler builds and nav-positions the hub pages from docs-content://products/ - navigation-v2.yml: replace the external gist link with real page: entries for Kibana and Elasticsearch hub pages in docs-content Together with the PageCrossLinkLeaf fix (already merged from nav-v2), this wires hub pages end-to-end through the assembler preview build. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- config/assembler.yml | 1 + config/navigation-v2.yml | 12 ++++++------ config/navigation.yml | 1 + 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/config/assembler.yml b/config/assembler.yml index e8e2acd183..e8092aabf0 100644 --- a/config/assembler.yml +++ b/config/assembler.yml @@ -73,6 +73,7 @@ shared_configuration: ### narrative: checkout_strategy: full + current: hub-pages ### # 'references' defines a map of `elastic/ * diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index b6704bd304..39f9aea547 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -3307,13 +3307,13 @@ nav: children: - toc: docs-content://release-notes/intro - # Until hub pages are produced through the V1 navigation/toc chain so the - # assembler can resolve them, this section is an external link to the - # Kibana gist preview -- the visual reference for the prototype, with its - # own island-style nav and stubs already wired up. - # https://gist.github.com/florent-leborgne/c9075416b50ec111a09897b7be4462cc - section: Product hubs - url: https://htmlpreview.github.io/?https://gist.github.com/florent-leborgne/c9075416b50ec111a09897b7be4462cc/raw/kibana-hub.html + url: /products/ + children: + - page: docs-content://products/kibana/v9.md + title: Kibana + - page: docs-content://products/elasticsearch/v9.md + title: Elasticsearch - section: Extension points url: /extend/ diff --git a/config/navigation.yml b/config/navigation.yml index 01d93faeec..3bf8dd5dda 100644 --- a/config/navigation.yml +++ b/config/navigation.yml @@ -17,6 +17,7 @@ toc: ############# # NARRATIVE # ############# + - toc: products - toc: get-started - toc: solutions - toc: manage-data From ca9489ea1a1a9073c46d128c28f2e5187eb865a5 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 4 May 2026 16:27:29 +0200 Subject: [PATCH 28/56] style: fix Prettier formatting in pages-nav-v2.ts and styles.css Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Assets/pages-nav-v2.ts | 34 +++++++++---------- .../Assets/styles.css | 5 +-- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts index 6f49ca75cc..7582c5e01b 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts @@ -381,25 +381,25 @@ function scheduleNavV2CollapsedFolderLayoutWarmup( function initNavV2FolderLayoutWarmup(nav: HTMLElement) { primeNavV2FolderLayoutsSync(nav, 14) - nav.querySelectorAll('li.group-navigation > .nav-folder-peer').forEach( - (peer) => { - if (peer.dataset.navV2PointerWarmBound === 'true') { - return - } - - peer.dataset.navV2PointerWarmBound = 'true' - const warm = () => { - warmFolderSubtreeLayoutFromPeer(peer) - } + nav.querySelectorAll( + 'li.group-navigation > .nav-folder-peer' + ).forEach((peer) => { + if (peer.dataset.navV2PointerWarmBound === 'true') { + return + } - peer.addEventListener('pointerenter', warm, { passive: true }) - /* - * Runs immediately before click (after hover path): pays layout once so the grid - * transition is less likely to share a frame with the first full subtree measure. - */ - peer.addEventListener('pointerdown', warm, { passive: true }) + peer.dataset.navV2PointerWarmBound = 'true' + const warm = () => { + warmFolderSubtreeLayoutFromPeer(peer) } - ) + + peer.addEventListener('pointerenter', warm, { passive: true }) + /* + * Runs immediately before click (after hover path): pays layout once so the grid + * transition is less likely to share a frame with the first full subtree measure. + */ + peer.addEventListener('pointerdown', warm, { passive: true }) + }) scheduleNavV2CollapsedFolderLayoutWarmup(nav, 14) } diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 1c651b0a35..c29f5ea3e6 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -222,7 +222,9 @@ body:has([data-nav-v2]) aside.sidebar { nav[data-nav-v2] li > .docs-sidebar-nav-v2__label--top - + ul.docs-sidebar-nav-v2__subsection:has(> li > .docs-sidebar-nav-v2__label--nested) { + + ul.docs-sidebar-nav-v2__subsection:has( + > li > .docs-sidebar-nav-v2__label--nested + ) { gap: 0.5rem !important; } @@ -612,7 +614,6 @@ nav.docs-sidebar-nav-v2 transform: rotate(0deg); } - @layer components { .sidebar-trial-card { background: var(--color-green-30); From 10330f7158edea9a0568bc5e8fe61fe0f9c96dc4 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 4 May 2026 16:34:27 +0200 Subject: [PATCH 29/56] fix(assembler): warn instead of error when branch has no link index entry When the configured branch (e.g. a prototype/dev branch) has never been published, it won't have a link index entry. Fall back to checking out the branch directly by name rather than blocking the entire build with an error. Cross-link resolution will use whatever entries are available. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Sourcing/RepositorySourcesFetcher.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/services/Elastic.Documentation.Assembler/Sourcing/RepositorySourcesFetcher.cs b/src/services/Elastic.Documentation.Assembler/Sourcing/RepositorySourcesFetcher.cs index f53f37c40d..d2b01e5b61 100644 --- a/src/services/Elastic.Documentation.Assembler/Sourcing/RepositorySourcesFetcher.cs +++ b/src/services/Elastic.Documentation.Assembler/Sourcing/RepositorySourcesFetcher.cs @@ -97,10 +97,13 @@ await Task.Run(() => { if (!entry.TryGetValue(branch, out var entryInfo)) { - context.Collector.EmitError("", $"'{repo.Key}' does not have a '{branch}' entry in link index"); - return; + // Branch has no published link index (e.g. a prototype or dev branch). + // Warn and check out the branch directly without a pinned git ref; + // cross-link resolution will use whatever index entries are available. + context.Collector.EmitWarning("", $"'{repo.Key}' does not have a '{branch}' entry in link index, checking out branch directly"); } - gitRef = entryInfo.GitReference; + else + gitRef = entryInfo.GitReference; } var cloneInformation = RepositorySourcer.CloneRef(repo.Value, gitRef, fetchLatest, assumeCloned: assumeCloned); From a1262cdec1141103d005ed2778e2f7c27f156feb Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 4 May 2026 16:42:16 +0200 Subject: [PATCH 30/56] fix(assembler): gracefully handle unpublished branches in cross-link fetching For prototype/dev branches (e.g. hub-pages) that have never been deployed and therefore have no link index entry: - RepositoryPublishValidationService: emit warnings instead of errors so validate-assembler doesn't block the build - CrossLinkFetcher.FetchCrossLinks: fall back to main/master cross-link data rather than throwing when the requested branch isn't in the index Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../CrossLinks/CrossLinkFetcher.cs | 7 +++++++ .../ContentSources/RepositoryPublishValidationService.cs | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index 893754c2d0..a6f00f9d9c 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -107,6 +107,13 @@ protected async Task FetchCrossLinks(string repository, string[ return await FetchLinkIndexEntry(repository, linkIndexEntry, ctx); } + // Branch not published yet (e.g. a prototype/dev branch) — fall back to main or master. + foreach (var fallback in new[] { "main", "master" }) + { + if (repositoryLinks.TryGetValue(fallback, out var fallbackEntry)) + return await FetchLinkIndexEntry(repository, fallbackEntry, ctx); + } + throw new Exception($"Repository found in link index however none of: '{string.Join(", ", keys)}' branches found"); } diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs index 8f11b65b68..0c67cc354d 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs @@ -47,13 +47,13 @@ public async Task ValidatePublishStatus(IDiagnosticsCollector collector, C var next = repository.GetBranch(ContentSource.Next); if (!registryMapping.TryGetValue(next, out _)) { - collector.EmitError(reportPath, + collector.EmitWarning(reportPath, $"'{repository.Name}' has not yet published links.json for configured 'next' content source: '{next}' see {linkIndexReader.RegistryUrl}"); } if (!registryMapping.TryGetValue(current, out _)) { - collector.EmitError(reportPath, + collector.EmitWarning(reportPath, $"'{repository.Name}' has not yet published links.json for configured 'current' content source: '{current}' see {linkIndexReader.RegistryUrl}"); } } From c6b9d67beca99a5b4ac16a91e99e93a7a08769c3 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 4 May 2026 17:09:40 +0200 Subject: [PATCH 31/56] fix(nav): resolve V2 page entries from nested TOCs V2 page links need to register their source files after the V2 tree is built so assembler layouts can position pages that are only surfaced through navigation-v2.yml. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../NavigationItemExtensions.cs | 1 + .../V2/PageCrossLinkLeaf.cs | 2 + .../V2/PageFolderNavigationNode.cs | 3 + .../V2/SiteNavigationV2.cs | 171 ++++++++++++++++++ .../Assembler/SiteNavigationTests.cs | 76 ++++++++ 5 files changed, 253 insertions(+) diff --git a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs index 9c6e7cf815..762561469d 100644 --- a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs +++ b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs @@ -122,6 +122,7 @@ private static void BuildNavigationLookupsRecursive( _ = navigationByOrder.TryAdd(documentationFileNode.NavigationIndex, documentationFileNode); _ = navigationByOrder.TryAdd(documentationFileNode.Index.NavigationIndex, documentationFileNode.Index); _ = urlToFile.TryAdd(documentationFileNode.Url, documentationFileNode.Index.Model); + _ = urlToFile.TryAdd(documentationFileNode.Index.Url, documentationFileNode.Index.Model); foreach (var child in documentationFileNode.NavigationItems) BuildNavigationLookupsRecursive(child, navigationDocumentationFileLookup, navigationByOrder, urlToFile); break; diff --git a/src/Elastic.Documentation.Navigation/V2/PageCrossLinkLeaf.cs b/src/Elastic.Documentation.Navigation/V2/PageCrossLinkLeaf.cs index f1210849db..51927aaf14 100644 --- a/src/Elastic.Documentation.Navigation/V2/PageCrossLinkLeaf.cs +++ b/src/Elastic.Documentation.Navigation/V2/PageCrossLinkLeaf.cs @@ -15,6 +15,8 @@ public class PageCrossLinkLeaf( INodeNavigationItem? parent ) : ILeafNavigationItem, INavigationModel { + public Uri Page { get; } = page; + /// public INavigationModel Model => this; diff --git a/src/Elastic.Documentation.Navigation/V2/PageFolderNavigationNode.cs b/src/Elastic.Documentation.Navigation/V2/PageFolderNavigationNode.cs index a610f70a4b..b19b2994b4 100644 --- a/src/Elastic.Documentation.Navigation/V2/PageFolderNavigationNode.cs +++ b/src/Elastic.Documentation.Navigation/V2/PageFolderNavigationNode.cs @@ -25,6 +25,7 @@ public PageFolderNavigationNode( { Id = ShortId.Create("page-folder", title); NavigationTitle = title; + Page = page; Url = ResolveUrl(page, sitePrefix); NavigationItems = children; Parent = parent; @@ -35,6 +36,8 @@ public PageFolderNavigationNode( /// public string Id { get; } + public Uri Page { get; } + /// public string Url { get; } diff --git a/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs b/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs index 1f6057df51..4b9cf4f5a7 100644 --- a/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs +++ b/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs @@ -34,6 +34,7 @@ public SiteNavigationV2( { var prefix = sitePrefix ?? string.Empty; V2NavigationItems = BuildV2Items(v2File.Nav, Nodes, this, prefix); + RegisterV2PageLookups(); Sections = BuildSections(V2NavigationItems); Islands = BuildIslands(Sections); BuildUrlToSectionLookup(); @@ -153,6 +154,176 @@ private void BuildTocRootToIslandLookup() _ = _tocRootToIsland.TryAdd(island.SourceTocRootId, island); } + private void RegisterV2PageLookups() + { + var urlToFile = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var node in Nodes.Values) + CollectDocumentationFilesByUrl(node, urlToFile, Url); + + RegisterV2PageLookups(V2NavigationItems, urlToFile); + } + + private void RegisterV2PageLookups( + IEnumerable items, + IReadOnlyDictionary urlToFile + ) + { + foreach (var item in items) + { + switch (item) + { + case PageCrossLinkLeaf pageCrossLink: + RegisterV2PageLookup(pageCrossLink.Page, pageCrossLink, urlToFile); + break; + case PageFolderNavigationNode pageFolder: + RegisterV2PageLookup(pageFolder.Page, pageFolder, urlToFile); + break; + } + + if (item is INodeNavigationItem node) + RegisterV2PageLookups(node.NavigationItems, urlToFile); + } + } + + private void RegisterV2PageLookup( + Uri page, + INavigationItem item, + IReadOnlyDictionary urlToFile + ) + { + if (TryResolvePageSource(page, out var file) || urlToFile.TryGetValue(item.Url, out file)) + _ = NavigationDocumentationFileLookup.TryAdd(file, item); + } + + private bool TryResolvePageSource(Uri page, out IDocumentationFile file) + { + file = null!; + var pagePath = GetUriPath(page); + if (string.IsNullOrEmpty(pagePath)) + return false; + + var segments = pagePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + for (var length = segments.Length; length > 0; length--) + { + var tocPath = string.Join('/', segments.Take(length)); + if (!Nodes.TryGetValue(CreateTocUri(page.Scheme, tocPath), out var node)) + continue; + + var remainingPath = string.Join('/', segments.Skip(length)); + if (string.IsNullOrEmpty(remainingPath)) + { + file = node.Index.Model; + return true; + } + + if (TryFindDocumentationFile(node, remainingPath, out file)) + return true; + } + + return false; + } + + private static bool TryFindDocumentationFile( + INavigationItem item, + string remainingPath, + out IDocumentationFile file + ) + { + switch (item) + { + case ILeafNavigationItem leaf when MatchesRemainingPath(leaf.Url, remainingPath): + file = leaf.Model; + return true; + case INodeNavigationItem node: + if (MatchesRemainingPath(node.Index.Url, remainingPath)) + { + file = node.Index.Model; + return true; + } + foreach (var child in node.NavigationItems) + { + if (TryFindDocumentationFile(child, remainingPath, out file)) + return true; + } + break; + case INodeNavigationItem node: + foreach (var child in node.NavigationItems) + { + if (TryFindDocumentationFile(child, remainingPath, out file)) + return true; + } + break; + } + + file = null!; + return false; + } + + private static bool MatchesRemainingPath(string url, string remainingPath) + { + var normalizedPath = NormalizePagePath(remainingPath); + return url.TrimEnd('/').EndsWith($"/{normalizedPath}", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePagePath(string path) + { + var normalized = path.Trim('/'); + if (normalized.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^3]; + if (normalized.EndsWith("/index", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^6]; + return normalized; + } + + private static Uri CreateTocUri(string scheme, string path) + { + var slash = path.IndexOf('/'); + return slash < 0 + ? new Uri($"{scheme}://{path}") + : new Uri($"{scheme}://{path[..slash]}/{path[(slash + 1)..]}"); + } + + private static string GetUriPath(Uri uri) => (uri.Host + uri.AbsolutePath).Trim('/'); + + private static void CollectDocumentationFilesByUrl( + INavigationItem item, + Dictionary urlToFile, + string sitePrefix + ) + { + switch (item) + { + case ILeafNavigationItem leaf: + AddDocumentationFileUrl(urlToFile, leaf.Url, leaf.Model, sitePrefix); + break; + case INodeNavigationItem node: + AddDocumentationFileUrl(urlToFile, node.Url, node.Index.Model, sitePrefix); + AddDocumentationFileUrl(urlToFile, node.Index.Url, node.Index.Model, sitePrefix); + foreach (var child in node.NavigationItems) + CollectDocumentationFilesByUrl(child, urlToFile, sitePrefix); + break; + case INodeNavigationItem node: + foreach (var child in node.NavigationItems) + CollectDocumentationFilesByUrl(child, urlToFile, sitePrefix); + break; + } + } + + private static void AddDocumentationFileUrl( + Dictionary urlToFile, + string url, + IDocumentationFile file, + string sitePrefix + ) + { + _ = urlToFile.TryAdd(url, file); + + if (sitePrefix == "/" || string.IsNullOrEmpty(url) || !url.StartsWith('/')) + return; + + _ = urlToFile.TryAdd($"{sitePrefix.TrimEnd('/')}{url}", file); + } + private static IReadOnlyList BuildV2Items( IReadOnlyList v2Items, IReadOnlyDictionary> nodes, diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs index c79316636d..89bf6a0914 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Node; using Elastic.Documentation.Navigation.Tests.Isolation; +using Elastic.Documentation.Navigation.V2; namespace Elastic.Documentation.Navigation.Tests.Assembler; @@ -305,4 +306,79 @@ public void SitePrefixAppliedToMultipleNavigationItems(string? sitePrefix, strin var searchItem = navigation.NavigationItems.Skip(1).First(); searchItem.Url.Should().Be(expectedSearchUrl, $"search URL should be '{expectedSearchUrl}' with sitePrefix '{sitePrefix}'"); } + + [Fact] + public void SiteNavigationV2PageEntriesResolveFilesFromUnseenChildTocs() + { + var fileSystem = new MockFileSystem(); + var docsContentDir = "/checkouts/current/docs-content"; + + // language=yaml + var docsetYaml = """ + project: Docs content + toc: + - file: index.md + - toc: products + """; + fileSystem.AddFile($"{docsContentDir}/docs/docset.yml", new MockFileData(docsetYaml)); + fileSystem.AddFile($"{docsContentDir}/docs/index.md", new MockFileData("# Docs content")); + + // language=yaml + var productsTocYaml = """ + toc: + - file: index.md + - toc: elasticsearch + """; + fileSystem.AddFile($"{docsContentDir}/docs/products/toc.yml", new MockFileData(productsTocYaml)); + fileSystem.AddFile($"{docsContentDir}/docs/products/index.md", new MockFileData("# Product hubs")); + + // language=yaml + var elasticsearchTocYaml = """ + toc: + - file: v9.md + """; + fileSystem.AddFile($"{docsContentDir}/docs/products/elasticsearch/toc.yml", new MockFileData(elasticsearchTocYaml)); + fileSystem.AddFile($"{docsContentDir}/docs/products/elasticsearch/v9.md", new MockFileData("# Elasticsearch")); + + // language=yaml + var siteNavYaml = """ + toc: + - toc: products + """; + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + + // language=yaml + var v2NavYaml = """ + nav: + - section: Product hubs + url: /products/ + children: + - page: docs-content://products/elasticsearch/v9.md + title: Elasticsearch + """; + var v2File = NavigationV2File.Deserialize(v2NavYaml); + + var docsContentContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, docsContentDir, output); + var docset = DocumentationSetFile.LoadAndResolve( + docsContentContext.Collector, + fileSystem.FileInfo.New($"{docsContentDir}/docs/docset.yml"), + FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts") + ); + var docsContentNav = new DocumentationSetNavigation( + docset, + docsContentContext, + GenericDocumentationFileFactory.Instance + ); + + var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, docsContentDir, output); + var navigation = new SiteNavigationV2(v2File, siteNavFile, siteContext, [docsContentNav], sitePrefix: "docs"); + + var elasticsearchToc = navigation.Nodes[new Uri("docs-content://products/elasticsearch")]; + var elasticsearchFile = elasticsearchToc.Index.Model; + + navigation.NavigationDocumentationFileLookup.TryGetValue(elasticsearchFile, out var navigationItem) + .Should().BeTrue("V2 page entries should map docs files even when their child TOC is not declared in navigation.yml"); + navigationItem.Should().BeOfType(); + navigationItem.Url.Should().Be("/docs/products/elasticsearch/v9"); + } } From d556dff3c9f26911e00af4f4c2d3a264e869429e Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 4 May 2026 17:20:59 +0200 Subject: [PATCH 32/56] fix(nav): register files from unseen child TOCs Assembler builds can process pages from nested TOCs that are not explicitly declared in global navigation, so register those files for positional lookup and let V2 page entries replace them when present. Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- .../Assembler/SiteNavigation.cs | 5 ++ .../NavigationItemExtensions.cs | 29 +++++++++ .../V2/SiteNavigationV2.cs | 5 +- .../Assembler/SiteNavigationTests.cs | 63 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs index 291c80bed3..0e59fb4cd6 100644 --- a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs @@ -104,6 +104,11 @@ public SiteNavigation( // Build positional navigation lookup tables from all navigation items in a single traversal NavigationDocumentationFileLookup = []; NavigationIndexedByOrder = this.BuildNavigationLookups(NavigationDocumentationFileLookup); + foreach (var node in UnseenNodes) + { + if (_nodes.TryGetValue(node, out var value)) + value.AddNavigationFileLookups(NavigationDocumentationFileLookup); + } } public HashSet DeclaredPhantoms { get; } diff --git a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs index 762561469d..c851904805 100644 --- a/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs +++ b/src/Elastic.Documentation.Navigation/NavigationItemExtensions.cs @@ -94,6 +94,12 @@ public static FrozenDictionary BuildNavigationLookups( return navigationByOrder.ToFrozenDictionary(); } + public static void AddNavigationFileLookups( + this INavigationItem rootItem, + ConditionalWeakTable navigationDocumentationFileLookup + ) => + AddNavigationFileLookupsRecursive(rootItem, navigationDocumentationFileLookup); + /// /// Recursively builds both NavigationDocumentationFileLookup and NavigationIndexedByOrder in a single traversal, /// also collecting a URL-to-file map used by the second pass to resolve V2 page: entries. @@ -134,6 +140,29 @@ private static void BuildNavigationLookupsRecursive( } } + private static void AddNavigationFileLookupsRecursive( + INavigationItem item, + ConditionalWeakTable navigationDocumentationFileLookup) + { + switch (item) + { + case CrossLinkNavigationLeaf: + break; + case ILeafNavigationItem documentationFileLeaf: + _ = navigationDocumentationFileLookup.TryAdd(documentationFileLeaf.Model, documentationFileLeaf); + break; + case INodeNavigationItem documentationFileNode: + _ = navigationDocumentationFileLookup.TryAdd(documentationFileNode.Index.Model, documentationFileNode); + foreach (var child in documentationFileNode.NavigationItems) + AddNavigationFileLookupsRecursive(child, navigationDocumentationFileLookup); + break; + case INodeNavigationItem node: + foreach (var child in node.NavigationItems) + AddNavigationFileLookupsRecursive(child, navigationDocumentationFileLookup); + break; + } + } + /// /// Second pass: for every V2 page: entry () whose URL /// matches a file already known from the V1 toc traversal, register that file in the lookup diff --git a/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs b/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs index 4b9cf4f5a7..7d9f1aa8ff 100644 --- a/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs +++ b/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs @@ -192,7 +192,10 @@ IReadOnlyDictionary urlToFile ) { if (TryResolvePageSource(page, out var file) || urlToFile.TryGetValue(item.Url, out file)) - _ = NavigationDocumentationFileLookup.TryAdd(file, item); + { + _ = NavigationDocumentationFileLookup.Remove(file); + NavigationDocumentationFileLookup.Add(file, item); + } } private bool TryResolvePageSource(Uri page, out IDocumentationFile file) diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs index 89bf6a0914..27daf3f7cb 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs @@ -381,4 +381,67 @@ public void SiteNavigationV2PageEntriesResolveFilesFromUnseenChildTocs() navigationItem.Should().BeOfType(); navigationItem.Url.Should().Be("/docs/products/elasticsearch/v9"); } + + [Fact] + public void SiteNavigationResolvesFilesFromUnseenChildTocs() + { + var fileSystem = new MockFileSystem(); + var docsContentDir = "/checkouts/current/docs-content"; + + // language=yaml + var docsetYaml = """ + project: Docs content + toc: + - file: index.md + - toc: products + """; + fileSystem.AddFile($"{docsContentDir}/docs/docset.yml", new MockFileData(docsetYaml)); + fileSystem.AddFile($"{docsContentDir}/docs/index.md", new MockFileData("# Docs content")); + + // language=yaml + var productsTocYaml = """ + toc: + - file: index.md + - toc: elasticsearch + """; + fileSystem.AddFile($"{docsContentDir}/docs/products/toc.yml", new MockFileData(productsTocYaml)); + fileSystem.AddFile($"{docsContentDir}/docs/products/index.md", new MockFileData("# Product hubs")); + + // language=yaml + var elasticsearchTocYaml = """ + toc: + - file: v9.md + """; + fileSystem.AddFile($"{docsContentDir}/docs/products/elasticsearch/toc.yml", new MockFileData(elasticsearchTocYaml)); + fileSystem.AddFile($"{docsContentDir}/docs/products/elasticsearch/v9.md", new MockFileData("# Elasticsearch")); + + // language=yaml + var siteNavYaml = """ + toc: + - toc: products + """; + var siteNavFile = SiteNavigationFile.Deserialize(siteNavYaml); + + var docsContentContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, docsContentDir, output); + var docset = DocumentationSetFile.LoadAndResolve( + docsContentContext.Collector, + fileSystem.FileInfo.New($"{docsContentDir}/docs/docset.yml"), + FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts") + ); + var docsContentNav = new DocumentationSetNavigation( + docset, + docsContentContext, + GenericDocumentationFileFactory.Instance + ); + + var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, docsContentDir, output); + var navigation = new SiteNavigation(siteNavFile, siteContext, [docsContentNav], sitePrefix: "docs"); + + var elasticsearchToc = navigation.Nodes[new Uri("docs-content://products/elasticsearch")]; + var elasticsearchFile = elasticsearchToc.Index.Model; + + navigation.NavigationDocumentationFileLookup.TryGetValue(elasticsearchFile, out var navigationItem) + .Should().BeTrue("unseen child TOCs can still own files that the assembler processes"); + navigationItem.Should().BeSameAs(elasticsearchToc); + } } From 65bc6de90ab1068f506661063d4212d699df44b4 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 4 May 2026 19:28:30 +0200 Subject: [PATCH 33/56] Refine page-top metadata: pills inside Availability box, text+? tooltips Successive refinements after the initial restructure: - Move product pills from above the H1 into the box's title row, alongside the "This page applies to" label. Drops ProductPills.cshtml partial and the page-meta divider; the box is now the single page-top metadata zone. - Drop the Subscription placeholder row and its CSS. - Render applies_to entries as plain text + a small "?" icon (CSS-only reskin of the existing applies-to-popover), comma-separated. The "?" still triggers the rich tooltip on hover/click. No more "or" separator. - Sort items so Serverless lands first, then other available items, with unavailable items at the end (struck-through + dimmed). - Title typography lifts the row above the rest of the box without shouting (15px / 700 / ink-dark vs 13px / 600 / grey-80 row labels). - Stack tooltip description now substitutes {base}/{current}/{base-major} from the VersioningSystem so the tooltip surfaces the actual range ("Elastic Stack 9.0.0 to 9.4.0...") instead of a static blurb. - All four Serverless tooltip descriptions append a sentence noting that Serverless UI/capabilities can differ from versioned Elastic Stack. Co-Authored-By: Claude Sonnet 4.6 --- .../Assets/markdown/metadata-box.css | 303 ++++++++++-------- .../Myst/Components/ApplicabilityRenderer.cs | 20 +- .../Myst/Components/ApplicableToViewModel.cs | 19 +- .../Myst/Components/ProductDescriptions.cs | 13 +- src/Elastic.Markdown/Page/Index.cshtml | 2 - src/Elastic.Markdown/Page/MetadataBox.cshtml | 107 ++++--- src/Elastic.Markdown/Page/ProductPills.cshtml | 29 -- 7 files changed, 276 insertions(+), 217 deletions(-) delete mode 100644 src/Elastic.Markdown/Page/ProductPills.cshtml diff --git a/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css b/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css index 08e6357a16..7d90dca7fc 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/metadata-box.css @@ -1,39 +1,184 @@ /* - * Page-top product pills + Requirements box. The pills sit just below the - * H1 (one per `products:` frontmatter entry whose product has `hub:` set); - * the box sits below the pills and surfaces applies_to-driven metadata in - * a labeled two-column layout. Hub layout opts out (the partials return - * early when YamlFrontMatter.Layout is set). + * Page-top "Availability" box. Sits below the H1 and consolidates two + * page-level metadata systems in a single container: + * + * - Product hub pills, in the summary row alongside the "Availability" + * label, for navigation to each product's hub page. + * - applies_to-driven rows (Versions / Deployments) below the summary, + * collapsible via native
    , expanded by default. + * + * Hub layout (and any frontmatter Layout) opts out -- the partial returns + * early. The box only renders when at least one of pills or rows exists. */ @layer components { - /* - * Product hub pills below the H1. Brand-color dot + display name. - */ - /* Thin separator that sits between the H1 and the Requirements box, - carrying the visual weight the old applies-to summary line used to - provide. Keeps the page-top chrome anchored. */ - .page-meta-divider { - height: 0; - margin: 18px 0; - border: none; + .metadata-box { + margin: 16px 0 24px 0; + padding: 8px 14px; + background: var(--color-grey-10); + border: 1px solid var(--color-grey-20); + border-radius: 6px; + font-size: 13px; + line-height: 1.4; + } + + .metadata-box-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; + cursor: pointer; + list-style: none; + padding: 2px 0 6px 0; + user-select: none; + } + .metadata-box-heading::-webkit-details-marker { + display: none; + } + .metadata-box-heading:focus-visible { + outline: 2px solid var(--color-blue-elastic); + outline-offset: 2px; + border-radius: 3px; + } + + .metadata-box-summary-only .metadata-box-heading { + cursor: default; + } + + /* Title sits one step above the row labels in the visual hierarchy: + slightly larger, heavier weight, full ink color. Row labels stay + smaller and lighter, so the title reads as the box's heading and + the pills next to it become the headlined entries. */ + .metadata-box-title { + flex: 0 0 auto; + font-size: 15px; + font-weight: 700; + color: var(--color-ink-dark); + } + + .metadata-box-pills { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + flex: 1 1 auto; + } + + .metadata-box-chevron { + flex-shrink: 0; + margin-left: auto; + color: var(--color-grey-60); + transition: transform 0.15s ease; + } + .metadata-box[open] .metadata-box-chevron { + transform: rotate(180deg); + } + + .metadata-box-rows { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; + padding-top: 10px; border-top: 1px solid var(--color-grey-20); } - .product-pills { + .metadata-row { display: flex; flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + min-height: 24px; + } + + .metadata-label { + flex: 0 0 auto; + min-width: 100px; + font-weight: 600; + color: var(--color-grey-80); + } + + .metadata-values { + display: inline-flex; + flex-wrap: wrap; + align-items: center; gap: 6px; + min-width: 0; + } + + /* + * Render applies_to entries as plain comma-separated text + a small + * "?" icon that carries the existing popover tooltip. Implemented in + * CSS so we don't touch the popover web component: + * - Override the global `display: contents` on the popover host so + * it has its own box (and ::before content can render). + * - Use ::before with `attr(badge-key)` to print the entry's name. + * - Reskin the visible `.applicable-info` pill into a 16px "?" + * circle that still triggers the tooltip on hover/click. + */ + .metadata-values.applies applies-to-popover { + display: inline-flex; + align-items: center; + color: var(--color-ink-dark); + } + .metadata-values applies-to-popover::before { + content: attr(badge-key); + } + .metadata-values applies-to-popover:not(:first-child)::before { + content: ', ' attr(badge-key); + } + + .metadata-values .applicable-info { + all: unset; + display: inline-flex; align-items: center; - /* Pull tight against the breadcrumb's bottom padding so the pills - read as part of the breadcrumb strip, not a separate row. */ - margin: -16px 0 14px 0; + justify-content: center; + width: 14px; + height: 14px; + margin-left: 4px; + border-radius: 999px; + background: var(--color-grey-20); + color: var(--color-grey-80); + font-size: 10px; + font-weight: 700; + line-height: 1; + cursor: help; + transition: + background-color 0.15s ease, + color 0.15s ease; + } + .metadata-values .applicable-info::before { + content: '?'; + } + .metadata-values .applicable-info > * { + display: none; + } + .metadata-values .applicable-info:hover { + background: var(--color-grey-30); + color: var(--color-ink-dark); } - /* Override `.markdown-content a` (blue + underline) with a more - specific selector. Pills are nav affordances, not body links. - `!important` on color + decoration is a safety net against any - utility-layer rules that beat us on cascade order. */ + /* Unavailable items (sorted to the end by ApplicableToViewModel) render + dimmed + struck-through to read as "not in scope" rather than as a + peer alternative. */ + .metadata-values applies-to-popover[lifecycle-class='unavailable']::before { + text-decoration: line-through; + text-decoration-color: var(--color-ink-light); + color: var(--color-ink-light); + } + .metadata-values + applies-to-popover[lifecycle-class='unavailable'] + .applicable-info { + opacity: 0.65; + } + + /* + * Product hub pills (used inside the metadata box's summary row). + * Brand-color dot + display name + arrow. Rendered as anchors, so we + * override `.markdown-content a` (blue + underline) explicitly -- + * `!important` is the safety net against utility-layer rules that beat + * us on cascade order. + */ .markdown-content a.product-badge, a.product-badge { display: inline-flex; @@ -125,118 +270,6 @@ background: rgb(0 191 179 / 0.06); } - /* - * Requirements box. Two columns: a heading on the left that titles the - * whole box, and a stack of labeled rows on the right. Hidden when no - * applies_to-driven row would render. - */ - .metadata-box { - margin: 16px 0 24px 0; - padding: 8px 14px; - background: var(--color-grey-10); - border: 1px solid var(--color-grey-20); - border-radius: 6px; - font-size: 13px; - line-height: 1.4; - } - - .metadata-box-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - cursor: pointer; - list-style: none; - padding: 4px 0; - font-size: 14px; - font-weight: 600; - color: var(--color-ink-dark); - user-select: none; - } - .metadata-box-heading::-webkit-details-marker { - display: none; - } - .metadata-box-heading:focus-visible { - outline: 2px solid var(--color-blue-elastic); - outline-offset: 2px; - border-radius: 3px; - } - - .metadata-box-chevron { - flex-shrink: 0; - color: var(--color-grey-60); - transition: transform 0.15s ease; - } - .metadata-box[open] .metadata-box-chevron { - transform: rotate(180deg); - } - - .metadata-box-rows { - display: flex; - flex-direction: column; - gap: 4px; - margin-top: 8px; - padding-top: 10px; - border-top: 1px solid var(--color-grey-20); - } - - .metadata-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 10px; - min-height: 24px; - } - - .metadata-label { - flex: 0 0 auto; - min-width: 100px; - font-weight: 600; - color: var(--color-ink-dark); - } - - .metadata-values { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - row-gap: 4px; - min-width: 0; - } - - /* - * When the row has multiple entries, separate them with "or". Override - * the global `.applies applies-to-popover { display: contents }` so the - * popover's ::before generates a real separator inline. - */ - .metadata-values.applies applies-to-popover { - display: inline-flex; - align-items: center; - } - .metadata-values > * + *::before { - content: 'or'; - margin: 0 8px; - color: var(--color-ink-light); - font-size: 12px; - font-style: italic; - } - - /* - * Plain pill for placeholder values like the Subscription row, until - * real metadata replaces it. Visually quieter than the applies_to - * popover badges so it reads as informational, not interactive. - */ - .metadata-pill { - display: inline-flex; - align-items: center; - padding: 2px 10px; - border-radius: 999px; - background: var(--color-white); - border: 1px solid var(--color-grey-20); - color: var(--color-ink-dark); - font-size: 12px; - line-height: 1.3; - } - @media (max-width: 640px) { .metadata-row { flex-direction: column; diff --git a/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs b/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs index a6a3dd4a6d..14708ddaf0 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs @@ -164,8 +164,10 @@ private static PopoverData BuildPopoverData( var showVersionNote = productInfo is { IncludeVersionNote: true } && versioningSystem.IsVersioned(); + var description = InterpolateVersionPlaceholders(productInfo?.Description, versioningSystem); + return new PopoverData( - ProductDescription: productInfo?.Description, + ProductDescription: description, AvailabilityItems: orderedApplicabilities.Select(applicability => BuildAvailabilityItem(applicability, versioningSystem, productName, applicabilities.Count)).OfType().ToArray(), AdditionalInfo: productInfo?.AdditionalAvailabilityInfo, ShowVersionNote: showVersionNote, @@ -173,6 +175,22 @@ private static PopoverData BuildPopoverData( ); } + /// + /// Substitutes {base}, {current}, and {base-major} placeholders in + /// product descriptions with values from the versioning system, so tooltips can surface + /// the actual version range without hardcoding it in ProductDescriptions. + /// + private static string? InterpolateVersionPlaceholders(string? template, VersioningSystem versioningSystem) + { + if (string.IsNullOrEmpty(template)) + return template; + + return template + .Replace("{base}", versioningSystem.Base.ToString()) + .Replace("{current}", versioningSystem.Current.ToString()) + .Replace("{base-major}", versioningSystem.Base.Major.ToString()); + } + /// /// Builds an availability item for an applicability entry. /// Returns null if the item should not be added to the availability list. diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs index 7dffae80ea..1f0992f4a3 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs @@ -91,7 +91,24 @@ public IReadOnlyCollection GetApplicabilityItems() _ => CollectCombinedRaw() }; - return RenderGroupedItems(rawItems).ToArray(); + var rendered = RenderGroupedItems(rawItems); + + // In the metadata-box rows, surface Serverless first, then other available + // items, then unavailable items at the end. The CSS suppresses the "or" + // separator before unavailable items so they read as a distinct group. + if (BadgePlacement is ApplicabilityBadgePlacement.StackVersionsRow + or ApplicabilityBadgePlacement.DeploymentsRow + or ApplicabilityBadgePlacement.OtherProductsRow) + { + rendered = rendered + .Select((item, index) => (item, index)) + .OrderBy(x => x.item.RenderData.LifecycleClass == "unavailable" ? 1 : 0) + .ThenBy(x => x.item.Key.StartsWith("Serverless", StringComparison.Ordinal) ? 0 : 1) + .ThenBy(x => x.index) + .Select(x => x.item); + } + + return rendered.ToArray(); } private List CollectCombinedRaw() diff --git a/src/Elastic.Markdown/Myst/Components/ProductDescriptions.cs b/src/Elastic.Markdown/Myst/Components/ProductDescriptions.cs index bee9e8a7c8..612fd1af87 100644 --- a/src/Elastic.Markdown/Myst/Components/ProductDescriptions.cs +++ b/src/Elastic.Markdown/Myst/Components/ProductDescriptions.cs @@ -34,33 +34,34 @@ bool IncludeVersionNote private static readonly Dictionary Descriptions = new() { - // Stack + // Stack -- {base} and {current} are interpolated by ApplicabilityRenderer + // from the Stack VersioningSystem so the tooltip surfaces the actual range. [VersioningSystemId.Stack] = new ProductInfo( - Description: "The Elastic Stack includes Elastic's core products such as Elasticsearch, Kibana, Logstash, and Beats.", + Description: "This documentation applies to Elastic Stack {base} to {current}. To know if this functionality exists in versions prior to v{base-major}, refer to the corresponding version of the documentation.", AdditionalAvailabilityInfo: "Unless stated otherwise on the page, this functionality is available when your Elastic Stack is deployed on Elastic Cloud Hosted, Elastic Cloud Enterprise, Elastic Cloud on Kubernetes, and self-managed environments.", IncludeVersionNote: true ), // Serverless [VersioningSystemId.Serverless] = new ProductInfo( - Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", + Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud. Their UI and capabilities can differ from the traditionally versioned Elastic Stack.", AdditionalAvailabilityInfo: "Serverless interfaces and procedures might differ from classic Elastic Stack deployments.", IncludeVersionNote: false ), // Serverless Project Types [VersioningSystemId.ElasticsearchProject] = new ProductInfo( - Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", + Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud. Their UI and capabilities can differ from the traditionally versioned Elastic Stack.", AdditionalAvailabilityInfo: null, IncludeVersionNote: false ), [VersioningSystemId.ObservabilityProject] = new ProductInfo( - Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", + Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud. Their UI and capabilities can differ from the traditionally versioned Elastic Stack.", AdditionalAvailabilityInfo: null, IncludeVersionNote: false ), [VersioningSystemId.SecurityProject] = new ProductInfo( - Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud.", + Description: "Elastic Cloud Serverless projects are autoscaled environments, fully managed by Elastic and available on Elastic Cloud. Their UI and capabilities can differ from the traditionally versioned Elastic Stack.", AdditionalAvailabilityInfo: null, IncludeVersionNote: false ), diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index 4f89660c1c..70ca017a50 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -92,10 +92,8 @@
    @if (Model.CurrentDocument.YamlFrontMatter?.Layout != MarkdownPageLayout.Hub) { - @await RenderPartialAsync(ProductPills.Create(Model)) @* This way it's correctly rendered as

    text

    instead of

    text

    *@ @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) -
    @await RenderPartialAsync(MetadataBox.Create(Model)) } @(new HtmlString(Model.MarkdownHtml)) diff --git a/src/Elastic.Markdown/Page/MetadataBox.cshtml b/src/Elastic.Markdown/Page/MetadataBox.cshtml index 0b49d8dffd..c0ae7791fc 100644 --- a/src/Elastic.Markdown/Page/MetadataBox.cshtml +++ b/src/Elastic.Markdown/Page/MetadataBox.cshtml @@ -5,6 +5,12 @@ if (layout is not null) return; + var prefix = Model.UrlPathPrefix?.TrimEnd('/') ?? string.Empty; + var productHubs = Model.Products + .Where(p => !string.IsNullOrWhiteSpace(p.Hub)) + .OrderBy(p => p.DisplayName, StringComparer.Ordinal) + .ToList(); + var hasStackVersions = false; var hasDeployments = false; if (Model.AppliesTo is not null) @@ -14,56 +20,71 @@ hasDeployments = Model.AppliesTo.Deployment is not null; } - // Subscription is a placeholder until the metadata exists. Hardcoded so the - // row is always visible alongside the real applies_to data. - const string subscriptionPlaceholder = "Enterprise"; - - if (!hasStackVersions && !hasDeployments) + var hasPills = productHubs.Count > 0; + var hasRows = hasStackVersions || hasDeployments; + if (!hasPills && !hasRows) return; } -
    + @if (hasRows) + { + - + }
    diff --git a/src/Elastic.Markdown/Page/ProductPills.cshtml b/src/Elastic.Markdown/Page/ProductPills.cshtml deleted file mode 100644 index f601189ff6..0000000000 --- a/src/Elastic.Markdown/Page/ProductPills.cshtml +++ /dev/null @@ -1,29 +0,0 @@ -@inherits RazorSlice -@{ - var layout = Model.CurrentDocument.YamlFrontMatter?.Layout; - if (layout is not null) - return; - - var prefix = Model.UrlPathPrefix?.TrimEnd('/') ?? string.Empty; - var productHubs = Model.Products - .Where(p => !string.IsNullOrWhiteSpace(p.Hub)) - .OrderBy(p => p.DisplayName, StringComparer.Ordinal) - .ToList(); - - if (productHubs.Count == 0) - return; -} - From 63f927e9a646f67e59c8d60b5264145933ba6d16 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 4 May 2026 20:15:17 +0200 Subject: [PATCH 34/56] Repoint product hub URLs at the real /products//v9/ pages The two hub gist HTML files were a stand-in while the V2 nav wiring was unresolved. That's fixed upstream and the docs-content `hub-pages` branch now serves the real hub markdown, so product pills should link to the canonical URLs (`/products/elasticsearch/v9`, `/products/kibana/v9`) instead of the gist previews. The two visual-reference gists are no longer used by any code path. The design handoff gist is still referenced in the PR and remains the human-facing UX/technical write-up. Co-Authored-By: Claude Sonnet 4.6 --- config/products.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/products.yml b/config/products.yml index da1fcb230a..874f835c70 100644 --- a/config/products.yml +++ b/config/products.yml @@ -147,7 +147,7 @@ products: elasticsearch: display: 'Elasticsearch' versioning: 'stack' - hub: 'https://htmlpreview.github.io/?https://gist.github.com/florent-leborgne/b2ddb83d7b849980d1668aef600d0139/raw/elasticsearch-hub.html' + hub: 'products/elasticsearch/v9' elasticsearch-client: display: 'Elasticsearch Client' versioning: 'stack' @@ -208,7 +208,7 @@ products: kibana: display: 'Kibana' versioning: 'stack' - hub: 'https://htmlpreview.github.io/?https://gist.github.com/florent-leborgne/c9075416b50ec111a09897b7be4462cc/raw/kibana-hub.html' + hub: 'products/kibana/v9' logstash: display: 'Logstash' versioning: 'stack' From 1fab04a8cd2dcfdf8c317d8f58b9d25153c2c033 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 4 May 2026 20:29:40 +0200 Subject: [PATCH 35/56] Add Product hubs landing page to V2 nav References docs-content://products/index.md so /products/ resolves to the landing page instead of 404. The landing lists Elasticsearch and Kibana hubs alongside stubs for upcoming products and deployment runbooks. Companion docs-content commit lands the index.md itself on the hub-pages branch. --- config/navigation-v2.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index 39f9aea547..6dce230cac 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -3310,6 +3310,8 @@ nav: - section: Product hubs url: /products/ children: + - page: docs-content://products/index.md + title: Product hubs - page: docs-content://products/kibana/v9.md title: Kibana - page: docs-content://products/elasticsearch/v9.md From 1c18fa145583b2df17f6e6cc076e903b9c50f33e Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 4 May 2026 20:40:15 +0200 Subject: [PATCH 36/56] Update authoring snapshot tests for new applies-to tooltip copy Stack and Serverless productDescription text changed (Stack interpolates {base}/{current}/{base-major} from the VersioningSystem; Serverless appends a sentence about UI/capabilities differing from versioned Stack), so the popover-data attribute in 40 snapshot fixtures across ApplicableToComponent.fs, AppliesToRole.fs, and Admonitions.fs needed to be updated to match. The test fixture's stack VersioningSystem has base==current==8.0.0, so the substituted string reads "Elastic Stack 8.0.0 to 8.0.0... prior to v8". Co-Authored-By: Claude Sonnet 4.6 --- .../Applicability/ApplicableToComponent.fs | 74 +++++++++---------- tests/authoring/Blocks/Admonitions.fs | 6 +- tests/authoring/Inline/AppliesToRole.fs | 4 +- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/tests/authoring/Applicability/ApplicableToComponent.fs b/tests/authoring/Applicability/ApplicableToComponent.fs index b6970a7828..c1ad9ae8ac 100644 --- a/tests/authoring/Applicability/ApplicableToComponent.fs +++ b/tests/authoring/Applicability/ApplicableToComponent.fs @@ -31,7 +31,7 @@ stack: ga 9.0.0 let ``renders GA with version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -47,7 +47,7 @@ stack: preview 9.1.0 let ``renders preview future version as planned`` () = markdown |> convertsToHtml """

    - +

    """ @@ -63,7 +63,7 @@ stack: beta 8.8.0 let ``renders beta future version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -79,7 +79,7 @@ stack: deprecated 8.7.0 let ``renders deprecation planned`` () = markdown |> convertsToHtml """

    - +

    """ @@ -95,7 +95,7 @@ stack: removed 8.6.0 let ``renders planned for removal`` () = markdown |> convertsToHtml """

    - +

    """ @@ -111,7 +111,7 @@ stack: ga let ``renders ga without version in badge or popover`` () = markdown |> convertsToHtml """

    - +

    """ @@ -128,7 +128,7 @@ serverless: ga let ``renders serverless ga`` () = markdown |> convertsToHtml """

    - +

    """ @@ -146,11 +146,11 @@ serverless: let ``renders serverless individual projects`` () = markdown |> convertsToHtml """

    - + - + - +

    """ @@ -292,7 +292,7 @@ stack: ga 8.8.0, preview 8.1.0 let ``renders Planned when GA and Preview are both unreleased`` () = markdown |> convertsToHtml """

    - +

    """ @@ -308,7 +308,7 @@ stack: deprecated 9.1.0 let ``renders deprecation planned for future version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -324,7 +324,7 @@ stack: removed 9.1.0 let ``renders removal planned for future version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -341,7 +341,7 @@ stack: unavailable let ``renders unavailable`` () = markdown |> convertsToHtml """

    - +

    """ @@ -398,11 +398,11 @@ apm_agent_java: beta 9.1.0 let ``renders complex mixed scenario`` () = markdown |> convertsToHtml """

    - + - + - + @@ -428,7 +428,7 @@ deployment: let ``renders stack and ece planned`` () = markdown |> convertsToHtml """

    - + @@ -446,7 +446,7 @@ stack: let ``no version defaults to ga`` () = markdown |> convertsToHtml """

    - +

    """ @@ -470,8 +470,8 @@ product: ga 9.0.0 let ``renders VersioningSystemId coverage`` () = markdown |> convertsToHtml """

    - - + + @@ -493,7 +493,7 @@ stack: ga 8.0.0, beta 8.1.0 let ``renders multiple lifecycles with ellipsis and shows GA lifecycle`` () = markdown |> convertsToHtml """

    - +

    """ @@ -509,7 +509,7 @@ stack: ga 7.0.0 let ``renders ga since released version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -525,7 +525,7 @@ stack: preview 7.0.0 let ``renders preview since released version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -541,7 +541,7 @@ stack: beta 7.0.0 let ``renders beta since released version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -557,7 +557,7 @@ stack: deprecated 7.0.0 let ``renders deprecated since released version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -573,7 +573,7 @@ stack: removed 7.0.0 let ``renders removed in released version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -590,7 +590,7 @@ stack: ga =7.5 let ``renders ga in exact released version`` () = markdown |> convertsToHtml """

    - +

    """ @@ -606,7 +606,7 @@ stack: ga 7.0-8.0 let ``renders ga from-to when both ends released`` () = markdown |> convertsToHtml """

    - +

    """ @@ -622,7 +622,7 @@ stack: ga 7.0-9.0 let ``renders ga since min when max unreleased`` () = markdown |> convertsToHtml """

    - +

    """ @@ -639,7 +639,7 @@ stack: preview 7.0, ga 7.5 let ``renders ga badge with both lifecycles in popover`` () = markdown |> convertsToHtml """

    - +

    """ @@ -655,7 +655,7 @@ stack: preview 7.5.4! let ``renders patch version when explicitly requested with exclamation mark`` () = markdown |> convertsToHtml """

    - +

    """ @@ -671,7 +671,7 @@ stack: preview 7.5.4 let ``hides patch version when no exclamation mark used`` () = markdown |> convertsToHtml """

    - +

    """ @@ -687,7 +687,7 @@ stack: beta 7.0.3!-7.5.2! let ``renders range with patch versions when both have exclamation marks`` () = markdown |> convertsToHtml """

    - +

    """ @@ -703,7 +703,7 @@ stack: ga 7.0.5!-7.5 let ``renders range with patch on mininum version only when explicit operator is used`` () = markdown |> convertsToHtml """

    - +

    """ @@ -719,7 +719,7 @@ stack: ga 7.0-7.5.3! let ``renders range with patch on maxinum version only when explicit operator is used`` () = markdown |> convertsToHtml """

    - +

    """ @@ -735,7 +735,7 @@ stack: ga =7.5.3! let ``renders exact version with patch when explicit operator is used`` () = markdown |> convertsToHtml """

    - +

    """ diff --git a/tests/authoring/Blocks/Admonitions.fs b/tests/authoring/Blocks/Admonitions.fs index 7d0585d504..719fa637d0 100644 --- a/tests/authoring/Blocks/Admonitions.fs +++ b/tests/authoring/Blocks/Admonitions.fs @@ -64,7 +64,7 @@ This is a custom admonition with applies_to information.
    Note - +
    @@ -76,7 +76,7 @@ This is a custom admonition with applies_to information.
    Warning - +
    @@ -88,7 +88,7 @@ This is a custom admonition with applies_to information.
    Tip - +
    diff --git a/tests/authoring/Inline/AppliesToRole.fs b/tests/authoring/Inline/AppliesToRole.fs index 81bb0f78fd..3c93c00cf3 100644 --- a/tests/authoring/Inline/AppliesToRole.fs +++ b/tests/authoring/Inline/AppliesToRole.fs @@ -30,7 +30,7 @@ This is an inline {applies_to}`stack: preview 9.1` element. markdown |> convertsToHtml """

    This is an inline - + element.

    @@ -150,7 +150,7 @@ This is an inline {applies_to}`stack: preview 8.0, ga 8.1` element. markdown |> convertsToHtml """

    This is an inline - + element.

    From 54c3a7e272bbebb9024651de7d81609519a42d7d Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 4 May 2026 21:13:46 +0200 Subject: [PATCH 37/56] Stop pill click from toggling the metadata box The product pill lives inside
    /, so a native click on the pill toggles the box and the link doesn't navigate. `onclick="event.stopPropagation()"` lets the link navigate while the toggle is suppressed. Toggle still works on every other part of the summary (label, chevron, empty space). --- src/Elastic.Markdown/Page/MetadataBox.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Page/MetadataBox.cshtml b/src/Elastic.Markdown/Page/MetadataBox.cshtml index c0ae7791fc..58a1035a77 100644 --- a/src/Elastic.Markdown/Page/MetadataBox.cshtml +++ b/src/Elastic.Markdown/Page/MetadataBox.cshtml @@ -37,7 +37,7 @@ var isAbsolute = hub.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || hub.StartsWith("https://", StringComparison.OrdinalIgnoreCase); var href = isAbsolute ? hub : $"{prefix}/{hub.Trim('/')}/"; - + @product.DisplayName From 3357eb60d7526ec21ed9cb5a00340375210b3f89 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 4 May 2026 22:46:32 +0200 Subject: [PATCH 38/56] Make hub-page navigation work cleanly from htmx-boosted swaps Three small fixes triggered by clicking through to hub pages: - MetadataBox.cshtml: product pill now carries hx-boost="false" so the body-level hx-boost="true" doesn't intercept the click. The onclick="event.stopPropagation()" still suppresses the
    toggle so the click navigates rather than collapsing the box. - _Layout.cshtml: hub layout adds an empty
    + @* Empty TOC slot so htmx hx-select-oob="#toc-nav" swaps don't fail when boosted from a regular page. Hub pages have their own inline {on-this-page} chip. *@ + From 588260f13f075b0bb5906d61bbbbfe356367832a Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Thu, 7 May 2026 10:58:47 +0200 Subject: [PATCH 39/56] Update top navigation order Co-Authored-By: GPT-5.5 Co-authored-by: Cursor --- config/navigation-v2.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index 6dce230cac..b08a0ff54c 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -3285,6 +3285,16 @@ nav: # - page: docs-content://troubleshoot/ingest/opentelemetry/index.md # title: Troubleshoot EDOT + - section: Products + url: /products/ + children: + - page: docs-content://products/index.md + title: Product hubs + - page: docs-content://products/kibana/v9.md + title: Kibana + - page: docs-content://products/elasticsearch/v9.md + title: Elasticsearch + - section: APIs url: https://www.elastic.co/docs/api @@ -3297,25 +3307,15 @@ nav: - island: Logstash versioned plugins toc: logstash-docs-md://vpr - - section: Troubleshoot - url: /troubleshoot/ - children: - - toc: docs-content://troubleshoot - - section: Release notes url: /release-notes/ children: - toc: docs-content://release-notes/intro - - section: Product hubs - url: /products/ + - section: Troubleshoot + url: /troubleshoot/ children: - - page: docs-content://products/index.md - title: Product hubs - - page: docs-content://products/kibana/v9.md - title: Kibana - - page: docs-content://products/elasticsearch/v9.md - title: Elasticsearch + - toc: docs-content://troubleshoot - section: Extension points url: /extend/ From 3e262e4b3f3ad782ac1d84d343bacc68dc7b9e2e Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 7 May 2026 15:59:53 +0200 Subject: [PATCH 40/56] Subsection product hubs nav island into Stack products + Deployment runbooks Group the Product hubs sidebar by category and surface upcoming products as non-clickable stubs, matching the gist's prototype island. Uses the existing nav-v2 grammar: - '- label: ' creates a non-clickable section header - '- title: ' creates a non-clickable leaf entry (used for stubs) Stack products: Elasticsearch, Kibana (linked) + Logstash, Beats (soon). Deployment runbooks: Cloud Hosted, Cloud Serverless, Self-managed (all soon). --- config/navigation-v2.yml | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index b08a0ff54c..83ce92fbd0 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -3285,16 +3285,6 @@ nav: # - page: docs-content://troubleshoot/ingest/opentelemetry/index.md # title: Troubleshoot EDOT - - section: Products - url: /products/ - children: - - page: docs-content://products/index.md - title: Product hubs - - page: docs-content://products/kibana/v9.md - title: Kibana - - page: docs-content://products/elasticsearch/v9.md - title: Elasticsearch - - section: APIs url: https://www.elastic.co/docs/api @@ -3307,15 +3297,34 @@ nav: - island: Logstash versioned plugins toc: logstash-docs-md://vpr + - section: Troubleshoot + url: /troubleshoot/ + children: + - toc: docs-content://troubleshoot + - section: Release notes url: /release-notes/ children: - toc: docs-content://release-notes/intro - - section: Troubleshoot - url: /troubleshoot/ + - section: Product hubs + url: /products/ children: - - toc: docs-content://troubleshoot + - page: docs-content://products/index.md + title: Product hubs + - label: Stack products + children: + - page: docs-content://products/elasticsearch/v9.md + title: Elasticsearch + - page: docs-content://products/kibana/v9.md + title: Kibana + - title: Logstash (soon) + - title: Beats (soon) + - label: Deployment runbooks + children: + - title: Elastic Cloud Hosted (soon) + - title: Elastic Cloud Serverless (soon) + - title: Self-managed (soon) - section: Extension points url: /extend/ From 17bbea5437c8eb79143b8f0b00852bd2c0d1023f Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Thu, 7 May 2026 17:15:54 +0200 Subject: [PATCH 41/56] Top-bar dropdown for sections + restore Products rename and reorder Two related changes that go together: 1. Add a `dropdown: true` opt-in to V2 nav sections. When set, the secondary nav renders the section as a
    / with a chevron, and the open panel lists the section's children grouped by `- label:` subsections. `- title:` items without a `page:` render as muted "soon" stubs. Affects: - SectionNavV2Item (parser) reads `dropdown:`. - SectionNavigationNode + NavigationSection thread the flag. - _SecondaryNav.cshtml renders the dropdown UI when set. - secondary-nav-dropdown.css covers the visual layer. 2. Restore commit 588260f1 ("Update top navigation order") that was silently reverted during an earlier rebase resolution. Section renamed Product hubs -> Products, moved to second position right after Guides. Release notes moved before Troubleshoot. Final order: Guides -> Products -> APIs -> Reference -> Release notes -> Troubleshoot -> Extension points. Wires Products as the first user of the new dropdown so reviewers can jump straight from the top bar to a specific product hub without going through the /products/ landing page. Co-Authored-By: Claude Sonnet 4.6 --- config/navigation-v2.yml | 45 ++++---- .../Toc/NavigationV2File.cs | 7 +- .../V2/NavigationSection.cs | 1 + .../V2/SectionNavigationNode.cs | 5 + .../V2/SiteNavigationV2.cs | 6 +- .../Assets/secondary-nav-dropdown.css | 102 ++++++++++++++++++ .../Assets/styles.css | 1 + .../Layout/_SecondaryNav.cshtml | 72 ++++++++++++- .../Navigation/GlobalNavigationHtmlWriter.cs | 2 +- 9 files changed, 213 insertions(+), 28 deletions(-) create mode 100644 src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index 83ce92fbd0..1969f8185e 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -3285,6 +3285,26 @@ nav: # - page: docs-content://troubleshoot/ingest/opentelemetry/index.md # title: Troubleshoot EDOT + - section: Products + url: /products/ + dropdown: true + children: + - page: docs-content://products/index.md + title: Product hubs + - label: Stack products + children: + - page: docs-content://products/elasticsearch/v9.md + title: Elasticsearch + - page: docs-content://products/kibana/v9.md + title: Kibana + - title: Logstash (soon) + - title: Beats (soon) + - label: Deployment runbooks + children: + - title: Elastic Cloud Hosted (soon) + - title: Elastic Cloud Serverless (soon) + - title: Self-managed (soon) + - section: APIs url: https://www.elastic.co/docs/api @@ -3297,34 +3317,15 @@ nav: - island: Logstash versioned plugins toc: logstash-docs-md://vpr - - section: Troubleshoot - url: /troubleshoot/ - children: - - toc: docs-content://troubleshoot - - section: Release notes url: /release-notes/ children: - toc: docs-content://release-notes/intro - - section: Product hubs - url: /products/ + - section: Troubleshoot + url: /troubleshoot/ children: - - page: docs-content://products/index.md - title: Product hubs - - label: Stack products - children: - - page: docs-content://products/elasticsearch/v9.md - title: Elasticsearch - - page: docs-content://products/kibana/v9.md - title: Kibana - - title: Logstash (soon) - - title: Beats (soon) - - label: Deployment runbooks - children: - - title: Elastic Cloud Hosted (soon) - - title: Elastic Cloud Serverless (soon) - - title: Self-managed (soon) + - toc: docs-content://troubleshoot - section: Extension points url: /extend/ diff --git a/src/Elastic.Documentation.Configuration/Toc/NavigationV2File.cs b/src/Elastic.Documentation.Configuration/Toc/NavigationV2File.cs index 71cbf85b70..ab85ff9cdd 100644 --- a/src/Elastic.Documentation.Configuration/Toc/NavigationV2File.cs +++ b/src/Elastic.Documentation.Configuration/Toc/NavigationV2File.cs @@ -36,6 +36,7 @@ public record SectionNavV2Item( string Label, string Url, bool Isolated, + bool Dropdown, IReadOnlyList Children ) : INavV2Item; @@ -139,10 +140,14 @@ private static IReadOnlyList ReadItemList(IParser parser, ObjectDese && isoVal is string isoStr && bool.TryParse(isoStr, out var isoBool) && isoBool; + var dropdown = dict.TryGetValue("dropdown", out var ddVal) + && ddVal is string ddStr + && bool.TryParse(ddStr, out var ddBool) + && ddBool; var sectionChildren = dict.TryGetValue("children", out var sch) && sch is IReadOnlyList sChildList ? sChildList : []; - return new SectionNavV2Item(sectionStr, sectionUrl, isolated, sectionChildren); + return new SectionNavV2Item(sectionStr, sectionUrl, isolated, dropdown, sectionChildren); } if (dict.TryGetValue("island", out var islandVal) && islandVal is string islandStr) diff --git a/src/Elastic.Documentation.Navigation/V2/NavigationSection.cs b/src/Elastic.Documentation.Navigation/V2/NavigationSection.cs index b0ee805d19..467d61db13 100644 --- a/src/Elastic.Documentation.Navigation/V2/NavigationSection.cs +++ b/src/Elastic.Documentation.Navigation/V2/NavigationSection.cs @@ -13,6 +13,7 @@ public record NavigationSection( string Label, string Url, bool Isolated, + bool Dropdown, IReadOnlyList NavigationItems ); diff --git a/src/Elastic.Documentation.Navigation/V2/SectionNavigationNode.cs b/src/Elastic.Documentation.Navigation/V2/SectionNavigationNode.cs index 2e0012bf75..eb6f37ec2d 100644 --- a/src/Elastic.Documentation.Navigation/V2/SectionNavigationNode.cs +++ b/src/Elastic.Documentation.Navigation/V2/SectionNavigationNode.cs @@ -19,6 +19,7 @@ public SectionNavigationNode( string label, string url, bool isolated, + bool dropdown, IReadOnlyCollection children, INodeNavigationItem? parent ) @@ -27,6 +28,7 @@ public SectionNavigationNode( NavigationTitle = label; Url = url; Isolated = isolated; + Dropdown = dropdown; NavigationItems = children; Parent = parent; NavigationRoot = parent?.NavigationRoot!; @@ -36,6 +38,9 @@ public SectionNavigationNode( /// Whether this section is excluded from the top bar and renders with a back arrow. public bool Isolated { get; } + /// Whether the top-bar entry should render as a dropdown listing the section's children. + public bool Dropdown { get; } + /// public string Id { get; } diff --git a/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs b/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs index 7d9f1aa8ff..c07e24a304 100644 --- a/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs +++ b/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs @@ -86,7 +86,7 @@ public SiteNavigationV2( private static IReadOnlyList BuildSections(IReadOnlyList items) => items .OfType() - .Select(s => new NavigationSection(s.Id, s.NavigationTitle, s.Url, s.Isolated, [.. s.NavigationItems])) + .Select(s => new NavigationSection(s.Id, s.NavigationTitle, s.Url, s.Isolated, s.Dropdown, [.. s.NavigationItems])) .ToList(); private static IReadOnlyList BuildIslands(IReadOnlyList sections) @@ -402,9 +402,9 @@ private static SectionNavigationNode CreateSection( string sitePrefix ) { - var placeholder = new SectionNavigationNode(section.Label, section.Url, section.Isolated, [], parent); + var placeholder = new SectionNavigationNode(section.Label, section.Url, section.Isolated, section.Dropdown, [], parent); var children = BuildV2Items(section.Children, nodes, placeholder, sitePrefix); - return new SectionNavigationNode(section.Label, section.Url, section.Isolated, children, parent); + return new SectionNavigationNode(section.Label, section.Url, section.Isolated, section.Dropdown, children, parent); } private static INavigationItem? CreateIsland( diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css b/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css new file mode 100644 index 0000000000..ea82bd88da --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css @@ -0,0 +1,102 @@ +/* + * Top-bar section dropdown. Opt in by setting `dropdown: true` on a + * `- section:` entry in `navigation-v2.yml`. The section's own URL + * remains the link target on the summary's anchor; the chevron toggles + * a panel listing the section's children so users can jump straight + * to a child page without going through the section landing. + */ + +@layer components { + .secondary-nav-dropdown { + display: inline-flex; + align-items: center; + } + .secondary-nav-dropdown summary { + list-style: none; + } + .secondary-nav-dropdown summary::-webkit-details-marker { + display: none; + } + + .secondary-nav-dropdown-chevron { + flex-shrink: 0; + color: var(--color-grey-60); + transition: transform 0.15s ease; + } + .secondary-nav-dropdown[open] .secondary-nav-dropdown-chevron { + transform: rotate(180deg); + } + + .secondary-nav-dropdown-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 220px; + max-width: 320px; + z-index: 50; + display: flex; + flex-direction: column; + padding: 6px 0; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 6px; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.08); + font-weight: 500; + } + + .secondary-nav-dropdown-group-label { + padding: 8px 14px 4px 14px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-grey-70); + user-select: none; + } + .secondary-nav-dropdown-group-label:not(:first-child) { + margin-top: 4px; + border-top: 1px solid var(--color-grey-15, var(--color-grey-20)); + padding-top: 10px; + } + + .secondary-nav-dropdown-link { + display: block; + padding: 6px 14px; + font-size: 14px; + color: var(--color-ink-dark) !important; + text-decoration: none !important; + transition: background-color 0.12s ease; + } + .secondary-nav-dropdown-link:hover { + background: var(--color-grey-10); + color: var(--color-blue-elastic) !important; + } + + .secondary-nav-dropdown-stub { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + font-size: 14px; + color: var(--color-grey-60); + cursor: default; + } + .secondary-nav-dropdown-stub::after { + content: 'soon'; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 4px; + background: var(--color-grey-15, var(--color-grey-20)); + color: var(--color-grey-70); + } + + /* Close the menu when the user clicks outside is browser-default for +
    ; just suppress the htmx-boost on summary clicks so the + first click only toggles. */ + .secondary-nav-dropdown summary a { + text-decoration: none; + } +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 9a3843dda7..85c696851f 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -29,6 +29,7 @@ @import './markdown/agent-skill.css'; @import './markdown/hub.css'; @import './markdown/metadata-box.css'; +@import './secondary-nav-dropdown.css'; @import './markdown/contributors.css'; @import './api-docs.css'; @import 'tippy.js/dist/tippy.css'; diff --git a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml index 21ea826449..0f9dea550c 100644 --- a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml @@ -1,4 +1,6 @@ @using System.Linq +@using Elastic.Documentation.Navigation +@using Elastic.Documentation.Navigation.V2 @inherits RazorSlice
    @@ -12,12 +14,79 @@ { var isAbsolute = navTab.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase); var isActive = !isAbsolute && navTab.Id == Model.ActiveSectionId; -
  • @if (isAbsolute) { @navTab.Label } + else if (navTab.Dropdown) + { +
    + + + @navTab.Label + + + +
    + @foreach (var child in navTab.NavigationItems) + { + var childUrl = child.Url; + var childTitle = child.NavigationTitle; + var childItems = child is INodeNavigationItem nn ? nn.NavigationItems : null; + var isHidden = child is ILeafNavigationItem hl && hl.Hidden; + + if (isHidden) + { + // Section index leaf is already linked from the summary; skip. + continue; + } + if (childItems is { Count: > 0 }) + { +
    @childTitle
    + foreach (var grandchild in childItems) + { + var gcUrl = grandchild.Url; + var gcTitle = grandchild.NavigationTitle; + var gcHasChildren = grandchild is INodeNavigationItem gn && gn.NavigationItems.Count > 0; + if (!gcHasChildren && string.IsNullOrEmpty(gcUrl)) + { + @gcTitle + } + else if (!string.IsNullOrEmpty(gcUrl)) + { + + @gcTitle + + } + } + } + else if (!string.IsNullOrEmpty(childUrl)) + { + + @childTitle + + } + else + { + @childTitle + } + } +
    +
    + } else {
  • + diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs index 1f93a310ff..caef5c711d 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs @@ -141,7 +141,7 @@ Cancel ctx _logger.LogInformation("Rendering V2 island navigation: {IslandLabel} ({IslandId})", island.Label, island.Id); var wrapper = new SectionNavigationV2Wrapper( - new NavigationSection(island.Id, island.Label, "", false, island.NavigationItems), + new NavigationSection(island.Id, island.Label, "", false, false, island.NavigationItems), navV2 ); var model = new NavigationViewModel From 6d5be8b58b9d1e9e523363ea095019a33b51c962 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 8 May 2026 10:23:29 +0200 Subject: [PATCH 42/56] Make Products section index a placeholder leaf The `- page: docs-content://products/index.md / title: Product hubs` crosslink pointed at a file that does not exist in docs-content, which caused /products/ to 404. Replace it with `- title: Product hubs` (no page key) so the placeholder system generates a "Coming soon" stub at that URL, per the mechanism documented in PR #2927. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/navigation-v2.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index 1969f8185e..823be31735 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -3289,8 +3289,7 @@ nav: url: /products/ dropdown: true children: - - page: docs-content://products/index.md - title: Product hubs + - title: Product hubs - label: Stack products children: - page: docs-content://products/elasticsearch/v9.md From 80a366e5c8592577f6f69b6375bae292b393a5a8 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 8 May 2026 11:17:51 +0200 Subject: [PATCH 43/56] Revert "Make Products section index a placeholder leaf" This reverts commit 6d5be8b58b9d1e9e523363ea095019a33b51c962. The Products dropdown is the discovery surface, so /products/ doesn't need a placeholder landing page. Drops the index entry from the Products section's children entirely, and switches the dropdown summary in _SecondaryNav.cshtml from an to a non-clickable so clicking the title row toggles the dropdown instead of trying to navigate to a non-existent landing. --- config/navigation-v2.yml | 1 - .../Layout/_SecondaryNav.cshtml | 9 +-------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index 823be31735..1d2b98bd1d 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -3289,7 +3289,6 @@ nav: url: /products/ dropdown: true children: - - title: Product hubs - label: Stack products children: - page: docs-content://products/elasticsearch/v9.md diff --git a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml index 0f9dea550c..596898c90c 100644 --- a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml @@ -24,14 +24,7 @@ {
    - - @navTab.Label - + @navTab.Label From 113403af1cf85b16d09a16212a290077b5aecb00 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 8 May 2026 11:43:51 +0200 Subject: [PATCH 44/56] Drop all-caps from dropdown subsection labels The secondary-nav-dropdown-group-label was rendering as uppercase with letter-spacing (UI-pattern look), which Florent didn't want. Plain title case at 12px / weight 700 / grey-80 reads cleaner and matches the rest of the menu typography. --- .../Assets/secondary-nav-dropdown.css | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css b/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css index ea82bd88da..48580da254 100644 --- a/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css +++ b/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css @@ -46,11 +46,9 @@ .secondary-nav-dropdown-group-label { padding: 8px 14px 4px 14px; - font-size: 11px; + font-size: 12px; font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-grey-70); + color: var(--color-grey-80); user-select: none; } .secondary-nav-dropdown-group-label:not(:first-child) { From d7b636076b8ac414edc485f8210382a53d6a28de Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 11 May 2026 18:31:01 +0200 Subject: [PATCH 45/56] Prefix hub directive URLs with the site path prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit {hero}, {link-card}, and {whats-new} emit YAML body URLs verbatim into attributes — they don't go through Markdig's link resolver. So root-relative URLs like \`/deploy-manage/...\` landed on the page as-is and broke under the \`/docs/\` path prefix the assembler builds with. Add a HubUrl.Prefix helper that prepends the site path prefix to root-relative URLs (leaves absolute http/https/mailto and anchors alone, and won't double-prefix). Pass block.Build.UrlPathPrefix into the three view models, and call Model.PrefixUrl(...) in the cshtml where hrefs are emitted. Co-Authored-By: Claude Sonnet 4.6 --- .../Myst/Directives/DirectiveHtmlRenderer.cs | 9 +++-- .../Myst/Directives/Hub/HeroView.cshtml | 4 +- .../Myst/Directives/Hub/HeroViewModel.cs | 2 + .../Myst/Directives/Hub/HubUrl.cs | 39 +++++++++++++++++++ .../Myst/Directives/Hub/LinkCardView.cshtml | 6 +-- .../Myst/Directives/Hub/LinkCardViewModel.cs | 2 + .../Myst/Directives/Hub/WhatsNewView.cshtml | 4 +- .../Myst/Directives/Hub/WhatsNewViewModel.cs | 2 + 8 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 8934da227a..a1e6d37ae7 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -387,7 +387,8 @@ private static void WriteHero(HtmlRenderer renderer, HeroBlock block) ShowSearch = block.ShowSearch, QuickLinks = block.QuickLinks, OtherVersions = block.OtherVersions, - ReleasesHtml = releasesHtml + ReleasesHtml = releasesHtml, + SitePathPrefix = block.Build.UrlPathPrefix }); RenderRazorSlice(slice, renderer); } @@ -411,7 +412,8 @@ private static void WriteLinkCard(HtmlRenderer renderer, LinkCardBlock block) { DirectiveBlock = block, Data = block.Data, - IconSvg = ProductIcons.Get(block.Data.Icon) + IconSvg = ProductIcons.Get(block.Data.Icon), + SitePathPrefix = block.Build.UrlPathPrefix }); RenderRazorSlice(slice, renderer); } @@ -421,7 +423,8 @@ private static void WriteWhatsNew(HtmlRenderer renderer, WhatsNewBlock block) var slice = WhatsNewView.Create(new WhatsNewViewModel { DirectiveBlock = block, - Data = block.Data + Data = block.Data, + SitePathPrefix = block.Build.UrlPathPrefix }); RenderRazorSlice(slice, renderer); } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml index d25284d072..cdc5e4b402 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml @@ -68,7 +68,7 @@ } else { -
  • @v.Label
  • +
  • @v.Label
  • } } @@ -87,7 +87,7 @@ } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs index 7c971bdd02..dfbae92ff3 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs @@ -15,4 +15,6 @@ public class HeroViewModel : DirectiveViewModel public required IReadOnlyList QuickLinks { get; init; } public required IReadOnlyList OtherVersions { get; init; } public required string? ReleasesHtml { get; init; } + public required string? SitePathPrefix { get; init; } + public string? PrefixUrl(string? url) => HubUrl.Prefix(url, SitePathPrefix); } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs new file mode 100644 index 0000000000..9f1b2835d3 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs @@ -0,0 +1,39 @@ +// 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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// URL helpers for hub directives ({hero}, {link-card}, {whats-new}) whose YAML body +/// values are emitted verbatim into href attributes and don't go through Markdig's +/// link resolver. Root-relative URLs (e.g. "/deploy-manage/...") need the site's +/// URL path prefix (e.g. "/docs") prepended so they resolve on the assembled site. +/// +internal static class HubUrl +{ + /// + /// Prefix a root-relative URL with the site's path prefix. Absolute URLs + /// (http/https/mailto), anchors, and URLs already under the prefix are + /// returned unchanged. + /// + public static string? Prefix(string? url, string? sitePathPrefix) + { + if (string.IsNullOrEmpty(url)) + return url; + if (string.IsNullOrEmpty(sitePathPrefix)) + return url; + if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) + || url.StartsWith('#')) + return url; + + var prefix = "/" + sitePathPrefix.Trim('/'); + if (url == prefix || url.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase)) + return url; + if (!url.StartsWith('/')) + return url; + return prefix + url; + } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml index 2017da0d05..33f081f118 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml @@ -16,7 +16,7 @@

    @if (!string.IsNullOrWhiteSpace(d.Link)) { - @d.Title + @d.Title } else { @@ -37,7 +37,7 @@ { if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) continue; -
  • @link.Label
  • +
  • @link.Label
  • } } @@ -55,7 +55,7 @@ var link = aside.Links[i]; if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) continue; - @link.Label + @link.Label @if (i < aside.Links.Length - 1) { diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs index ff75e4821f..1ceea4bc45 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs @@ -8,4 +8,6 @@ public class LinkCardViewModel : DirectiveViewModel { public required LinkCardData Data { get; init; } public required string? IconSvg { get; init; } + public required string? SitePathPrefix { get; init; } + public string? PrefixUrl(string? url) => HubUrl.Prefix(url, SitePathPrefix); } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml index ea449797ec..ceeca9a986 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml @@ -23,7 +23,7 @@ var link = d.ReleaseLinks[i]; if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) continue; - @link.Label + @link.Label @if (i < d.ReleaseLinks.Length - 1) { @@ -42,7 +42,7 @@
  • @if (!string.IsNullOrWhiteSpace(item.Link)) { - + @item.Title @if (!string.IsNullOrWhiteSpace(item.Description)) { diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs index 678e31f650..601b7b8eb4 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs @@ -7,4 +7,6 @@ namespace Elastic.Markdown.Myst.Directives.Hub; public class WhatsNewViewModel : DirectiveViewModel { public required WhatsNewData Data { get; init; } + public required string? SitePathPrefix { get; init; } + public string? PrefixUrl(string? url) => HubUrl.Prefix(url, SitePathPrefix); } From 0a758ebd736485ed5b1f414557bd1a7153fae3cd Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 11 May 2026 18:42:37 +0200 Subject: [PATCH 46/56] Hub links opt out of body-level hx-boost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The body has hx-boost=\"true\" hx-swap=\"none\" so boosted clicks need an explicit hx-select-oob to swap anything. Hub-directive links don't carry those attributes — clicks pushed the URL but swapped no content, so the page appeared to just scroll to the top. Add hx-boost=\"false\" to every in {hero}, {link-card}, and {whats-new} so they fall back to native navigation. Same fix already in place for the page-top product pills. --- src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml | 4 ++-- src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs | 4 ++++ .../Myst/Directives/Hub/LinkCardView.cshtml | 6 +++--- .../Myst/Directives/Hub/WhatsNewView.cshtml | 4 ++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml index cdc5e4b402..6017272119 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml @@ -68,7 +68,7 @@ } else { -
  • @v.Label
  • +
  • @v.Label
  • } } @@ -87,7 +87,7 @@ } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs index 9f1b2835d3..a74e3b6e65 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs @@ -9,9 +9,13 @@ namespace Elastic.Markdown.Myst.Directives.Hub; /// values are emitted verbatim into href attributes and don't go through Markdig's /// link resolver. Root-relative URLs (e.g. "/deploy-manage/...") need the site's /// URL path prefix (e.g. "/docs") prepended so they resolve on the assembled site. +/// They also need to opt out of body-level hx-boost because the layout uses +/// hx-swap="none" by default — without an explicit hx-select-oob, +/// boosted clicks fetch the URL but swap nothing. ///

    internal static class HubUrl { + /// /// Prefix a root-relative URL with the site's path prefix. Absolute URLs /// (http/https/mailto), anchors, and URLs already under the prefix are diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml index 33f081f118..2ff734685b 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml @@ -16,7 +16,7 @@

    @if (!string.IsNullOrWhiteSpace(d.Link)) { - @d.Title + @d.Title } else { @@ -37,7 +37,7 @@ { if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) continue; -
  • @link.Label
  • +
  • @link.Label
  • } } @@ -55,7 +55,7 @@ var link = aside.Links[i]; if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) continue; - @link.Label + @link.Label @if (i < aside.Links.Length - 1) { diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml index ceeca9a986..b5b1dcef23 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml @@ -23,7 +23,7 @@ var link = d.ReleaseLinks[i]; if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) continue; - @link.Label + @link.Label @if (i < d.ReleaseLinks.Length - 1) { @@ -42,7 +42,7 @@
  • @if (!string.IsNullOrWhiteSpace(item.Link)) { - + @item.Title @if (!string.IsNullOrWhiteSpace(item.Description)) { From de8dec6ade81e5dcedc3b71fad9112e7639a5234 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 11 May 2026 18:58:08 +0200 Subject: [PATCH 47/56] HubUrl strips .md and /index.md so authors can write file paths Hub directives ({hero}, {link-card}, {whats-new}) emit URLs verbatim into . Authors naturally write .md file paths (matching how body markdown works) but the assembler serves clean URLs without .md. So /reference/elasticsearch-clients/index.md 404'd on the assembled site. HubUrl.Prefix now strips trailing .md and /index.md before prepending the site path prefix. Anchors are preserved. Also fixes 4 source URLs whose docs-content folder names differ from the slug the assembler serves (e.g. /release-notes/elastic- security/ -> /release-notes/security), and the wrong fleet- integrations target. All 193 internal hub-page links now resolve (76/76 elasticsearch, 117/117 kibana, validated against the local assembler serve). --- .../Myst/Directives/Hub/HubUrl.cs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs index a74e3b6e65..4afa0cdfda 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HubUrl.cs @@ -25,19 +25,32 @@ internal static class HubUrl { if (string.IsNullOrEmpty(url)) return url; - if (string.IsNullOrEmpty(sitePathPrefix)) - return url; if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) || url.StartsWith('#')) return url; + // Convert markdown file paths to URL paths so authors can write + // e.g. /deploy-manage/...md and we render /deploy-manage/... . + // Anchor is preserved. + var clean = url; + var hash = clean.IndexOf('#'); + var anchor = hash >= 0 ? clean[hash..] : string.Empty; + if (hash >= 0) + clean = clean[..hash]; + if (clean.EndsWith("/index.md", StringComparison.OrdinalIgnoreCase)) + clean = clean[..^"/index.md".Length]; + else if (clean.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + clean = clean[..^".md".Length]; + + if (string.IsNullOrEmpty(sitePathPrefix)) + return clean + anchor; var prefix = "/" + sitePathPrefix.Trim('/'); - if (url == prefix || url.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase)) - return url; - if (!url.StartsWith('/')) - return url; - return prefix + url; + if (clean == prefix || clean.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase)) + return clean + anchor; + if (!clean.StartsWith('/')) + return clean + anchor; + return prefix + clean + anchor; } } From 5bc32e39847b37486170817986116808f61196f5 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 11 May 2026 20:12:45 +0200 Subject: [PATCH 48/56] fix(nav-v2): restore V2 page lookup registration lost in merge The RegisterV2PageLookups machinery from d556dff3 was dropped when nav-v2-sections was merged into hub-pages. Without it, V2 page: entries referencing files in unseen child TOCs are shadowed by the V1 TableOfContentsNavigation registration, breaking positional layout for those pages. Restores the original logic and uses Remove + Add so V2 entries override V1 toc entries on collision. Co-Authored-By: Claude Opus 4.7 --- .../V2/SiteNavigationV2.cs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs b/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs index 3ef6b1354d..c07e24a304 100644 --- a/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs +++ b/src/Elastic.Documentation.Navigation/V2/SiteNavigationV2.cs @@ -34,6 +34,7 @@ public SiteNavigationV2( { var prefix = sitePrefix ?? string.Empty; V2NavigationItems = BuildV2Items(v2File.Nav, Nodes, this, prefix); + RegisterV2PageLookups(); Sections = BuildSections(V2NavigationItems); Islands = BuildIslands(Sections); BuildUrlToSectionLookup(); @@ -153,6 +154,179 @@ private void BuildTocRootToIslandLookup() _ = _tocRootToIsland.TryAdd(island.SourceTocRootId, island); } + private void RegisterV2PageLookups() + { + var urlToFile = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var node in Nodes.Values) + CollectDocumentationFilesByUrl(node, urlToFile, Url); + + RegisterV2PageLookups(V2NavigationItems, urlToFile); + } + + private void RegisterV2PageLookups( + IEnumerable items, + IReadOnlyDictionary urlToFile + ) + { + foreach (var item in items) + { + switch (item) + { + case PageCrossLinkLeaf pageCrossLink: + RegisterV2PageLookup(pageCrossLink.Page, pageCrossLink, urlToFile); + break; + case PageFolderNavigationNode pageFolder: + RegisterV2PageLookup(pageFolder.Page, pageFolder, urlToFile); + break; + } + + if (item is INodeNavigationItem node) + RegisterV2PageLookups(node.NavigationItems, urlToFile); + } + } + + private void RegisterV2PageLookup( + Uri page, + INavigationItem item, + IReadOnlyDictionary urlToFile + ) + { + if (TryResolvePageSource(page, out var file) || urlToFile.TryGetValue(item.Url, out file)) + { + _ = NavigationDocumentationFileLookup.Remove(file); + NavigationDocumentationFileLookup.Add(file, item); + } + } + + private bool TryResolvePageSource(Uri page, out IDocumentationFile file) + { + file = null!; + var pagePath = GetUriPath(page); + if (string.IsNullOrEmpty(pagePath)) + return false; + + var segments = pagePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + for (var length = segments.Length; length > 0; length--) + { + var tocPath = string.Join('/', segments.Take(length)); + if (!Nodes.TryGetValue(CreateTocUri(page.Scheme, tocPath), out var node)) + continue; + + var remainingPath = string.Join('/', segments.Skip(length)); + if (string.IsNullOrEmpty(remainingPath)) + { + file = node.Index.Model; + return true; + } + + if (TryFindDocumentationFile(node, remainingPath, out file)) + return true; + } + + return false; + } + + private static bool TryFindDocumentationFile( + INavigationItem item, + string remainingPath, + out IDocumentationFile file + ) + { + switch (item) + { + case ILeafNavigationItem leaf when MatchesRemainingPath(leaf.Url, remainingPath): + file = leaf.Model; + return true; + case INodeNavigationItem node: + if (MatchesRemainingPath(node.Index.Url, remainingPath)) + { + file = node.Index.Model; + return true; + } + foreach (var child in node.NavigationItems) + { + if (TryFindDocumentationFile(child, remainingPath, out file)) + return true; + } + break; + case INodeNavigationItem node: + foreach (var child in node.NavigationItems) + { + if (TryFindDocumentationFile(child, remainingPath, out file)) + return true; + } + break; + } + + file = null!; + return false; + } + + private static bool MatchesRemainingPath(string url, string remainingPath) + { + var normalizedPath = NormalizePagePath(remainingPath); + return url.TrimEnd('/').EndsWith($"/{normalizedPath}", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePagePath(string path) + { + var normalized = path.Trim('/'); + if (normalized.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^3]; + if (normalized.EndsWith("/index", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^6]; + return normalized; + } + + private static Uri CreateTocUri(string scheme, string path) + { + var slash = path.IndexOf('/'); + return slash < 0 + ? new Uri($"{scheme}://{path}") + : new Uri($"{scheme}://{path[..slash]}/{path[(slash + 1)..]}"); + } + + private static string GetUriPath(Uri uri) => (uri.Host + uri.AbsolutePath).Trim('/'); + + private static void CollectDocumentationFilesByUrl( + INavigationItem item, + Dictionary urlToFile, + string sitePrefix + ) + { + switch (item) + { + case ILeafNavigationItem leaf: + AddDocumentationFileUrl(urlToFile, leaf.Url, leaf.Model, sitePrefix); + break; + case INodeNavigationItem node: + AddDocumentationFileUrl(urlToFile, node.Url, node.Index.Model, sitePrefix); + AddDocumentationFileUrl(urlToFile, node.Index.Url, node.Index.Model, sitePrefix); + foreach (var child in node.NavigationItems) + CollectDocumentationFilesByUrl(child, urlToFile, sitePrefix); + break; + case INodeNavigationItem node: + foreach (var child in node.NavigationItems) + CollectDocumentationFilesByUrl(child, urlToFile, sitePrefix); + break; + } + } + + private static void AddDocumentationFileUrl( + Dictionary urlToFile, + string url, + IDocumentationFile file, + string sitePrefix + ) + { + _ = urlToFile.TryAdd(url, file); + + if (sitePrefix == "/" || string.IsNullOrEmpty(url) || !url.StartsWith('/')) + return; + + _ = urlToFile.TryAdd($"{sitePrefix.TrimEnd('/')}{url}", file); + } + private static IReadOnlyList BuildV2Items( IReadOnlyList v2Items, IReadOnlyDictionary> nodes, From 393f73bd22089b93c4146294d77105d7e8c19b30 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Mon, 11 May 2026 21:53:18 +0200 Subject: [PATCH 49/56] Validate links in {hero}, {link-card}, {whats-new} directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL fields in hub directive YAML bodies and options never went through Markdig's link parser, so cross-link resolution, missing-file checks, and link-index emission were all skipped — broken hub links shipped as silent 404s. Adds HubLinkValidator, invoked from each hub block's FinalizeAndValidate for every URL field. It: - resolves declared cross-link schemes via CrossLinkResolver.TryResolve, rewrites the value with the resolved URL, and registers via Build.Collector.EmitCrossLink - emits errors for undeclared schemes (with cursor:/vscode: passthrough) - checks internal absolute paths against the docset source dir in isolated builds (skipped under assembler/codex where cross-docset paths are legal — assembler-aware validation is a follow-up) - honours configured redirects with a warning suggesting the new path Wired only into HeroBlock, LinkCardBlock, and WhatsNewBlock. Surfaces ~40 previously-silent broken cross-link references in docs-content's kibana/elasticsearch hub pages. Co-Authored-By: Claude Opus 4.7 --- .../Myst/Directives/Hub/HeroBlock.cs | 6 +- .../Myst/Directives/Hub/HubLinkValidator.cs | 135 ++++++++++++++++++ .../Myst/Directives/Hub/LinkCardBlock.cs | 9 ++ .../Myst/Directives/Hub/WhatsNewBlock.cs | 12 ++ 4 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HubLinkValidator.cs diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs index 8787ad312f..b776239d70 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs @@ -48,9 +48,11 @@ public override void FinalizeAndValidate(ParserContext context) // search defaults to true; explicit ":search: false" hides it ShowSearch = TryPropBool("search") ?? true; QuickLinks = ParsePairs(Prop("quick-links"), allowEmptyUrl: false) - .Select(p => new HeroQuickLink(p.Label, p.Url!)).ToList(); + .Select(p => new HeroQuickLink(p.Label, HubLinkValidator.ValidateAndResolve(p.Url, this, context) ?? p.Url!)) + .ToList(); OtherVersions = ParsePairs(Prop("versions"), allowEmptyUrl: true) - .Select(p => new HeroVersion(p.Label, p.Url)).ToList(); + .Select(p => new HeroVersion(p.Label, HubLinkValidator.ValidateAndResolve(p.Url, this, context))) + .ToList(); Releases = Prop("releases"); if (string.IsNullOrWhiteSpace(Title)) diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HubLinkValidator.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HubLinkValidator.cs new file mode 100644 index 0000000000..159a59ab2e --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HubLinkValidator.cs @@ -0,0 +1,135 @@ +// 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 Elastic.Documentation; +using Elastic.Documentation.Links; +using Elastic.Markdown.Diagnostics; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Validates and resolves URL values supplied in hub directive YAML bodies and +/// options. These URLs never pass through Markdig's LinkInlineParser, so +/// without this helper the normal cross-link resolution, missing-file checks, +/// and link-index emission are skipped — broken hub links ship silently. +/// +/// Returns the resolved URL (or the original on failure) so callers can write +/// it back into their YAML data records. Errors / hints are emitted against +/// the supplying . +/// +internal static class HubLinkValidator +{ + /// + /// Validate and, when applicable, resolve cross-link + /// schemes to their final form. Returns the (possibly rewritten) URL. + /// + public static string? ValidateAndResolve(string? url, DirectiveBlock block, ParserContext context) + { + if (string.IsNullOrWhiteSpace(url) || block.SkipValidation) + return url; + + var trimmed = url.Trim(); + if (trimmed.Length == 0 || trimmed[0] == '#') + return url; + + if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase)) + return url; + + if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && CrossLinkValidator.IsCrossLink(uri)) + return ResolveCrossLink(url, uri, block, context); + + // Treat anything else as a docset-internal path. Hub directives are authored + // with site-absolute paths (e.g. "/explore-analyze/discover.md"); relative + // paths don't have a meaningful base because the YAML body isn't anchored + // to a markdown file location the way an inline [text](url) link is. + if (!trimmed.StartsWith('/')) + { + block.EmitError($"Hub directive link `{url}` must be an absolute path starting with `/`, a cross-link scheme (e.g. `kibana://`), or an external URL."); + return url; + } + + ValidateInternal(url, block, context); + return url; + } + + private static string ResolveCrossLink(string original, Uri uri, DirectiveBlock block, ParserContext context) + { + var resolver = context.CrossLinkResolver; + if (!resolver.IsDeclaredCrossLinkScheme(uri.Scheme)) + { + // Custom passthrough protocols (cursor:, vscode:) — leave alone. + if (IsPassthroughCustomProtocolScheme(uri.Scheme)) + return original; + block.EmitError($"Hub directive link `{original}` uses cross-link scheme `{uri.Scheme}://` which is not declared under `cross_links` in docset.yml."); + return original; + } + + context.Build.Collector.EmitCrossLink(original); + return resolver.TryResolve(s => block.EmitError(s), uri, out var resolved) + ? resolved.ToString() + : original; + } + + private static void ValidateInternal(string url, DirectiveBlock block, ParserContext context) + { + // In Assembler/Codex builds an absolute path may target a file owned by a + // different docset (the assembled site is the union of all docsets), so the + // current docset's source dir isn't the right basis for an existence check. + // Cross-docset references should ideally use the cross-link scheme so they + // resolve through CrossLinkResolver, but we don't have a way to assert that + // at this layer yet — flagging here would produce false positives. + if (context.Build.BuildType != BuildType.Isolated) + return; + + var (path, _) = SplitAnchor(url); + if (string.IsNullOrEmpty(path) || path == "/") + return; + + var sourceDir = context.Build.DocumentationSourceDirectory.FullName; + var fs = context.Build.ReadFileSystem; + var rel = path.TrimStart('/'); + + // Probe candidates in order: as-given, with .md, with /index.md. + // docs-builder URLs typically omit .md, so authors write /explore-analyze/discover + // for /explore-analyze/discover.md or /explore-analyze/discover/index.md. + string[] candidates = path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? [rel] + : [rel, rel + ".md", rel.TrimEnd('/') + "/index.md"]; + + foreach (var candidate in candidates) + { + if (context.TryFindDocumentByRelativePath(candidate) is not null) + return; + var pathOnDisk = Path.GetFullPath(Path.Join(sourceDir, candidate)); + if (fs.File.Exists(pathOnDisk)) + return; + } + + // Honour configured redirects so old paths emit a hint, not an error. + if (context.Configuration.Redirects is not null + && context.Configuration.Redirects.TryGetValue(rel, out var redirect)) + { + var to = redirect.To + ?? (redirect.Many is not null + ? string.Join(", ", redirect.Many.Select(m => m.To)) + : "unknown"); + block.EmitWarning($"Hub directive link `{url}` has a redirect; update to: {to}"); + return; + } + + block.EmitError($"Hub directive link `{url}` does not exist. If it was recently removed add a redirect."); + } + + private static (string Path, string? Anchor) SplitAnchor(string url) + { + var hash = url.IndexOf('#'); + return hash < 0 ? (url, null) : (url[..hash], url[(hash + 1)..]); + } + + private static bool IsPassthroughCustomProtocolScheme(string scheme) => + scheme.Equals("cursor", StringComparison.OrdinalIgnoreCase) + || scheme.StartsWith("vscode", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs index a116fd6e81..61fadeb63d 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs @@ -64,6 +64,15 @@ public override void FinalizeAndValidate(ParserContext context) if (string.IsNullOrWhiteSpace(Data.Title)) this.EmitError("{link-card} requires a `title` field in its YAML body."); + + Data.Link = HubLinkValidator.ValidateAndResolve(Data.Link, this, context); + foreach (var link in Data.Links) + link.Url = HubLinkValidator.ValidateAndResolve(link.Url, this, context); + if (Data.Aside is not null) + { + foreach (var link in Data.Aside.Links) + link.Url = HubLinkValidator.ValidateAndResolve(link.Url, this, context); + } } } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs index f61250d815..cf271a5ba8 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs @@ -49,6 +49,7 @@ public override void FinalizeAndValidate(ParserContext context) return; } Data = resolved; + ValidateLinks(context); return; } @@ -66,7 +67,18 @@ public override void FinalizeAndValidate(ParserContext context) catch (YamlException ex) { this.EmitError($"{{whats-new}} YAML parse error: {ex.Message}"); + return; } + + ValidateLinks(context); + } + + private void ValidateLinks(ParserContext context) + { + foreach (var link in Data.ReleaseLinks) + link.Url = HubLinkValidator.ValidateAndResolve(link.Url, this, context); + foreach (var item in Data.Items) + item.Link = HubLinkValidator.ValidateAndResolve(item.Link, this, context); } private WhatsNewData? LoadFromCentralConfig(string productKey) From 4d2c04bb75446d7e0201dce1cc29cd29ddee1d62 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Tue, 23 Jun 2026 19:08:03 +0200 Subject: [PATCH 50/56] Simplify hero, make on-this-page sticky, trim Products dropdown - Hero directive now renders only icon, title, description, and releases; drop the search box, version dropdown, and quick-link options end to end - Make the hub "on this page" component sticky on desktop - Restore the Products dropdown rendering and scroll-container fix lost in the nav-v2 merge, and remove the deployment-runbook and "soon" stubs Co-authored-by: Cursor --- config/navigation-v2.yml | 7 - docs/syntax/hero.md | 27 +- docs/syntax/hub-pages.md | 3 - docs/testing/products/elasticsearch/v9.md | 3 - docs/testing/products/kibana/v9.md | 3 - .../Assets/markdown/hub.css | 240 +----------------- .../Layout/_SecondaryNav.cshtml | 2 +- .../Myst/Directives/DirectiveHtmlRenderer.cs | 7 +- .../Myst/Directives/Hub/HeroBlock.cs | 61 +---- .../Myst/Directives/Hub/HeroView.cshtml | 71 ------ .../Myst/Directives/Hub/HeroViewModel.cs | 6 - 11 files changed, 13 insertions(+), 417 deletions(-) diff --git a/config/navigation-v2.yml b/config/navigation-v2.yml index 7a6257df31..e5cf213a92 100644 --- a/config/navigation-v2.yml +++ b/config/navigation-v2.yml @@ -4276,13 +4276,6 @@ nav: title: Elasticsearch - page: docs-content://products/kibana/v9.md title: Kibana - - title: Logstash (soon) - - title: Beats (soon) - - label: Deployment runbooks - children: - - title: Elastic Cloud Hosted (soon) - - title: Elastic Cloud Serverless (soon) - - title: Self-managed (soon) - section: APIs url: https://www.elastic.co/docs/api diff --git a/docs/syntax/hero.md b/docs/syntax/hero.md index 4c245b4c66..9c203aa6b6 100644 --- a/docs/syntax/hero.md +++ b/docs/syntax/hero.md @@ -1,6 +1,6 @@ # Hero -A full-bleed hero band with a product icon, page title, description, fake search box, version dropdown, quick-action pills, and an optional release-status line. Designed for the [hub layout](hub-pages.md) but reusable on any page. +A full-bleed hero band with a product icon, page title, description, and an optional release-status line. Designed for the [hub layout](hub-pages.md) but reusable on any page. All hero content is supplied via options. The directive body is not used. @@ -11,7 +11,6 @@ All hero content is supplied via options. The directive body is not used. :icon: kibana :title: Kibana :description: The UI for the Elasticsearch platform. -:version: v9 / Serverless (current) ::: ``` @@ -24,35 +23,15 @@ The `:title:` option doubles as the page title (no body H1 needed). `:descriptio | `:title:` | string | **Required.** Page heading shown next to the icon. Also picked up as the document's page title. | | `:description:` | inline markdown | One-line summary shown below the title. Supports bold, italics, and links. | | `:icon:` | string | Product key. Resolves to an inline SVG via the product-icon lookup. Known keys: `elasticsearch`, `kibana`, `observability`, `security`. Unknown keys fall back to a single-letter chip. | -| `:version:` | string | Label inside the version chip (e.g. `v9 / Serverless (current)`). | -| `:versions:` | comma list | When set, the chip becomes a `
    ` dropdown listing other versions. Items use `Label` (greyed, "soon") or `Label=URL` (clickable). | -| `:quick-links:` | comma list, `Label=URL` | Pill bar below the version chip. | -| `:releases:` | inline markdown | Small status line under the pills, supports inline links and bold/italic. | -| `:search:` | bool, default `true` | Show the placeholder search box. | +| `:releases:` | inline markdown | Small status line under the title, supports inline links and bold/italic. | -## Version dropdown - -```markdown -:::{hero} -:icon: kibana -:title: Kibana -:description: The UI for the Elasticsearch platform. -:version: v9 / Serverless (current) -:versions: v8=https://www.elastic.co/docs/products/kibana/8.x,v7 -::: -``` - -`v8=...` is a clickable link. `v7` (no `=URL`) renders greyed out with a "soon" badge. - -## Quick links and releases +## Releases ```markdown :::{hero} :icon: elasticsearch :title: Elasticsearch :description: The distributed search and analytics engine. -:version: v9 / Serverless (current) -:quick-links: Install=/install,API reference=/api,Release notes=/release-notes :releases: Latest: [Stack 9.4.1](/rn) (Mar 28, 2026) · [Serverless deployed](/srn) Apr 1, 2026 ::: ``` diff --git a/docs/syntax/hub-pages.md b/docs/syntax/hub-pages.md index 71d3a9aaee..10de9e6676 100644 --- a/docs/syntax/hub-pages.md +++ b/docs/syntax/hub-pages.md @@ -40,9 +40,6 @@ layout: hub :icon: kibana :title: Kibana :description: The UI for the Elasticsearch platform. -:version: v9 / Serverless (current) -:versions: v8,v7 -:quick-links: Install=/install,Tutorial=/tutorial :releases: Latest: [Stack 9.4.1](/rn) (Mar 28, 2026) ::: diff --git a/docs/testing/products/elasticsearch/v9.md b/docs/testing/products/elasticsearch/v9.md index 8c0caa1aca..c4cda54c46 100644 --- a/docs/testing/products/elasticsearch/v9.md +++ b/docs/testing/products/elasticsearch/v9.md @@ -6,9 +6,6 @@ layout: hub :icon: elasticsearch :title: Elasticsearch :description: The distributed search and analytics engine. Store, search, and analyze data at any scale, with vector search, AI toolkits, and a high-performance retrieval engine. -:version: v9 / Serverless (current) -:versions: v8,v7 -:quick-links: Install=https://www.elastic.co/docs/deploy-manage/deploy/self-managed/installing-elasticsearch,Quick start=https://www.elastic.co/docs/reference/elasticsearch/rest-apis/api-examples,API reference=https://www.elastic.co/docs/api/doc/elasticsearch,Release notes=https://www.elastic.co/docs/release-notes/elasticsearch :releases: Latest: [Stack 9.4.1](https://www.elastic.co/docs/release-notes/elasticsearch) (Mar 28, 2026) · [Serverless deployed](https://www.elastic.co/docs/release-notes/serverless) Apr 1, 2026 ::: diff --git a/docs/testing/products/kibana/v9.md b/docs/testing/products/kibana/v9.md index 360edb2f7e..cbd5bb9c60 100644 --- a/docs/testing/products/kibana/v9.md +++ b/docs/testing/products/kibana/v9.md @@ -6,9 +6,6 @@ layout: hub :icon: kibana :title: Kibana :description: The UI for the Elasticsearch platform. Explore and visualize your data, build dashboards, set up alerts, automate tasks with AI, and use purpose-built solutions for Search, Observability, and Security. -:version: v9 / Serverless (current) -:versions: v8,v7 -:quick-links: Install=/install,Tutorial=/tutorial,API reference=/api,Release notes=/release-notes :releases: Latest: [Stack 9.4.1](/release-notes/kibana) (Mar 28, 2026) · [Serverless deployed](/release-notes/serverless) Apr 1, 2026 ::: diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 1a9f814d0b..0f3fcb5764 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -85,241 +85,6 @@ line-height: 1.6; } - .hub-hero .hub-hero-search { - margin-top: 20px; - margin-bottom: 20px; - max-width: 480px; - } - .hub-hero .hub-hero-search-box { - display: flex; - align-items: center; - gap: 10px; - background: var(--color-white); - border-radius: 10px; - padding: 10px 16px; - box-shadow: 0 2px 12px rgb(0 0 0 / 0.15); - color: var(--color-grey-80); - cursor: text; - } - .hub-hero .hub-hero-search-icon { - flex-shrink: 0; - } - .hub-hero .hub-hero-search-box input { - flex: 1; - border: none; - outline: none; - background: transparent; - font-size: 15px; - font-family: inherit; - color: var(--color-ink-dark); - min-width: 0; - } - .hub-hero .hub-hero-search-box input::placeholder { - color: var(--color-grey-40); - } - - .hub-hero .hub-hero-meta { - @apply mt-2 flex flex-wrap items-center gap-3; - } - - .hub-hero .hub-hero-version { - display: inline-flex; - align-items: center; - gap: 8px; - background: rgb(255 255 255 / 0.08); - border: 1px solid rgb(255 255 255 / 0.15); - border-radius: 20px; - padding: 5px 14px; - font-size: 13px; - color: rgb(255 255 255 / 0.85); - } - .hub-hero .hub-hero-version-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: #9adc30; - } - - /* Version dropdown ------------------------------------------------- */ - .hub-hero details.hub-hero-version-dropdown { - position: relative; - display: inline-block; - padding: 0; - border: 0; - background: transparent; - } - .hub-hero details.hub-hero-version-dropdown summary { - display: inline-flex; - align-items: center; - gap: 10px; - background: rgb(255 255 255 / 0.12); - border: 1px solid rgb(255 255 255 / 0.25); - border-radius: 999px; - padding: 4px 4px 4px 14px; - font-size: 13px; - color: var(--color-white); - cursor: pointer; - list-style: none; - user-select: none; - transition: - background 0.15s, - border-color 0.15s; - } - .hub-hero - details.hub-hero-version-dropdown - summary::-webkit-details-marker { - display: none; - } - .hub-hero details.hub-hero-version-dropdown summary:hover { - background: rgb(255 255 255 / 0.2); - border-color: rgb(255 255 255 / 0.4); - } - .hub-hero details[open].hub-hero-version-dropdown summary { - background: rgb(255 255 255 / 0.2); - border-color: rgb(255 255 255 / 0.4); - } - .hub-hero .hub-hero-version-caret { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 50%; - background: rgb(255 255 255 / 0.22); - color: var(--color-white); - transition: - transform 0.18s ease, - background 0.15s; - } - .hub-hero .hub-hero-version-caret svg { - display: block; - width: 16px; - height: 16px; - } - .hub-hero - details.hub-hero-version-dropdown - summary:hover - .hub-hero-version-caret { - background: rgb(255 255 255 / 0.3); - } - .hub-hero details[open] .hub-hero-version-caret { - transform: rotate(180deg); - } - - .hub-hero .hub-hero-version-menu { - position: absolute; - top: calc(100% + 6px); - left: 0; - min-width: 220px; - background: var(--color-white); - border: 1px solid var(--color-grey-20); - border-radius: 10px; - padding: 4px; - margin: 0; - list-style: none; - box-shadow: 0 8px 24px rgb(0 0 0 / 0.15); - font-size: 13px; - color: var(--color-ink-dark); - z-index: 20; - } - .hub-hero .hub-hero-version-menu li { - margin: 0; - padding: 0; - border-radius: 6px; - } - .hub-hero .hub-hero-version-menu li a, - .hub-hero - .hub-hero-version-menu - li - > span:not(.hub-hero-version-tick):not(.hub-hero-version-soon) { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 12px; - text-decoration: none; - color: inherit; - white-space: nowrap; - } - .hub-hero - .hub-hero-version-menu - li - > span:not(.hub-hero-version-tick):not(.hub-hero-version-soon) { - display: flex; - } - .hub-hero .hub-hero-version-menu li { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 12px; - white-space: nowrap; - } - .hub-hero .hub-hero-version-menu li a, - .hub-hero .hub-hero-version-menu li > span { - padding: 0; - } - .hub-hero - .hub-hero-version-menu - li:hover:not(.hub-hero-version-current):not( - .hub-hero-version-disabled - ) { - background: var(--color-grey-10); - } - .hub-hero .hub-hero-version-menu li a { - color: inherit; - text-decoration: none; - flex: 1; - } - .hub-hero .hub-hero-version-menu li a:hover { - color: var(--color-blue-elastic-100); - text-decoration: none; - } - .hub-hero .hub-hero-version-current { - font-weight: 600; - color: var(--color-ink-dark); - } - .hub-hero .hub-hero-version-tick { - color: var(--color-blue-elastic-100); - font-weight: 700; - } - .hub-hero .hub-hero-version-disabled { - color: var(--color-grey-40); - cursor: not-allowed; - } - .hub-hero .hub-hero-version-soon { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--color-grey-40); - background: var(--color-grey-10); - padding: 2px 6px; - border-radius: 4px; - } - - .hub-hero .hub-hero-pills { - @apply m-0 flex list-none flex-wrap gap-2 p-0; - } - .hub-hero .hub-hero-pills li { - @apply m-0 p-0; - } - .hub-hero .hub-hero-pills a { - font-size: 13px; - padding: 5px 14px; - border-radius: 20px; - background: rgb(255 255 255 / 0.06); - border: 1px solid rgb(255 255 255 / 0.12); - color: rgb(255 255 255 / 0.85); - text-decoration: none; - transition: background 0.15s; - } - .hub-hero .hub-hero-pills a:hover { - background: rgb(255 255 255 / 0.12); - color: var(--color-white); - text-decoration: none; - } - .hub-hero .hub-hero-releases { @apply mt-4; font-size: 12px; @@ -337,12 +102,16 @@ /* On this page ----------------------------------------------------- */ .hub-on-this-page { @apply mx-auto w-full max-w-5xl; + position: sticky; + top: calc(var(--offset-top) + 8px); + z-index: 20; margin-top: 40px; margin-bottom: 36px; padding: 20px 28px; background: var(--color-white); border: 1px solid var(--color-grey-20); border-radius: 16px; + box-shadow: 0 2px 8px rgb(0 0 0 / 6%); font-size: 14px; display: flex; flex-wrap: wrap; @@ -654,6 +423,7 @@ grid-template-columns: 1fr; } .hub-on-this-page { + position: static; flex-direction: column; } .hub-card { diff --git a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml index bd495662ff..d8c718b691 100644 --- a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml @@ -6,7 +6,7 @@
  • +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs index 1ceea4bc45..78431f4190 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs @@ -9,5 +9,9 @@ public class LinkCardViewModel : DirectiveViewModel public required LinkCardData Data { get; init; } public required string? IconSvg { get; init; } public required string? SitePathPrefix { get; init; } + + /// Rendered as a titled link column inside an {explore} accordion. + public bool IsColumn { get; init; } + public string? PrefixUrl(string? url) => HubUrl.Prefix(url, SitePathPrefix); } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs index cf271a5ba8..5e4eb13ddd 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs @@ -77,6 +77,8 @@ private void ValidateLinks(ParserContext context) { foreach (var link in Data.ReleaseLinks) link.Url = HubLinkValidator.ValidateAndResolve(link.Url, this, context); + if (Data.UpgradeLink is { } upgrade) + upgrade.Url = HubLinkValidator.ValidateAndResolve(upgrade.Url, this, context); foreach (var item in Data.Items) item.Link = HubLinkValidator.ValidateAndResolve(item.Link, this, context); } @@ -125,12 +127,18 @@ public record WhatsNewData [YamlMember(Alias = "id")] public string? Id { get; set; } + [YamlMember(Alias = "intro")] + public string? Intro { get; set; } + [YamlMember(Alias = "badge")] public string? Badge { get; set; } = "New"; [YamlMember(Alias = "release-links")] public LinkCardLink[] ReleaseLinks { get; set; } = []; + [YamlMember(Alias = "upgrade-link")] + public LinkCardLink? UpgradeLink { get; set; } + [YamlMember(Alias = "items")] public WhatsNewItem[] Items { get; set; } = []; @@ -151,4 +159,16 @@ public record WhatsNewItem [YamlMember(Alias = "meta")] public string? Meta { get; set; } + + [YamlMember(Alias = "date")] + public string? Date { get; set; } + + [YamlMember(Alias = "tag")] + public string? Tag { get; set; } + + [YamlMember(Alias = "badge")] + public string? Badge { get; set; } + + [YamlMember(Alias = "featured")] + public bool Featured { get; set; } } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml index b5b1dcef23..07ac230fe2 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml @@ -2,69 +2,106 @@ @{ var d = Model.Data; + var upgrade = d.UpgradeLink; + var hasUpgrade = upgrade is not null && !string.IsNullOrWhiteSpace(upgrade.Url) && !string.IsNullOrWhiteSpace(upgrade.Label); }
    - @if (!string.IsNullOrWhiteSpace(d.Badge)) - { - @d.Badge - } - @if (!string.IsNullOrWhiteSpace(d.Title)) - { - @d.Title - } +
    + @if (!string.IsNullOrWhiteSpace(d.Title)) + { +

    @d.Title

    + } + @if (!string.IsNullOrWhiteSpace(d.Intro)) + { +

    @d.Intro

    + } +
    @if (d.ReleaseLinks.Length > 0) { - - Release notes: - @for (var i = 0; i < d.ReleaseLinks.Length; i++) - { - var link = d.ReleaseLinks[i]; - if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) - continue; - @link.Label - @if (i < d.ReleaseLinks.Length - 1) +
    + Latest release notes: +
      + @foreach (var link in d.ReleaseLinks) { - + if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) + { + continue; + } +
    • @link.Label
    • } - } - +
    +
    }
    + @if (d.Items.Length > 0) { -
    diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 16ac8d5a1b..bf68795d04 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -44,6 +44,11 @@ public static T Deserialize(string yaml, ProductsConfiguration products) [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardData))] [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardLink))] [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardAside))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedData))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedInstall))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedTutorial))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedStep))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedStepOption))] [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewData))] [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewItem))] [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewConfig))] From e3342d84ba80e8b0b8b8bcb6a069ecea87fe3a90 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Mon, 6 Jul 2026 13:25:58 +0200 Subject: [PATCH 54/56] Bump Microsoft.OpenApi to 3.5.4 to fix NU1903 audit failure GHSA-v5pm-xwqc-g5wc flags Microsoft.OpenApi <= 3.5.3 for a high severity circular schema reference issue, surfacing as NU1903 and failing CI restore under TreatWarningsAsErrors. 3.5.4 is the first patched version and matches the bump already on main. Co-authored-by: Cursor --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 67661a36f1..3c4ddafe3c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -56,7 +56,7 @@ - + From fa6182fda8e626ddfa16afecb6175d3fa12c6c08 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Mon, 6 Jul 2026 15:17:44 +0200 Subject: [PATCH 55/56] Fix Prettier formatting in hub.css Use single quotes for the accordion marker content to satisfy the prettier --check step in the npm CI job. Co-authored-by: Cursor --- src/Elastic.Documentation.Site/Assets/markdown/hub.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 0d5094b8fc..d483cc1205 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -757,7 +757,7 @@ display: none; } .hub-accordion-summary::marker { - content: ""; + content: ''; } .hub-accordion-title { font-size: 1.143rem; From d84fa3f465cbb2a703bef18651018c0f3f62dabc Mon Sep 17 00:00:00 2001 From: Florent LB Date: Mon, 6 Jul 2026 17:33:19 +0200 Subject: [PATCH 56/56] Set required Cta on placeholder page view model The cta feature merged from main made MarkdownLayoutViewModel.Cta a required member. PlaceholderPageWriter builds that view model directly and must set it; use the built-in default, matching pages with no cta config. Co-authored-by: Cursor --- .../Building/PlaceholderPageWriter.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/Elastic.Documentation.Assembler/Building/PlaceholderPageWriter.cs b/src/services/Elastic.Documentation.Assembler/Building/PlaceholderPageWriter.cs index 116266a1b2..ca6d9c2255 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/PlaceholderPageWriter.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/PlaceholderPageWriter.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Extensions; using Elastic.Documentation.Navigation; @@ -165,7 +166,8 @@ NavigationRenderResult renderResult VersioningSystem = context.VersionsConfiguration.GetVersioningSystem(VersioningSystemId.All), VersionDropdownSerializedModel = null, CurrentVersion = null, - AllVersionsUrl = null + AllVersionsUrl = null, + Cta = Cta.Default }; } }