From fbc727f2b2129ca9bbd937a1a4de468bbc61a122 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Mon, 10 Aug 2026 20:55:46 +0200 Subject: [PATCH 1/7] Add {card-group}, {link-card}, and {explore} These three ship together because {explore} is what switches the other two into their second rendering mode. A reviewer cannot judge the mechanism from any one of them alone. {card-group} is a section heading and a card grid. {link-card} is one card, with a title, a description, a primary link list, and an optional aside. Its body is a fixed YAML schema rather than markdown, so a missing title or invalid YAML fails the build. {explore} wraps card groups into a stack of collapsible accordions. Nesting drives the mode switch, not an option. Inside {explore} a card group renders as an accordion and a link card renders as a titled link column, with the description dropped and the aside rendered as a badge cluster under its own authored label. Driving this from the ancestor rather than a per-directive option means every card grid elsewhere on the site keeps working unchanged. Adds HubDirectiveViewModel, which centralises the attributes a hub link needs. Hub links come from options and YAML bodies, so they never pass through Markdig's link renderer and each view was repeating the external, anchor, and preload rules by hand. {hero} moves onto it too. The LLM export keeps the whole structure. The curated grouping of links is what a hub page is for, and the nav tree in llms.txt is neither ordered nor curated, so it is not a substitute. The search body deliberately gets nothing from these three directives: section, card, and link titles are the tokens that would let a hub outrank the pages it links to on a specific query. Adds doesNotContainHtml to the authoring assertions, for content a directive drops on purpose. Co-Authored-By: Claude Opus 5 --- docs/_docset.yml | 3 + docs/examples/products/docs-builder.md | 208 ++++++++++ docs/syntax/card-group.md | 49 +++ docs/syntax/explore.md | 71 ++++ docs/syntax/hero.md | 2 +- docs/syntax/hub-pages.md | 5 +- docs/syntax/link-card.md | 69 ++++ .../Assets/markdown/hub.css | 370 +++++++++++++++++- .../Myst/Directives/DirectiveBlockParser.cs | 9 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 53 +++ .../Myst/Directives/Hub/CardGroupBlock.cs | 46 +++ .../Myst/Directives/Hub/CardGroupView.cshtml | 42 ++ .../Myst/Directives/Hub/CardGroupViewModel.cs | 25 ++ .../Myst/Directives/Hub/ExploreBlock.cs | 49 +++ .../Myst/Directives/Hub/ExploreView.cshtml | 12 + .../Myst/Directives/Hub/ExploreViewModel.cs | 12 + .../Myst/Directives/Hub/HeroView.cshtml | 8 +- .../Myst/Directives/Hub/HeroViewModel.cs | 21 +- .../Directives/Hub/HubDirectiveViewModel.cs | 51 +++ .../Myst/Directives/Hub/HubExplore.cs | 36 ++ .../Myst/Directives/Hub/HubYamlBody.cs | 96 +++++ .../Myst/Directives/Hub/LinkCardBlock.cs | 124 ++++++ .../Myst/Directives/Hub/LinkCardView.cshtml | 116 ++++++ .../Myst/Directives/Hub/LinkCardViewModel.cs | 15 + .../LlmMarkdown/LlmBlockRenderers.cs | 91 ++++- .../PlainText/PlainTextBlockRenderers.cs | 7 + .../Myst/YamlSerialization.cs | 3 + tests/authoring/Blocks/Hub/CardsAndExplore.fs | 275 +++++++++++++ tests/authoring/authoring.fsproj | 1 + 29 files changed, 1844 insertions(+), 25 deletions(-) create mode 100644 docs/syntax/card-group.md create mode 100644 docs/syntax/explore.md create mode 100644 docs/syntax/link-card.md create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/ExploreBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/ExploreView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/ExploreViewModel.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HubDirectiveViewModel.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HubExplore.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HubYamlBody.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs create mode 100644 tests/authoring/Blocks/Hub/CardsAndExplore.fs diff --git a/docs/_docset.yml b/docs/_docset.yml index a05806da81..e518cc269d 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -110,6 +110,7 @@ toc: - file: applies-switch.md - file: automated_settings.md - file: buttons.md + - file: card-group.md - file: changelog.md - file: code.md - file: comments.md @@ -117,6 +118,7 @@ toc: - file: csv-include.md - file: dropdowns.md - file: definition-lists.md + - file: explore.md - file: file_inclusion.md - file: footnotes.md - file: frontmatter.md @@ -126,6 +128,7 @@ toc: - file: images.md - file: videos.md - file: kbd.md + - file: link-card.md - file: math.md - file: diagrams.md - file: lists.md diff --git a/docs/examples/products/docs-builder.md b/docs/examples/products/docs-builder.md index 283f45e46b..c9df155451 100644 --- a/docs/examples/products/docs-builder.md +++ b/docs/examples/products/docs-builder.md @@ -9,4 +9,212 @@ description: docs-builder documentation. Build, validate, and publish Elastic do :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) +:tertiary-action: [Explore docs-builder](#explore) ::: + +::::{card-group} +:title: Get hands-on +:id: hands-on +:intro: New to the toolchain? Follow a guided path from install to published page. + +:::{link-card} +title: Write your first page +link: /getting-started/writing-content.md +description: Author a page, add links, and preview it locally. +links: + - label: Writing content + url: /getting-started/writing-content.md + - label: Pages and links + url: /getting-started/pages-and-links.md +::: + +:::{link-card} +title: Serve and publish +link: /getting-started/serve.md +description: Run the local preview server, then publish the built site. +links: + - label: Serve locally + url: /getting-started/serve.md + - label: Publish + url: /getting-started/publish.md +::: + +:::{link-card} +title: Syntax reference +link: /syntax/index.md +description: Every directive and role the toolchain understands. +links: + - label: Browse the syntax guide + url: /syntax/index.md + - label: Hub pages + url: /syntax/hub-pages.md +::: +:::: + +::::{card-group} +:title: Documentation this toolchain builds +:id: solutions +:intro: The published Elastic documentation, linked with cross-repository links. +:variant: solutions + +:::{link-card} +icon: elasticsearch +variant: es +title: Elasticsearch +description: Search and analytics documentation. +links: + - label: Search solution + url: docs-content://solutions/search.md + - label: Manage data + url: docs-content://manage-data/index.md +::: + +:::{link-card} +icon: observability +variant: obs +title: Observability +description: Logs, metrics, traces, and alerting documentation. +links: + - label: Observability solution + url: docs-content://solutions/observability.md + - label: Explore and analyze + url: docs-content://explore-analyze/index.md +::: + +:::{link-card} +icon: security +variant: sec +title: Security +description: SIEM, endpoint, and detection documentation. +links: + - label: Security solution + url: docs-content://solutions/security.md + - label: Deploy and manage + url: docs-content://deploy-manage/index.md +::: +:::: + +:::::{explore} +:id: explore +:title: Explore docs-builder +:intro: Find what you need, organized by task, from authoring and building to publishing and operating. + +::::{card-group} +:title: Authoring +:id: authoring + +:::{link-card} +title: Syntax +links: + - label: Directives + url: /syntax/directives.md + - label: Hub pages + url: /syntax/hub-pages.md + - label: Hero + url: /syntax/hero.md +aside: + label: Card directives + links: + - label: Card group + url: /syntax/card-group.md + - label: Link card + url: /syntax/link-card.md + - label: Explore + url: /syntax/explore.md + - label: Page card + url: /syntax/page-card.md +::: + +:::{link-card} +title: Getting started +links: + - label: Installation + url: /getting-started/installation.md + - label: Writing content + url: /getting-started/writing-content.md + - label: Pages and links + url: /getting-started/pages-and-links.md +::: + +:::{link-card} +title: Formatting +links: + - label: Code blocks + url: /syntax/code.md + - label: Tables + url: /syntax/tables.md + - label: Lists + url: /syntax/lists.md + - label: Admonitions + url: /syntax/admonitions.md +::: + +:::{link-card} +title: Page metadata +links: + - label: Frontmatter + url: /syntax/frontmatter.md + - label: Links + url: /syntax/links.md + - label: Substitutions + url: /syntax/substitutions.md +::: +:::: + +::::{card-group} +:title: Builds and configuration +:id: builds + +:::{link-card} +title: Build types +links: + - label: Isolated builds + url: /documentation/isolated/configure/index.md + - label: Assembler + url: /documentation/assembler/configure/index.md + - label: Codex + url: /documentation/codex/index.md +::: + +:::{link-card} +title: Catalog +links: + - label: Products + url: /documentation/catalog/products.md + - label: Versions + url: /documentation/catalog/versions.md +aside: + label: Also see + links: + - label: Synonyms + url: /documentation/catalog/synonyms.md + - label: Legacy URLs + url: /documentation/catalog/legacy-url-mappings.md +::: +:::: + +::::{card-group} +:title: Structured data and operations +:id: data + +:::{link-card} +title: Exporters +links: + - label: Overview + url: /data/exporters/index.md + - label: LLM markdown + url: /data/exporters/llm.md + - label: Plain text + url: /data/exporters/plain-text.md +::: + +:::{link-card} +title: Operations +links: + - label: Distributed builds + url: /documentation/distributed-builds.md + - label: Infrastructure + url: /documentation/assembler/infrastructure.md +::: +:::: +::::: diff --git a/docs/syntax/card-group.md b/docs/syntax/card-group.md new file mode 100644 index 0000000000..b63b3ea3db --- /dev/null +++ b/docs/syntax/card-group.md @@ -0,0 +1,49 @@ +# Card group + +A section heading and a card grid container. It wraps one or more [`{link-card}`](link-card.md) directives. + +See the [docs-builder documentation hub](../examples/products/docs-builder.md) for both rendering modes on one page. + +## Basic + +```markdown +::::{card-group} +:title: Install and administer +:id: install + +:::{link-card} +title: Self-managed +link: /getting-started/installation.md +description: Run on your own infrastructure. +::: + +:::{link-card} +title: Serve locally +link: /getting-started/serve.md +description: Preview the site while you write. +::: +:::: +``` + +The outer fence uses **four** colons, so the inner three-colon fences are not read as a closing fence. Add as many extra colons to the outer fence as the nesting needs. + +## Options + +| Option | Notes | +|---|---| +| `:title:` | H2 heading. Optional. Without it, only the grid renders. | +| `:intro:` | Intro paragraph below the heading. | +| `:id:` | Section anchor. | +| `:variant:` | Set to `solutions` to lock a 3-up grid. | + +## Layout + +By default the grid auto-fills 1, 2, or 3 columns based on the available width. Card heights match within a row. + +With `:variant: solutions`, the grid locks to three equal columns, stepping to two and then one at narrower widths. A fourth card wraps to the next row instead of compressing the layout into a narrow fourth column. + +## Inside `{explore}` + +Nest a card group in an [`{explore}`](explore.md) section and it renders as a collapsible accordion instead of a titled grid. The `:title:` becomes the accordion header, and each child [`{link-card}`](link-card.md) renders as a link column. + +No option controls this. The `{explore}` ancestor drives it. That keeps every card grid elsewhere on the site working unchanged, and it means an author wraps existing groups in `{explore}` rather than learning a second directive. diff --git a/docs/syntax/explore.md b/docs/syntax/explore.md new file mode 100644 index 0000000000..dc73fcc6c6 --- /dev/null +++ b/docs/syntax/explore.md @@ -0,0 +1,71 @@ +# Explore + +The browse-everything section of a [hub page](hub-pages.md). It is a titled band holding a stack of collapsible accordions, and it wraps one or more [`{card-group}`](card-group.md) directives. + +A hub's full link list can run to nine or ten sections. Without grouping that is a very long page, so `{explore}` collapses it into a scannable stack. + +See the [docs-builder documentation hub](../examples/products/docs-builder.md) for a rendered stack. + +## Basic + +```markdown +:::::{explore} +:id: explore +:title: Explore the docs toolchain +:intro: Find what you need, organized by task. + +::::{card-group} +:title: Quick links +:id: quick-links + +:::{link-card} +title: Releases and APIs +links: + - label: Exporters + url: /data/exporters/index.md + - label: API reference + url: /data/api.md +::: +:::: + +::::{card-group} +:title: Authoring +:id: authoring + +:::{link-card} +title: Syntax +links: + - label: Directives + url: /syntax/directives.md +::: +:::: +::::: +``` + +## Options + +| Option | Notes | +|---|---| +| `:title:` | **Required.** H2 heading, for example "Explore Elasticsearch". | +| `:intro:` | Intro paragraph below the heading. | +| `:id:` | Section anchor. Use `explore` so `{hero}`'s tertiary action can jump to it. | + +## What nesting changes + +`{explore}` carries no options for the accordions. Nesting drives everything: + +- Each [`{card-group}`](card-group.md) inside becomes one accordion. Its `:title:` is the accordion header. +- The first accordion is expanded. The rest are collapsed. +- Each [`{link-card}`](link-card.md) inside renders as a link column rather than a bordered card. + +Toggling uses native `
` and ``, so it works without JavaScript. + +## Fence depth + +Nesting three directives needs three fence widths. The outer fence always needs one more colon than its deepest child: + +| Directive | Fence | +|---|---| +| `{explore}` | `:::::` | +| `{card-group}` | `::::` | +| `{link-card}` | `:::` | diff --git a/docs/syntax/hero.md b/docs/syntax/hero.md index 75d4be1fd5..206693582e 100644 --- a/docs/syntax/hero.md +++ b/docs/syntax/hero.md @@ -4,7 +4,7 @@ A full-bleed identity band with a product icon, page title, description, and up 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. +See the [docs-builder documentation hub](../examples/products/docs-builder.md) for a rendered hero. ## Basic diff --git a/docs/syntax/hub-pages.md b/docs/syntax/hub-pages.md index 2fb8ab6846..1eba1b8615 100644 --- a/docs/syntax/hub-pages.md +++ b/docs/syntax/hub-pages.md @@ -4,7 +4,7 @@ A hub page is a product-scoped landing page. It gives a reader one 360° view of 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. +See the [docs-builder documentation hub](../examples/products/docs-builder.md) for a complete page. ## Enable the layout @@ -54,6 +54,9 @@ Write both deliberately. The search body indexes the hero title and description | Directive | Purpose | |---|---| | [`{hero}`](hero.md) | Identity band. Carries the product icon, the page title, a description, and up to three actions. | +| [`{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, link list, optional aside. Renders as a link column inside `{explore}`. | +| [`{explore}`](explore.md) | The browse-everything section. A stack of collapsible accordions. | ## Page skeleton diff --git a/docs/syntax/link-card.md b/docs/syntax/link-card.md new file mode 100644 index 0000000000..cd5fdf5139 --- /dev/null +++ b/docs/syntax/link-card.md @@ -0,0 +1,69 @@ +# Link card + +A card with a title, description, a primary link list, and an optional aside. It is designed to sit inside a [`{card-group}`](card-group.md), and it renders standalone too. + +See the [docs-builder documentation hub](../examples/products/docs-builder.md) for both rendering modes on one page. + +## Basic + +```markdown +:::{link-card} +title: Writing content +link: /getting-started/writing-content.md +description: Author a page, add links, and preview it locally. +links: + - label: Pages and links + url: /getting-started/pages-and-links.md + - label: Syntax guide + url: /syntax/index.md +::: +``` + +The body is **YAML, not markdown**. The directive expects a fixed schema and renders it, so an author fills in fields rather than writing markup. A missing `title` or invalid YAML fails the build. + +## Schema + +```yaml +title: Writing content # required, the card heading +link: /getting-started/serve.md # optional, makes the title clickable +description: One short blurb. # optional +icon: elasticsearch # optional, product-keyed inline SVG +variant: es # optional accent: es, obs, or sec +links: # optional, the primary link list + - label: Pages and links + url: /getting-started/pages-and-links.md +aside: # optional bottom rail + label: Build types + links: + - label: Isolated + url: /documentation/isolated/configure/index.md + - label: Assembler + url: /documentation/assembler/index.md +``` + +## Variants + +`variant: es`, `obs`, or `sec` adds a left border in the matching solution colour. Use it for solution cards. + +`icon` takes the same product keys as [`{hero}`](hero.md): `elasticsearch`, `kibana`, `observability`, `security`. + +## Inside `{explore}` + +Nested in an [`{explore}`](explore.md) section, through a `{card-group}` ancestor, the same YAML renders as a titled link column instead of a bordered card. Two things change: + +- `description` is dropped. A column is a pure link index. +- `aside` renders as a badge cluster under its own `label`, rather than an inline dot-separated list. + +The `aside` label is authored. There is no fixed label text. + +## Links + +Every `link`, every entry in `links`, and every entry in `aside.links` validates at build time. Use one of these forms: + +| Form | Example | Behavior | +|---|---|---| +| Site-absolute path | `/syntax/index.md` | The markdown extension is stripped. The link preloads on hover. | +| Cross-link scheme | `elasticsearch://reference/index.md` | Resolves through the link index. | +| External URL | `https://www.elastic.co/docs/api/doc/elasticsearch` | Opens in a new tab, with `rel="noopener noreferrer"`. | + +A relative path such as `foo.md` is rejected. Prefer a cross-link scheme for any page outside the current repository. A site-absolute path that points into another repository's documentation set is validated nowhere. diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 4274fa36a5..4196d51f8b 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -154,9 +154,375 @@ flex-shrink: 0; } - /* The arrow slides and the hero animates its hover states. Honour a reduced-motion - preference. Nothing about the resting appearance changes. */ + /* Zone (section heading) ------------------------------------------ */ + .hub-zone { + @apply mx-auto w-full max-w-5xl; + margin-top: 56px; + margin-bottom: 24px; + scroll-margin-top: 120px; + } + .hub-zone:first-of-type { + margin-top: 0; + } + .hub-zone .hub-zone-title { + font-size: 1.571rem; + font-weight: 700; + color: var(--color-ink-dark); + line-height: 1.2; + margin: 0; + } + .hub-zone .hub-zone-intro { + color: var(--color-ink-light); + margin-top: 8px; + } + + /* Card grid -------------------------------------------------------- */ + .hub-card-grid { + @apply m-0 mx-auto w-full max-w-5xl list-none p-0; + display: grid; + /* min() so a container narrower than one track shrinks it instead of overflowing. */ + grid-template-columns: repeat(auto-fill, minmax(min(310px, 100%), 1fr)); + gap: 24px; + } + + /* Solutions variant: lock to a 3-up layout that tolerates a future 4th card + (it wraps to the next row rather than shrinking into a 4th narrow column). */ + .hub-card-grid-solutions { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + /* Card ------------------------------------------------------------- */ + .hub-card { + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 14px; + padding: 22px 22px 20px; + transition: + box-shadow 0.15s, + border-color 0.15s; + margin: 0; + list-style: none; + } + .hub-card:hover, + .hub-card:focus-within { + border-color: var(--color-grey-30); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.05); + } + + .hub-card .hub-card-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; + } + .hub-card .hub-card-icon svg { + width: 28px; + height: 28px; + display: block; + } + .hub-card .hub-card-title { + font-size: 1.0625rem; + font-weight: 700; + color: var(--color-ink-dark); + margin: 0; + } + .hub-card .hub-card-title a { + color: var(--color-ink-dark); + text-decoration: none; + } + .hub-card .hub-card-title a:hover { + color: var(--color-blue-elastic); + text-decoration: none; + } + + .hub-card .hub-card-desc { + font-size: 14px; + color: var(--color-ink-light); + margin-bottom: 14px; + line-height: 1.5; + } + + .hub-card .hub-card-links { + list-style: none; + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; + } + .hub-card .hub-card-links li { + margin: 0; + padding: 0; + } + .hub-card .hub-card-links li a { + font-size: 14px; + font-weight: 600; + color: var(--color-blue-elastic-100); + padding: 3px 0; + display: inline-flex; + align-items: baseline; + gap: 6px; + text-decoration: none; + } + .hub-card .hub-card-links li a:hover { + color: var(--color-blue-elastic-110); + text-decoration: underline; + } + .hub-card .hub-card-links li a::before { + content: '\203A'; + color: var(--color-grey-40); + font-weight: 700; + font-size: 15px; + } + + /* Card aside (Panel types, Chart types, Key features...) */ + .hub-card .hub-card-aside { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--color-grey-20); + } + .hub-card .hub-card-aside-label { + font-size: 12px; + color: var(--color-grey-80); + margin-bottom: 6px; + font-weight: 600; + letter-spacing: 0.3px; + } + .hub-card .hub-card-aside-links { + display: flex; + flex-wrap: wrap; + gap: 0; + font-size: 13px; + line-height: 1.9; + } + .hub-card .hub-card-aside-links a { + color: var(--color-blue-elastic-100); + font-weight: 500; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + } + .hub-card .hub-card-aside-links a:hover { + color: var(--color-blue-elastic-110); + } + .hub-card .hub-card-aside-sep { + color: var(--color-grey-30); + margin: 0 6px; + } + + /* Explore {product} — accordion stack ------------------------------ */ + .hub-explore { + @apply mx-auto w-full max-w-5xl; + border-top: 1px solid var(--color-grey-20); + } + + .hub-accordion { + border-bottom: 1px solid var(--color-grey-20); + } + .hub-accordion-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 4px; + cursor: pointer; + list-style: none; + user-select: none; + } + /* Hide the native disclosure triangle across browsers. */ + .hub-accordion-summary::-webkit-details-marker { + display: none; + } + .hub-accordion-summary::marker { + content: ''; + } + .hub-accordion-title { + /* margin: 0 keeps the heading rendering exactly as the span it replaced. */ + margin: 0; + font-size: 1.143rem; + font-weight: 700; + color: var(--color-ink-dark); + line-height: 1.3; + } + .hub-accordion-icon { + flex: none; + color: var(--color-ink-light); + } + /* Plus when collapsed, minus when open: drop the vertical stroke on [open]. */ + .hub-accordion-icon-v { + transition: opacity 0.15s ease; + } + .hub-accordion[open] > .hub-accordion-summary .hub-accordion-icon-v { + opacity: 0; + } + .hub-accordion-summary:hover .hub-accordion-title, + .hub-accordion-summary:focus-visible .hub-accordion-title { + color: var(--color-blue-elastic); + } + .hub-accordion-body { + padding: 4px 4px 28px; + container-type: inline-size; + } + .hub-accordion-intro { + color: var(--color-ink-light); + margin: 0 0 20px; + } + + /* Columns inside an accordion -------------------------------------- */ + /* Column count follows the accordion body's own width (container query), not the + viewport — the hub's left nav narrows the body, so a viewport query would + under-count. Targets a 4-up layout like the design when there's room. */ + .hub-explore-cols { + @apply m-0 list-none p-0; + display: grid; + grid-template-columns: 1fr; + gap: 28px 24px; + align-items: start; + } + @container (min-width: 420px) { + .hub-explore-cols { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + @container (min-width: 620px) { + .hub-explore-cols { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + @container (min-width: 820px) { + .hub-explore-cols { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + .hub-col { + margin: 0; + list-style: none; + min-width: 0; + } + .hub-col-title { + font-size: 0.929rem; + font-weight: 700; + color: var(--color-ink-dark); + margin: 0 0 4px; + line-height: 1.3; + } + .hub-col-title a { + color: var(--color-ink-dark); + text-decoration: none; + } + .hub-col-title a:hover { + color: var(--color-blue-elastic); + text-decoration: underline; + } + .hub-col-desc { + font-size: 0.786rem; + color: var(--color-ink-light); + margin: 0 0 10px; + line-height: 1.4; + } + /* A column is a pure link index, so its entries carry the site's standard link + affordance rather than a quieter bespoke one. A list marker is used, not a flex + column, because markers do not render on flex children. */ + .hub-col-links { + margin: 0; + padding-left: 1.1em; + list-style: disc; + } + .hub-col-links li { + margin: 0 0 4px; + list-style: disc; + } + .hub-col-links a { + font-size: 0.857rem; + color: var(--color-blue-elastic-100); + text-decoration: underline; + line-height: 1.35; + } + .hub-col-links a:hover, + .hub-col-links a:focus-visible { + color: var(--color-blue-elastic-110); + } + + /* "Even more" badge cluster under a column ------------------------- */ + .hub-explore-more { + margin-top: 14px; + } + /* The label is authored, for example "Panel types", so it keeps the casing the writer + chose rather than being shouted in uppercase. */ + .hub-explore-more-label { + font-size: 0.714rem; + font-weight: 600; + color: var(--color-ink-light); + margin-bottom: 8px; + } + .hub-explore-more-badges { + @apply m-0 list-none p-0; + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .hub-explore-more-badges li { + margin: 0; + list-style: none; + } + .hub-explore-more-badges a { + display: inline-block; + font-size: 0.75rem; + line-height: 1.4; + padding: 2px 8px; + border-radius: 4px; + background: var(--color-grey-10, #f5f7fa); + border: 1px solid var(--color-grey-20); + color: var(--color-ink-dark); + text-decoration: none; + transition: + background 0.15s, + border-color 0.15s; + } + .hub-explore-more-badges a:hover, + .hub-explore-more-badges a:focus-visible { + background: var(--color-white); + border-color: var(--color-grey-30); + color: var(--color-blue-elastic); + } + + @media (max-width: 768px) { + .hub-hero h1 { + font-size: 2rem; + } + .hub-card-grid { + grid-template-columns: 1fr; + } + .hub-on-this-page { + position: static; + flex-direction: column; + } + .hub-card { + padding: 20px; + } + } + + /* Solutions grid steps down gracefully; declared after the base mobile rule + so it wins over .hub-card-grid on shared elements. */ + @media (max-width: 980px) { + .hub-card-grid-solutions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + @media (max-width: 640px) { + .hub-card-grid-solutions { + grid-template-columns: 1fr; + } + } + + /* Hover and arrow transitions are decorative. Honour a reduced-motion preference. + Nothing about the resting appearance changes. */ @media (prefers-reduced-motion: reduce) { + .hub-card, + .hub-accordion-icon-v, + .hub-explore-more-badges a { + transition: none; + } .hub-hero .hub-hero-eyebrow-link, .hub-hero .hub-hero-action, .hub-arrow { diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index bafdbfcbd1..565a9ab7c0 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -141,6 +141,15 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{hero}") > 0) return new HeroBlock(this, context); + if (info.IndexOf("{explore}") > 0) + return new ExploreBlock(this, context); + + if (info.IndexOf("{card-group}") > 0) + return new CardGroupBlock(this, context); + + if (info.IndexOf("{link-card}") > 0) + return new LinkCardBlock(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 e0e989a156..35fbf81013 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -108,6 +108,15 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case HeroBlock heroBlock: WriteHero(renderer, heroBlock); return; + case ExploreBlock exploreBlock: + WriteExplore(renderer, exploreBlock); + return; + case CardGroupBlock cardGroupBlock: + WriteCardGroup(renderer, cardGroupBlock); + return; + case LinkCardBlock linkCardBlock: + WriteLinkCard(renderer, linkCardBlock); + return; case PageCardBlock pageCardBlock: WritePageCard(renderer, pageCardBlock); return; @@ -217,6 +226,50 @@ private static void WriteHero(HtmlRenderer renderer, HeroBlock block) RenderRazorSlice(slice, renderer); } + private static void WriteExplore(HtmlRenderer renderer, ExploreBlock block) + { + var slice = ExploreView.Create(new ExploreViewModel + { + DirectiveBlock = block, + Title = block.Title, + Intro = block.Intro, + Anchor = block.Anchor + }); + RenderRazorSlice(slice, renderer); + } + + private static void WriteCardGroup(HtmlRenderer renderer, CardGroupBlock block) + { + var explore = HubExplore.FindAncestor(block); + var slice = CardGroupView.Create(new CardGroupViewModel + { + DirectiveBlock = block, + Title = block.Title, + Intro = block.Intro, + Anchor = block.Anchor, + Variant = block.Variant, + IsAccordion = explore is not null, + IsOpen = explore is not null && HubExplore.IsFirstCardGroup(explore, block), + AccordionGroup = explore is null + ? null + : string.IsNullOrWhiteSpace(explore.Anchor) ? "hub-explore" : $"hub-explore-{explore.Anchor}" + }); + RenderRazorSlice(slice, renderer); + } + + private static void WriteLinkCard(HtmlRenderer renderer, LinkCardBlock block) + { + var slice = LinkCardView.Create(new LinkCardViewModel + { + DirectiveBlock = block, + Data = block.Data, + IconSvg = ProductIcons.Get(block.Data.Icon), + SitePathPrefix = block.Build.UrlPathPrefix, + IsColumn = HubExplore.FindAncestor(block) is not null + }); + 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. diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs new file mode 100644 index 0000000000..ed6a11d43a --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupBlock.cs @@ -0,0 +1,46 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Container directive that renders a titled section housing a grid of +/// children. Generic and reusable wherever a +/// linked-card grid is appropriate. +/// +/// +/// +/// ::::{card-group} +/// :title: Install and deploy +/// :intro: Set up Elasticsearch on your platform of choice. +/// :id: install +/// +/// :::{link-card} Self-managed +/// :link: /deploy-manage/deploy/self-managed +/// Run on your own infrastructure. +/// ::: +/// :::: +/// +/// +public class CardGroupBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + public override string Directive => "card-group"; + + public string? Title { get; private set; } + public string? Intro { get; private set; } + public string? Anchor { get; private set; } + public string? Variant { get; private set; } + + public override void FinalizeAndValidate(ParserContext context) + { + Title = Prop("title"); + Intro = Prop("intro"); + Anchor = Prop("id"); + Variant = Prop("variant"); + } + + public override IEnumerable GeneratedAnchors => + string.IsNullOrWhiteSpace(Anchor) ? [] : [Anchor]; +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml new file mode 100644 index 0000000000..0b98b91d05 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml @@ -0,0 +1,42 @@ +@inherits RazorSlice + +@if (Model.IsAccordion) +{ +
+ + @* A heading, not a span. Without it the accordion group is missing from the page + outline, and a reader navigating by heading jumps from the section straight into the + column titles inside it. *@ +

@Model.Title

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

@Model.Intro

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

@Model.Title

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

@Model.Intro

+ } +
+ } +
    + @Model.RenderBlock() +
+} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs new file mode 100644 index 0000000000..105c82e821 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs @@ -0,0 +1,25 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class CardGroupViewModel : DirectiveViewModel +{ + public required string? Title { get; init; } + public required string? Intro { get; init; } + public required string? Anchor { get; init; } + public required string? Variant { get; init; } + + /// Rendered as a collapsible accordion group inside an {explore} section. + public bool IsAccordion { get; init; } + + /// The accordion is expanded by default (the first group in an Explore stack). + public bool IsOpen { get; init; } + + /// + /// Shared <details name> group so the accordions open exclusively: expanding one + /// collapses the others in the same Explore section. + /// + public string? AccordionGroup { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/ExploreBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/ExploreBlock.cs new file mode 100644 index 0000000000..781e343107 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/ExploreBlock.cs @@ -0,0 +1,49 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Markdown.Diagnostics; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// The "Explore {product}" hub section: a titled band that houses a stack of +/// children rendered as collapsible accordion groups. +/// Nested card-groups and their link-cards detect this ancestor and switch to +/// their accordion/column rendering. +/// +/// +/// +/// :::::{explore} +/// :title: Explore Kibana +/// :intro: Explore the apps and capabilities that help you act on your data. +/// +/// ::::{card-group} +/// :title: Install & admin +/// ... link-cards ... +/// :::: +/// ::::: +/// +/// +public class ExploreBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + public override string Directive => "explore"; + + public string? Title { get; private set; } + public string? Intro { get; private set; } + public string? Anchor { get; private set; } + + public override void FinalizeAndValidate(ParserContext context) + { + Title = Prop("title"); + Intro = Prop("intro"); + Anchor = Prop("id"); + + if (string.IsNullOrWhiteSpace(Title)) + this.EmitError("{explore} requires a `:title:` option."); + } + + public override IEnumerable GeneratedAnchors => + string.IsNullOrWhiteSpace(Anchor) ? [] : [Anchor]; +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/ExploreView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/ExploreView.cshtml new file mode 100644 index 0000000000..799ac3141f --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/ExploreView.cshtml @@ -0,0 +1,12 @@ +@inherits RazorSlice + +
+

@Model.Title

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

@Model.Intro

+ } +
+
+ @Model.RenderBlock() +
diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/ExploreViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/ExploreViewModel.cs new file mode 100644 index 0000000000..77415c609f --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/ExploreViewModel.cs @@ -0,0 +1,12 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class ExploreViewModel : DirectiveViewModel +{ + public required string? Title { get; init; } + public required string? Intro { get; init; } + public required string? Anchor { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml index 272d17debb..64f4befbdd 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml @@ -4,7 +4,7 @@
- + Browse all Elastic docs - + @action.Label diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs index 729dc6c682..eb6a5fac1a 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs @@ -4,7 +4,7 @@ namespace Elastic.Markdown.Myst.Directives.Hub; -public class HeroViewModel : DirectiveViewModel +public class HeroViewModel : HubDirectiveViewModel { public required string? IconKey { get; init; } public required string? IconSvg { get; init; } @@ -16,9 +16,6 @@ public class HeroViewModel : DirectiveViewModel public required string? SecondaryActionUrl { get; init; } public required string? TertiaryActionLabel { get; init; } public required string? TertiaryActionUrl { get; init; } - public required string? SitePathPrefix { get; init; } - public string? PrefixUrl(string? url) => DirectiveLinkValidator.ToHref(url, SitePathPrefix); - public IReadOnlyList Actions { get @@ -36,20 +33,16 @@ private void Add(List actions, string? label, string? url) if (string.IsNullOrWhiteSpace(label) || string.IsNullOrWhiteSpace(url)) return; - var isAnchor = url[0] == '#'; - // A cross-link resolves to a full URL but still points at documentation this site serves, - // so it is not external. Inline links make the same distinction. - var isExternal = url.StartsWith("http", StringComparison.OrdinalIgnoreCase) - && !DirectiveLinkValidator.IsResolvedCrossLink((DirectiveBlock)DirectiveBlock, url); - actions.Add(new HeroAction(label, url, isAnchor, isExternal)); + // IsAnchor drives the arrow. The href and its link attributes come from the shared + // base, which owns the external and cross-link rules. + actions.Add(new HeroAction(label, url, IsAnchor: url[0] == '#')); } } /// /// One hero call to action. The three actions carry equal weight and render as neutral /// buttons, so the option a label came from does not change its appearance. -/// drives the arrow that marks an in-page jump. -/// follows the same rules as inline links: an external link -/// opens in a new tab and skips preloading, and only an internal link is worth preloading. +/// drives the arrow that marks an in-page jump. Link attributes +/// come from . /// -public sealed record HeroAction(string Label, string Url, bool IsAnchor, bool IsExternal); +public sealed record HeroAction(string Label, string Url, bool IsAnchor); diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HubDirectiveViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HubDirectiveViewModel.cs new file mode 100644 index 0000000000..b7d71f80e8 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HubDirectiveViewModel.cs @@ -0,0 +1,51 @@ +// 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; +using Elastic.Documentation.Site; +using Microsoft.AspNetCore.Html; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Shared base for hub directive view models. Hub links come from directive options and YAML +/// bodies, so they are written straight into an href rather than going through Markdig's link +/// renderer. This centralises the attributes an anchor needs, so every hub link behaves the +/// same way as an inline link. +/// +public abstract class HubDirectiveViewModel : DirectiveViewModel +{ + public required string? SitePathPrefix { get; init; } + + /// Resolve a URL to a final href, applying the site path prefix. + public string? PrefixUrl(string? url) => DirectiveLinkValidator.ToHref(url, SitePathPrefix); + + /// + /// Render the full attribute set for a hub link: the resolved href, plus the same treatment + /// inline links get. An external link opens in a new tab and is not preloaded. An in-page + /// anchor is not preloaded either. Only a link this site serves is worth preloading. + /// + public HtmlString LinkAttributes(string? url) + { + var href = PrefixUrl(url) ?? string.Empty; + var attributes = new StringBuilder(); + _ = attributes.Append("href=\"").Append(HtmlEncoder.Encode(href)).Append('"'); + + // A cross-link resolves to a full URL but still points at documentation this site serves, + // so it is not external. Inline links make the same distinction. + if (IsExternal(url) && !DirectiveLinkValidator.IsResolvedCrossLink((DirectiveBlock)DirectiveBlock, url)) + _ = attributes.Append(" target=\"_blank\" rel=\"noopener noreferrer\""); + else if (!IsAnchor(url)) + _ = attributes.Append(" preload=\"").Append(Htmx.Preload).Append('"'); + + return new HtmlString(attributes.ToString()); + } + + private static bool IsExternal(string? url) => + url is not null && url.StartsWith("http", StringComparison.OrdinalIgnoreCase); + + private static bool IsAnchor(string? url) => url is ['#', ..]; + + private static System.Text.Encodings.Web.HtmlEncoder HtmlEncoder => System.Text.Encodings.Web.HtmlEncoder.Default; +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HubExplore.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HubExplore.cs new file mode 100644 index 0000000000..602a759bd8 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HubExplore.cs @@ -0,0 +1,36 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Markdig.Syntax; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Helpers for the "Explore {product}" section. Nested card-groups and link-cards +/// switch to their accordion/column rendering when they sit inside an +/// , detected by walking the Markdig parent chain. +/// +internal static class HubExplore +{ + public static ExploreBlock? FindAncestor(Block? block) + { + for (var parent = block?.Parent; parent is not null; parent = parent.Parent) + { + if (parent is ExploreBlock explore) + return explore; + } + return null; + } + + /// The first accordion in an Explore stack is expanded by default. + public static bool IsFirstCardGroup(ExploreBlock explore, CardGroupBlock card) + { + foreach (var child in explore) + { + if (child is CardGroupBlock candidate) + return ReferenceEquals(candidate, card); + } + return false; + } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HubYamlBody.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HubYamlBody.cs new file mode 100644 index 0000000000..fe885e3ce0 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HubYamlBody.cs @@ -0,0 +1,96 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Markdig.Syntax; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Helpers for directives that read their body as raw YAML directly from the +/// source file. The directive's children remain markdown blocks (so renderers +/// that don't recognize the directive still produce something), but the +/// canonical structured data comes from the YAML. +/// +internal static class HubYamlBody +{ + /// + /// Returns the raw text between the opening and closing fences of a directive + /// block, or null when no fenced body could be located. + /// + public static string? Extract(IBlockExtension block, IFileSystemFileReader reader) + { + if (block is not Block markdig) + return null; + + string source; + try + { + source = reader.ReadAllText(block.CurrentFile.FullName); + } + catch + { + return null; + } + + var lines = source.Split('\n'); + var openingLine = markdig.Line; + if (openingLine < 0 || openingLine >= lines.Length) + return null; + + var fence = ExtractFenceMarker(lines[openingLine]); + if (fence is null) + return null; + + var closingLine = -1; + for (var i = openingLine + 1; i < lines.Length; i++) + { + var trimmed = lines[i].TrimStart(); + if (trimmed.StartsWith(fence, StringComparison.Ordinal) && IsClosingFence(trimmed, fence)) + { + closingLine = i; + break; + } + } + if (closingLine < 0) + return null; + + var body = string.Join('\n', lines, openingLine + 1, closingLine - openingLine - 1); + return string.IsNullOrWhiteSpace(body) ? null : body; + } + + private static string? ExtractFenceMarker(string openingLine) + { + var trimmed = openingLine.TrimStart(); + var count = 0; + while (count < trimmed.Length && trimmed[count] == ':') + count++; + return count >= 3 ? new string(':', count) : null; + } + + private static bool IsClosingFence(string trimmed, string fence) + { + if (!trimmed.StartsWith(fence, StringComparison.Ordinal)) + return false; + for (var i = fence.Length; i < trimmed.Length; i++) + { + if (!char.IsWhiteSpace(trimmed[i])) + return false; + } + return true; + } +} + +/// +/// Minimal abstraction over file reading so HubYamlBody can be tested without a +/// full BuildContext. +/// +public interface IFileSystemFileReader +{ + string ReadAllText(string path); +} + +internal sealed class BuildContextFileReader(System.IO.Abstractions.IFileSystem fileSystem) : IFileSystemFileReader +{ + public string ReadAllText(string path) => fileSystem.File.ReadAllText(path); +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs new file mode 100644 index 0000000000..2751c56432 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs @@ -0,0 +1,124 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Markdown.Diagnostics; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// A rich card with title, link, description, primary-link list, and an optional +/// aside (e.g. "Panel types: A · B · C"). The card schema is YAML-formatted in the +/// directive body for predictable structure. +/// +/// +/// +/// :::{link-card} +/// title: Discover +/// link: /discover/ +/// description: Browse documents, filter, and query your indices in real time. +/// links: +/// - label: Get started with Discover +/// url: /discover/get-started +/// - label: Use ES|QL in Kibana +/// url: /esql +/// aside: +/// label: Panel types +/// links: +/// - label: Visualizations +/// url: /viz +/// - label: Maps +/// url: /maps +/// ::: +/// +/// +public class LinkCardBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context), IBlockTitle +{ + public override string Directive => "link-card"; + + public LinkCardData Data { get; private set; } = LinkCardData.Empty; + + public string Title => Data.Title ?? string.Empty; + + public override void FinalizeAndValidate(ParserContext context) + { + var yaml = HubYamlBody.Extract(this, new BuildContextFileReader(Build.ReadFileSystem)); + if (yaml is null) + { + this.EmitError("{link-card} requires a YAML body. See the link-card directive docs."); + return; + } + + try + { + Data = YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration) ?? LinkCardData.Empty; + } + catch (YamlException ex) + { + this.EmitError($"{{link-card}} YAML parse error: {ex.Message}"); + return; + } + + if (string.IsNullOrWhiteSpace(Data.Title)) + this.EmitError("{link-card} requires a `title` field in its YAML body."); + + Data.Link = DirectiveLinkValidator.ValidateAndResolve(Data.Link, this, context); + foreach (var link in Data.Links) + link.Url = DirectiveLinkValidator.ValidateAndResolve(link.Url, this, context); + if (Data.Aside is not null) + { + foreach (var link in Data.Aside.Links) + link.Url = DirectiveLinkValidator.ValidateAndResolve(link.Url, this, context); + } + } +} + +[YamlSerializable] +public record LinkCardData +{ + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "link")] + public string? Link { get; set; } + + [YamlMember(Alias = "description")] + public string? Description { get; set; } + + [YamlMember(Alias = "icon")] + public string? Icon { get; set; } + + [YamlMember(Alias = "variant")] + public string? Variant { get; set; } + + [YamlMember(Alias = "links")] + public LinkCardLink[] Links { get; set; } = []; + + [YamlMember(Alias = "aside")] + public LinkCardAside? Aside { get; set; } + + public static LinkCardData Empty { get; } = new(); +} + +[YamlSerializable] +public record LinkCardLink +{ + [YamlMember(Alias = "label")] + public string? Label { get; set; } + + [YamlMember(Alias = "url")] + public string? Url { get; set; } +} + +[YamlSerializable] +public record LinkCardAside +{ + [YamlMember(Alias = "label")] + public string? Label { get; set; } + + [YamlMember(Alias = "links")] + public LinkCardLink[] Links { get; set; } = []; +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml new file mode 100644 index 0000000000..3682f73636 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml @@ -0,0 +1,116 @@ +@inherits RazorSlice + +@{ + var d = Model.Data; +} + +@if (Model.IsColumn) +{ +
  • +

    + @if (!string.IsNullOrWhiteSpace(d.Link)) + { + @d.Title + } + else + { + @d.Title + } +

    + + @if (d.Links.Length > 0) + { + + } + + @if (d.Aside is { } aside && aside.Links.Length > 0) + { +
    + @if (!string.IsNullOrWhiteSpace(aside.Label)) + { +
    @aside.Label
    + } +
      + @foreach (var link in aside.Links) + { + if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) + continue; +
    • @link.Label
    • + } +
    +
    + } +
  • +} +else +{ + var classes = "hub-card"; + if (!string.IsNullOrWhiteSpace(d.Variant)) + classes += " hub-card-sol hub-card-sol-" + d.Variant; + +
  • +
    + @if (!string.IsNullOrWhiteSpace(Model.IconSvg)) + { + @(new HtmlString(Model.IconSvg)) + } +

    + @if (!string.IsNullOrWhiteSpace(d.Link)) + { + @d.Title + } + else + { + @d.Title + } +

    +
    + + @if (!string.IsNullOrWhiteSpace(d.Description)) + { +

    @d.Description

    + } + + @if (d.Links.Length > 0) + { + + } + + @if (d.Aside is { } aside && aside.Links.Length > 0) + { +
    + @if (!string.IsNullOrWhiteSpace(aside.Label)) + { +
    @aside.Label
    + } + +
    + } +
  • +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs new file mode 100644 index 0000000000..cb9b7e8d26 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardViewModel.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class LinkCardViewModel : HubDirectiveViewModel +{ + public required LinkCardData Data { get; init; } + public required string? IconSvg { get; init; } + + /// Rendered as a titled link column inside an {explore} accordion. + public bool IsColumn { get; init; } + +} diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 4b3ddca5bd..9804645d24 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -505,6 +505,15 @@ protected override void Write(LlmMarkdownRenderer renderer, DirectiveBlock obj) case HeroBlock heroBlock: WriteHeroBlock(renderer, heroBlock); return; + case ExploreBlock exploreBlock: + WriteExploreBlock(renderer, exploreBlock); + return; + case CardGroupBlock cardGroupBlock: + WriteCardGroupBlock(renderer, cardGroupBlock); + return; + case LinkCardBlock linkCardBlock: + WriteLinkCardBlock(renderer, linkCardBlock); + return; } // Ensure single empty line before directive @@ -571,13 +580,93 @@ private static void WriteHeroBlock(LlmMarkdownRenderer renderer, HeroBlock heroB renderer.EnsureLine(); } + // The curated grouping of links is what a hub page is for, so the export keeps the whole + // structure: each section becomes a heading and each card a titled link list. + private static void WriteExploreBlock(LlmMarkdownRenderer renderer, ExploreBlock block) + { + renderer.EnsureBlockSpacing(); + if (!string.IsNullOrEmpty(block.Title)) + { + renderer.WriteLine($"## {block.Title}"); + renderer.EnsureLine(); + } + if (!string.IsNullOrEmpty(block.Intro)) + { + renderer.WriteLine(block.Intro); + renderer.EnsureLine(); + } + renderer.WriteChildren(block); + } + + private static void WriteCardGroupBlock(LlmMarkdownRenderer renderer, CardGroupBlock block) + { + renderer.EnsureBlockSpacing(); + if (!string.IsNullOrEmpty(block.Title)) + { + renderer.WriteLine($"### {block.Title}"); + renderer.EnsureLine(); + } + if (!string.IsNullOrEmpty(block.Intro)) + { + renderer.WriteLine(block.Intro); + renderer.EnsureLine(); + } + renderer.WriteChildren(block); + } + + private static void WriteLinkCardBlock(LlmMarkdownRenderer renderer, LinkCardBlock block) + { + var data = block.Data; + renderer.EnsureBlockSpacing(); + + if (!string.IsNullOrEmpty(data.Title)) + { + var heading = string.IsNullOrEmpty(data.Link) + ? data.Title + : $"[{data.Title}]({HubLinkForLlm(renderer, data.Link)})"; + renderer.WriteLine($"#### {heading}"); + renderer.EnsureLine(); + } + + // The description is hidden in column mode, but the export is not laid out, so it is + // always useful context here. + if (!string.IsNullOrEmpty(data.Description)) + { + renderer.WriteLine(data.Description); + renderer.EnsureLine(); + } + + foreach (var link in data.Links) + WriteHeroAction(renderer, link.Label, link.Url); + + if (data.Aside is { } aside) + { + if (!string.IsNullOrEmpty(aside.Label)) + { + renderer.EnsureLine(); + renderer.WriteLine($"{aside.Label}:"); + } + foreach (var link in aside.Links) + WriteHeroAction(renderer, link.Label, link.Url); + } + + renderer.EnsureLine(); + } + + // Also used for card links: the shape is the same, a labelled link on its own line. private static void WriteHeroAction(LlmMarkdownRenderer renderer, string? label, string? url) { if (string.IsNullOrEmpty(label) || string.IsNullOrEmpty(url)) return; - renderer.WriteLine($"- [{label}]({LlmRenderingHelpers.MakeAbsoluteUrl(renderer, url)})"); + renderer.WriteLine($"- [{label}]({HubLinkForLlm(renderer, url)})"); } + // Hub links are authored as markdown paths. Strip the extension so the export matches how + // inline links render. The site path prefix is not applied: MakeAbsoluteUrl resolves against + // the canonical base URL instead. + private static string? HubLinkForLlm(LlmMarkdownRenderer renderer, string? url) => + LlmRenderingHelpers.MakeAbsoluteUrl(renderer, DirectiveLinkValidator.ToHref(url, null)); + private static void WriteImageBlock(LlmMarkdownRenderer renderer, ImageBlock imageBlock) { renderer.EnsureBlockSpacing(); diff --git a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs index a95c196818..6e60bfbe24 100644 --- a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs @@ -286,6 +286,13 @@ protected override void Write(PlainTextRenderer renderer, DirectiveBlock obj) renderer.EnsureLine(); return; + // Deliberately contributes nothing. Section, card, and link titles are the tokens + // that would let a hub outrank the pages it links to on a specific query. + case ExploreBlock: + case CardGroupBlock: + case LinkCardBlock: + return; + case AgentSkillBlock agentSkillBlock: renderer.EnsureBlockSpacing(); renderer.WriteLine("Agent skill available"); diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index e5b4c5c1b2..be57652254 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -81,4 +81,7 @@ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializ [YamlSerializable(typeof(ChangelogDirectiveConfigYaml))] [YamlSerializable(typeof(ChangelogDirectiveBundleConfigYaml))] [YamlSerializable(typeof(ListingFrontMatter))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardData))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardLink))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.LinkCardAside))] public partial class DocsBuilderYamlStaticContext; diff --git a/tests/authoring/Blocks/Hub/CardsAndExplore.fs b/tests/authoring/Blocks/Hub/CardsAndExplore.fs new file mode 100644 index 0000000000..eec90b3551 --- /dev/null +++ b/tests/authoring/Blocks/Hub/CardsAndExplore.fs @@ -0,0 +1,275 @@ +// 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``.``card and explore elements`` + +open Xunit +open authoring + +// {card-group} and {link-card} render two ways. Which one is decided entirely by whether an +// {explore} ancestor is present, not by any option. These tests pin both modes and the +// ancestor detection that switches between them. + +type ``card group standalone`` () = + static let markdown = Setup.Markdown """ +::::{card-group} +:title: Get hands-on +:id: hands-on +:intro: Follow a guided quickstart. + +:::{link-card} +title: Writing content +link: /index.md +description: Author a page and preview it. +links: + - label: Pages and links + url: /index.md +::: +:::: +""" + + [] + let ``renders a heading and a grid, not an accordion`` () = + markdown |> convertsToContainingHtml """ +
    +

    Get hands-on

    +

    Follow a guided quickstart.

    +
    +""" + + [] + let ``renders the card with its description`` () = + markdown |> convertsToContainingHtml """

    Author a page and preview it.

    """ + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``card group with the solutions variant`` () = + static let markdown = Setup.Markdown """ +::::{card-group} +:title: Browse by area +:variant: solutions + +:::{link-card} +title: Build the docs +::: +:::: +""" + + [] + let ``locks the three column grid`` () = + markdown |> convertsToContainingHtml """ +
      +
    • +
      +

      + Build the docs +

      +
      +
    • +
    +""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``card group nested in explore`` () = + static let markdown = Setup.Markdown """ +:::::{explore} +:id: explore +:title: Explore the docs +:intro: Organized by task. + +::::{card-group} +:title: Quick links +:id: quick-links + +:::{link-card} +title: Releases +description: This description is dropped in column mode. +links: + - label: Exporters + url: /index.md +aside: + label: Also see + links: + - label: Versions + url: /index.md +::: +:::: + +::::{card-group} +:title: Authoring +:id: authoring + +:::{link-card} +title: Syntax +::: +:::: +::::: +""" + + [] + let ``renders the explore heading`` () = + markdown |> convertsToContainingHtml """ +
    +

    Explore the docs

    +

    Organized by task.

    +
    +""" + + // One snapshot covers what nesting changes: accordion mode, the first accordion open and + // the rest closed, link cards as columns, and the aside as a badge cluster under its own + // label rather than a fixed string. It also pins the heading levels, so an Explore stack + // keeps a complete outline: section h2, accordion h3, column h4. + [] + let ``renders the accordion stack`` () = + markdown |> convertsToContainingHtml """ +
    + +
    + +

    Authoring

    + +
    +
    +
      +
    • +

      + Syntax +

      +
    • +
    +
    +
    +
    +""" + + // A column is a pure link index, so the description is deliberately dropped. + [] + let ``drops the description in column mode`` () = + markdown |> doesNotConvertToContainingHtml """This description is dropped in column mode.""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``link card without a title`` () = + static let markdown = Setup.Markdown """ +:::{link-card} +description: No title here. +::: +""" + + [] + let ``errors`` () = + markdown |> hasError "requires a `title`" + +type ``link card with a relative link`` () = + static let markdown = Setup.Markdown """ +:::{link-card} +title: Broken +links: + - label: Relative + url: nope.md +::: +""" + + [] + let ``rejects a relative path`` () = + markdown |> hasError "must be an absolute path starting with `/`" + +type ``link card with a missing target`` () = + static let markdown = Setup.Markdown """ +:::{link-card} +title: Broken +links: + - label: Missing + url: /does-not-exist.md +::: +""" + + [] + let ``errors on a link that does not resolve`` () = + markdown |> hasError "does not exist" + +type ``link card with a cross-link`` () = + static let markdown = Setup.Markdown """ +::::{card-group} +:title: Documentation this toolchain builds + +:::{link-card} +icon: elasticsearch +variant: es +title: Elasticsearch +description: Search and analytics documentation. +links: + - label: Elastic documentation + url: docs-content://get-started/index.md +::: +:::: +""" + + // Covers three things at once: the icon and variant accent, and that a cross-link resolves + // to a full URL without being treated as external. Inline links make the same distinction. + [] + let ``renders the icon and variant, and does not open the cross-link in a new tab`` () = + markdown |> convertsToContainingHtml """ +
      +
    • +
      + + + +

      + Elasticsearch +

      +
      +

      Search and analytics documentation.

      + +
    • +
    +""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index 7f299d64f7..723a8d7ba8 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -56,6 +56,7 @@ + From 1cf98b5faa4559791d605c9d9560b1e8e6f82534 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Wed, 12 Aug 2026 20:53:02 +0200 Subject: [PATCH 2/7] Apply review feedback to cards and the explore section Martijn's review on #3826. - Card link lists and column link lists take the site's standard link affordance: a bullet, blue, underlined. The leading chevron is gone. An arrow now means a control that behaves like a button, such as a hero action, rather than an entry in a link index. - The aside cluster takes the {button} directive's secondary treatment in a new small size, because those are buttons rather than cards. The size modifier composes with either button type and is available to any caller. - Only a card that carries its own link lifts on hover, and that card now has a whole-card target. A card holding several links and no link of its own no longer promises a click it cannot answer. - Card and explore type moves onto the site's scale. - Explore accordions no longer share a `name`, so a reader can hold several open and compare them. Co-Authored-By: Claude Opus 5 --- docs/syntax/explore.md | 1 + .../Assets/markdown/button.css | 8 ++ .../Assets/markdown/hub.css | 106 +++++++++--------- .../Myst/Directives/DirectiveHtmlRenderer.cs | 5 +- .../Myst/Directives/Hub/CardGroupView.cshtml | 4 +- .../Myst/Directives/Hub/CardGroupViewModel.cs | 6 - .../Myst/Directives/Hub/LinkCardView.cshtml | 6 +- tests/authoring/Blocks/Hub/CardsAndExplore.fs | 6 +- 8 files changed, 74 insertions(+), 68 deletions(-) diff --git a/docs/syntax/explore.md b/docs/syntax/explore.md index dc73fcc6c6..e368f7532c 100644 --- a/docs/syntax/explore.md +++ b/docs/syntax/explore.md @@ -56,6 +56,7 @@ links: - Each [`{card-group}`](card-group.md) inside becomes one accordion. Its `:title:` is the accordion header. - The first accordion is expanded. The rest are collapsed. +- A reader can expand as many accordions as they want. Expanding one does not collapse the others. - Each [`{link-card}`](link-card.md) inside renders as a link column rather than a bordered card. Toggling uses native `
    ` and ``, so it works without JavaScript. diff --git a/src/Elastic.Documentation.Site/Assets/markdown/button.css b/src/Elastic.Documentation.Site/Assets/markdown/button.css index 911f13c19a..32cf7f9d2e 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/button.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/button.css @@ -59,6 +59,14 @@ @apply text-ink-dark hover:text-ink-dark border-grey-70 hover:border-ink-dark hover:bg-grey-10 focus:ring-blue-elastic-50 flex h-10 cursor-pointer items-center justify-center rounded-sm border px-6 py-2 text-center font-sans text-base font-medium text-nowrap no-underline focus:ring-4 focus:outline-none; } +/* Small size modifier. Composes with either type. Used where a cluster of buttons + sits inside dense content, such as a hub card's aside. Height stays above the + 24px pointer-target floor. Declared after both types so it wins on height, + padding, and type size. */ +.doc-button-small a { + @apply h-8 px-3 text-sm; +} + /* Remove external link icon from buttons */ .doc-button-wrapper a[target='_blank']::after, .doc-button-item a[target='_blank']::after { diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 4196d51f8b..1a314d4f61 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -165,7 +165,7 @@ margin-top: 0; } .hub-zone .hub-zone-title { - font-size: 1.571rem; + font-size: var(--text-2xl); font-weight: 700; color: var(--color-ink-dark); line-height: 1.2; @@ -203,11 +203,29 @@ margin: 0; list-style: none; } - .hub-card:hover, + /* Only a card that is itself a link lifts on hover. A card without one holds + several links and no whole-card target, so a lift would promise a click the + card cannot answer. Keyboard focus still lifts either way, because it marks + where focus sits rather than what is clickable. */ + .hub-card-linked:hover, .hub-card:focus-within { border-color: var(--color-grey-30); box-shadow: 0 2px 8px rgb(0 0 0 / 0.05); } + /* The card title's link covers the whole card, so the lift and the target agree. + Links inside the card sit above it and stay clickable in their own right. */ + .hub-card-linked .hub-card-title a::after { + content: ''; + position: absolute; + inset: 0; + } + .hub-card-linked { + position: relative; + } + .hub-card .hub-card-links, + .hub-card .hub-card-aside { + position: relative; + } .hub-card .hub-card-head { display: flex; @@ -221,7 +239,7 @@ display: block; } .hub-card .hub-card-title { - font-size: 1.0625rem; + font-size: var(--text-base); font-weight: 700; color: var(--color-ink-dark); margin: 0; @@ -236,43 +254,36 @@ } .hub-card .hub-card-desc { - font-size: 14px; + font-size: var(--text-sm); color: var(--color-ink-light); margin-bottom: 14px; line-height: 1.5; } + /* A card's link list is an index, not a set of buttons, so its entries take the + site's standard link affordance: a bullet, blue, underlined. An arrow is + reserved for a control that behaves like a button, such as a hero action. */ .hub-card .hub-card-links { - list-style: none; - display: flex; - flex-direction: column; - gap: 2px; margin: 0; - padding: 0; + padding-left: 1.1em; + list-style: disc; } .hub-card .hub-card-links li { - margin: 0; - padding: 0; + margin: 0 0 4px; + list-style: disc; } .hub-card .hub-card-links li a { - font-size: 14px; - font-weight: 600; + font-size: var(--text-base); color: var(--color-blue-elastic-100); - padding: 3px 0; - display: inline-flex; - align-items: baseline; - gap: 6px; - text-decoration: none; - } - .hub-card .hub-card-links li a:hover { - color: var(--color-blue-elastic-110); text-decoration: underline; + /* Inline-block so the padding counts toward the target height. */ + display: inline-block; + padding-block: 2px; + line-height: 1.35; } - .hub-card .hub-card-links li a::before { - content: '\203A'; - color: var(--color-grey-40); - font-weight: 700; - font-size: 15px; + .hub-card .hub-card-links li a:hover, + .hub-card .hub-card-links li a:focus-visible { + color: var(--color-blue-elastic-110); } /* Card aside (Panel types, Chart types, Key features...) */ @@ -282,7 +293,7 @@ border-top: 1px solid var(--color-grey-20); } .hub-card .hub-card-aside-label { - font-size: 12px; + font-size: var(--text-xs); color: var(--color-grey-80); margin-bottom: 6px; font-weight: 600; @@ -292,7 +303,7 @@ display: flex; flex-wrap: wrap; gap: 0; - font-size: 13px; + font-size: var(--text-sm); line-height: 1.9; } .hub-card .hub-card-aside-links a { @@ -339,7 +350,7 @@ .hub-accordion-title { /* margin: 0 keeps the heading rendering exactly as the span it replaced. */ margin: 0; - font-size: 1.143rem; + font-size: var(--text-xl); font-weight: 700; color: var(--color-ink-dark); line-height: 1.3; @@ -400,7 +411,7 @@ min-width: 0; } .hub-col-title { - font-size: 0.929rem; + font-size: var(--text-base); font-weight: 700; color: var(--color-ink-dark); margin: 0 0 4px; @@ -415,7 +426,7 @@ text-decoration: underline; } .hub-col-desc { - font-size: 0.786rem; + font-size: var(--text-sm); color: var(--color-ink-light); margin: 0 0 10px; line-height: 1.4; @@ -433,9 +444,12 @@ list-style: disc; } .hub-col-links a { - font-size: 0.857rem; + font-size: var(--text-base); color: var(--color-blue-elastic-100); text-decoration: underline; + /* Inline-block so the padding counts toward the target height. */ + display: inline-block; + padding-block: 2px; line-height: 1.35; } .hub-col-links a:hover, @@ -450,7 +464,7 @@ /* The label is authored, for example "Panel types", so it keeps the casing the writer chose rather than being shouted in uppercase. */ .hub-explore-more-label { - font-size: 0.714rem; + font-size: var(--text-xs); font-weight: 600; color: var(--color-ink-light); margin-bottom: 8px; @@ -465,30 +479,16 @@ margin: 0; list-style: none; } - .hub-explore-more-badges a { - display: inline-block; - font-size: 0.75rem; - line-height: 1.4; - padding: 2px 8px; - border-radius: 4px; - background: var(--color-grey-10, #f5f7fa); - border: 1px solid var(--color-grey-20); - color: var(--color-ink-dark); - text-decoration: none; - transition: - background 0.15s, - border-color 0.15s; - } - .hub-explore-more-badges a:hover, - .hub-explore-more-badges a:focus-visible { - background: var(--color-white); - border-color: var(--color-grey-30); - color: var(--color-blue-elastic); + /* These are buttons, not cards, so they take the {button} directive's secondary + treatment in its small size. Nothing about their type, colour, height or + radius is set here. */ + .hub-explore-more-badges li { + display: inline-flex; } @media (max-width: 768px) { .hub-hero h1 { - font-size: 2rem; + font-size: var(--text-3xl); } .hub-card-grid { grid-template-columns: 1fr; diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 35fbf81013..7c4f5f6f1e 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -249,10 +249,7 @@ private static void WriteCardGroup(HtmlRenderer renderer, CardGroupBlock block) Anchor = block.Anchor, Variant = block.Variant, IsAccordion = explore is not null, - IsOpen = explore is not null && HubExplore.IsFirstCardGroup(explore, block), - AccordionGroup = explore is null - ? null - : string.IsNullOrWhiteSpace(explore.Anchor) ? "hub-explore" : $"hub-explore-{explore.Anchor}" + IsOpen = explore is not null && HubExplore.IsFirstCardGroup(explore, block) }); RenderRazorSlice(slice, renderer); } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml index 0b98b91d05..9ac42ef18d 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml @@ -2,7 +2,9 @@ @if (Model.IsAccordion) { -
    + @* No `name` attribute: a browse surface should let a reader keep several sections open + and compare them. A shared name would close every other accordion on each expand. *@ +
    @* A heading, not a span. Without it the accordion group is missing from the page outline, and a reader navigating by heading jumps from the section straight into the diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs index 105c82e821..4a29a646cf 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs @@ -16,10 +16,4 @@ public class CardGroupViewModel : DirectiveViewModel /// The accordion is expanded by default (the first group in an Explore stack). public bool IsOpen { get; init; } - - /// - /// Shared <details name> group so the accordions open exclusively: expanding one - /// collapses the others in the same Explore section. - /// - public string? AccordionGroup { get; init; } } diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml index 3682f73636..648956cc45 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml @@ -42,7 +42,7 @@ { if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) continue; -
  • @link.Label
  • +
  • @link.Label
  • }
    @@ -52,6 +52,10 @@ else { var classes = "hub-card"; + // A card with its own link gets a whole-card target. Without one there is nothing + // for a card-wide hover state to point at. + if (!string.IsNullOrWhiteSpace(d.Link)) + classes += " hub-card-linked"; if (!string.IsNullOrWhiteSpace(d.Variant)) classes += " hub-card-sol hub-card-sol-" + d.Variant; diff --git a/tests/authoring/Blocks/Hub/CardsAndExplore.fs b/tests/authoring/Blocks/Hub/CardsAndExplore.fs index eec90b3551..eb004e6cbd 100644 --- a/tests/authoring/Blocks/Hub/CardsAndExplore.fs +++ b/tests/authoring/Blocks/Hub/CardsAndExplore.fs @@ -126,7 +126,7 @@ title: Syntax let ``renders the accordion stack`` () = markdown |> convertsToContainingHtml """
    -
    From 9c0cef02825a34f635de333a525758df6810c48a Mon Sep 17 00:00:00 2001 From: Florent LB Date: Thu, 13 Aug 2026 18:41:17 +0200 Subject: [PATCH 6/7] Make the linked card's hover state visible The hover border moved from grey-20 to grey-30, a change from 1.22:1 to 1.49:1 against white. That is not a visible state change, and a state indicator has to clear 3:1. It now uses grey-80 at 4.15:1, which is what {page-card} already uses for the same job. The resting border stays grey-20. It is a container edge rather than the thing that identifies the control, and {page-card} rests there too. Co-Authored-By: Claude Opus 5 --- src/Elastic.Documentation.Site/Assets/markdown/hub.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index a3f08aae15..b7da8795f2 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -209,7 +209,10 @@ where focus sits rather than what is clickable. */ .hub-card-linked:hover, .hub-card:focus-within { - border-color: var(--color-grey-30); + /* grey-80 is what {page-card} uses for the same job, and it clears the 3:1 ratio a + state indicator needs. The resting grey-20 border is a container edge, not the + thing that identifies the control, so it stays quiet. */ + border-color: var(--color-grey-80); box-shadow: 0 2px 8px rgb(0 0 0 / 0.05); } /* The card title's link covers the whole card, so the lift and the target agree. From d2fbc88220e23689c07a0c3653d282704873178f Mon Sep 17 00:00:00 2001 From: Florent LB Date: Thu, 13 Aug 2026 19:09:49 +0200 Subject: [PATCH 7/7] Restore the explore container width The explore stack lost its max-width, so the accordion rows ran the full content column while the section heading above them stayed within the shared width. The rows started well left of their own heading. The rule was collateral damage when the aside styling was removed: the deleted range ran past the aside block and took the container with it. The accordion also drops its horizontal padding, so a title starts on the same line as the section heading and the toggle icon ends on the same line as its right edge. Co-Authored-By: Claude Opus 5 --- .../Assets/markdown/hub.css | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index b7da8795f2..0095fe2326 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -288,6 +288,14 @@ color: var(--color-blue-elastic-110); } + /* Explore {product} — accordion stack ------------------------------ */ + /* Shares the section max-width, so the accordion rows line up with the zone + heading rather than running wider than it. */ + .hub-explore { + @apply mx-auto w-full max-w-5xl; + border-top: 1px solid var(--color-grey-20); + } + .hub-accordion { border-bottom: 1px solid var(--color-grey-20); } @@ -296,7 +304,9 @@ align-items: center; justify-content: space-between; gap: 16px; - padding: 18px 4px; + /* No horizontal padding: the accordion title has to start on the same line as the + section heading, and the toggle icon has to end on the same line as its right edge. */ + padding: 18px 0; cursor: pointer; list-style: none; user-select: none; @@ -332,7 +342,7 @@ color: var(--color-blue-elastic); } .hub-accordion-body { - padding: 4px 4px 28px; + padding: 4px 0 28px; container-type: inline-size; } .hub-accordion-intro {