From 430513fa96f44fd126363757d42fc0a22e3dd757 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Tue, 11 Aug 2026 09:51:31 +0200 Subject: [PATCH 1/5] Add {whats-new} A recency panel for a hub page. A reader who bookmarks a hub wants a quick answer to "what changed recently" without hunting through release notes. The panel content moves out of the build tool. The prototype reads config/whats-new.yml from docs-builder, which means a writer editing a highlight card opens a pull request against the build tool and waits for a release. It now reads hub-whats-new.yml from the root of the current documentation set, beside changelog.yml and redirects.yml. That makes the directive documentation-set scoped. It cannot render another repository's panel, and this is a constraint rather than a syntax gap. Cross-link resolution maps pages through the link index, and a YAML data file is not a page. In an isolated build the other repository is not checked out, so there would be no file to read. Every hub page lives in the same repository as its content file, so the limit costs nothing today. The common case stays one line, `:product: `. Omitting it accepts the same schema as an inline YAML body, for a one-off panel. Adds containsRawHtml to the authoring assertions. The pretty-printed comparison only matches a childless element or a snapshot taken from the outermost element of the directive output, so asserting a class on a container previously meant snapshotting its whole subtree. Follow-up, not in this change: a skill in docs-content that updates hub-whats-new.yml from the release notes of each product that has a hub page. Co-Authored-By: Claude Opus 5 --- docs/_docset.yml | 1 + docs/examples/products/docs-builder.md | 4 + docs/hub-whats-new.yml | 49 +++++ docs/syntax/hub-pages.md | 1 + docs/syntax/whats-new.md | 79 ++++++++ .../Assets/markdown/hub.css | 156 ++++++++++++++++ .../Myst/Directives/DirectiveBlockParser.cs | 3 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 14 ++ .../Myst/Directives/Hub/WhatsNewBlock.cs | 173 ++++++++++++++++++ .../Myst/Directives/Hub/WhatsNewView.cshtml | 117 ++++++++++++ .../Myst/Directives/Hub/WhatsNewViewModel.cs | 10 + .../LlmMarkdown/LlmBlockRenderers.cs | 54 ++++++ .../PlainText/PlainTextBlockRenderers.cs | 1 + .../Myst/YamlSerialization.cs | 3 + tests/authoring/Blocks/Hub/WhatsNew.fs | 110 +++++++++++ tests/authoring/authoring.fsproj | 1 + 16 files changed, 776 insertions(+) create mode 100644 docs/hub-whats-new.yml create mode 100644 docs/syntax/whats-new.md 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 create mode 100644 tests/authoring/Blocks/Hub/WhatsNew.fs diff --git a/docs/_docset.yml b/docs/_docset.yml index d18d7681c..89f358d3a 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -146,6 +146,7 @@ toc: - file: tables.md - file: tabs.md - file: titles.md + - file: whats-new.md # Examples - folder: examples diff --git a/docs/examples/products/docs-builder.md b/docs/examples/products/docs-builder.md index 1c15e9c8f..2457e2067 100644 --- a/docs/examples/products/docs-builder.md +++ b/docs/examples/products/docs-builder.md @@ -38,6 +38,10 @@ steps: description: Check links, syntax, and frontmatter before you open a pull request. ::: +:::{whats-new} +:product: docs-builder +::: + ::::{card-group} :title: Get hands-on :id: hands-on diff --git a/docs/hub-whats-new.yml b/docs/hub-whats-new.yml new file mode 100644 index 000000000..34c26a91a --- /dev/null +++ b/docs/hub-whats-new.yml @@ -0,0 +1,49 @@ +# Content for the {whats-new} panel on hub pages, keyed by product. +# +# This file lives at the root of the documentation set, alongside changelog.yml and +# redirects.yml, so a writer edits it without touching the build tool. A hub page renders +# a panel from it with one line: `:product: `. +# +# The directive reads this file from the current documentation set only. It cannot render +# another repository's panel. +products: + docs-builder: + title: What's new in docs-builder + id: whats-new + intro: Recent additions to the toolchain and the syntax it understands. + # More than one release stream can be listed here. + release-links: + - label: Release notes + url: /data/release-notes/index.md + - label: Changelog reference + url: /data/release-notes/overview.md + upgrade-link: + label: Upgrade docs-builder + url: /getting-started/installation.md + items: + - title: Hub pages + description: A product-scoped landing page composed entirely from directives. + link: /syntax/hub-pages.md + date: AUG 2026 + tag: Syntax + featured: true + - title: Explore sections + description: Collapse a long link list into a stack of accordions. + link: /syntax/explore.md + date: AUG 2026 + tag: Syntax + - title: Get started sections + description: One opinionated onboarding path, with a copyable install command. + link: /syntax/get-started.md + date: AUG 2026 + tag: Syntax + - title: Link cards + description: A card with a title, a description, and a validated link list. + link: /syntax/link-card.md + date: AUG 2026 + tag: Syntax + - title: Card groups + description: Group related cards under a heading, or as one accordion. + link: /syntax/card-group.md + date: AUG 2026 + tag: Syntax diff --git a/docs/syntax/hub-pages.md b/docs/syntax/hub-pages.md index e01bec467..431f8d8e5 100644 --- a/docs/syntax/hub-pages.md +++ b/docs/syntax/hub-pages.md @@ -57,6 +57,7 @@ Write both deliberately. The search body indexes the hero title and description | [`{card-group}`](card-group.md) | Section heading and card grid. Renders as an accordion inside `{explore}`. | | [`{link-card}`](link-card.md) | One card: title, description, and a list of links. Renders as a link column inside `{explore}`. | | [`{get-started}`](get-started.md) | Onboarding funnel. An install command, a tutorial link, and numbered steps. | +| [`{whats-new}`](whats-new.md) | Recency panel. Dated highlight cards, authored once in `hub-whats-new.yml`. | | [`{explore}`](explore.md) | The browse-everything section. A stack of collapsible accordions. | ## Page skeleton diff --git a/docs/syntax/whats-new.md b/docs/syntax/whats-new.md new file mode 100644 index 000000000..4314147c2 --- /dev/null +++ b/docs/syntax/whats-new.md @@ -0,0 +1,79 @@ +# What's new + +A recency panel for a [hub page](hub-pages.md). A reader who bookmarks a hub wants a quick answer to "what changed recently" without hunting through release notes. + +See the [docs-builder documentation hub](../examples/products/docs-builder.md) for a rendered panel. + +## Basic + +The common case is one line: + +```markdown +:::{whats-new} +:product: docs-builder +::: +``` + +`:product:` looks the key up in `hub-whats-new.yml` at the root of the documentation set. The content is authored once there and every page that names the same product renders the same panel. One edit updates them all. + +## Where the content lives + +`hub-whats-new.yml` sits beside `changelog.yml` and `redirects.yml`, at the root of the content repository rather than in the build tool. A writer edits the panel without opening docs-builder, and without waiting for a docs-builder release. + +```yaml +products: + docs-builder: + title: What's new in docs-builder + id: whats-new + intro: Recent additions to the toolchain. + release-links: + - label: View release notes + url: /data/release-notes/index.md + items: + - title: Hub pages + description: A product-scoped landing page composed entirely from directives. + link: /syntax/hub-pages.md + date: AUG 2026 + tag: Syntax + featured: true +``` + +| Field | Notes | +|---|---| +| `title` | H2 heading. | +| `id` | Section anchor. Use `whats-new` so `{hero}`'s secondary action can jump to it. | +| `intro` | One-line lead. | +| `release-links` | Links to the full release notes, shown beside the heading. List more than one when a product has several release streams. | +| `upgrade-link` | An upgrade prompt below the grid. Takes `label` and `url`. | +| `items` | The highlight cards. | + +Each item takes a `title`, a `description`, a `link`, a `date` and a `tag`. Mark one item `featured: true` to span two columns. + +Every field except `title` is optional. The example file uses each one once, so you can start from it and delete what you do not need. + +## Inline body + +Omit `:product:` and give the directive the same schema as a YAML body, for a one-off panel that does not belong in the shared file: + +```markdown +:::{whats-new} +title: What's new +items: + - title: Hub pages + description: A product-scoped landing page. + link: /syntax/hub-pages.md + date: AUG 2026 +::: +``` + +## Scope limit + +The directive reads the file in the current documentation set. It cannot render another repository's panel. + +That is not a syntax gap. Cross-link resolution maps pages through the link index, and a YAML data file is not a page. In an isolated build the other repository is not checked out, so there is no file to read at all. + +Every hub page lives in the same repository as its content file, so this costs nothing today. + +## Links + +Every `release-links[].url`, `upgrade-link.url`, and `items[].link` validates at build time, using the same forms as [`{link-card}`](link-card.md#links). diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 564cb73ee..548a96089 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -370,6 +370,162 @@ color: var(--color-blue-elastic-100, #0b64dd); } + /* What's new ------------------------------------------------------- */ + .hub-whats-new { + @apply mx-auto w-full max-w-5xl; + margin-bottom: 56px; + scroll-margin-top: 120px; + } + .hub-whats-new .hub-wn-header { + @apply mb-6 flex flex-wrap items-end justify-between gap-4; + } + .hub-whats-new .hub-wn-title { + font-size: 1.571rem; + font-weight: 700; + color: var(--color-ink-dark); + line-height: 1.2; + margin: 0; + } + .hub-whats-new .hub-wn-intro { + color: var(--color-ink-light); + margin-top: 8px; + max-width: 60ch; + } + .hub-whats-new .hub-wn-rn { + @apply flex flex-shrink-0 flex-wrap items-center gap-x-2 gap-y-1; + } + .hub-whats-new .hub-wn-footer { + @apply mt-4 flex flex-wrap items-center justify-center gap-x-2 gap-y-1; + } + .hub-whats-new .hub-wn-footer-text { + font-size: 0.875rem; + color: var(--color-ink-light); + } + .hub-whats-new .hub-wn-upgrade { + @apply inline-flex items-center gap-1.5; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-blue-elastic-100); + text-decoration: none; + } + .hub-whats-new .hub-wn-upgrade:hover, + .hub-whats-new .hub-wn-upgrade:focus-visible { + text-decoration: underline; + } + .hub-whats-new .hub-wn-upgrade svg { + flex-shrink: 0; + } + .hub-whats-new .hub-wn-rn-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-ink-light); + } + .hub-whats-new .hub-wn-rn-list { + @apply m-0 flex flex-wrap items-center p-0; + list-style: none; + } + .hub-whats-new .hub-wn-rn-list li { + @apply inline-flex items-center; + } + .hub-whats-new .hub-wn-rn-list li:not(:last-child)::after { + content: 'ยท'; + color: var(--color-grey-40); + margin: 0 10px; + font-weight: 700; + } + .hub-whats-new .hub-wn-rn-link { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-blue-elastic-100); + text-decoration: none; + } + .hub-whats-new .hub-wn-rn-link:hover, + .hub-whats-new .hub-wn-rn-link:focus-visible { + text-decoration: underline; + } + + /* What's new card grid */ + .hub-whats-new .hub-wn-grid { + @apply m-0 grid list-none p-0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + } + .hub-whats-new .hub-wn-card { + margin: 0; + } + .hub-whats-new .hub-wn-card-featured { + grid-column: span 2; + } + .hub-whats-new .hub-wn-card-link { + @apply flex h-full flex-col gap-2.5; + padding: 18px 20px; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 14px; + text-decoration: none; + color: inherit; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + } + .hub-whats-new a.hub-wn-card-link:hover, + .hub-whats-new .hub-wn-card-link:focus-visible { + border-color: var(--color-blue-elastic-60, #7db8e8); + box-shadow: 0 2px 10px rgb(0 0 0 / 0.06); + } + .hub-whats-new .hub-wn-card-meta { + @apply flex items-center justify-between gap-2; + font-size: 0.75rem; + } + .hub-whats-new .hub-wn-card-meta-left { + @apply inline-flex items-center gap-2; + } + .hub-whats-new .hub-wn-card-date { + font-weight: 600; + color: var(--color-ink-light); + text-transform: uppercase; + letter-spacing: 0.03em; + } + .hub-whats-new .hub-wn-card-tag { + color: var(--color-grey-80, #69707d); + font-weight: 500; + } + .hub-whats-new .hub-wn-card-title { + font-size: 0.9375rem; + font-weight: 700; + color: var(--color-ink-dark); + line-height: 1.3; + margin: 0; + } + .hub-whats-new .hub-wn-card-featured .hub-wn-card-title { + font-size: 1.125rem; + } + .hub-whats-new .hub-wn-card-desc { + font-size: 0.8125rem; + color: var(--color-ink-light); + line-height: 1.45; + margin: 0; + } + .hub-whats-new .hub-wn-card-more { + @apply mt-auto inline-flex items-center gap-1.5; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-blue-elastic-100); + } + @media (max-width: 900px) { + .hub-whats-new .hub-wn-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + @media (max-width: 640px) { + .hub-whats-new .hub-wn-grid { + grid-template-columns: 1fr; + } + .hub-whats-new .hub-wn-card-featured { + grid-column: span 1; + } + } + /* Zone (section heading) ------------------------------------------ */ .hub-zone { @apply mx-auto w-full max-w-5xl; diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index add4cf362..d9e56299a 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -153,6 +153,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{get-started}") > 0) return new GetStartedBlock(this, context); + if (info.IndexOf("{whats-new}") > 0) + return new WhatsNewBlock(this, context); + if (info.IndexOf("{agent-skill}") > 0) return new AgentSkillBlock(this, context); diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 31057f6bd..904c963f3 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -120,6 +120,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case GetStartedBlock getStartedBlock: WriteGetStarted(renderer, getStartedBlock); return; + case WhatsNewBlock whatsNewBlock: + WriteWhatsNew(renderer, whatsNewBlock); + return; case PageCardBlock pageCardBlock: WritePageCard(renderer, pageCardBlock); return; @@ -270,6 +273,17 @@ private static void WriteLinkCard(HtmlRenderer renderer, LinkCardBlock block) RenderRazorSlice(slice, renderer); } + private static void WriteWhatsNew(HtmlRenderer renderer, WhatsNewBlock block) + { + var slice = WhatsNewView.Create(new WhatsNewViewModel + { + DirectiveBlock = block, + Data = block.Data, + SitePathPrefix = block.Build.UrlPathPrefix + }); + RenderRazorSlice(slice, renderer); + } + private static void WriteGetStarted(HtmlRenderer renderer, GetStartedBlock block) { var data = block.Data; 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 000000000..1c54c33e2 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs @@ -0,0 +1,173 @@ +// 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.Concurrent; +using Elastic.Markdown.Diagnostics; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Renders the "What's new" panel for a product. Two usage shapes: +/// +/// Centralized lookup (preferred): +/// +/// :::{whats-new} +/// :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 const string WhatsNewFileName = "hub-whats-new.yml"; + + 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 {WhatsNewFileName} at the root of this documentation set."); + return; + } + Data = resolved; + ValidateLinks(context); + return; + } + + var yaml = HubYamlBody.Extract(this, new BuildContextFileReader(Build.ReadFileSystem)); + if (yaml is null) + { + this.EmitError("{whats-new} requires either a `:product:` option or a YAML body."); + return; + } + + try + { + Data = YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration) ?? WhatsNewData.Empty; + } + 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 = DirectiveLinkValidator.ValidateAndResolve(link.Url, this, context); + if (Data.UpgradeLink is { } upgrade) + upgrade.Url = DirectiveLinkValidator.ValidateAndResolve(upgrade.Url, this, context); + foreach (var item in Data.Items) + item.Link = DirectiveLinkValidator.ValidateAndResolve(item.Link, this, context); + } + + /// + /// The panel content lives in the content repository, next to the pages that use it, so a + /// writer edits it without touching the build tool. It is read from the current documentation + /// set, which means the directive cannot render another repository's panel. In an isolated + /// build the other repository is not checked out at all, so there would be no file to read. + /// + private WhatsNewData? LoadFromCentralConfig(string productKey) + { + var path = Path.Combine(Build.DocumentationSourceDirectory.FullName, WhatsNewFileName); + if (!Build.ReadFileSystem.File.Exists(path)) + return null; + + var config = CentralConfigCache.GetOrAdd(path, p => + { + try + { + var yaml = Build.ReadFileSystem.File.ReadAllText(p); + 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 +{ + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "id")] + public string? Id { get; set; } + + [YamlMember(Alias = "intro")] + public string? Intro { get; set; } + + [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; } = []; + + 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 = "date")] + public string? Date { get; set; } + + [YamlMember(Alias = "tag")] + public string? Tag { 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 new file mode 100644 index 000000000..c1b727274 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml @@ -0,0 +1,117 @@ +@inherits RazorSlice + +@{ + 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.Title)) + { +

@d.Title

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

@d.Intro

+ } +
+ @if (d.ReleaseLinks.Length > 0) + { +
+ 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) + { + + } + + @if (hasUpgrade) + { + + } +
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 000000000..764c756e4 --- /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 : HubDirectiveViewModel +{ + public required WhatsNewData Data { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 0a4073322..74265537a 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -517,6 +517,9 @@ protected override void Write(LlmMarkdownRenderer renderer, DirectiveBlock obj) case GetStartedBlock getStartedBlock: WriteGetStartedBlock(renderer, getStartedBlock); return; + case WhatsNewBlock whatsNewBlock: + WriteWhatsNewBlock(renderer, whatsNewBlock); + return; } // Ensure single empty line before directive @@ -583,6 +586,57 @@ private static void WriteHeroBlock(LlmMarkdownRenderer renderer, HeroBlock heroB renderer.EnsureLine(); } + // Dated highlights, so the export keeps the date and tag alongside each title. A reader + // asking "what changed recently in X" wants exactly this list. + private static void WriteWhatsNewBlock(LlmMarkdownRenderer renderer, WhatsNewBlock block) + { + var data = block.Data; + renderer.EnsureBlockSpacing(); + + if (!string.IsNullOrEmpty(data.Title)) + { + renderer.WriteLine($"## {data.Title}"); + renderer.EnsureLine(); + } + if (!string.IsNullOrEmpty(data.Intro)) + { + renderer.WriteLine(data.Intro); + renderer.EnsureLine(); + } + + foreach (var link in data.ReleaseLinks) + WriteHeroAction(renderer, link.Label, link.Url); + + foreach (var item in data.Items) + WriteWhatsNewItem(renderer, item); + + if (data.UpgradeLink is { } upgrade) + WriteHeroAction(renderer, upgrade.Label, upgrade.Url); + + renderer.EnsureLine(); + } + + private static void WriteWhatsNewItem(LlmMarkdownRenderer renderer, WhatsNewItem item) + { + if (string.IsNullOrEmpty(item.Title)) + return; + + renderer.EnsureLine(); + var title = string.IsNullOrEmpty(item.Link) + ? item.Title + : $"[{item.Title}]({HubLinkForLlm(renderer, item.Link)})"; + + var meta = new List(2); + if (!string.IsNullOrEmpty(item.Date)) + meta.Add(item.Date); + if (!string.IsNullOrEmpty(item.Tag)) + meta.Add(item.Tag); + + renderer.WriteLine(meta.Count > 0 ? $"- {title} ({string.Join(", ", meta)})" : $"- {title}"); + if (!string.IsNullOrEmpty(item.Description)) + renderer.WriteLine($" {item.Description}"); + } + // The onboarding path is a sequence, so it exports as an ordered list. Options under a step // become sub-items, each with its command or its link. private static void WriteGetStartedBlock(LlmMarkdownRenderer renderer, GetStartedBlock block) diff --git a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs index 3b01468f1..49c8f4dfd 100644 --- a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs @@ -292,6 +292,7 @@ protected override void Write(PlainTextRenderer renderer, DirectiveBlock obj) case CardGroupBlock: case LinkCardBlock: case GetStartedBlock: + case WhatsNewBlock: return; case AgentSkillBlock agentSkillBlock: diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 36bb30ce0..65ed9abcf 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -86,4 +86,7 @@ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializ [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedData))] [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))] public partial class DocsBuilderYamlStaticContext; diff --git a/tests/authoring/Blocks/Hub/WhatsNew.fs b/tests/authoring/Blocks/Hub/WhatsNew.fs new file mode 100644 index 000000000..fd9320676 --- /dev/null +++ b/tests/authoring/Blocks/Hub/WhatsNew.fs @@ -0,0 +1,110 @@ +// 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 +module ``AuthoringTests``.``block elements``.``hub``.``whats new elements`` + +open Xunit +open authoring + +// The :product: path reads hub-whats-new.yml from the documentation set root. The authoring +// harness builds from a temporary set with no such file, so these cover the inline-body path +// and the error raised when a product key cannot be resolved. + +type ``whats new with an inline body`` () = + static let markdown = Setup.Markdown """ +:::{whats-new} +title: What's new in docs-builder +id: whats-new +intro: Recent additions to the toolchain. +items: + - title: Hub pages + description: A product-scoped landing page. + link: /index.md + date: AUG 2026 + tag: Syntax + featured: true + - title: Explore sections + description: Collapse a long link list. + link: /index.md + date: AUG 2026 + tag: Syntax +::: +""" + + [] + let ``renders the heading and intro`` () = + markdown |> convertsToContainingHtml """

What's new in docs-builder

""" + + [] + let ``renders a card per item`` () = + markdown |> convertsToContainingHtml """

Hub pages

""" + + [] + let ``spans the featured card across two columns`` () = + markdown |> convertsToContainingRawHtml """