From ce54c7de49b2b7394f3b3d52b841863d06a4ed5b Mon Sep 17 00:00:00 2001 From: Florent LB Date: Mon, 10 Aug 2026 19:43:46 +0200 Subject: [PATCH 1/9] Add hub page layout and the {hero} directive Adds the first two pieces of the hub page mechanism, rebuilt on the current navigation implementation. The prototype branch is merged with nav-v2, which is out of scope, so nothing from nav-v2 ports. Adds `layout: hub`, which drops the right-rail table of contents and the previous/next navigation, keeps the left sidebar, and gives the body the full content column. Page-title detection gains two fallbacks, so a page composed only of directives still resolves a title: an H1 nested in a directive, then the first {hero} directive's :title: option. Adds {hero}, the identity band. It carries a product icon, the page title, a description, and up to three optional actions. Promotes the prototype's hub-only link validator to a shared DirectiveLinkValidator. Directive links never pass through Markdig's inline link parser, so every directive that takes a URL had invented its own handling. {page-card} moves onto the shared class and its hand-rolled copy goes. It keeps its previous contract: relative links resolve against the source file, and no file-existence check runs, because page-card links can target generated pages that have no markdown file on disk. Guards DirectiveBlockParser.TryContinue so an ancestor directive stops consuming option lines once it has opened a nested directive child. Without the guard the ancestor swallows every descendant's options and the last one wins. This affects every nested directive, so it lands with regression tests over tab sets, steppers, and dropdowns. Adds LLM markdown and plain-text renderers for {hero}. A hub page has no body prose, so without them it exports as an empty shell. The plain-text renderer feeds the search index and emits the hero title and description only. Hub pages answer generic " docs" queries. Indexing section and card titles would let a hub compete with the pages it links to on specific queries. Co-Authored-By: Claude Opus 5 --- docs/_docset.yml | 10 + docs/examples/index.md | 11 ++ docs/examples/products/elasticsearch.md | 10 + docs/syntax/hero.md | 59 ++++++ docs/syntax/hub-pages.md | 68 +++++++ .../Assets/markdown/hub.css | 153 +++++++++++++++ .../Assets/styles.css | 1 + src/Elastic.Markdown/IO/MarkdownFile.cs | 16 ++ src/Elastic.Markdown/MarkdownPageLayout.cs | 3 +- .../Myst/Directives/DirectiveBlockParser.cs | 10 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 40 ++++ .../Myst/Directives/DirectiveLinkValidator.cs | 181 ++++++++++++++++++ .../Myst/Directives/Hub/HeroBlock.cs | 75 ++++++++ .../Myst/Directives/Hub/HeroView.cshtml | 51 +++++ .../Myst/Directives/Hub/HeroViewModel.cs | 41 ++++ .../Myst/Directives/Hub/ProductIcons.cs | 68 +++++++ .../Myst/Directives/PageCard/PageCardBlock.cs | 33 ++-- .../LlmMarkdown/LlmBlockRenderers.cs | 30 +++ .../PlainText/PlainTextBlockRenderers.cs | 13 ++ src/Elastic.Markdown/_Layout.cshtml | 28 +++ tests/authoring/Blocks/Hub/Hero.fs | 106 ++++++++++ .../Blocks/NestedDirectiveOptions.fs | 77 ++++++++ tests/authoring/Blocks/PageCards.fs | 77 ++++++++ tests/authoring/authoring.fsproj | 3 + 24 files changed, 1145 insertions(+), 19 deletions(-) create mode 100644 docs/examples/index.md create mode 100644 docs/examples/products/elasticsearch.md create mode 100644 docs/syntax/hero.md create mode 100644 docs/syntax/hub-pages.md create mode 100644 src/Elastic.Documentation.Site/Assets/markdown/hub.css create mode 100644 src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs create mode 100644 tests/authoring/Blocks/Hub/Hero.fs create mode 100644 tests/authoring/Blocks/NestedDirectiveOptions.fs create mode 100644 tests/authoring/Blocks/PageCards.fs diff --git a/docs/_docset.yml b/docs/_docset.yml index 6f66132a8a..509614b99d 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: elasticsearch.md + # Documentation builds - folder: documentation children: diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000000..8a304bcfc5 --- /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. + +## Product hubs + +Hub pages use `layout: hub` and are composed entirely from directives. See [Hub pages](../syntax/hub-pages.md). + +- [Elasticsearch documentation hub](products/elasticsearch.md) diff --git a/docs/examples/products/elasticsearch.md b/docs/examples/products/elasticsearch.md new file mode 100644 index 0000000000..0f2961a128 --- /dev/null +++ b/docs/examples/products/elasticsearch.md @@ -0,0 +1,10 @@ +--- +layout: hub +description: Elasticsearch documentation. Index, search, and analyze your data at any scale, and run Elasticsearch as a managed service or in your own environment. +--- + +:::{hero} +:icon: elasticsearch +:title: Elasticsearch documentation hub +:description: The distributed search and analytics engine at the heart of the Elastic platform. Index, search, and analyze data at any scale, build semantic and vector search experiences, and power applications. Run it as a managed service or in your own environment. +::: diff --git a/docs/syntax/hero.md b/docs/syntax/hero.md new file mode 100644 index 0000000000..0323f14693 --- /dev/null +++ b/docs/syntax/hero.md @@ -0,0 +1,59 @@ +# 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/elasticsearch.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. + +```markdown +:::{hero} +:icon: kibana +:title: Kibana documentation hub +:description: The UI for the Elasticsearch platform. +:primary-action: [Get started](#get-started) +:secondary-action: [Browse the docs](/explore-analyze.md) +::: +``` + +An action whose URL starts with `#` renders with a chevron, to signal an in-page jump. + +Action URLs validate at build time. Use one of these forms: + +- An in-page anchor, for example `#get-started`. +- A site-absolute path that starts with `/`, for a page in the same repository. +- A cross-link scheme such as `elasticsearch://`, for a page in another repository. +- An external URL. + +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..047e525a97 --- /dev/null +++ b/docs/syntax/hub-pages.md @@ -0,0 +1,68 @@ +# 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/elasticsearch.md) for a complete page. + +## Enable the layout + +Set `layout: hub` in the page frontmatter: + +```yaml +--- +layout: hub +--- +``` + +## 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..244a133a62 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -0,0 +1,153 @@ +/* + * 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. */ + .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 24px 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: 0.8125rem; + 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); + border: 1px solid var(--color-grey-20); + 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-grey-30); + } + .hub-hero .hub-hero-eyebrow-arrow { + flex-shrink: 0; + transition: transform 0.15s ease; + } + .hub-hero .hub-hero-eyebrow-link:hover .hub-hero-eyebrow-arrow, + .hub-hero .hub-hero-eyebrow-link:focus-visible .hub-hero-eyebrow-arrow { + transform: translateX(2px); + } + .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); + } + .hub-hero .hub-hero-icon-svg { + background-color: transparent; + } + .hub-hero .hub-hero-icon-svg svg { + width: 40px; + height: 40px; + } + .hub-hero .hub-hero-top h1 { + font-size: 2.285rem; + 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: 1.071rem; + 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; + } + .hub-hero .hub-hero-action { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + font-size: 0.9375rem; + font-weight: 600; + border-radius: 8px; + text-decoration: none; + color: var(--color-ink-dark); + background: var(--color-white); + border: 1px solid var(--color-grey-30); + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; + } + .hub-hero .hub-hero-action svg { + flex-shrink: 0; + color: var(--color-ink-light); + } + .hub-hero .hub-hero-action:hover, + .hub-hero .hub-hero-action:focus-visible { + background: var(--color-grey-10); + border-color: var(--color-grey-40); + } + .hub-hero .hub-hero-action-primary { + border-color: var(--color-ink-dark); + } + .hub-hero .hub-hero-action-primary:hover, + .hub-hero .hub-hero-action-primary:focus-visible { + border-color: var(--color-ink-dark); + background: var(--color-grey-10); + } +} 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..19506215f9 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,12 +154,27 @@ 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; if (yamlFrontMatter.NavigationTitle 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..5a92793a1d --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs @@ -0,0 +1,181 @@ +// 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 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)) + return path[..^"/index.md".Length]; + 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); + return resolver.TryResolve(s => block.EmitError(s), uri, out var resolved) + ? resolved.ToString() + : original; + } + + private static void ValidateInternal(string url, DirectiveBlock block, ParserContext context) + { + // In assembler 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(@"^\[(?