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..450b3a6c4 --- /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..27c1c1caf --- /dev/null +++ b/docs/syntax/whats-new.md @@ -0,0 +1,81 @@ +# 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. + +The `date` renders as you write it. Use sentence case, for example `Aug 2026`. + +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..1701003ba 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -370,6 +370,165 @@ 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: var(--text-2xl); + 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: var(--text-base); + color: var(--color-ink-light); + } + .hub-whats-new .hub-wn-upgrade { + @apply inline-flex items-center gap-1.5; + font-size: var(--text-base); + 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: var(--text-base); + 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: var(--text-base); + 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; + } + /* Same hover treatment as every other card that is itself a link. The pale blue this + replaced read at 2.12:1 against white, so the state was barely visible. */ + .hub-whats-new a.hub-wn-card-link:hover, + .hub-whats-new .hub-wn-card-link:focus-visible { + border-color: var(--color-grey-80); + 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: var(--text-sm); + } + .hub-whats-new .hub-wn-card-meta-left { + @apply inline-flex items-center gap-2; + } + /* The date is authored, so it keeps the casing the writer chose. Forcing + uppercase shouts, and it mangles a month name in any language. */ + .hub-whats-new .hub-wn-card-date { + font-weight: 600; + color: var(--color-ink-light); + 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: var(--text-base); + 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: var(--text-lg); + } + .hub-whats-new .hub-wn-card-desc { + font-size: var(--text-base); + 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: var(--text-base); + 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..d61d05ecb --- /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 """