diff --git a/docs/_docset.yml b/docs/_docset.yml
index 6f66132a8a..a05806da81 100644
--- a/docs/_docset.yml
+++ b/docs/_docset.yml
@@ -120,6 +120,8 @@ toc:
- file: file_inclusion.md
- file: footnotes.md
- file: frontmatter.md
+ - file: hero.md
+ - file: hub-pages.md
- file: icons.md
- file: images.md
- file: videos.md
@@ -141,6 +143,14 @@ toc:
- file: tabs.md
- file: titles.md
+ # Examples
+ - folder: examples
+ children:
+ - file: index.md
+ - folder: products
+ children:
+ - file: docs-builder.md
+
# Documentation builds
- folder: documentation
children:
diff --git a/docs/examples/index.md b/docs/examples/index.md
new file mode 100644
index 0000000000..54c82639bc
--- /dev/null
+++ b/docs/examples/index.md
@@ -0,0 +1,11 @@
+# Examples
+
+Complete pages that show docs-builder syntax rendering in context. Each syntax page explains one directive in isolation. These pages show how directives combine into a finished page.
+
+Use them to check what a page type looks like before you author one, and to verify rendering changes against a realistic page rather than a fixture.
+
+## Hubs
+
+Hub pages use `layout: hub` and are composed entirely from directives. See [Hub pages](../syntax/hub-pages.md).
+
+- [docs-builder documentation hub](products/docs-builder.md)
diff --git a/docs/examples/products/docs-builder.md b/docs/examples/products/docs-builder.md
new file mode 100644
index 0000000000..283f45e46b
--- /dev/null
+++ b/docs/examples/products/docs-builder.md
@@ -0,0 +1,12 @@
+---
+layout: hub
+description: docs-builder documentation. Build, validate, and publish Elastic documentation from Markdown across many repositories.
+---
+
+:::{hero}
+:icon: docs-builder
+:title: docs-builder documentation hub
+:description: The toolchain that builds Elastic's documentation. Author in Markdown, validate cross-repository links, preview locally, and publish one unified site.
+:primary-action: [Install docs-builder](/getting-started/installation.md)
+:secondary-action: [Elastic documentation](docs-content://get-started/index.md)
+:::
diff --git a/docs/syntax/hero.md b/docs/syntax/hero.md
new file mode 100644
index 0000000000..75d4be1fd5
--- /dev/null
+++ b/docs/syntax/hero.md
@@ -0,0 +1,64 @@
+# Hero
+
+A full-bleed identity band with a product icon, page title, description, and up to three actions. It is designed for the [hub layout](hub-pages.md), and it works on any page.
+
+All hero content comes from options. The directive body is not used.
+
+See the [Elasticsearch documentation hub](../examples/products/docs-builder.md) for a rendered hero.
+
+## Basic
+
+```markdown
+:::{hero}
+:icon: elasticsearch
+:title: Elasticsearch documentation hub
+:description: The distributed search and analytics engine at the heart of the Elastic platform.
+:::
+```
+
+The `:title:` option doubles as the page title, so a hub page needs no body H1. See [Page title](hub-pages.md#page-title).
+
+## Options
+
+| Option | Type | Notes |
+|---|---|---|
+| `:title:` | string | **Required.** Renders as the page `
` next to the icon. Also used as the document title. |
+| `:description:` | inline markdown | One-line summary below the title. Supports bold, italics, and links. |
+| `:icon:` | string | Product key. Resolves to an inline SVG. Known keys: `elasticsearch`, `kibana`, `observability`, `security`. An unknown key falls back to a single-letter chip. |
+| `:primary-action:` | markdown link | First action. Format: `[Label](/url)` or `[Label](#anchor)`. |
+| `:secondary-action:` | markdown link | Second action. |
+| `:tertiary-action:` | markdown link | Third action. |
+
+## Actions
+
+Each action is a single markdown link. Actions render left to right, in the order primary, secondary, tertiary. Actions are optional. Omit them for a pure identity hero.
+
+The option names set the order, not the weight. All three render as neutral buttons, the monochrome variant of the [button](/syntax/buttons.md) directive. A blue variant would make each action claim to be the call to action.
+
+```markdown
+:::{hero}
+:icon: elasticsearch
+:title: Elasticsearch documentation hub
+:description: The distributed search and analytics engine.
+:primary-action: [Install Elasticsearch](https://www.elastic.co/downloads/elasticsearch)
+:secondary-action: [Get started](#get-started)
+:tertiary-action: [Syntax reference](/syntax/hero.md)
+:::
+```
+
+Actions render as buttons, and no button on the site carries an arrow. The arrow belongs to the eyebrow link, which sends the reader onward to the docs home.
+
+Action URLs validate at build time. Use one of these forms:
+
+| Form | Example | Behavior |
+|---|---|---|
+| In-page anchor | `#get-started` | Jumps to a section on the same page. Does not preload. |
+| Site-absolute path | `/syntax/hero.md` | The markdown extension is stripped. The link preloads on hover. |
+| Cross-link scheme | `docs-content://get-started/index.md` | Resolves through the link index. Not treated as external, so it does not open in a new tab. |
+| External URL | `https://www.elastic.co/downloads/elasticsearch` | Opens in a new tab, with `rel="noopener noreferrer"`. Does not preload. |
+
+A relative path such as `foo.md` is rejected. This differs from an inline markdown link, where a relative path resolves against the source file's directory.
+
+## Description markup
+
+`:description:` is a directive option, not a body block, so it never reaches the document pipeline. It renders with the default Markdown pipeline. Basic inline markup works. Substitutions, roles, and link validation do not apply inside it. Keep the description to plain prose.
diff --git a/docs/syntax/hub-pages.md b/docs/syntax/hub-pages.md
new file mode 100644
index 0000000000..2fb8ab6846
--- /dev/null
+++ b/docs/syntax/hub-pages.md
@@ -0,0 +1,70 @@
+# Hub pages
+
+A hub page is a product-scoped landing page. It gives a reader one 360° view of a product across versions, deployment types, and surfaces.
+
+Hub pages are composed entirely from directives. There is no free-form body content. That constraint is deliberate. It lets every link validate at build time, and it keeps every hub structurally consistent whoever authors it.
+
+See the [Elasticsearch documentation hub](../examples/products/docs-builder.md) for a complete page.
+
+## Enable the layout
+
+Set `layout: hub` in the page frontmatter:
+
+```yaml
+---
+layout: hub
+---
+```
+
+A page with `layout: hub` must contain a `{hero}` directive. The build fails without one, because the layout removes the page H1 and leaves the page with no title.
+
+## What the layout changes
+
+The hub layout differs from the default page layout in three ways:
+
+- The right-rail table of contents is removed. The version dropdown lives in that rail, so a hub page does not show it.
+- The previous and next page navigation is removed.
+- The body owns the full width of the content column, so directives can render full-bleed sections.
+
+The left sidebar stays. A reader can move between sibling hubs from there.
+
+## Page title
+
+A hub page has no authored H1. The page title comes from the first `{hero}` directive's `:title:` option.
+
+Title detection tries three sources in order:
+
+1. A top-level H1 in the body.
+2. An H1 nested inside a directive.
+3. The `:title:` option of the first `{hero}`.
+
+One field therefore drives both the on-page heading and the browser tab title.
+
+## Search
+
+A hub page exists to answer generic queries such as "Elasticsearch docs". Two fields carry that:
+
+- The `{hero}` `:title:` option, which becomes the indexed page title.
+- The frontmatter `description`, which becomes the indexed description.
+
+Write both deliberately. The search body indexes the hero title and description only. Section and card titles stay out, so a hub does not compete with the pages it links to on specific queries.
+
+## Directives
+
+| Directive | Purpose |
+|---|---|
+| [`{hero}`](hero.md) | Identity band. Carries the product icon, the page title, a description, and up to three actions. |
+
+## Page skeleton
+
+```markdown
+---
+layout: hub
+---
+
+:::{hero}
+:icon: elasticsearch
+:title: Elasticsearch documentation hub
+:description: The distributed search and analytics engine at the heart of the Elastic platform.
+:::
+```
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..4274fa36a5
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css
@@ -0,0 +1,170 @@
+/*
+ * Hub-page styling. Class names mirror the directive output:
+ *
+ * .hub-page page wrapper, set by RenderHub in _Layout.cshtml
+ * .hub-content article wrapper inside the content column
+ * .hub-hero hero section, light, with a divider below
+ *
+ * Later hub directives append their own sections to this file.
+ */
+
+@layer components {
+ /* Page wrapper: full-width content. Stays white so the page reads
+ consistently whether the user lands via full navigation or via an
+ htmx-boosted swap from a regular docs page.
+
+ The content column supplies the horizontal gutter. No hub section adds one
+ of its own: a section that did would inset itself alone and break alignment
+ with the rest below the shared max-width. */
+ .hub-page .hub-content {
+ @apply mb-20 w-full max-w-none p-0;
+ }
+
+ /* Hero ------------------------------------------------------------- */
+ /* Light hero on the page background, separated from the body by a rule
+ (matches the redesign, no dark band). */
+ .hub-hero {
+ background-color: transparent;
+ color: var(--color-ink-dark);
+ padding: 40px 0 28px;
+ margin-bottom: 44px;
+ border-bottom: 1px solid var(--color-grey-20);
+ }
+ .hub-hero .hub-hero-inner {
+ @apply mx-auto w-full max-w-5xl;
+ }
+ .hub-hero .hub-hero-eyebrow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px 14px;
+ margin: 0 0 20px;
+ font-size: var(--text-base);
+ letter-spacing: 0.02em;
+ }
+ .hub-hero .hub-hero-eyebrow-label {
+ font-weight: 600;
+ color: var(--color-ink-light);
+ }
+ .hub-hero .hub-hero-eyebrow-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 12px;
+ font-weight: 600;
+ color: var(--color-ink-dark);
+ text-decoration: none;
+ background: var(--color-white);
+ /* grey-70 is the lightest token clearing the 3:1 non-text contrast ratio, which
+ the border needs because it is what identifies this control. Same reasoning as
+ the neutral button in button.css. */
+ border: 1px solid var(--color-grey-70);
+ border-radius: 999px;
+ transition:
+ background 0.15s ease,
+ border-color 0.15s ease;
+ }
+ .hub-hero .hub-hero-eyebrow-link:hover,
+ .hub-hero .hub-hero-eyebrow-link:focus-visible {
+ background: var(--color-grey-10);
+ border-color: var(--color-ink-dark);
+ }
+ /* Shared arrow -----------------------------------------------------
+ The site puts an arrow on exactly two kinds of link: one that sends the
+ reader onward out of a section, and a row that is itself the target. It
+ never marks an ordinary link, and never a button. Hub links that qualify
+ use this class.
+
+ The glyph and the hover slide come from `.link-arrow` in styles.css. The
+ class is not reused directly because it is nested under `.link`, which
+ also forces a blue semibold type treatment that a pill or a card would
+ have to undo. */
+ .hub-arrow {
+ flex-shrink: 0;
+ width: 1.25em;
+ height: 1.25em;
+ transition: transform 0.15s ease-out;
+ }
+ a:hover .hub-arrow,
+ a:focus-visible .hub-arrow {
+ transform: translateX(4px);
+ }
+ .hub-hero .hub-hero-top {
+ display: flex;
+ align-items: center;
+ column-gap: 14px;
+ margin-bottom: 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;
+ width: 48px;
+ height: 48px;
+ background-color: var(--color-grey-10);
+ color: var(--color-ink-dark);
+ }
+ /* The letter fallback keeps the fixed chip. A product mark does not: its box hugs
+ the glyph, so the mark's left edge lines up with the eyebrow and the description
+ below it. Each icon's viewBox is cropped to its glyph, so there is no transparent
+ padding left to absorb. Width follows from the glyph's own aspect ratio. */
+ .hub-hero .hub-hero-icon-svg {
+ width: auto;
+ height: 40px;
+ background-color: transparent;
+ border-radius: 0;
+ }
+ .hub-hero .hub-hero-icon-svg svg {
+ width: auto;
+ height: 40px;
+ }
+ .hub-hero .hub-hero-top h1 {
+ font-size: var(--text-4xl);
+ font-weight: 700;
+ line-height: 1.2;
+ letter-spacing: -0.5px;
+ color: var(--color-ink-dark);
+ margin: 0;
+ }
+ .hub-hero .hub-hero-top h1 a.headerlink {
+ color: var(--color-ink-dark);
+ text-decoration: none;
+ }
+ .hub-hero .hub-hero-desc {
+ font-size: var(--text-base);
+ color: var(--color-ink-light);
+ max-width: 760px;
+ line-height: 1.6;
+ margin: 0;
+ }
+
+ .hub-hero .hub-hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 24px;
+ }
+ /* The three actions carry equal weight, so they all take the neutral button
+ treatment from the {button} directive rather than a bespoke hero button. A blue
+ variant would make each one claim to be the call to action. Only the layout
+ properties the hero needs are set here: the type, height, colour and radius come
+ from `.doc-button-neutral`. */
+ .hub-hero .hub-hero-action {
+ gap: 8px;
+ }
+ .hub-hero .hub-hero-action svg {
+ flex-shrink: 0;
+ }
+
+ /* The arrow slides and the hero animates its hover states. Honour a reduced-motion
+ preference. Nothing about the resting appearance changes. */
+ @media (prefers-reduced-motion: reduce) {
+ .hub-hero .hub-hero-eyebrow-link,
+ .hub-hero .hub-hero-action,
+ .hub-arrow {
+ transition: none;
+ }
+ a:hover .hub-arrow,
+ a:focus-visible .hub-arrow {
+ transform: none;
+ }
+ }
+}
diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css
index c3599fa395..373aa70ed5 100644
--- a/src/Elastic.Documentation.Site/Assets/styles.css
+++ b/src/Elastic.Documentation.Site/Assets/styles.css
@@ -29,6 +29,7 @@
@import './markdown/cli-modifiers.css';
@import './markdown/contributors.css';
@import './markdown/storybook.css';
+@import './markdown/hub.css';
@import './api-docs.css';
@import 'tippy.js/dist/tippy.css';
diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs
index 9ad3b74072..ac18e68285 100644
--- a/src/Elastic.Markdown/IO/MarkdownFile.cs
+++ b/src/Elastic.Markdown/IO/MarkdownFile.cs
@@ -12,6 +12,7 @@
using Elastic.Markdown.Myst;
using Elastic.Markdown.Myst.Directives;
using Elastic.Markdown.Myst.Directives.Changelog;
+using Elastic.Markdown.Myst.Directives.Hub;
using Elastic.Markdown.Myst.Directives.Include;
using Elastic.Markdown.Myst.Directives.Settings;
using Elastic.Markdown.Myst.Directives.Stepper;
@@ -153,14 +154,35 @@ private IReadOnlyDictionary GetSubstitutions()
return allProperties;
}
+ // A page with no top-level H1 can still declare its title inside a directive, either as a
+ // nested H1 or as {hero}'s :title: option. Hub pages are composed purely from directives,
+ // so this is the only title source they have.
+ private static string? FindNestedTitle(MarkdownDocument document)
+ {
+ if (document.Descendants().FirstOrDefault(h => h.Level == 1)?.GetData("header") is string nestedHeading)
+ return nestedHeading;
+
+ var heroTitle = document.Descendants().FirstOrDefault()?.Title;
+ return string.IsNullOrWhiteSpace(heroTitle) ? null : heroTitle;
+ }
+
protected void ReadDocumentInstructions(MarkdownDocument document, Func documentationFileLookup)
{
Title = document
.FirstOrDefault(block => block is HeadingBlock { Level: 1 })?
.GetData("header") as string ?? Title;
+ if (Title == RelativePath)
+ Title = FindNestedTitle(document) ?? Title;
+
var yamlFrontMatter = ProcessYamlFrontMatter(document);
YamlFrontMatter = yamlFrontMatter;
+
+ // The hub layout suppresses the page H1, so {hero} is the only thing that can title the
+ // page. Without it the page renders with no title at all and falls back to its file path.
+ if (yamlFrontMatter.Layout == MarkdownPageLayout.Hub && !document.Descendants().Any())
+ Collector.EmitError(FilePath, "A page with `layout: hub` requires a {hero} directive. Without it the page renders without a title.");
+
if (yamlFrontMatter.NavigationTitle is not null)
NavigationTitle = yamlFrontMatter.NavigationTitle;
if (yamlFrontMatter.Description is not null)
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/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
index 550a0f0fa1..bafdbfcbd1 100644
--- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
@@ -10,6 +10,7 @@
using Elastic.Markdown.Myst.Directives.Changelog;
using Elastic.Markdown.Myst.Directives.CliModifiers;
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.Listing;
@@ -137,6 +138,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor)
if (info.IndexOf("{math}") > 0)
return new MathBlock(this, context);
+ if (info.IndexOf("{hero}") > 0)
+ return new HeroBlock(this, context);
+
if (info.IndexOf("{agent-skill}") > 0)
return new AgentSkillBlock(this, context);
@@ -239,6 +243,12 @@ public override BlockState TryContinue(BlockProcessor processor, Block block)
if (block is not DirectiveBlock directiveBlock)
return base.TryContinue(processor, block);
+ // Once a directive has opened a nested directive child, an option line belongs to
+ // that inner directive, not this ancestor. Without this guard the ancestor swallows
+ // every descendant's options (last one wins) and corrupts its own.
+ if (directiveBlock.LastChild is DirectiveBlock)
+ return base.TryContinue(processor, block);
+
var tokens = line.ToString().Split(':', 2, RemoveEmptyEntries | TrimEntries);
if (tokens.Length < 1)
return base.TryContinue(processor, block);
diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
index 1a97e90b4e..e0e989a156 100644
--- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
@@ -16,6 +16,7 @@
using Elastic.Markdown.Myst.Directives.CliModifiers;
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.Listing;
@@ -104,6 +105,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo
case MathBlock mathBlock:
WriteMathBlock(renderer, mathBlock);
return;
+ case HeroBlock heroBlock:
+ WriteHero(renderer, heroBlock);
+ return;
case PageCardBlock pageCardBlock:
WritePageCard(renderer, pageCardBlock);
return;
@@ -193,6 +197,42 @@ private static void WriteImageCarousel(HtmlRenderer renderer, ImageCarouselBlock
RenderRazorSlice(slice, renderer);
}
+ private static void WriteHero(HtmlRenderer renderer, HeroBlock block)
+ {
+ var slice = HeroView.Create(new HeroViewModel
+ {
+ DirectiveBlock = block,
+ IconKey = block.Icon,
+ IconSvg = block.IconSvg,
+ Title = block.Title,
+ DescriptionHtml = RenderInlineMarkdown(block.Description),
+ PrimaryActionLabel = block.PrimaryActionLabel,
+ PrimaryActionUrl = block.PrimaryActionUrl,
+ SecondaryActionLabel = block.SecondaryActionLabel,
+ SecondaryActionUrl = block.SecondaryActionUrl,
+ TertiaryActionLabel = block.TertiaryActionLabel,
+ TertiaryActionUrl = block.TertiaryActionUrl,
+ SitePathPrefix = block.Build.UrlPathPrefix
+ });
+ RenderRazorSlice(slice, renderer);
+ }
+
+ // The hero description is a directive option, not a body block, so it never reaches
+ // the document pipeline. Render it with the default Markdig pipeline for basic inline
+ // markup. Substitutions, roles, and link validation do not apply inside this option.
+ private static string? RenderInlineMarkdown(string? source)
+ {
+ if (string.IsNullOrWhiteSpace(source))
+ return null;
+
+ var html = Markdig.Markdown.ToHtml(source).Trim();
+ 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 WritePageCard(HtmlRenderer renderer, PageCardBlock block)
{
var slice = PageCardView.Create(new PageCardViewModel
diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs
new file mode 100644
index 0000000000..09ccc14d6f
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs
@@ -0,0 +1,210 @@
+// 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;
+
+///
+/// Validates and resolves URL values supplied as directive options or in directive YAML
+/// bodies. 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 and broken directive links ship silently.
+///
+/// Returns the resolved URL (or the original on failure) so callers can write it back into
+/// their own data. Errors and hints are emitted against the supplying .
+///
+internal static class DirectiveLinkValidator
+{
+ ///
+ /// Resolve cross-link schemes and check that an internal path points at a real file.
+ /// Requires a site-absolute path, a cross-link scheme, an anchor, or an external URL.
+ /// Use this for directives whose links come from a YAML body or an option.
+ ///
+ public static string? ValidateAndResolve(string? url, DirectiveBlock block, ParserContext context) =>
+ Resolve(url, block, context, allowRelative: false, checkFileExists: true);
+
+ ///
+ /// Resolve cross-link schemes only, and accept a path relative to the source file.
+ /// Use this for directives whose links may target a generated page with no file on disk,
+ /// such as the CLI reference, where a file probe reports false positives.
+ ///
+ public static string? ResolveWithoutFileCheck(string? url, DirectiveBlock block, ParserContext context) =>
+ Resolve(url, block, context, allowRelative: true, checkFileExists: false);
+
+ private static string? Resolve(string? url, DirectiveBlock block, ParserContext context, bool allowRelative, bool checkFileExists)
+ {
+ if (string.IsNullOrWhiteSpace(url) || block.SkipValidation)
+ return url;
+
+ var trimmed = url.Trim();
+ if (trimmed.Length == 0 || trimmed[0] == '#')
+ return url;
+
+ if (IsExternal(trimmed))
+ return url;
+
+ if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && CrossLinkValidator.IsCrossLink(uri))
+ return ResolveCrossLink(url, uri, block, context);
+
+ if (!trimmed.StartsWith('/') && !allowRelative)
+ {
+ block.EmitError($"Directive link `{url}` must be an absolute path starting with `/`, a cross-link scheme (for example `kibana://`), or an external URL.");
+ return url;
+ }
+
+ if (checkFileExists)
+ ValidateInternal(url, block, context);
+ return url;
+ }
+
+ ///
+ /// Turn a validated URL into a final href. Strips the markdown extension and applies the
+ /// site's URL path prefix. External URLs and anchors are returned unchanged.
+ ///
+ public static string? ToHref(string? url, string? sitePathPrefix)
+ {
+ if (string.IsNullOrEmpty(url))
+ return url;
+ if (IsExternal(url) || url.StartsWith('#'))
+ return url;
+
+ var (path, anchor) = SplitAnchor(url);
+ path = StripMarkdownExtension(path);
+
+ if (string.IsNullOrEmpty(sitePathPrefix) || !path.StartsWith('/'))
+ return path + anchor;
+
+ var prefix = "/" + sitePathPrefix.Trim('/');
+ if (path == prefix || path.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase))
+ return path + anchor;
+
+ return prefix + path + anchor;
+ }
+
+ private const string ResolvedCrossLinksKey = "resolvedCrossLinks";
+
+ private static void RememberCrossLink(DirectiveBlock block, string resolved)
+ {
+ if (block.GetData(ResolvedCrossLinksKey) is not HashSet resolvedLinks)
+ {
+ resolvedLinks = [];
+ block.SetData(ResolvedCrossLinksKey, resolvedLinks);
+ }
+ _ = resolvedLinks.Add(resolved);
+ }
+
+ /// True when came from a cross-link scheme on this block.
+ public static bool IsResolvedCrossLink(DirectiveBlock block, string? url) =>
+ url is not null
+ && block.GetData(ResolvedCrossLinksKey) is HashSet resolvedLinks
+ && resolvedLinks.Contains(url, StringComparer.OrdinalIgnoreCase);
+
+ private static bool IsExternal(string url) =>
+ url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
+ || url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase);
+
+ private static string StripMarkdownExtension(string path)
+ {
+ if (path.EndsWith("/index.md", StringComparison.OrdinalIgnoreCase))
+ {
+ // "/index.md" is the site root, so stripping the whole segment would leave an
+ // empty href rather than "/".
+ var stripped = path[..^"/index.md".Length];
+ return stripped.Length == 0 ? "/" : stripped;
+ }
+
+ return path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
+ ? path[..^".md".Length]
+ : path;
+ }
+
+ 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:) are left alone.
+ if (IsPassthroughCustomProtocolScheme(uri.Scheme))
+ return original;
+ block.EmitError($"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);
+ if (!resolver.TryResolve(s => block.EmitError(s), uri, out var resolved))
+ return original;
+
+ // A cross-link resolves to a full URL, but it still points at documentation this site
+ // serves. Record it so the view model does not mistake it for an external link and open
+ // it in a new tab. Inline links make the same distinction.
+ RememberCrossLink(block, resolved.ToString());
+ return resolved.ToString();
+ }
+
+ private static void ValidateInternal(string url, DirectiveBlock block, ParserContext context)
+ {
+ // In assembler and codex builds an absolute path may target a file owned by a different
+ // docset, because the assembled site is the union of every docset. The current docset's
+ // source directory is not the right basis for an existence check there, so flagging would
+ // produce false positives. Cross-docset references should use a cross-link scheme instead.
+ if (context.Build.BuildType != BuildType.Isolated)
+ return;
+
+ var (path, _) = SplitAnchor(url);
+ if (string.IsNullOrEmpty(path) || path == "/")
+ return;
+
+ var sourceDir = context.Build.DocumentationSourceDirectory.FullName;
+ var baseDir = path.StartsWith('/') ? sourceDir : context.MarkdownSourcePath.Directory!.FullName;
+ var relativeToBase = path.TrimStart('/');
+
+ foreach (var candidate in ProbeCandidates(relativeToBase))
+ {
+ if (context.TryFindDocumentByRelativePath(candidate) is not null)
+ return;
+ if (context.Build.ReadFileSystem.File.Exists(Path.GetFullPath(Path.Join(baseDir, candidate))))
+ return;
+ }
+
+ if (TryEmitRedirectWarning(url, relativeToBase, block, context))
+ return;
+
+ block.EmitError($"Directive link `{url}` does not exist. If it was recently removed add a redirect.");
+ }
+
+ // docs-builder URLs usually omit the extension, so /explore-analyze/discover may mean
+ // discover.md or discover/index.md. Probe as given first.
+ private static string[] ProbeCandidates(string path) =>
+ path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
+ ? [path]
+ : [path, path + ".md", path.TrimEnd('/') + "/index.md"];
+
+ private static bool TryEmitRedirectWarning(string url, string relativeToBase, DirectiveBlock block, ParserContext context)
+ {
+ if (context.Configuration.Redirects is null
+ || !context.Configuration.Redirects.TryGetValue(relativeToBase, out var redirect))
+ return false;
+
+ var to = redirect.To
+ ?? (redirect.Many is not null
+ ? string.Join(", ", redirect.Many.Select(m => m.To))
+ : "unknown");
+ block.EmitWarning($"Directive link `{url}` has a redirect; update to: {to}");
+ return true;
+ }
+
+ private static (string Path, string? Anchor) SplitAnchor(string url)
+ {
+ var hash = url.IndexOf('#');
+ return hash < 0 ? (url, null) : (url[..hash], url[hash..]);
+ }
+
+ 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/HeroBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs
new file mode 100644
index 0000000000..913cafadb4
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs
@@ -0,0 +1,75 @@
+// 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.Text.RegularExpressions;
+using Elastic.Markdown.Diagnostics;
+
+namespace Elastic.Markdown.Myst.Directives.Hub;
+
+///
+/// Renders a full-bleed page hero with a product icon, title, description, and
+/// up to three call-to-action buttons. All content is supplied via options -- the
+/// body is unused. Release cadence lives in {whats-new}.
+///
+///
+///
+/// :::{hero}
+/// :icon: kibana
+/// :title: Kibana documentation hub
+/// :description: The UI for the Elasticsearch platform.
+/// :primary-action: [Get started](#get-started)
+/// :secondary-action: [What's new](#whats-new)
+/// :tertiary-action: [Explore Kibana docs](#explore)
+/// :::
+///
+///
+public partial class HeroBlock(DirectiveBlockParser parser, ParserContext context)
+ : DirectiveBlock(parser, context)
+{
+ public override string Directive => "hero";
+
+ public string? Icon { get; private set; }
+ public string? IconSvg { get; private set; }
+ public string? Title { get; private set; }
+ public string? Description { get; private set; }
+ public string? PrimaryActionLabel { get; private set; }
+ public string? PrimaryActionUrl { get; private set; }
+ public string? SecondaryActionLabel { get; private set; }
+ public string? SecondaryActionUrl { get; private set; }
+ public string? TertiaryActionLabel { get; private set; }
+ public string? TertiaryActionUrl { get; private set; }
+
+ public override void FinalizeAndValidate(ParserContext context)
+ {
+ Icon = Prop("icon");
+ IconSvg = ProductIcons.Get(Icon);
+ Title = Prop("title");
+ Description = Prop("description");
+ (PrimaryActionLabel, PrimaryActionUrl) = ParseAction(Prop("primary-action"), context);
+ (SecondaryActionLabel, SecondaryActionUrl) = ParseAction(Prop("secondary-action"), context);
+ (TertiaryActionLabel, TertiaryActionUrl) = ParseAction(Prop("tertiary-action"), context);
+
+ if (string.IsNullOrWhiteSpace(Title))
+ this.EmitError("{hero} requires a `:title:` option.");
+ }
+
+ private (string? Label, string? Url) ParseAction(string? value, ParserContext context)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return (null, null);
+
+ var match = MarkdownLink().Match(value.Trim());
+ if (!match.Success)
+ {
+ this.EmitError($"{{hero}} action `{value.Trim()}` must be a markdown link, for example `[Get started](#get-started)`.");
+ return (null, null);
+ }
+
+ var url = DirectiveLinkValidator.ValidateAndResolve(match.Groups["url"].Value.Trim(), this, context);
+ return (match.Groups["label"].Value.Trim(), url);
+ }
+
+ [GeneratedRegex(@"^\[(?
*@
- @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw)))
- @if (Model.AppliesTo is not null)
+ @* A hub page carries its title and identity in {hero}, so the page-level heading and the
+ applies-to badge are suppressed. Rendering them would duplicate the hero's H1. *@
+ @if (Model.CurrentDocument.YamlFrontMatter?.Layout != MarkdownPageLayout.Hub)
{
-
- @await RenderPartialAsync(ApplicableToComponent.Create(new ApplicableToViewModel
+ @* This way it's correctly rendered as
+ }
}
@(new HtmlString(Model.MarkdownHtml))
diff --git a/src/Elastic.Markdown/_Layout.cshtml b/src/Elastic.Markdown/_Layout.cshtml
index 1f68edeae2..679e71e91d 100644
--- a/src/Elastic.Markdown/_Layout.cshtml
+++ b/src/Elastic.Markdown/_Layout.cshtml
@@ -58,6 +58,35 @@
}
+
+ @* Hub pages are composed entirely from directives and own the full content column.
+ They drop the right-rail table of contents and the prev/next nav, but keep the
+ pages nav so a reader can move between sibling hubs. *@
+ private async Task RenderHub()
+ {
+
+
+
+
+ @* Keeps the markdown-content id, which hljs, smooth-scroll and toc-nav select on,
+ but not the class. typography.css is unlayered and hub.css sits in @@layer
+ components, so the class would override every hub rule regardless of
+ specificity. Hub directives bring their own typography. *@
+
+
+ @await RenderBodyAsync()
+
+
+ @await RenderPartialAsync(_PagesNav.Create(Model))
+
+
+
+ }
}
@if (RenderHeaderAndFooter)
@@ -87,6 +116,9 @@
case MarkdownPageLayout.FullSearch:
await RenderPartialAsync(_FullSearch.Create(Model));
break;
+ case MarkdownPageLayout.Hub:
+ await RenderHub();
+ break;
default:
await RenderDefault();
break;
diff --git a/tests/authoring/Blocks/Hub/Hero.fs b/tests/authoring/Blocks/Hub/Hero.fs
new file mode 100644
index 0000000000..252687db6c
--- /dev/null
+++ b/tests/authoring/Blocks/Hub/Hero.fs
@@ -0,0 +1,287 @@
+// 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``.``hero elements``
+
+open Xunit
+open authoring
+
+type ``hero with title only`` () =
+ static let markdown = Setup.Markdown """
+:::{hero}
+:title: Elasticsearch documentation hub
+:::
+"""
+
+ []
+ let ``renders the title as an h1`` () =
+ markdown |> convertsToContainingHtml """
Elasticsearch documentation hub
"""
+
+ []
+ let ``renders the fixed eyebrow`` () =
+ markdown |> convertsToContainingHtml """Browse all Elastic docs"""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``hero without a title`` () =
+ static let markdown = Setup.Markdown """
+:::{hero}
+:description: Missing the required title option.
+:::
+"""
+
+ []
+ let ``errors`` () =
+ markdown |> hasError "{hero} requires a `:title:` option."
+
+type ``hero with a description`` () =
+ static let markdown = Setup.Markdown """
+:::{hero}
+:title: Kibana documentation hub
+:description: The UI for the **Elasticsearch** platform.
+:::
+"""
+
+ []
+ let ``renders inline markup in the description`` () =
+ markdown |> convertsToContainingHtml """Elasticsearch"""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``hero with an unknown icon key`` () =
+ static let markdown = Setup.Markdown """
+:::{hero}
+:icon: notaproduct
+:title: Something else
+:::
+"""
+
+ []
+ let ``falls back to a letter chip`` () =
+ markdown |> convertsToContainingHtml """N"""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``hero with anchor actions`` () =
+ static let markdown = Setup.Markdown """
+:::{hero}
+:title: Elasticsearch documentation hub
+:primary-action: [Get started](#get-started)
+:secondary-action: [What's new](#whats-new)
+:::
+"""
+
+ // Actions render as buttons, and no button on the site carries an arrow, not even
+ // an anchor action that jumps within the page. The assertion starts at the section,
+ // because the pretty-printer only matches from the outermost element of the output.
+ []
+ let ``renders both actions as buttons without an arrow`` () =
+ markdown |> convertsToContainingHtml """
+
+
+
+"""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``hero with an external action`` () =
+ static let markdown = Setup.Markdown """
+:::{hero}
+:title: Elasticsearch documentation hub
+:primary-action: [Install Elasticsearch](https://www.elastic.co/downloads/elasticsearch)
+:::
+"""
+
+ // External links follow the same rules as inline links: they open in a new tab. The
+ // assertion starts at the section, because the pretty-printer only matches from the
+ // outermost element of the directive output. It also strips `preload`, so the absence
+ // of preloading on an external action cannot be asserted here.
+ []
+ let ``opens in a new tab`` () =
+ markdown |> convertsToContainingHtml """
+
+
+
+"""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``hero with a relative action url`` () =
+ static let markdown = Setup.Markdown """
+:::{hero}
+:title: Elasticsearch documentation hub
+:primary-action: [Get started](get-started.md)
+:::
+"""
+
+ []
+ let ``rejects a relative path`` () =
+ markdown |> hasError "must be an absolute path starting with `/`"
+
+type ``hero with a malformed action`` () =
+ static let markdown = Setup.Markdown """
+:::{hero}
+:title: Elasticsearch documentation hub
+:primary-action: Get started
+:::
+"""
+
+ []
+ let ``errors`` () =
+ markdown |> hasError "must be a markdown link"
+
+type ``hero with a cross-link action`` () =
+ static let markdown = Setup.Markdown """
+:::{hero}
+:title: docs-builder documentation hub
+:primary-action: [Elastic documentation](docs-content://get-started/index.md)
+:::
+"""
+
+ // A cross-link resolves to a full URL but still points at documentation this site serves,
+ // so it must not open in a new tab. Inline links make the same distinction. The assertion
+ // snapshots the section rather than looking for target="_blank" anywhere on the page,
+ // because site chrome carries that attribute too.
+ []
+ let ``does not open in a new tab`` () =
+ markdown |> convertsToContainingHtml """
+
+
+
+"""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+// These two set frontmatter, so they use Setup.Document. Setup.Markdown prepends an H1,
+// which would push the frontmatter into the body where it never parses.
+type ``hub layout without a hero`` () =
+ static let markdown = Setup.Document """---
+layout: hub
+---
+
+Body content with no hero directive.
+"""
+
+ // The hub layout removes the page H1, so {hero} is the only thing that can title the page.
+ []
+ let ``errors`` () =
+ markdown |> hasError "A page with `layout: hub` requires a {hero} directive."
+
+type ``hub layout with a hero`` () =
+ static let markdown = Setup.Document """---
+layout: hub
+---
+
+:::{hero}
+:title: Elasticsearch documentation hub
+:::
+"""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
diff --git a/tests/authoring/Blocks/NestedDirectiveOptions.fs b/tests/authoring/Blocks/NestedDirectiveOptions.fs
new file mode 100644
index 0000000000..8427558d9c
--- /dev/null
+++ b/tests/authoring/Blocks/NestedDirectiveOptions.fs
@@ -0,0 +1,129 @@
+// 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``.``nested directive options``
+
+open Xunit
+open authoring
+
+// DirectiveBlockParser.TryContinue stops an ancestor directive consuming an option line once
+// it has opened a nested directive child. Without the guard the ancestor also takes every
+// descendant's options, and the last one wins.
+//
+// No existing directive pair shares an option name, so the collision is latent for them:
+// {tab-set} reads group while {tab-item} reads sync and selected, and {applies-switch} and
+// {applies-item} split the same way. These tests pin that each option still reaches the block
+// that declared it, which is what the guard must not break.
+
+type ``tab set with its own group and per-item sync`` () =
+ static let markdown = Setup.Markdown """
+::::{tab-set}
+:group: install-method
+
+:::{tab-item} Local
+:sync: local
+local body
+:::
+
+:::{tab-item} Container
+:sync: container
+container body
+:::
+::::
+"""
+
+ // The group is declared on the tab-set before any child, so it still reaches the tab-set.
+ []
+ let ``the tab set keeps its own group`` () =
+ markdown |> convertsToContainingRawHtml "data-sync-group=\"install-method\""
+
+ // Each sync reaches the item that declared it, rather than all landing on the last one.
+ []
+ let ``the first item keeps its own sync`` () =
+ markdown |> convertsToContainingRawHtml "data-sync-id=\"local\""
+
+ []
+ let ``the second item keeps its own sync`` () =
+ markdown |> convertsToContainingRawHtml "data-sync-id=\"container\""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``applies switch with its own group and per-item sync`` () =
+ static let markdown = Setup.Markdown """
+::::{applies-switch}
+:group: deployment
+
+:::{applies-item} serverless: ga
+:sync: serverless
+serverless body
+:::
+
+:::{applies-item} stack: ga 9.0+
+:sync: self-managed
+self-managed body
+:::
+::::
+"""
+
+ []
+ let ``the switch keeps its own group`` () =
+ markdown |> convertsToContainingRawHtml "data-sync-group=\"deployment\""
+
+ []
+ let ``the first item keeps its own sync`` () =
+ markdown |> convertsToContainingRawHtml "data-sync-id=\"serverless\""
+
+ []
+ let ``the second item keeps its own sync`` () =
+ markdown |> convertsToContainingRawHtml "data-sync-id=\"self-managed\""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``stepper with per-step anchors`` () =
+ static let markdown = Setup.Markdown """
+::::{stepper}
+:::{step} Install
+:anchor: install-step
+Install the thing.
+:::
+:::{step} Configure
+:anchor: configure-step
+Configure the thing.
+:::
+::::
+"""
+
+ []
+ let ``each step keeps its own anchor`` () =
+ markdown |> convertsToContainingRawHtml "install-step"
+
+ []
+ let ``the second step keeps its own anchor`` () =
+ markdown |> convertsToContainingRawHtml "configure-step"
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``dropdown wrapping an admonition with its own name`` () =
+ static let markdown = Setup.Markdown """
+::::{dropdown} Outer summary
+:open:
+:::{note}
+:name: inner-note
+Inner content.
+:::
+::::
+"""
+
+ []
+ let ``the dropdown keeps its own open state`` () =
+ markdown |> convertsToContainingRawHtml "Outer summary"
+
+ []
+ let ``the nested admonition keeps its own name`` () =
+ markdown |> convertsToContainingRawHtml "inner-note"
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
diff --git a/tests/authoring/Blocks/PageCards.fs b/tests/authoring/Blocks/PageCards.fs
new file mode 100644
index 0000000000..600651afb7
--- /dev/null
+++ b/tests/authoring/Blocks/PageCards.fs
@@ -0,0 +1,77 @@
+// 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``.``page card elements``
+
+open Xunit
+open authoring
+
+// {page-card} moved onto the shared DirectiveLinkValidator. These tests pin the contract it
+// had before that move: relative links resolve against the source file, and no file-existence
+// check runs, because page-card links can target generated pages with no markdown on disk.
+
+type ``page card with a relative link`` () =
+ static let markdown = Setup.Markdown """
+:::{page-card} [Admonitions](admonitions.md)
+Callout boxes for notes and warnings.
+:::
+"""
+
+ []
+ let ``resolves the link relative to the source file`` () =
+ markdown |> convertsToContainingHtml """
+
+
+
+
+"""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``page card with a dot-relative link`` () =
+ static let markdown = Setup.Markdown """
+:::{page-card} [Add](./add.md)
+:::
+"""
+
+ // The CLI reference generates page-cards pointing at generated pages that have no markdown
+ // file on disk. A file-existence check here would report false positives on every one.
+ []
+ let ``does not check that the target file exists`` () = markdown |> hasNoErrors
+
+type ``page card without a description`` () =
+ static let markdown = Setup.Markdown """
+:::{page-card} [Tables](tables.md)
+:::
+"""
+
+ []
+ let ``has no errors`` () = markdown |> hasNoErrors
+
+type ``page card with an absolute url`` () =
+ static let markdown = Setup.Markdown """
+:::{page-card} [Elastic](https://www.elastic.co)
+:::
+"""
+
+ []
+ let ``errors`` () =
+ markdown |> hasError "page-card url must be a local .md path or crosslink"
+
+type ``page card without a markdown link`` () =
+ static let markdown = Setup.Markdown """
+:::{page-card} Admonitions
+:::
+"""
+
+ []
+ let ``errors`` () =
+ markdown |> hasError "page-card requires a markdown link argument"
diff --git a/tests/authoring/Framework/HtmlAssertions.fs b/tests/authoring/Framework/HtmlAssertions.fs
index eaa648d322..2782987012 100644
--- a/tests/authoring/Framework/HtmlAssertions.fs
+++ b/tests/authoring/Framework/HtmlAssertions.fs
@@ -167,3 +167,47 @@ But was not found in:
let defaultFile = actual.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md")
defaultFile |> containsHtml expected
+
+ /// Asserts a raw fragment is present in the rendered HTML, without pretty-printing.
+ /// The pretty-printed comparison only matches an element with no children, or one
+ /// snapshotted from the outermost element of the directive output. Use this to assert an
+ /// attribute on a container without snapshotting its whole subtree.
+ []
+ let containsRawHtml (expected: string) (actual: MarkdownResult) =
+ if not <| actual.Html.Contains expected then
+ let msg = $"""Expected html to contain:
+{expected}
+
+But it was not found in:
+
+{actual.Html}
+"""
+ raise (XunitException(msg))
+
+ []
+ let convertsToContainingRawHtml (expected: string) (actual: Lazy) =
+ let actual = actual.Value
+
+ let defaultFile = actual.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md")
+ defaultFile |> containsRawHtml expected
+
+ /// Asserts a fragment is absent from the rendered HTML. Checks the whole page, so the
+ /// needle must be unique to the directive under test and not appear in site chrome.
+ []
+ let doesNotContainHtml (expected: string) (actual: MarkdownResult) =
+ if actual.Html.Contains expected then
+ let msg = $"""Expected html NOT to contain:
+{expected}
+
+But it was found in:
+
+{actual.Html}
+"""
+ raise (XunitException(msg))
+
+ []
+ let doesNotConvertToContainingHtml (expected: string) (actual: Lazy) =
+ let actual = actual.Value
+
+ let defaultFile = actual.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md")
+ defaultFile |> doesNotContainHtml expected
diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj
index db9aafb701..7f299d64f7 100644
--- a/tests/authoring/authoring.fsproj
+++ b/tests/authoring/authoring.fsproj
@@ -53,6 +53,9 @@
+
+
+