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..1b13353a5d 100644 --- a/docs/examples/products/docs-builder.md +++ b/docs/examples/products/docs-builder.md @@ -9,4 +9,216 @@ 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 +::: + +:::{link-card} +title: 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 +::: + +:::{link-card} +title: Catalog reference +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..e368f7532c --- /dev/null +++ b/docs/syntax/explore.md @@ -0,0 +1,72 @@ +# 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. +- 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. + +## 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..048ed37e61 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, and a list of links. 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..f9db9fb9c0 --- /dev/null +++ b/docs/syntax/link-card.md @@ -0,0 +1,60 @@ +# Link card + +A card with a title, a description, and a list of links. 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 card's links + - label: Pages and links + url: /getting-started/pages-and-links.md +``` + +A card holds one group of links. To present a second group, add a second card. There is no +sub-list, so every group of links reads the same way wherever it appears. + +## 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. One thing changes: `description` is dropped, because a column is a pure link index. + +## Links + +Every `link` and every entry in `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..0095fe2326 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -154,9 +154,316 @@ 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: var(--text-2xl); + 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; + } + /* 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 { + /* 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. + 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 { + position: relative; + } + + .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: var(--text-base); + 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: var(--text-base); + 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 { + margin: 0; + padding-left: 1.1em; + list-style: disc; + } + .hub-card .hub-card-links li { + margin: 0 0 4px; + list-style: disc; + } + .hub-card .hub-card-links li a { + 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-card .hub-card-links li a:hover, + .hub-card .hub-card-links li a:focus-visible { + 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); + } + .hub-accordion-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + /* 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; + } + /* 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: var(--text-xl); + 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 0 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: var(--text-base); + 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: var(--text-base); + 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: 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, + .hub-col-links a:focus-visible { + color: var(--color-blue-elastic-110); + } + + @media (max-width: 768px) { + .hub-hero h1 { + font-size: var(--text-3xl); + } + .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 { + 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..7c4f5f6f1e 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,47 @@ 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) + }); + 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..9ac42ef18d --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupView.cshtml @@ -0,0 +1,44 @@ +@inherits RazorSlice + +@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 + 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..4a29a646cf --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/CardGroupViewModel.cs @@ -0,0 +1,19 @@ +// 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; } +} 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..c43be99b43 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,14 @@ 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)); + actions.Add(new HeroAction(label, url)); } } /// /// 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. +/// buttons, so the option a label came from does not change its appearance. The href and +/// its link attributes come from , which +/// owns the anchor, external, and cross-link rules. /// -public sealed record HeroAction(string Label, string Url, bool IsAnchor, bool IsExternal); +public sealed record HeroAction(string Label, string Url); 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..d98e21d3c2 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardBlock.cs @@ -0,0 +1,98 @@ +// 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 card with a title, an optional link and description, and a list of links. 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 +/// ::: +/// +/// +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); + } +} + +[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; } = []; + + 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; } +} 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..444093df7c --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/LinkCardView.cshtml @@ -0,0 +1,80 @@ +@inherits RazorSlice + +@{ + var d = Model.Data; +} + +@if (Model.IsColumn) +{ +
  • +

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

    + + @if (d.Links.Length > 0) + { + + } +
  • +} +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; + +
  • +
    + @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) + { + + } + +
  • +} 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..a8de739529 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,82 @@ 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); + + 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..6dbe9b5933 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -81,4 +81,6 @@ 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))] 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..6605e4487e --- /dev/null +++ b/tests/authoring/Blocks/Hub/CardsAndExplore.fs @@ -0,0 +1,261 @@ +// 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 +::: +:::: + +::::{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, and link cards as columns. 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 @@ +