From a746fb29cad51d4cac866f3b465ba8064c56c40e Mon Sep 17 00:00:00 2001 From: Vlada Chirmicci Date: Fri, 31 Jul 2026 14:17:24 +0100 Subject: [PATCH 01/11] Adding another card to the Solutions and use cases grid Adding the Vector Database card for the new Serverless project type. --- .../vector-database-logo-color-64px.svg | 5 +++ .../Layout/_LandingPage.cshtml | 32 ++++++++++++------- 2 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 src/Elastic.Documentation.Site/_static/vector-database-logo-color-64px.svg diff --git a/src/Elastic.Documentation.Site/_static/vector-database-logo-color-64px.svg b/src/Elastic.Documentation.Site/_static/vector-database-logo-color-64px.svg new file mode 100644 index 0000000000..4ff2638944 --- /dev/null +++ b/src/Elastic.Documentation.Site/_static/vector-database-logo-color-64px.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Elastic.Markdown/Layout/_LandingPage.cshtml b/src/Elastic.Markdown/Layout/_LandingPage.cshtml index 40c333834f..7181332fd1 100644 --- a/src/Elastic.Markdown/Layout/_LandingPage.cshtml +++ b/src/Elastic.Markdown/Layout/_LandingPage.cshtml @@ -63,21 +63,31 @@ Solutions and use cases -
-
- Search logo -
+
+
+ Search logo +

Elasticsearch

-

Build powerful search and RAG applications using Elasticsearch's vector database, AI toolkit, and advanced retrieval capabilities.

+

Build search and analytics across structured data, logs, metrics, documents, and vectors as part of the broader stack.

-
- Observability logo -
+
+ Vector Database logo +
+

Vector Database

+

Build your primary workload around embeddings: semantic search, RAG, or AI retrieval, with built-in models and vector-optimized defaults.

+ +
+
+
+ Observability logo +

Observability

Resolve problems with open, flexible, and unified observability powered by advanced machine learning and analytics.

@@ -86,9 +96,9 @@
-
- Security logo -
+
+ Security logo +

Security

Detect, investigate, and respond to threats with AI-driven security analytics to protect your organization at scale.

From 3854a16885156722b75379b1aa150a22d87e9f14 Mon Sep 17 00:00:00 2001 From: Vlada Chirmicci Date: Wed, 5 Aug 2026 10:48:29 +0100 Subject: [PATCH 02/11] Adjust link text to View (versionless) docs Discussed in https://elastic.slack.com/archives/C05PP2LEC1X/p1785503200382199?thread_ts=1785425265.209679&cid=C05PP2LEC1X --- src/Elastic.Markdown/Layout/_LandingPage.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Layout/_LandingPage.cshtml b/src/Elastic.Markdown/Layout/_LandingPage.cshtml index 7181332fd1..a3fb4844a3 100644 --- a/src/Elastic.Markdown/Layout/_LandingPage.cshtml +++ b/src/Elastic.Markdown/Layout/_LandingPage.cshtml @@ -81,7 +81,7 @@

Vector Database

Build your primary workload around embeddings: semantic search, RAG, or AI retrieval, with built-in models and vector-optimized defaults.

From a068ba3d81180c30c16b940d21a8e48ed4f18fb0 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 14 Aug 2026 11:46:20 +0200 Subject: [PATCH 03/11] Fix scoped-filesystem access-denied errors in codex builds (#3865) Co-authored-by: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../Building/CodexBuildService.cs | 71 ++++++++++++++---- src/Elastic.Codex/CodexGenerator.cs | 2 +- .../Indexing/CodexIndexService.cs | 6 +- .../FileSystems/CheckoutsFileSystem.cs | 20 ++--- .../FileSystems/DocumentationFileSystem.cs | 29 ++----- .../Extensions/IFileInfoExtensions.cs | 32 ++++++++ .../FileSystems/AssemblyWriteFileSystem.cs | 18 ++--- .../DocumentationWriteFileSystem.cs | 19 ++--- .../Exporters/LlmMarkdownExporter.cs | 12 ++- .../IsolatedBuildService.cs | 75 ------------------- .../Commands/Codex/CodexCommands.cs | 12 +-- .../Commands/Codex/CodexIndexCommand.cs | 9 +-- 12 files changed, 138 insertions(+), 167 deletions(-) diff --git a/src/Elastic.Codex/Building/CodexBuildService.cs b/src/Elastic.Codex/Building/CodexBuildService.cs index ad361ed781..534683e3a9 100644 --- a/src/Elastic.Codex/Building/CodexBuildService.cs +++ b/src/Elastic.Codex/Building/CodexBuildService.cs @@ -15,7 +15,6 @@ using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Elastic.Documentation.Isolated; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; @@ -25,6 +24,7 @@ using Elastic.Documentation.Services; using Elastic.Documentation.Site; using Elastic.Documentation.Site.Navigation; +using Elastic.Markdown; using Elastic.Markdown.Exporters; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; @@ -37,8 +37,7 @@ namespace Elastic.Codex.Building; /// public class CodexBuildService( ILoggerFactory logFactory, - IConfigurationContext configurationContext, - IsolatedBuildService isolatedBuildService) : IService + IConfigurationContext configurationContext) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -97,9 +96,8 @@ public async Task BuildAll( if (exporters is not null && buildContexts.Count > 0) { var firstContext = buildContexts[0].BuildContext; - firstContext.Endpoints.BuildType = "codex"; firstContext.Endpoints.Environment = environment; // from codex.yml; ensures the correct index (e.g. internal) - sharedExporters = exporters.CreateMarkdownExporters(logFactory, firstContext).ToArray(); + sharedExporters = CreateExporters(context, firstContext, exporters); var startTasks = sharedExporters.Select(async e => await e.StartAsync(ctx)); await Task.WhenAll(startTasks); } @@ -253,16 +251,37 @@ private async Task BuildDocumentationSet( try { + var docContext = buildContext.BuildContext; + var documentationSet = buildContext.DocumentationSet; var codexBreadcrumbs = ResolveCodexBreadcrumbs(context, buildContext); + var manageLifecycle = sharedExporters is null; + var allExporters = sharedExporters ?? CreateExporters(context, docContext, ExportOptions.Default); - return await isolatedBuildService.BuildDocumentationSet( - buildContext.DocumentationSet, - null, // Use doc set's navigation for traversal - null, // Use default navigation HTML writer (doc set's navigation) - ExportOptions.Default, - sharedExporters, - pageViewFactory: new CodexPageViewFactory(context.Configuration.Title, codexBreadcrumbs), - ctx); + if (manageLifecycle) + { + var startTasks = allExporters.Select(async e => await e.StartAsync(ctx)); + await Task.WhenAll(startTasks); + } + + var generator = new DocumentationGenerator( + documentationSet, + logFactory, + documentationSet, + markdownExporters: allExporters, + pageViewFactory: new CodexPageViewFactory(context.Configuration.Title, codexBreadcrumbs)); + + var result = await generator.GenerateAll(ctx); + + if (manageLifecycle) + { + var finishTasks = allExporters.Select(async e => await e.FinishExportAsync(docContext.OutputDirectory, ctx)); + _ = await Task.WhenAll(finishTasks); + + var stopTasks = allExporters.Select(async e => await e.StopAsync(ctx)); + await Task.WhenAll(stopTasks); + } + + return new BuildDocumentationSetResult(docContext.Collector.Errors == 0, result.Redirects); } catch (Exception ex) { @@ -273,6 +292,25 @@ private async Task BuildDocumentationSet( } } + /// + /// Composes markdown exporters for a codex documentation set build. The LLM exporter is special-cased + /// because it writes sibling {folder}.md files one level above a docset's own output directory — + /// out of scope for a per-docset write filesystem — so it needs the codex-wide write filesystem instead. + /// + private IMarkdownExporter[] CreateExporters(CodexContext context, BuildContext docContext, IReadOnlySet exporters) + { + docContext.Endpoints.BuildType = "codex"; + if (!exporters.Contains(Exporter.LLMText)) + return exporters.CreateMarkdownExporters(logFactory, docContext).ToArray(); + + // ExportOptions.Default is a shared static HashSet -- copy before mutating, never touch it in place. + var withoutLlm = new HashSet(exporters); + _ = withoutLlm.Remove(Exporter.LLMText); + return withoutLlm.CreateMarkdownExporters(logFactory, docContext) + .Append(new LlmMarkdownExporter(writeFileSystem: context.WriteFileSystem)) + .ToArray(); + } + private static void CollectRedirects( Dictionary allRedirects, IReadOnlyDictionary redirects, @@ -392,6 +430,13 @@ public record CodexBuildResult( IReadOnlyList DocumentationSets, CodexGenerator? CodexGenerator = null); +/// +/// Result of building a documentation set, including redirects for aggregation in the codex build. +/// +/// Whether the build completed without errors. +/// Redirect mappings from the documentation set, if available. +public record BuildDocumentationSetResult(bool Success, IReadOnlyDictionary Redirects); + /// /// Build context for a single documentation set within the codex. /// diff --git a/src/Elastic.Codex/CodexGenerator.cs b/src/Elastic.Codex/CodexGenerator.cs index a6b9948e2c..bb556deda5 100644 --- a/src/Elastic.Codex/CodexGenerator.cs +++ b/src/Elastic.Codex/CodexGenerator.cs @@ -38,7 +38,7 @@ public interface ICodexPageRenderer public class CodexGenerator(ILoggerFactory logFactory, BuildContext context, IDirectoryInfo outputDirectory) { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _writeFileSystem = context.WriteFileSystem; + private readonly IFileSystem _writeFileSystem = outputDirectory.FileSystem; private readonly StaticFileContentHashProvider _contentHashProvider = new(new EmbeddedOrPhysicalFileProvider(context)); private readonly IDirectoryInfo _outputDirectory = outputDirectory; diff --git a/src/Elastic.Codex/Indexing/CodexIndexService.cs b/src/Elastic.Codex/Indexing/CodexIndexService.cs index c1235c58be..5f45c452cb 100644 --- a/src/Elastic.Codex/Indexing/CodexIndexService.cs +++ b/src/Elastic.Codex/Indexing/CodexIndexService.cs @@ -7,7 +7,6 @@ using Elastic.Codex.Sourcing; using Elastic.Documentation; using Elastic.Documentation.Configuration; -using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; @@ -21,8 +20,7 @@ namespace Elastic.Codex.Indexing; /// public class CodexIndexService( ILoggerFactory logFactory, - IConfigurationContext configurationContext, - IsolatedBuildService isolatedBuildService + IConfigurationContext configurationContext ) : IService { /// @@ -39,7 +37,7 @@ public async Task Index( await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, esOptions, codexContext.Collector, fileSystem, ctx); var exporters = new HashSet { Exporter.Elasticsearch }; - var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); + var buildService = new CodexBuildService(logFactory, configurationContext); var result = await buildService.BuildAll(codexContext, cloneResult, fileSystem, ctx, exporters); return result.DocumentationSets.Count > 0; } diff --git a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs index 448ee8cd9b..9273457705 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs @@ -48,23 +48,17 @@ private static ScopedFileSystemOptions BuildReadOptions(IDirectoryInfo root, IEn var rootPath = root.FullName; var roots = new List { rootPath }; - // AppData is disjointness-filtered too: on CI the checkouts directory lives inside AppData - // (/home/runner/.local/share/elastic/docs-builder/checkouts/...), so AppData would subsume - // root and the ScopedFileSystem constructor would throw. - var appData = Paths.ApplicationData.FullName; - if (!IsSubPath(appData, rootPath, fs) && !IsSubPath(rootPath, appData, fs)) - roots.Add(appData); + // On CI (and for codex checkouts) the checkouts directory lives inside AppData + // (e.g. /home/runner/.local/share/elastic/docs-builder/checkouts/..., or + // AppData/codex/clone/). AddDisjointRoot keeps whichever of the two is the outer + // path rather than dropping AppData outright, so sibling AppData directories (e.g. + // config-runtime) stay in scope even when root itself is nested inside AppData. + roots.AddDisjointRoot(Paths.ApplicationData.FullName, fs); if (extraRoots is not null) { foreach (var extra in extraRoots) - { - if (string.IsNullOrEmpty(extra)) - continue; - // Drop descendants of root (already covered) and ancestors (would subsume root, causing overlap). - if (!IsSubPath(extra, rootPath, fs) && !IsSubPath(rootPath, extra, fs) && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) - roots.Add(extra); - } + roots.AddDisjointRoot(extra, fs); } return new ScopedFileSystemOptions([.. roots]) diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs index e3738dcc8f..9ab2486fec 100644 --- a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs @@ -84,31 +84,18 @@ private static ScopedFileSystemOptions BuildReadOptions(ResolvedDocumentationPat var checkoutPath = paths.CheckoutDirectory.FullName; var roots = new List { checkoutPath }; - // AppData is disjointness-filtered: on CI each individual docset checkout lives inside AppData - // (/home/runner/.local/share/elastic/docs-builder/checkouts/current/), so AppData would - // subsume checkoutPath and trigger ValidateRootsAreDisjoint. - var appData = Configuration.Paths.ApplicationData.FullName; - if (!IDirectoryInfoExtensions.IsSubPath(appData, checkoutPath, fs) - && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, appData, fs)) - { - roots.Add(appData); - } + // On CI (and for codex checkouts) the docset checkout lives inside AppData + // (e.g. /home/runner/.local/share/elastic/docs-builder/checkouts/current/, or + // AppData/codex/clone/). AddDisjointRoot keeps whichever of the two is the outer + // path rather than dropping AppData outright, so sibling AppData directories (e.g. + // config-runtime) stay in scope even when the checkout itself is nested inside AppData. + roots.AddDisjointRoot(Configuration.Paths.ApplicationData.FullName, fs); foreach (var gitDir in paths.GitDirectories) - { - if (!IDirectoryInfoExtensions.IsSubPath(gitDir, checkoutPath, fs)) - roots.Add(gitDir); - } + roots.AddDisjointRoot(gitDir, fs); foreach (var extra in paths.ExtraRoots) - { - if (!string.IsNullOrEmpty(extra) - && !IDirectoryInfoExtensions.IsSubPath(extra, checkoutPath, fs) - && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) - { - roots.Add(extra); - } - } + roots.AddDisjointRoot(extra, fs); return new ScopedFileSystemOptions([.. roots]) { diff --git a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs index 8985ff2a23..9ca708fddb 100644 --- a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs +++ b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs @@ -121,6 +121,38 @@ public static bool IsSubPathOf(this IDirectoryInfo directory, IDirectoryInfo par public static bool IsSubPath(string path, string parent, IFileSystem fs) => fs.DirectoryInfo.New(path).IsSubPathOf(fs.DirectoryInfo.New(parent)); + /// + /// Adds to for a ScopedFileSystemOptions + /// root list, collapsing overlaps instead of just skipping them. + /// + /// A naive "only add if disjoint" filter (skip whenever it nests with + /// an existing root either way) silently narrows the scope to whichever root was added first. That + /// is wrong when the existing root is the narrower one — e.g. a docset checkout cloned under AppData + /// (AppData/checkouts/<repo>): keeping only the checkout path would hide sibling AppData + /// directories like config-runtime that legitimate reads/writes still need. This instead keeps + /// whichever of the two is the outer (containing) path. + /// + /// + public static void AddDisjointRoot(this List roots, string candidate, IFileSystem fs) + { + if (string.IsNullOrEmpty(candidate)) + return; + + for (var i = 0; i < roots.Count; i++) + { + if (IsSubPath(candidate, roots[i], fs)) // candidate already covered by an existing (wider) root + return; + if (IsSubPath(roots[i], candidate, fs)) // candidate is wider; it supersedes the existing root + { + roots[i] = candidate; + return; + } + } + + if (!roots.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + roots.Add(candidate); + } + /// Checks if has parent directory , defaults to OrdinalIgnoreCase comparison public static bool HasParent(this IDirectoryInfo directory, string parentName, StringComparison comparison = OrdinalIgnoreCase) { diff --git a/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs index cdf2374ed7..f2a01d093a 100644 --- a/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs +++ b/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs @@ -57,18 +57,14 @@ private static ScopedFileSystemOptions BuildOptions( var checkoutPath = checkout.FullName; var roots = new List { checkoutPath }; - // AppData is disjointness-filtered: on CI the checkouts directory lives inside AppData - // (/home/runner/.local/share/elastic/docs-builder/checkouts/...), so AppData would subsume - // checkoutPath and trigger ValidateRootsAreDisjoint. - var appData = ApplicationDataPath; - if (!IDirectoryInfoExtensions.IsSubPath(appData, checkoutPath, fs) - && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, appData, fs)) - { - roots.Add(appData); - } + // On CI the checkouts directory lives inside AppData + // (/home/runner/.local/share/elastic/docs-builder/checkouts/...). AddDisjointRoot keeps + // whichever of the two is the outer path rather than dropping AppData outright, so sibling + // AppData directories stay in scope even when the checkout itself is nested inside AppData. + roots.AddDisjointRoot(ApplicationDataPath, fs); - if (output is not null && !IDirectoryInfoExtensions.IsSubPath(output.FullName, checkoutPath, fs)) - roots.Add(output.FullName); + if (output is not null) + roots.AddDisjointRoot(output.FullName, fs); // MockFileSystem hardcodes its temp path ("C:\temp" on Windows, unix-ified to "/temp/" // elsewhere) instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses diff --git a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs index 3620699bf0..3930fecac7 100644 --- a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs +++ b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs @@ -63,18 +63,15 @@ private static ScopedFileSystemOptions BuildOptions( var checkoutPath = checkout.FullName; var roots = new List { checkoutPath }; - // AppData is disjointness-filtered: on CI each docset checkout lives inside AppData - // (/home/runner/.local/share/elastic/docs-builder/checkouts/current/), so AppData would - // subsume checkoutPath and trigger ValidateRootsAreDisjoint. - var appData = ApplicationDataPath; - if (!IDirectoryInfoExtensions.IsSubPath(appData, checkoutPath, fs) - && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, appData, fs)) - { - roots.Add(appData); - } + // On CI (and for codex checkouts) the docset checkout lives inside AppData + // (e.g. /home/runner/.local/share/elastic/docs-builder/checkouts/current/, or + // AppData/codex/clone/). AddDisjointRoot keeps whichever of the two is the outer + // path rather than dropping AppData outright, so sibling AppData directories (e.g. + // config-runtime) stay in scope even when the checkout itself is nested inside AppData. + roots.AddDisjointRoot(ApplicationDataPath, fs); - if (output is not null && !IDirectoryInfoExtensions.IsSubPath(output.FullName, checkout.FullName, fs)) - roots.Add(output.FullName); + if (output is not null) + roots.AddDisjointRoot(output.FullName, fs); // MockFileSystem hardcodes its temp path ("C:\temp" on Windows, unix-ified to "/temp/" // elsewhere) instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses diff --git a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs index 053f756fa0..67584f10ef 100644 --- a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.FileSystems; using Elastic.Markdown.Helpers; using Elastic.Markdown.Myst.Components; using Elastic.Markdown.Myst.Renderers.LlmMarkdown; @@ -20,7 +21,14 @@ namespace Elastic.Markdown.Exporters; /// /// Exports markdown files as LLM-optimized CommonMark using custom renderers /// -public class LlmMarkdownExporter(bool branded = false) : IMarkdownExporter +/// Omits Elastic boilerplate from generated llms.txt content when set. +/// +/// Filesystem to write exported files to. When , falls back to the per-file +/// 's write filesystem. Codex builds inject the +/// codex-wide write filesystem here, since this exporter writes sibling {folder}.md files one +/// level above a docset's own output directory, which is out of scope for a per-docset filesystem. +/// +public class LlmMarkdownExporter(bool branded = false, DocumentationWriteFileSystem? writeFileSystem = null) : IMarkdownExporter { private const string LlmsTxtTemplate = """ @@ -87,7 +95,7 @@ private static async Task AddFileAsync(ZipArchive archive, IFileInfo file, strin public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, Cancel ctx) { - var fs = fileContext.BuildContext.WriteFileSystem; + var fs = writeFileSystem ?? fileContext.BuildContext.WriteFileSystem; var llmMarkdown = ConvertToLlmMarkdown(fileContext.Document, fileContext.BuildContext); var outputFile = GetLlmOutputFile(fs, fileContext); if (outputFile.Directory is { Exists: false }) diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index 67951bf718..b492828af7 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -13,15 +13,11 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; -using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; -using Elastic.Documentation.Navigation; using Elastic.Documentation.Services; -using Elastic.Documentation.Site.Navigation; using Elastic.Markdown; using Elastic.Markdown.Exporters; using Elastic.Markdown.IO; -using Elastic.Markdown.Page; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; @@ -191,75 +187,4 @@ public async Task Build( return strict.Value ? context.Collector.Errors + context.Collector.Warnings == 0 : context.Collector.Errors == 0; } - - /// - /// Builds a pre-configured documentation set with optional injected navigation. - /// Used by portal builds where navigation spans multiple documentation sets. - /// When is provided, those exporters are used instead of - /// creating new ones, and their lifecycle (Start/Stop) is not managed by this method. - /// - public async Task BuildDocumentationSet( - DocumentationSet documentationSet, - INavigationTraversable? navigation = null, - INavigationHtmlWriter? navigationHtmlWriter = null, - IReadOnlySet? exporters = null, - IMarkdownExporter[]? externalExporters = null, - IPageViewFactory? pageViewFactory = null, - Cancel ctx = default) - { - var context = documentationSet.Context; - var manageLifecycle = externalExporters is null; - - IMarkdownExporter[] allExporters; - if (externalExporters is not null) - { - allExporters = externalExporters; - } - else - { - exporters ??= ExportOptions.Default; - context.Endpoints.BuildType = "codex"; - allExporters = exporters.CreateMarkdownExporters(logFactory, context).ToArray(); - } - - if (manageLifecycle) - { - var startTasks = allExporters.Select(async e => await e.StartAsync(ctx)); - await Task.WhenAll(startTasks); - } - - // Use the provided navigation or fall back to the doc set's own navigation - var effectiveNavigation = navigation ?? documentationSet; - - var generator = new DocumentationGenerator( - documentationSet, - logFactory, - effectiveNavigation, - navigationHtmlWriter, - null, - allExporters, - pageViewFactory: pageViewFactory); - - var result = await generator.GenerateAll(ctx); - - if (manageLifecycle) - { - var finishTasks = allExporters.Select(async e => await e.FinishExportAsync(context.OutputDirectory, ctx)); - _ = await Task.WhenAll(finishTasks); - - var stopTasks = allExporters.Select(async e => await e.StopAsync(ctx)); - await Task.WhenAll(stopTasks); - } - - _logger.LogInformation("Finished building documentation set {Name}", documentationSet.Context.Git.RepositoryName); - - return new BuildDocumentationSetResult(context.Collector.Errors == 0, result.Redirects); - } } - -/// -/// Result of building a documentation set, including redirects for aggregation in portal builds. -/// -/// Whether the build completed without errors. -/// Redirect mappings from the documentation set, if available. -public record BuildDocumentationSetResult(bool Success, IReadOnlyDictionary Redirects); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index 1bc343111f..6432c46ddb 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -4,7 +4,6 @@ using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; -using Actions.Core.Services; using Documentation.Builder.Http; using Elastic.Codex; using Elastic.Codex.Building; @@ -13,7 +12,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Elastic.Documentation.Isolated; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -33,9 +31,7 @@ namespace Documentation.Builder.Commands.Codex; internal sealed class CodexCommands( ILoggerFactory logFactory, IDiagnosticsCollector collector, - IConfigurationContext configurationContext, - ICoreService githubActionsService, - IEnvironmentVariables environmentVariables + IConfigurationContext configurationContext ) { /// Clone all repositories and build the portal in one step. @@ -76,8 +72,7 @@ public async Task CloneAndBuild( return cloneResult.Checkouts.Count > 0; }); - var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); - var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); + var buildService = new CodexBuildService(logFactory, configurationContext); serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, fs), strict, async (s, col, state, c) => { @@ -159,8 +154,7 @@ public async Task Build( return 1; } - var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); - var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); + var buildService = new CodexBuildService(logFactory, configurationContext); serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, fs), strict, async (s, col, state, c) => { diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 95cb649d94..58ebf2a72e 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -4,7 +4,6 @@ using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; -using Actions.Core.Services; using Elastic.Codex; using Elastic.Codex.Indexing; using Elastic.Codex.Sourcing; @@ -12,7 +11,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; -using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -24,9 +22,7 @@ namespace Documentation.Builder.Commands.Codex; internal sealed class CodexIndexCommand( ILoggerFactory logFactory, IDiagnosticsCollector collector, - IConfigurationContext configurationContext, - ICoreService githubActionsService, - IEnvironmentVariables environmentVariables + IConfigurationContext configurationContext ) { /// Index the built portal documentation into Elasticsearch. @@ -58,8 +54,7 @@ public async Task Index( return 1; } - var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); - var service = new CodexIndexService(logFactory, configurationContext, isolatedBuildService); + var service = new CodexIndexService(logFactory, configurationContext); serviceInvoker.AddCommand(service, (codexContext, cloneResult, fs, es), static async (s, col, state, c) => await s.Index(state.codexContext, state.cloneResult, state.fs, state.es, c) From cfe75d16b80e95db7bab5e69243d22db3b019cb9 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Fri, 14 Aug 2026 12:20:30 +0200 Subject: [PATCH 04/11] Hub pages 1/4: hub page layout and the {hero} directive (#3825) * Add hub page layout and the {hero} directive Adds the first two pieces of the hub page mechanism, rebuilt on the current navigation implementation. The prototype branch is merged with nav-v2, which is out of scope, so nothing from nav-v2 ports. Adds `layout: hub`, which drops the right-rail table of contents and the previous/next navigation, keeps the left sidebar, and gives the body the full content column. Page-title detection gains two fallbacks, so a page composed only of directives still resolves a title: an H1 nested in a directive, then the first {hero} directive's :title: option. Adds {hero}, the identity band. It carries a product icon, the page title, a description, and up to three optional actions. Promotes the prototype's hub-only link validator to a shared DirectiveLinkValidator. Directive links never pass through Markdig's inline link parser, so every directive that takes a URL had invented its own handling. {page-card} moves onto the shared class and its hand-rolled copy goes. It keeps its previous contract: relative links resolve against the source file, and no file-existence check runs, because page-card links can target generated pages that have no markdown file on disk. Guards DirectiveBlockParser.TryContinue so an ancestor directive stops consuming option lines once it has opened a nested directive child. Without the guard the ancestor swallows every descendant's options and the last one wins. This affects every nested directive, so it lands with regression tests over tab sets, steppers, and dropdowns. Adds LLM markdown and plain-text renderers for {hero}. A hub page has no body prose, so without them it exports as an empty shell. The plain-text renderer feeds the search index and emits the hero title and description only. Hub pages answer generic " docs" queries. Indexing section and card titles would let a hub compete with the pages it links to on specific queries. Co-Authored-By: Claude Opus 5 * Fix hub page regressions found in the rendered output Three defects, all introduced in this branch rather than carried from the prototype. Suppress the page-level H1 and the applies-to badge on a hub page. The prototype guards both in Index.cshtml and that guard was missed, so every hub page rendered the page title twice: once from Index.cshtml, once from {hero}. This also delivers the applies-to suppression that #1386 records but the code did not do. Drop the markdown-content class from the hub article. The class was added to reach the JS hooks, but those select on the markdown-content id, not the class. typography.css is unlayered and hub.css sits in @layer components, so unlayered rules win regardless of specificity. The class made typography.css restyle the hero eyebrow as a blue underlined link and skew the icon alignment against the heading. The id stays, so hljs, smooth-scroll and toc-nav keep working. Give hero actions the same link treatment as inline links. An external action now opens in a new tab with rel="noopener noreferrer" and no preload, and an anchor action no longer preloads. Only an internal link preloads. Fix an empty href for "/index.md". Stripping the whole "/index.md" segment left an empty string rather than the site root. The example page gains three actions, one internal and two external, to demonstrate both forms. Co-Authored-By: Claude Opus 5 * Align the hero product mark with the text column The mark sat about 9px right of the eyebrow and the description below it. Two insets stacked. Each icon's viewBox carried transparent padding around the glyph, which cost 5px at the rendered size. The 48px chip then centred the 40px SVG inside itself, which cost another 4px. Crops every icon's viewBox to its glyph, so no transparent padding is left to absorb, and lets the box hug the mark instead of forcing it into a fixed square. Width now follows from the glyph's own aspect ratio. The letter fallback keeps the chip. It has no glyph to align, and the chip's own edge already lines up with the text column. Co-Authored-By: Claude Opus 5 * Make the example a docs-builder hub, and keep cross-links out of new tabs The example page was an Elasticsearch hub whose links had all been repointed at docs-builder pages, leaving an Elasticsearch name, icon, hero and headings on top of docs-builder content. It is now a docs-builder hub throughout, renamed to docs/examples/products/docs-builder.md. Its hero demonstrates all three link forms: a site-absolute path, a docs-content cross-link, and an in-page anchor. The unknown `docs-builder` icon key also exercises the single-letter fallback. Fixes a defect the cross-link uncovered. A cross-link resolves to a full URL, so the hero treated it as external and opened it in a new tab. Inline links make the opposite call, and deliberately so: a cross-link still points at documentation this site serves. DirectiveLinkValidator now records the URLs it resolved from a cross-link scheme, and the view model consults that rather than guessing from the scheme. Adds doesNotContainHtml to the authoring assertions. Co-Authored-By: Claude Opus 5 * Apply review feedback to the hero and hub layout Martijn's review on #3825 and theletterf's questions. - The hero no longer adds its own horizontal padding. The content column supplies the gutter, so the hero aligned with the sections below it only above the shared max-width. Below it the hero sat 24px further in. - Hero type moves onto the site's scale. The root font size is 14px, so values written as 16px-root arithmetic rendered around 12% small. The description in particular used a size found nowhere else on the site. - The three hero actions carry equal weight and take the secondary button treatment from the {button} directive, rather than a bespoke hero button. The option names set order, not weight. - A page with `layout: hub` and no {hero} now fails the build. The layout removes the page H1, so without a hero the page has no title at all and falls back to its file path. Co-Authored-By: Claude Opus 5 * Raise the hero eyebrow to body size Martijn's point about tiny fonts applies to every piece of hub text, not only the ones in his screenshot. The eyebrow is a reading affordance, so it takes the body size. Co-Authored-By: Claude Opus 5 * Follow the site's rule for arrows on links The site puts an arrow on two kinds of link: one that sends the reader onward out of a section, such as "View all reference docs" on the landing page, and a row that is itself the target, such as {page-card} or the previous and next footer. It marks no ordinary link and no button. The hero followed neither. Its actions are buttons, so they carried an arrow the site gives no button, and only when the target was an in-page anchor. Both are gone. The eyebrow keeps its arrow, because it sends the reader onward to the docs home. It now uses the site's glyph and hover slide through one shared `.hub-arrow` class, rather than a second arrow drawn by hand. Co-Authored-By: Claude Opus 5 * Put the hero actions on the neutral button variant #3853 added `:type: neutral` to the {button} directive, a monochrome outline for clusters where blue competes with the primary action. That is exactly what the hero needs: three actions of equal rank, none of which should claim to be the call to action. #3853 also flagged that this stack used border colours failing the 3:1 non-text contrast ratio. The eyebrow pill moves from grey-20, which reads at 1.32:1, to grey-70 at 3.31:1. The border is what identifies that control, so it has to clear the threshold. Co-Authored-By: Claude Opus 5 * Add Logstash icon to hub ProductIcons (#3843) Hub heroes fall back to initials when a product key is missing from the inline SVG map. Logstash hubs need the product mark. Co-authored-by: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor Co-authored-by: Martijn Laarman --------- Co-authored-by: Claude Opus 5 Co-authored-by: Brandon Morelli Co-authored-by: Cursor Co-authored-by: Martijn Laarman --- docs/_docset.yml | 10 + docs/examples/index.md | 11 + docs/examples/products/docs-builder.md | 12 + docs/syntax/hero.md | 64 ++++ docs/syntax/hub-pages.md | 70 +++++ .../Assets/markdown/hub.css | 170 +++++++++++ .../Assets/styles.css | 1 + src/Elastic.Markdown/IO/MarkdownFile.cs | 22 ++ src/Elastic.Markdown/MarkdownPageLayout.cs | 3 +- .../Myst/Directives/DirectiveBlockParser.cs | 10 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 40 +++ .../Myst/Directives/DirectiveLinkValidator.cs | 210 +++++++++++++ .../Myst/Directives/Hub/HeroBlock.cs | 75 +++++ .../Myst/Directives/Hub/HeroView.cshtml | 51 ++++ .../Myst/Directives/Hub/HeroViewModel.cs | 55 ++++ .../Myst/Directives/Hub/ProductIcons.cs | 77 +++++ .../Myst/Directives/PageCard/PageCardBlock.cs | 33 +- .../LlmMarkdown/LlmBlockRenderers.cs | 30 ++ .../PlainText/PlainTextBlockRenderers.cs | 13 + src/Elastic.Markdown/Page/Index.cshtml | 25 +- src/Elastic.Markdown/_Layout.cshtml | 32 ++ tests/authoring/Blocks/Hub/Hero.fs | 287 ++++++++++++++++++ .../Blocks/NestedDirectiveOptions.fs | 129 ++++++++ tests/authoring/Blocks/PageCards.fs | 77 +++++ tests/authoring/Framework/HtmlAssertions.fs | 44 +++ tests/authoring/authoring.fsproj | 3 + 26 files changed, 1525 insertions(+), 29 deletions(-) create mode 100644 docs/examples/index.md create mode 100644 docs/examples/products/docs-builder.md create mode 100644 docs/syntax/hero.md create mode 100644 docs/syntax/hub-pages.md create mode 100644 src/Elastic.Documentation.Site/Assets/markdown/hub.css create mode 100644 src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HeroView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/HeroViewModel.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/ProductIcons.cs create mode 100644 tests/authoring/Blocks/Hub/Hero.fs create mode 100644 tests/authoring/Blocks/NestedDirectiveOptions.fs create mode 100644 tests/authoring/Blocks/PageCards.fs diff --git a/docs/_docset.yml b/docs/_docset.yml index 6f66132a8a..a05806da81 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -120,6 +120,8 @@ toc: - file: file_inclusion.md - file: footnotes.md - file: frontmatter.md + - file: hero.md + - file: hub-pages.md - file: icons.md - file: images.md - file: videos.md @@ -141,6 +143,14 @@ toc: - file: tabs.md - file: titles.md + # Examples + - folder: examples + children: + - file: index.md + - folder: products + children: + - file: docs-builder.md + # Documentation builds - folder: documentation children: diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000000..54c82639bc --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,11 @@ +# Examples + +Complete pages that show docs-builder syntax rendering in context. Each syntax page explains one directive in isolation. These pages show how directives combine into a finished page. + +Use them to check what a page type looks like before you author one, and to verify rendering changes against a realistic page rather than a fixture. + +## Hubs + +Hub pages use `layout: hub` and are composed entirely from directives. See [Hub pages](../syntax/hub-pages.md). + +- [docs-builder documentation hub](products/docs-builder.md) diff --git a/docs/examples/products/docs-builder.md b/docs/examples/products/docs-builder.md new file mode 100644 index 0000000000..283f45e46b --- /dev/null +++ b/docs/examples/products/docs-builder.md @@ -0,0 +1,12 @@ +--- +layout: hub +description: docs-builder documentation. Build, validate, and publish Elastic documentation from Markdown across many repositories. +--- + +:::{hero} +:icon: docs-builder +:title: docs-builder documentation hub +:description: The toolchain that builds Elastic's documentation. Author in Markdown, validate cross-repository links, preview locally, and publish one unified site. +:primary-action: [Install docs-builder](/getting-started/installation.md) +:secondary-action: [Elastic documentation](docs-content://get-started/index.md) +::: diff --git a/docs/syntax/hero.md b/docs/syntax/hero.md new file mode 100644 index 0000000000..75d4be1fd5 --- /dev/null +++ b/docs/syntax/hero.md @@ -0,0 +1,64 @@ +# Hero + +A full-bleed identity band with a product icon, page title, description, and up to three actions. It is designed for the [hub layout](hub-pages.md), and it works on any page. + +All hero content comes from options. The directive body is not used. + +See the [Elasticsearch documentation hub](../examples/products/docs-builder.md) for a rendered hero. + +## Basic + +```markdown +:::{hero} +:icon: elasticsearch +:title: Elasticsearch documentation hub +:description: The distributed search and analytics engine at the heart of the Elastic platform. +::: +``` + +The `:title:` option doubles as the page title, so a hub page needs no body H1. See [Page title](hub-pages.md#page-title). + +## Options + +| Option | Type | Notes | +|---|---|---| +| `:title:` | string | **Required.** Renders as the page `

` next to the icon. Also used as the document title. | +| `:description:` | inline markdown | One-line summary below the title. Supports bold, italics, and links. | +| `:icon:` | string | Product key. Resolves to an inline SVG. Known keys: `elasticsearch`, `kibana`, `observability`, `security`. An unknown key falls back to a single-letter chip. | +| `:primary-action:` | markdown link | First action. Format: `[Label](/url)` or `[Label](#anchor)`. | +| `:secondary-action:` | markdown link | Second action. | +| `:tertiary-action:` | markdown link | Third action. | + +## Actions + +Each action is a single markdown link. Actions render left to right, in the order primary, secondary, tertiary. Actions are optional. Omit them for a pure identity hero. + +The option names set the order, not the weight. All three render as neutral buttons, the monochrome variant of the [button](/syntax/buttons.md) directive. A blue variant would make each action claim to be the call to action. + +```markdown +:::{hero} +:icon: elasticsearch +:title: Elasticsearch documentation hub +:description: The distributed search and analytics engine. +:primary-action: [Install Elasticsearch](https://www.elastic.co/downloads/elasticsearch) +:secondary-action: [Get started](#get-started) +:tertiary-action: [Syntax reference](/syntax/hero.md) +::: +``` + +Actions render as buttons, and no button on the site carries an arrow. The arrow belongs to the eyebrow link, which sends the reader onward to the docs home. + +Action URLs validate at build time. Use one of these forms: + +| Form | Example | Behavior | +|---|---|---| +| In-page anchor | `#get-started` | Jumps to a section on the same page. Does not preload. | +| Site-absolute path | `/syntax/hero.md` | The markdown extension is stripped. The link preloads on hover. | +| Cross-link scheme | `docs-content://get-started/index.md` | Resolves through the link index. Not treated as external, so it does not open in a new tab. | +| External URL | `https://www.elastic.co/downloads/elasticsearch` | Opens in a new tab, with `rel="noopener noreferrer"`. Does not preload. | + +A relative path such as `foo.md` is rejected. This differs from an inline markdown link, where a relative path resolves against the source file's directory. + +## Description markup + +`:description:` is a directive option, not a body block, so it never reaches the document pipeline. It renders with the default Markdown pipeline. Basic inline markup works. Substitutions, roles, and link validation do not apply inside it. Keep the description to plain prose. diff --git a/docs/syntax/hub-pages.md b/docs/syntax/hub-pages.md new file mode 100644 index 0000000000..2fb8ab6846 --- /dev/null +++ b/docs/syntax/hub-pages.md @@ -0,0 +1,70 @@ +# Hub pages + +A hub page is a product-scoped landing page. It gives a reader one 360° view of a product across versions, deployment types, and surfaces. + +Hub pages are composed entirely from directives. There is no free-form body content. That constraint is deliberate. It lets every link validate at build time, and it keeps every hub structurally consistent whoever authors it. + +See the [Elasticsearch documentation hub](../examples/products/docs-builder.md) for a complete page. + +## Enable the layout + +Set `layout: hub` in the page frontmatter: + +```yaml +--- +layout: hub +--- +``` + +A page with `layout: hub` must contain a `{hero}` directive. The build fails without one, because the layout removes the page H1 and leaves the page with no title. + +## What the layout changes + +The hub layout differs from the default page layout in three ways: + +- The right-rail table of contents is removed. The version dropdown lives in that rail, so a hub page does not show it. +- The previous and next page navigation is removed. +- The body owns the full width of the content column, so directives can render full-bleed sections. + +The left sidebar stays. A reader can move between sibling hubs from there. + +## Page title + +A hub page has no authored H1. The page title comes from the first `{hero}` directive's `:title:` option. + +Title detection tries three sources in order: + +1. A top-level H1 in the body. +2. An H1 nested inside a directive. +3. The `:title:` option of the first `{hero}`. + +One field therefore drives both the on-page heading and the browser tab title. + +## Search + +A hub page exists to answer generic queries such as "Elasticsearch docs". Two fields carry that: + +- The `{hero}` `:title:` option, which becomes the indexed page title. +- The frontmatter `description`, which becomes the indexed description. + +Write both deliberately. The search body indexes the hero title and description only. Section and card titles stay out, so a hub does not compete with the pages it links to on specific queries. + +## Directives + +| Directive | Purpose | +|---|---| +| [`{hero}`](hero.md) | Identity band. Carries the product icon, the page title, a description, and up to three actions. | + +## Page skeleton + +```markdown +--- +layout: hub +--- + +:::{hero} +:icon: elasticsearch +:title: Elasticsearch documentation hub +:description: The distributed search and analytics engine at the heart of the Elastic platform. +::: +``` diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css new file mode 100644 index 0000000000..4274fa36a5 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -0,0 +1,170 @@ +/* + * Hub-page styling. Class names mirror the directive output: + * + * .hub-page page wrapper, set by RenderHub in _Layout.cshtml + * .hub-content article wrapper inside the content column + * .hub-hero hero section, light, with a divider below + * + * Later hub directives append their own sections to this file. + */ + +@layer components { + /* Page wrapper: full-width content. Stays white so the page reads + consistently whether the user lands via full navigation or via an + htmx-boosted swap from a regular docs page. + + The content column supplies the horizontal gutter. No hub section adds one + of its own: a section that did would inset itself alone and break alignment + with the rest below the shared max-width. */ + .hub-page .hub-content { + @apply mb-20 w-full max-w-none p-0; + } + + /* Hero ------------------------------------------------------------- */ + /* Light hero on the page background, separated from the body by a rule + (matches the redesign, no dark band). */ + .hub-hero { + background-color: transparent; + color: var(--color-ink-dark); + padding: 40px 0 28px; + margin-bottom: 44px; + border-bottom: 1px solid var(--color-grey-20); + } + .hub-hero .hub-hero-inner { + @apply mx-auto w-full max-w-5xl; + } + .hub-hero .hub-hero-eyebrow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + margin: 0 0 20px; + font-size: var(--text-base); + letter-spacing: 0.02em; + } + .hub-hero .hub-hero-eyebrow-label { + font-weight: 600; + color: var(--color-ink-light); + } + .hub-hero .hub-hero-eyebrow-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + font-weight: 600; + color: var(--color-ink-dark); + text-decoration: none; + background: var(--color-white); + /* grey-70 is the lightest token clearing the 3:1 non-text contrast ratio, which + the border needs because it is what identifies this control. Same reasoning as + the neutral button in button.css. */ + border: 1px solid var(--color-grey-70); + border-radius: 999px; + transition: + background 0.15s ease, + border-color 0.15s ease; + } + .hub-hero .hub-hero-eyebrow-link:hover, + .hub-hero .hub-hero-eyebrow-link:focus-visible { + background: var(--color-grey-10); + border-color: var(--color-ink-dark); + } + /* Shared arrow ----------------------------------------------------- + The site puts an arrow on exactly two kinds of link: one that sends the + reader onward out of a section, and a row that is itself the target. It + never marks an ordinary link, and never a button. Hub links that qualify + use this class. + + The glyph and the hover slide come from `.link-arrow` in styles.css. The + class is not reused directly because it is nested under `.link`, which + also forces a blue semibold type treatment that a pill or a card would + have to undo. */ + .hub-arrow { + flex-shrink: 0; + width: 1.25em; + height: 1.25em; + transition: transform 0.15s ease-out; + } + a:hover .hub-arrow, + a:focus-visible .hub-arrow { + transform: translateX(4px); + } + .hub-hero .hub-hero-top { + display: flex; + align-items: center; + column-gap: 14px; + margin-bottom: 16px; + } + .hub-hero .hub-hero-icon { + @apply inline-flex h-13 w-13 shrink-0 items-center justify-center rounded-xl text-2xl font-bold; + width: 48px; + height: 48px; + background-color: var(--color-grey-10); + color: var(--color-ink-dark); + } + /* The letter fallback keeps the fixed chip. A product mark does not: its box hugs + the glyph, so the mark's left edge lines up with the eyebrow and the description + below it. Each icon's viewBox is cropped to its glyph, so there is no transparent + padding left to absorb. Width follows from the glyph's own aspect ratio. */ + .hub-hero .hub-hero-icon-svg { + width: auto; + height: 40px; + background-color: transparent; + border-radius: 0; + } + .hub-hero .hub-hero-icon-svg svg { + width: auto; + height: 40px; + } + .hub-hero .hub-hero-top h1 { + font-size: var(--text-4xl); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.5px; + color: var(--color-ink-dark); + margin: 0; + } + .hub-hero .hub-hero-top h1 a.headerlink { + color: var(--color-ink-dark); + text-decoration: none; + } + .hub-hero .hub-hero-desc { + font-size: var(--text-base); + color: var(--color-ink-light); + max-width: 760px; + line-height: 1.6; + margin: 0; + } + + .hub-hero .hub-hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 24px; + } + /* The three actions carry equal weight, so they all take the neutral button + treatment from the {button} directive rather than a bespoke hero button. A blue + variant would make each one claim to be the call to action. Only the layout + properties the hero needs are set here: the type, height, colour and radius come + from `.doc-button-neutral`. */ + .hub-hero .hub-hero-action { + gap: 8px; + } + .hub-hero .hub-hero-action svg { + flex-shrink: 0; + } + + /* The arrow slides and the hero animates its hover states. Honour a reduced-motion + preference. Nothing about the resting appearance changes. */ + @media (prefers-reduced-motion: reduce) { + .hub-hero .hub-hero-eyebrow-link, + .hub-hero .hub-hero-action, + .hub-arrow { + transition: none; + } + a:hover .hub-arrow, + a:focus-visible .hub-arrow { + transform: none; + } + } +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index c3599fa395..373aa70ed5 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -29,6 +29,7 @@ @import './markdown/cli-modifiers.css'; @import './markdown/contributors.css'; @import './markdown/storybook.css'; +@import './markdown/hub.css'; @import './api-docs.css'; @import 'tippy.js/dist/tippy.css'; diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index 9ad3b74072..ac18e68285 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -12,6 +12,7 @@ using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.Directives.Changelog; +using Elastic.Markdown.Myst.Directives.Hub; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Myst.Directives.Settings; using Elastic.Markdown.Myst.Directives.Stepper; @@ -153,14 +154,35 @@ private IReadOnlyDictionary GetSubstitutions() return allProperties; } + // A page with no top-level H1 can still declare its title inside a directive, either as a + // nested H1 or as {hero}'s :title: option. Hub pages are composed purely from directives, + // so this is the only title source they have. + private static string? FindNestedTitle(MarkdownDocument document) + { + if (document.Descendants().FirstOrDefault(h => h.Level == 1)?.GetData("header") is string nestedHeading) + return nestedHeading; + + var heroTitle = document.Descendants().FirstOrDefault()?.Title; + return string.IsNullOrWhiteSpace(heroTitle) ? null : heroTitle; + } + protected void ReadDocumentInstructions(MarkdownDocument document, Func documentationFileLookup) { Title = document .FirstOrDefault(block => block is HeadingBlock { Level: 1 })? .GetData("header") as string ?? Title; + if (Title == RelativePath) + Title = FindNestedTitle(document) ?? Title; + var yamlFrontMatter = ProcessYamlFrontMatter(document); YamlFrontMatter = yamlFrontMatter; + + // The hub layout suppresses the page H1, so {hero} is the only thing that can title the + // page. Without it the page renders with no title at all and falls back to its file path. + if (yamlFrontMatter.Layout == MarkdownPageLayout.Hub && !document.Descendants().Any()) + Collector.EmitError(FilePath, "A page with `layout: hub` requires a {hero} directive. Without it the page renders without a title."); + if (yamlFrontMatter.NavigationTitle is not null) NavigationTitle = yamlFrontMatter.NavigationTitle; if (yamlFrontMatter.Description is not null) diff --git a/src/Elastic.Markdown/MarkdownPageLayout.cs b/src/Elastic.Markdown/MarkdownPageLayout.cs index 6563b2a0fc..7f80d07134 100644 --- a/src/Elastic.Markdown/MarkdownPageLayout.cs +++ b/src/Elastic.Markdown/MarkdownPageLayout.cs @@ -11,5 +11,6 @@ public enum MarkdownPageLayout [EnumMember(Value = "landing-page")] LandingPage, [EnumMember(Value = "not-found")] NotFound, [EnumMember(Value = "archive")] Archive, - [EnumMember(Value = "full-search")] FullSearch + [EnumMember(Value = "full-search")] FullSearch, + [EnumMember(Value = "hub")] Hub } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index 550a0f0fa1..bafdbfcbd1 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -10,6 +10,7 @@ using Elastic.Markdown.Myst.Directives.Changelog; using Elastic.Markdown.Myst.Directives.CliModifiers; using Elastic.Markdown.Myst.Directives.CsvInclude; +using Elastic.Markdown.Myst.Directives.Hub; using Elastic.Markdown.Myst.Directives.Image; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Myst.Directives.Listing; @@ -137,6 +138,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{math}") > 0) return new MathBlock(this, context); + if (info.IndexOf("{hero}") > 0) + return new HeroBlock(this, context); + if (info.IndexOf("{agent-skill}") > 0) return new AgentSkillBlock(this, context); @@ -239,6 +243,12 @@ public override BlockState TryContinue(BlockProcessor processor, Block block) if (block is not DirectiveBlock directiveBlock) return base.TryContinue(processor, block); + // Once a directive has opened a nested directive child, an option line belongs to + // that inner directive, not this ancestor. Without this guard the ancestor swallows + // every descendant's options (last one wins) and corrupts its own. + if (directiveBlock.LastChild is DirectiveBlock) + return base.TryContinue(processor, block); + var tokens = line.ToString().Split(':', 2, RemoveEmptyEntries | TrimEntries); if (tokens.Length < 1) return base.TryContinue(processor, block); diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 1a97e90b4e..e0e989a156 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -16,6 +16,7 @@ using Elastic.Markdown.Myst.Directives.CliModifiers; using Elastic.Markdown.Myst.Directives.CsvInclude; using Elastic.Markdown.Myst.Directives.Dropdown; +using Elastic.Markdown.Myst.Directives.Hub; using Elastic.Markdown.Myst.Directives.Image; using Elastic.Markdown.Myst.Directives.Include; using Elastic.Markdown.Myst.Directives.Listing; @@ -104,6 +105,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case MathBlock mathBlock: WriteMathBlock(renderer, mathBlock); return; + case HeroBlock heroBlock: + WriteHero(renderer, heroBlock); + return; case PageCardBlock pageCardBlock: WritePageCard(renderer, pageCardBlock); return; @@ -193,6 +197,42 @@ private static void WriteImageCarousel(HtmlRenderer renderer, ImageCarouselBlock RenderRazorSlice(slice, renderer); } + private static void WriteHero(HtmlRenderer renderer, HeroBlock block) + { + var slice = HeroView.Create(new HeroViewModel + { + DirectiveBlock = block, + IconKey = block.Icon, + IconSvg = block.IconSvg, + Title = block.Title, + DescriptionHtml = RenderInlineMarkdown(block.Description), + PrimaryActionLabel = block.PrimaryActionLabel, + PrimaryActionUrl = block.PrimaryActionUrl, + SecondaryActionLabel = block.SecondaryActionLabel, + SecondaryActionUrl = block.SecondaryActionUrl, + TertiaryActionLabel = block.TertiaryActionLabel, + TertiaryActionUrl = block.TertiaryActionUrl, + SitePathPrefix = block.Build.UrlPathPrefix + }); + RenderRazorSlice(slice, renderer); + } + + // The hero description is a directive option, not a body block, so it never reaches + // the document pipeline. Render it with the default Markdig pipeline for basic inline + // markup. Substitutions, roles, and link validation do not apply inside this option. + private static string? RenderInlineMarkdown(string? source) + { + if (string.IsNullOrWhiteSpace(source)) + return null; + + var html = Markdig.Markdown.ToHtml(source).Trim(); + const string open = "

"; + const string close = "

"; + if (html.StartsWith(open, StringComparison.Ordinal) && html.EndsWith(close, StringComparison.Ordinal)) + html = html[open.Length..^close.Length]; + return html; + } + private static void WritePageCard(HtmlRenderer renderer, PageCardBlock block) { var slice = PageCardView.Create(new PageCardViewModel diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs new file mode 100644 index 0000000000..09ccc14d6f --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveLinkValidator.cs @@ -0,0 +1,210 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Documentation; +using Elastic.Documentation.Links; +using Elastic.Markdown.Diagnostics; + +namespace Elastic.Markdown.Myst.Directives; + +/// +/// Validates and resolves URL values supplied as directive options or in directive YAML +/// bodies. These URLs never pass through Markdig's LinkInlineParser, so without this +/// helper the normal cross-link resolution, missing-file checks, and link-index emission are +/// skipped and broken directive links ship silently. +/// +/// Returns the resolved URL (or the original on failure) so callers can write it back into +/// their own data. Errors and hints are emitted against the supplying . +/// +internal static class DirectiveLinkValidator +{ + /// + /// Resolve cross-link schemes and check that an internal path points at a real file. + /// Requires a site-absolute path, a cross-link scheme, an anchor, or an external URL. + /// Use this for directives whose links come from a YAML body or an option. + /// + public static string? ValidateAndResolve(string? url, DirectiveBlock block, ParserContext context) => + Resolve(url, block, context, allowRelative: false, checkFileExists: true); + + /// + /// Resolve cross-link schemes only, and accept a path relative to the source file. + /// Use this for directives whose links may target a generated page with no file on disk, + /// such as the CLI reference, where a file probe reports false positives. + /// + public static string? ResolveWithoutFileCheck(string? url, DirectiveBlock block, ParserContext context) => + Resolve(url, block, context, allowRelative: true, checkFileExists: false); + + private static string? Resolve(string? url, DirectiveBlock block, ParserContext context, bool allowRelative, bool checkFileExists) + { + if (string.IsNullOrWhiteSpace(url) || block.SkipValidation) + return url; + + var trimmed = url.Trim(); + if (trimmed.Length == 0 || trimmed[0] == '#') + return url; + + if (IsExternal(trimmed)) + return url; + + if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && CrossLinkValidator.IsCrossLink(uri)) + return ResolveCrossLink(url, uri, block, context); + + if (!trimmed.StartsWith('/') && !allowRelative) + { + block.EmitError($"Directive link `{url}` must be an absolute path starting with `/`, a cross-link scheme (for example `kibana://`), or an external URL."); + return url; + } + + if (checkFileExists) + ValidateInternal(url, block, context); + return url; + } + + /// + /// Turn a validated URL into a final href. Strips the markdown extension and applies the + /// site's URL path prefix. External URLs and anchors are returned unchanged. + /// + public static string? ToHref(string? url, string? sitePathPrefix) + { + if (string.IsNullOrEmpty(url)) + return url; + if (IsExternal(url) || url.StartsWith('#')) + return url; + + var (path, anchor) = SplitAnchor(url); + path = StripMarkdownExtension(path); + + if (string.IsNullOrEmpty(sitePathPrefix) || !path.StartsWith('/')) + return path + anchor; + + var prefix = "/" + sitePathPrefix.Trim('/'); + if (path == prefix || path.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase)) + return path + anchor; + + return prefix + path + anchor; + } + + private const string ResolvedCrossLinksKey = "resolvedCrossLinks"; + + private static void RememberCrossLink(DirectiveBlock block, string resolved) + { + if (block.GetData(ResolvedCrossLinksKey) is not HashSet resolvedLinks) + { + resolvedLinks = []; + block.SetData(ResolvedCrossLinksKey, resolvedLinks); + } + _ = resolvedLinks.Add(resolved); + } + + /// True when came from a cross-link scheme on this block. + public static bool IsResolvedCrossLink(DirectiveBlock block, string? url) => + url is not null + && block.GetData(ResolvedCrossLinksKey) is HashSet resolvedLinks + && resolvedLinks.Contains(url, StringComparer.OrdinalIgnoreCase); + + private static bool IsExternal(string url) => + url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); + + private static string StripMarkdownExtension(string path) + { + if (path.EndsWith("/index.md", StringComparison.OrdinalIgnoreCase)) + { + // "/index.md" is the site root, so stripping the whole segment would leave an + // empty href rather than "/". + var stripped = path[..^"/index.md".Length]; + return stripped.Length == 0 ? "/" : stripped; + } + + return path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? path[..^".md".Length] + : path; + } + + private static string ResolveCrossLink(string original, Uri uri, DirectiveBlock block, ParserContext context) + { + var resolver = context.CrossLinkResolver; + if (!resolver.IsDeclaredCrossLinkScheme(uri.Scheme)) + { + // Custom passthrough protocols (cursor:, vscode:) are left alone. + if (IsPassthroughCustomProtocolScheme(uri.Scheme)) + return original; + block.EmitError($"Directive link `{original}` uses cross-link scheme `{uri.Scheme}://` which is not declared under `cross_links` in docset.yml."); + return original; + } + + context.Build.Collector.EmitCrossLink(original); + if (!resolver.TryResolve(s => block.EmitError(s), uri, out var resolved)) + return original; + + // A cross-link resolves to a full URL, but it still points at documentation this site + // serves. Record it so the view model does not mistake it for an external link and open + // it in a new tab. Inline links make the same distinction. + RememberCrossLink(block, resolved.ToString()); + return resolved.ToString(); + } + + private static void ValidateInternal(string url, DirectiveBlock block, ParserContext context) + { + // In assembler and codex builds an absolute path may target a file owned by a different + // docset, because the assembled site is the union of every docset. The current docset's + // source directory is not the right basis for an existence check there, so flagging would + // produce false positives. Cross-docset references should use a cross-link scheme instead. + if (context.Build.BuildType != BuildType.Isolated) + return; + + var (path, _) = SplitAnchor(url); + if (string.IsNullOrEmpty(path) || path == "/") + return; + + var sourceDir = context.Build.DocumentationSourceDirectory.FullName; + var baseDir = path.StartsWith('/') ? sourceDir : context.MarkdownSourcePath.Directory!.FullName; + var relativeToBase = path.TrimStart('/'); + + foreach (var candidate in ProbeCandidates(relativeToBase)) + { + if (context.TryFindDocumentByRelativePath(candidate) is not null) + return; + if (context.Build.ReadFileSystem.File.Exists(Path.GetFullPath(Path.Join(baseDir, candidate)))) + return; + } + + if (TryEmitRedirectWarning(url, relativeToBase, block, context)) + return; + + block.EmitError($"Directive link `{url}` does not exist. If it was recently removed add a redirect."); + } + + // docs-builder URLs usually omit the extension, so /explore-analyze/discover may mean + // discover.md or discover/index.md. Probe as given first. + private static string[] ProbeCandidates(string path) => + path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? [path] + : [path, path + ".md", path.TrimEnd('/') + "/index.md"]; + + private static bool TryEmitRedirectWarning(string url, string relativeToBase, DirectiveBlock block, ParserContext context) + { + if (context.Configuration.Redirects is null + || !context.Configuration.Redirects.TryGetValue(relativeToBase, out var redirect)) + return false; + + var to = redirect.To + ?? (redirect.Many is not null + ? string.Join(", ", redirect.Many.Select(m => m.To)) + : "unknown"); + block.EmitWarning($"Directive link `{url}` has a redirect; update to: {to}"); + return true; + } + + private static (string Path, string? Anchor) SplitAnchor(string url) + { + var hash = url.IndexOf('#'); + return hash < 0 ? (url, null) : (url[..hash], url[hash..]); + } + + private static bool IsPassthroughCustomProtocolScheme(string scheme) => + scheme.Equals("cursor", StringComparison.OrdinalIgnoreCase) + || scheme.StartsWith("vscode", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs new file mode 100644 index 0000000000..913cafadb4 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/HeroBlock.cs @@ -0,0 +1,75 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.RegularExpressions; +using Elastic.Markdown.Diagnostics; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Renders a full-bleed page hero with a product icon, title, description, and +/// up to three call-to-action buttons. All content is supplied via options -- the +/// body is unused. Release cadence lives in {whats-new}. +/// +/// +/// +/// :::{hero} +/// :icon: kibana +/// :title: Kibana documentation hub +/// :description: The UI for the Elasticsearch platform. +/// :primary-action: [Get started](#get-started) +/// :secondary-action: [What's new](#whats-new) +/// :tertiary-action: [Explore Kibana docs](#explore) +/// ::: +/// +/// +public partial class HeroBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + public override string Directive => "hero"; + + public string? Icon { get; private set; } + public string? IconSvg { get; private set; } + public string? Title { get; private set; } + public string? Description { get; private set; } + public string? PrimaryActionLabel { get; private set; } + public string? PrimaryActionUrl { get; private set; } + public string? SecondaryActionLabel { get; private set; } + public string? SecondaryActionUrl { get; private set; } + public string? TertiaryActionLabel { get; private set; } + public string? TertiaryActionUrl { get; private set; } + + public override void FinalizeAndValidate(ParserContext context) + { + Icon = Prop("icon"); + IconSvg = ProductIcons.Get(Icon); + Title = Prop("title"); + Description = Prop("description"); + (PrimaryActionLabel, PrimaryActionUrl) = ParseAction(Prop("primary-action"), context); + (SecondaryActionLabel, SecondaryActionUrl) = ParseAction(Prop("secondary-action"), context); + (TertiaryActionLabel, TertiaryActionUrl) = ParseAction(Prop("tertiary-action"), context); + + if (string.IsNullOrWhiteSpace(Title)) + this.EmitError("{hero} requires a `:title:` option."); + } + + private (string? Label, string? Url) ParseAction(string? value, ParserContext context) + { + if (string.IsNullOrWhiteSpace(value)) + return (null, null); + + var match = MarkdownLink().Match(value.Trim()); + if (!match.Success) + { + this.EmitError($"{{hero}} action `{value.Trim()}` must be a markdown link, for example `[Get started](#get-started)`."); + return (null, null); + } + + var url = DirectiveLinkValidator.ValidateAndResolve(match.Groups["url"].Value.Trim(), this, context); + return (match.Groups["label"].Value.Trim(), url); + } + + [GeneratedRegex(@"^\[(?

} + + @* Hub pages are composed entirely from directives and own the full content column. + They drop the right-rail table of contents and the prev/next nav, but keep the + pages nav so a reader can move between sibling hubs. *@ + private async Task RenderHub() + { +
+
+
+
+ @* Keeps the markdown-content id, which hljs, smooth-scroll and toc-nav select on, + but not the class. typography.css is unlayered and hub.css sits in @@layer + components, so the class would override every hub rule regardless of + specificity. Hub directives bring their own typography. *@ +
+ + @await RenderBodyAsync() +
+
+ @await RenderPartialAsync(_PagesNav.Create(Model)) +
+
+
+ } } @if (RenderHeaderAndFooter) @@ -87,6 +116,9 @@ case MarkdownPageLayout.FullSearch: await RenderPartialAsync(_FullSearch.Create(Model)); break; + case MarkdownPageLayout.Hub: + await RenderHub(); + break; default: await RenderDefault(); break; diff --git a/tests/authoring/Blocks/Hub/Hero.fs b/tests/authoring/Blocks/Hub/Hero.fs new file mode 100644 index 0000000000..252687db6c --- /dev/null +++ b/tests/authoring/Blocks/Hub/Hero.fs @@ -0,0 +1,287 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information +module ``AuthoringTests``.``block elements``.``hub``.``hero elements`` + +open Xunit +open authoring + +type ``hero with title only`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:title: Elasticsearch documentation hub +::: +""" + + [] + let ``renders the title as an h1`` () = + markdown |> convertsToContainingHtml """

Elasticsearch documentation hub

""" + + [] + let ``renders the fixed eyebrow`` () = + markdown |> convertsToContainingHtml """Browse all Elastic docs""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``hero without a title`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:description: Missing the required title option. +::: +""" + + [] + let ``errors`` () = + markdown |> hasError "{hero} requires a `:title:` option." + +type ``hero with a description`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:title: Kibana documentation hub +:description: The UI for the **Elasticsearch** platform. +::: +""" + + [] + let ``renders inline markup in the description`` () = + markdown |> convertsToContainingHtml """Elasticsearch""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``hero with an unknown icon key`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:icon: notaproduct +:title: Something else +::: +""" + + [] + let ``falls back to a letter chip`` () = + markdown |> convertsToContainingHtml """""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``hero with anchor actions`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:title: Elasticsearch documentation hub +:primary-action: [Get started](#get-started) +:secondary-action: [What's new](#whats-new) +::: +""" + + // Actions render as buttons, and no button on the site carries an arrow, not even + // an anchor action that jumps within the page. The assertion starts at the section, + // because the pretty-printer only matches from the outermost element of the output. + [] + let ``renders both actions as buttons without an arrow`` () = + markdown |> convertsToContainingHtml """ +
+
+ +
+

Elasticsearch documentation hub

+
+ +
+
+""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``hero with an external action`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:title: Elasticsearch documentation hub +:primary-action: [Install Elasticsearch](https://www.elastic.co/downloads/elasticsearch) +::: +""" + + // External links follow the same rules as inline links: they open in a new tab. The + // assertion starts at the section, because the pretty-printer only matches from the + // outermost element of the directive output. It also strips `preload`, so the absence + // of preloading on an external action cannot be asserted here. + [] + let ``opens in a new tab`` () = + markdown |> convertsToContainingHtml """ +
+
+ +
+

Elasticsearch documentation hub

+
+ +
+
+""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``hero with an internal action`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:title: Elasticsearch documentation hub +:primary-action: [Syntax reference](/index.md) +::: +""" + + [] + let ``strips the markdown extension and does not open a new tab`` () = + markdown |> convertsToContainingHtml """ +
+
+ +
+

Elasticsearch documentation hub

+
+ +
+
+""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``hero with a relative action url`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:title: Elasticsearch documentation hub +:primary-action: [Get started](get-started.md) +::: +""" + + [] + let ``rejects a relative path`` () = + markdown |> hasError "must be an absolute path starting with `/`" + +type ``hero with a malformed action`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:title: Elasticsearch documentation hub +:primary-action: Get started +::: +""" + + [] + let ``errors`` () = + markdown |> hasError "must be a markdown link" + +type ``hero with a cross-link action`` () = + static let markdown = Setup.Markdown """ +:::{hero} +:title: docs-builder documentation hub +:primary-action: [Elastic documentation](docs-content://get-started/index.md) +::: +""" + + // A cross-link resolves to a full URL but still points at documentation this site serves, + // so it must not open in a new tab. Inline links make the same distinction. The assertion + // snapshots the section rather than looking for target="_blank" anywhere on the page, + // because site chrome carries that attribute too. + [] + let ``does not open in a new tab`` () = + markdown |> convertsToContainingHtml """ +
+
+ +
+

docs-builder documentation hub

+
+ +
+
+""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +// These two set frontmatter, so they use Setup.Document. Setup.Markdown prepends an H1, +// which would push the frontmatter into the body where it never parses. +type ``hub layout without a hero`` () = + static let markdown = Setup.Document """--- +layout: hub +--- + +Body content with no hero directive. +""" + + // The hub layout removes the page H1, so {hero} is the only thing that can title the page. + [] + let ``errors`` () = + markdown |> hasError "A page with `layout: hub` requires a {hero} directive." + +type ``hub layout with a hero`` () = + static let markdown = Setup.Document """--- +layout: hub +--- + +:::{hero} +:title: Elasticsearch documentation hub +::: +""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors diff --git a/tests/authoring/Blocks/NestedDirectiveOptions.fs b/tests/authoring/Blocks/NestedDirectiveOptions.fs new file mode 100644 index 0000000000..8427558d9c --- /dev/null +++ b/tests/authoring/Blocks/NestedDirectiveOptions.fs @@ -0,0 +1,129 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information +module ``AuthoringTests``.``block elements``.``nested directive options`` + +open Xunit +open authoring + +// DirectiveBlockParser.TryContinue stops an ancestor directive consuming an option line once +// it has opened a nested directive child. Without the guard the ancestor also takes every +// descendant's options, and the last one wins. +// +// No existing directive pair shares an option name, so the collision is latent for them: +// {tab-set} reads group while {tab-item} reads sync and selected, and {applies-switch} and +// {applies-item} split the same way. These tests pin that each option still reaches the block +// that declared it, which is what the guard must not break. + +type ``tab set with its own group and per-item sync`` () = + static let markdown = Setup.Markdown """ +::::{tab-set} +:group: install-method + +:::{tab-item} Local +:sync: local +local body +::: + +:::{tab-item} Container +:sync: container +container body +::: +:::: +""" + + // The group is declared on the tab-set before any child, so it still reaches the tab-set. + [] + let ``the tab set keeps its own group`` () = + markdown |> convertsToContainingRawHtml "data-sync-group=\"install-method\"" + + // Each sync reaches the item that declared it, rather than all landing on the last one. + [] + let ``the first item keeps its own sync`` () = + markdown |> convertsToContainingRawHtml "data-sync-id=\"local\"" + + [] + let ``the second item keeps its own sync`` () = + markdown |> convertsToContainingRawHtml "data-sync-id=\"container\"" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``applies switch with its own group and per-item sync`` () = + static let markdown = Setup.Markdown """ +::::{applies-switch} +:group: deployment + +:::{applies-item} serverless: ga +:sync: serverless +serverless body +::: + +:::{applies-item} stack: ga 9.0+ +:sync: self-managed +self-managed body +::: +:::: +""" + + [] + let ``the switch keeps its own group`` () = + markdown |> convertsToContainingRawHtml "data-sync-group=\"deployment\"" + + [] + let ``the first item keeps its own sync`` () = + markdown |> convertsToContainingRawHtml "data-sync-id=\"serverless\"" + + [] + let ``the second item keeps its own sync`` () = + markdown |> convertsToContainingRawHtml "data-sync-id=\"self-managed\"" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``stepper with per-step anchors`` () = + static let markdown = Setup.Markdown """ +::::{stepper} +:::{step} Install +:anchor: install-step +Install the thing. +::: +:::{step} Configure +:anchor: configure-step +Configure the thing. +::: +:::: +""" + + [] + let ``each step keeps its own anchor`` () = + markdown |> convertsToContainingRawHtml "install-step" + + [] + let ``the second step keeps its own anchor`` () = + markdown |> convertsToContainingRawHtml "configure-step" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``dropdown wrapping an admonition with its own name`` () = + static let markdown = Setup.Markdown """ +::::{dropdown} Outer summary +:open: +:::{note} +:name: inner-note +Inner content. +::: +:::: +""" + + [] + let ``the dropdown keeps its own open state`` () = + markdown |> convertsToContainingRawHtml "Outer summary" + + [] + let ``the nested admonition keeps its own name`` () = + markdown |> convertsToContainingRawHtml "inner-note" + + [] + let ``has no errors`` () = markdown |> hasNoErrors diff --git a/tests/authoring/Blocks/PageCards.fs b/tests/authoring/Blocks/PageCards.fs new file mode 100644 index 0000000000..600651afb7 --- /dev/null +++ b/tests/authoring/Blocks/PageCards.fs @@ -0,0 +1,77 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information +module ``AuthoringTests``.``block elements``.``page card elements`` + +open Xunit +open authoring + +// {page-card} moved onto the shared DirectiveLinkValidator. These tests pin the contract it +// had before that move: relative links resolve against the source file, and no file-existence +// check runs, because page-card links can target generated pages with no markdown on disk. + +type ``page card with a relative link`` () = + static let markdown = Setup.Markdown """ +:::{page-card} [Admonitions](admonitions.md) +Callout boxes for notes and warnings. +::: +""" + + [] + let ``resolves the link relative to the source file`` () = + markdown |> convertsToContainingHtml """ + +
+
Admonitions
+
+

Callout boxes for notes and warnings.

+
+
+ + + +
+""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``page card with a dot-relative link`` () = + static let markdown = Setup.Markdown """ +:::{page-card} [Add](./add.md) +::: +""" + + // The CLI reference generates page-cards pointing at generated pages that have no markdown + // file on disk. A file-existence check here would report false positives on every one. + [] + let ``does not check that the target file exists`` () = markdown |> hasNoErrors + +type ``page card without a description`` () = + static let markdown = Setup.Markdown """ +:::{page-card} [Tables](tables.md) +::: +""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``page card with an absolute url`` () = + static let markdown = Setup.Markdown """ +:::{page-card} [Elastic](https://www.elastic.co) +::: +""" + + [] + let ``errors`` () = + markdown |> hasError "page-card url must be a local .md path or crosslink" + +type ``page card without a markdown link`` () = + static let markdown = Setup.Markdown """ +:::{page-card} Admonitions +::: +""" + + [] + let ``errors`` () = + markdown |> hasError "page-card requires a markdown link argument" diff --git a/tests/authoring/Framework/HtmlAssertions.fs b/tests/authoring/Framework/HtmlAssertions.fs index eaa648d322..2782987012 100644 --- a/tests/authoring/Framework/HtmlAssertions.fs +++ b/tests/authoring/Framework/HtmlAssertions.fs @@ -167,3 +167,47 @@ But was not found in: let defaultFile = actual.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md") defaultFile |> containsHtml expected + + /// Asserts a raw fragment is present in the rendered HTML, without pretty-printing. + /// The pretty-printed comparison only matches an element with no children, or one + /// snapshotted from the outermost element of the directive output. Use this to assert an + /// attribute on a container without snapshotting its whole subtree. + [] + let containsRawHtml (expected: string) (actual: MarkdownResult) = + if not <| actual.Html.Contains expected then + let msg = $"""Expected html to contain: +{expected} + +But it was not found in: + +{actual.Html} +""" + raise (XunitException(msg)) + + [] + let convertsToContainingRawHtml (expected: string) (actual: Lazy) = + let actual = actual.Value + + let defaultFile = actual.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md") + defaultFile |> containsRawHtml expected + + /// Asserts a fragment is absent from the rendered HTML. Checks the whole page, so the + /// needle must be unique to the directive under test and not appear in site chrome. + [] + let doesNotContainHtml (expected: string) (actual: MarkdownResult) = + if actual.Html.Contains expected then + let msg = $"""Expected html NOT to contain: +{expected} + +But it was found in: + +{actual.Html} +""" + raise (XunitException(msg)) + + [] + let doesNotConvertToContainingHtml (expected: string) (actual: Lazy) = + let actual = actual.Value + + let defaultFile = actual.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md") + defaultFile |> doesNotContainHtml expected diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index db9aafb701..7f299d64f7 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -53,6 +53,9 @@ + + + From b3b7944a9bad7ce3a14a72ec67a207749114b4f6 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Fri, 14 Aug 2026 12:20:30 +0200 Subject: [PATCH 05/11] Hub pages 2/4: {card-group}, {link-card}, and {explore} (#3826) * 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 * 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 * Raise card and explore text to body size Martijn's point about tiny fonts applies to every piece of hub text. Card descriptions, column descriptions, and aside links now read at the body size. The two cluster labels sit one step down, so they stay distinct from the column headings beside them, and nothing renders below 14px. The aside buttons keep 14px. They support the column links above them, so they must not outweigh them. Co-Authored-By: Claude Opus 5 * Drop the now unused anchor flag on a hero action The flag only ever drove the arrow, and the shared link attributes do their own anchor check for preloading. Co-Authored-By: Claude Opus 5 * Drop the aside from a link card A link card held two kinds of link: a primary list and an aside that rendered as a badge cluster in a column, or as inline dot-separated links in a card. That is two visual languages for the same thing, in one card. The aside also could not express what it promised. It said "these are secondary", and the only signal was its label. At 12px that label was too small to read, and at 14px it was indistinguishable from the column heading beside it. There is no size that reads as subordinate but legible. A second group of links now becomes a second card. One shape, one treatment, everywhere. This removes the `aside` field, both of its renderings, the badge styling, and the small button size added for it, which nothing else used. It also removes the label sizing question and the sub-24px target on the badges. Follow-ups: elastic/docs-content-internal#1385 and #1549 both specify `aside` and need updating. Co-Authored-By: Claude Opus 5 * 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 * 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 --------- Co-authored-by: Claude Opus 5 --- docs/_docset.yml | 3 + docs/examples/products/docs-builder.md | 212 ++++++++++++ docs/syntax/card-group.md | 49 +++ docs/syntax/explore.md | 72 ++++ docs/syntax/hero.md | 2 +- docs/syntax/hub-pages.md | 5 +- docs/syntax/link-card.md | 60 ++++ .../Assets/markdown/hub.css | 311 +++++++++++++++++- .../Myst/Directives/DirectiveBlockParser.cs | 9 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 50 +++ .../Myst/Directives/Hub/CardGroupBlock.cs | 46 +++ .../Myst/Directives/Hub/CardGroupView.cshtml | 44 +++ .../Myst/Directives/Hub/CardGroupViewModel.cs | 19 ++ .../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 | 98 ++++++ .../Myst/Directives/Hub/LinkCardView.cshtml | 80 +++++ .../Myst/Directives/Hub/LinkCardViewModel.cs | 15 + .../LlmMarkdown/LlmBlockRenderers.cs | 80 ++++- .../PlainText/PlainTextBlockRenderers.cs | 7 + .../Myst/YamlSerialization.cs | 2 + tests/authoring/Blocks/Hub/CardsAndExplore.fs | 261 +++++++++++++++ tests/authoring/authoring.fsproj | 1 + 29 files changed, 1685 insertions(+), 26 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..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 @@ + From c807d4d4808368624e794893f7b9af82cce83e57 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Fri, 14 Aug 2026 12:20:31 +0200 Subject: [PATCH 06/11] Hub pages 3/4: {get-started} (#3827) * Add {get-started} The onboarding section of a hub page. It gives a new reader one opinionated path to a first success, before they face the full link list. The section is optional. Keeps the top-level install and tutorial pair as first-class fields rather than treating it as prototype back-compatibility. The designs use exactly that shape: a copyable command beside a tutorial card, with numbered steps below. The install snippet renders inside `.highlight pre` with a language class, which is what the site's copy button and syntax highlighter already select on. Both work with no extra wiring, because the hub layout kept the markdown-content id. Steps take one of three shapes: plain, a whole-card link, or two or more equally weighted options side by side. Numbering is automatic and sits before the title, because the section describes a sequence and the number is what carries that. Drops the prototype's per-step icon field. The icons restate the step title, the number badge is already the visual anchor, and a schema field for decoration makes every author pick icons that then drift between hubs. The LLM export renders the steps as an ordered list with options as sub-items. The search body gets nothing, for the same reason as the other hub directives. Co-Authored-By: Claude Opus 5 * Apply review feedback to the get-started section Martijn's review on #3825 covered the whole page, so the same rules apply here. - Get started type moves onto the site's scale. The values it carried were 16px-root arithmetic against a 14px root, so every one rendered small. - Step numbers move from grey-70 to ink-light. grey-70 reads at 3.31:1 on white, short of the 4.5:1 minimum, and ink-light carries the same quiet weight at 7.03:1. Co-Authored-By: Claude Opus 5 * Raise get-started text to body size Martijn's point about tiny fonts applies to every piece of hub text. Step descriptions, option labels, option descriptions, option buttons, and the step links now read at the body size. The install command keeps 14px, which is what every other code block on the site renders at. Co-Authored-By: Claude Opus 5 * Use the shared arrow in get-started Both links that carry an arrow here send the reader onward out of the card, which is one of the two cases the site marks. They now use the same glyph and hover slide as every other hub arrow. The option button carries none, like every other button on the site. Co-Authored-By: Claude Opus 5 * Let a get-started section run to any number of steps Nothing in the schema limited the section to three steps. One CSS rule did: when a step carried options, the grid locked to two tracks, which suits one wide step and two others exactly. A fourth step left one card alone on a half-width row. The directive now counts the steps that flow in columns and sets the track count. Three across when they divide by three, two when they are even, so the last row is never short. A step carrying options still spans the full row and takes no track. Below 640px of available width the steps stack. The width is measured on the section rather than the viewport, because the left sidebar narrows the body. The example hub gains a fourth step, so the preview shows the new arrangement. Co-Authored-By: Claude Opus 5 * Give a linked step the shared card hover The step card hovered to a blue border while the other two card types hovered to grey. Blue also makes one step in a sequence look like the call to action. It now uses grey-80, the same treatment as {page-card} and a linked {link-card}. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- docs/_docset.yml | 1 + docs/examples/products/docs-builder.md | 26 +++ docs/syntax/get-started.md | 105 +++++++++ docs/syntax/hub-pages.md | 1 + .../Assets/markdown/hub.css | 216 ++++++++++++++++++ .../Myst/Directives/DirectiveBlockParser.cs | 3 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 40 ++++ .../Myst/Directives/Hub/GetStartedBlock.cs | 121 ++++++++++ .../Myst/Directives/Hub/GetStartedView.cshtml | 116 ++++++++++ .../Directives/Hub/GetStartedViewModel.cs | 51 +++++ .../LlmMarkdown/LlmBlockRenderers.cs | 48 ++++ .../PlainText/PlainTextBlockRenderers.cs | 1 + .../Myst/YamlSerialization.cs | 3 + tests/authoring/Blocks/Hub/GetStarted.fs | 154 +++++++++++++ tests/authoring/authoring.fsproj | 1 + 15 files changed, 887 insertions(+) create mode 100644 docs/syntax/get-started.md create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/GetStartedView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/GetStartedViewModel.cs create mode 100644 tests/authoring/Blocks/Hub/GetStarted.fs diff --git a/docs/_docset.yml b/docs/_docset.yml index e518cc269d..d18d7681cc 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -122,6 +122,7 @@ toc: - file: file_inclusion.md - file: footnotes.md - file: frontmatter.md + - file: get-started.md - file: hero.md - file: hub-pages.md - file: icons.md diff --git a/docs/examples/products/docs-builder.md b/docs/examples/products/docs-builder.md index 1b13353a5d..1c15e9c8fb 100644 --- a/docs/examples/products/docs-builder.md +++ b/docs/examples/products/docs-builder.md @@ -12,6 +12,32 @@ description: docs-builder documentation. Build, validate, and publish Elastic do :tertiary-action: [Explore docs-builder](#explore) ::: +:::{get-started} +title: Get started in 4 steps +intro: Install docs-builder, write your first page, then preview and publish it. +steps: + - title: Install docs-builder + options: + - label: Build from source + description: Clone the repository and build the CLI yourself. + code: dotnet build + language: sh + - label: Run in a container + description: No local install needed. + url: /getting-started/installation.md + url-label: Container setup + - title: Write your first page + description: Author Markdown, add links, and use the directive set. + link: /getting-started/writing-content.md + link-label: Start writing + - title: Preview and publish + description: Serve the site locally with live reload, then publish it. + link: /getting-started/serve.md + link-label: Serve locally + - title: Validate the build + description: Check links, syntax, and frontmatter before you open a pull request. +::: + ::::{card-group} :title: Get hands-on :id: hands-on diff --git a/docs/syntax/get-started.md b/docs/syntax/get-started.md new file mode 100644 index 0000000000..b20baaa8ee --- /dev/null +++ b/docs/syntax/get-started.md @@ -0,0 +1,105 @@ +# Get started + +The onboarding section of a [hub page](hub-pages.md). It gives a new reader one opinionated path to a first success, before they face the full link list. + +The section is optional. Skip it when the hero already says what to do next. + +See the [docs-builder documentation hub](../examples/products/docs-builder.md) for a rendered section. + +## Basic + +A hub's onboarding section is a numbered list of steps. Use as many as the path needs. The first step often offers two equally weighted ways to start, and the rest are single links. + +```markdown +:::{get-started} +title: Get started in 3 steps +intro: Install docs-builder, write your first page, then preview and publish it. +steps: + - title: Install docs-builder + options: + - label: Install locally + description: Install the CLI on your machine. + code: curl -sSL https://ela.st/docs-builder-install | sh + language: sh + - label: Run in a container + description: No local install needed. + url: /getting-started/installation.md + url-label: Container setup + - title: Write your first page + description: Author Markdown, add links, and use the directive set. + link: /getting-started/writing-content.md + link-label: Start writing + - title: Preview and publish + description: Serve the site locally with live reload, then publish it. + link: /getting-started/serve.md + link-label: Serve locally +::: +``` + +The body is YAML, not markdown, like [`{link-card}`](link-card.md). + +## Schema + +| Field | Notes | +|---|---| +| `title` | **Required.** H2 heading. | +| `intro` | One-line lead below the heading. | +| `steps` | The numbered steps. | + +Everything the section offers lives inside a step. An install command belongs in `steps[0].options[]`, which keeps the whole path inside the numbered sequence. + +A command in a step option goes through the standard code block, so it gets syntax highlighting and a copy button like every other code block on the site. + +Every field except `title` is optional. The [example hub](../examples/products/docs-builder.md) uses each one once, and shows all three step shapes, so you can start from it and delete what you do not need. + + +## How many steps + +There is no fixed number. The layout arranges whatever you write: + +- A step carrying `options` spans the full row. +- The remaining steps share the row, three across when they divide by three, two when they are even, so the last row is never short. +- Below a narrow width the steps stack into one column. + +Keep the list short enough to read as one path. Four steps still scan. Ten do not. + +## Step shapes + +A step takes one of three shapes. + +**Plain.** A `title` and a `description`. Nothing is clickable. + +```yaml +- title: Preview and publish + description: Serve the site locally, then publish it. +``` + +**Link.** Add `link` and `link-label`, and the whole step card becomes clickable. + +```yaml +- title: Write your first page + description: Author markdown, add links, and use the directive set. + link: /getting-started/writing-content.md + link-label: Start writing +``` + +**Options.** Add `options` for two or more equally weighted paths, shown side by side. Each option takes a `label`, a `description`, and either a copyable `code` snippet with its `language`, or a `url` with a `url-label`. + +```yaml +- title: Preview and publish + options: + - label: Preview locally + description: Serve the site with live reload while you write. + code: docs-builder serve + language: sh + - label: Publish + description: Build the site and publish it. + url: /getting-started/publish.md + url-label: How to publish +``` + +Steps are numbered automatically, in source order. The number sits before the title, because the section describes a sequence and the number is what carries that. + +## Links + +Every `steps[].link` and `options[].url` validates at build time, using the same forms as [`{link-card}`](link-card.md#links). diff --git a/docs/syntax/hub-pages.md b/docs/syntax/hub-pages.md index 048ed37e61..e01bec4672 100644 --- a/docs/syntax/hub-pages.md +++ b/docs/syntax/hub-pages.md @@ -56,6 +56,7 @@ Write both deliberately. The search body indexes the hero title and description | [`{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}`. | +| [`{get-started}`](get-started.md) | Onboarding funnel. An install command, a tutorial link, and numbered steps. | | [`{explore}`](explore.md) | The browse-everything section. A stack of collapsible accordions. | ## Page skeleton diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 0095fe2326..564cb73ee3 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -154,6 +154,222 @@ flex-shrink: 0; } + /* Get started ------------------------------------------------------ */ + /* A container, not the viewport: the left sidebar narrows the body, so the step + grid has to answer to the space it actually has. */ + .hub-get-started { + @apply mx-auto w-full max-w-5xl; + container-type: inline-size; + margin-bottom: 56px; + scroll-margin-top: 96px; + } + .hub-get-started .hub-get-started-title { + margin: 0 0 8px; + font-size: var(--text-xl); + font-weight: 700; + color: var(--color-ink); + } + .hub-get-started .hub-get-started-intro { + margin: 0 0 20px; + font-size: var(--text-base); + color: var(--color-ink-light, #4c4c4c); + line-height: 1.5; + } + .hub-get-started .hub-get-started-install { + position: relative; + flex: 1 1 380px; + min-width: 0; + margin: 0; + } + .hub-get-started .hub-get-started-install pre { + margin: 0; + padding: 12px 16px; + overflow-x: auto; + background: var(--color-ink-navy); + border-radius: 10px; + } + .hub-get-started .hub-get-started-install code { + font-family: var(--font-mono, monospace); + font-size: var(--text-sm); + color: var(--color-white); + background: transparent; + } + + /* The track count is authored, not inferred from the width: the directive counts the + steps that flow in columns and sets --hub-step-columns, so a section of any length + fills its rows evenly instead of leaving a short last row. */ + .hub-get-started .hub-get-started-steps { + @apply grid gap-4; + grid-template-columns: repeat( + var(--hub-step-columns, 2), + minmax(0, 1fr) + ); + margin: 0; + padding: 0; + list-style: none; + } + .hub-get-started .hub-get-started-step { + @apply relative flex flex-col; + padding: 20px; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 14px; + } + .hub-get-started .hub-get-started-step-head { + @apply flex items-center; + gap: 12px; + margin-bottom: 10px; + padding-right: 34px; + } + .hub-get-started .hub-get-started-step-num { + position: absolute; + top: 20px; + right: 20px; + font-size: var(--text-base); + font-weight: 600; + /* grey-70 reads at 3.31:1 on white, short of the 4.5:1 minimum. ink-light + carries the same quiet weight at 7.03:1 and is what other secondary text + on the page already uses. */ + color: var(--color-ink-light); + letter-spacing: 0.06em; + } + .hub-get-started .hub-get-started-step-title { + margin: 0; + font-size: var(--text-base); + font-weight: 700; + color: var(--color-ink-dark); + } + .hub-get-started .hub-get-started-step-desc { + margin: 0; + font-size: var(--text-base); + color: var(--color-ink-light, #4c4c4c); + line-height: 1.45; + } + + /* Below this the tracks are too narrow to hold a step title and a description. */ + @container (max-width: 640px) { + .hub-get-started .hub-get-started-steps { + grid-template-columns: 1fr; + } + } + + /* A step offering equally weighted options spans the full row, whatever the track count. */ + .hub-get-started .hub-get-started-step-wide { + grid-column: 1 / -1; + container-type: inline-size; + } + .hub-get-started .hub-get-started-options { + display: grid; + grid-template-columns: 1fr 1fr; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--color-grey-20); + } + .hub-get-started .hub-get-started-option { + @apply flex flex-col; + gap: 10px; + padding-right: 28px; + } + .hub-get-started .hub-get-started-option + .hub-get-started-option { + padding-right: 0; + padding-left: 28px; + border-left: 1px solid var(--color-grey-20); + } + .hub-get-started .hub-get-started-option-label { + font-size: var(--text-base); + font-weight: 700; + color: var(--color-ink-dark); + } + .hub-get-started .hub-get-started-option-desc { + margin: 0; + font-size: var(--text-base); + color: var(--color-ink-light, #4c4c4c); + line-height: 1.45; + } + .hub-get-started .hub-get-started-option .hub-get-started-install { + flex: none; + width: 100%; + margin-top: auto; + } + .hub-get-started .hub-get-started-option-btn { + @apply inline-flex items-center justify-center; + gap: 8px; + width: 100%; + min-height: 48px; + margin-top: auto; + padding: 0 18px; + font-size: var(--text-base); + font-weight: 600; + color: var(--color-ink-dark); + background: var(--color-white); + border: 1px solid var(--color-ink-dark); + border-radius: 8px; + transition: + background 0.15s ease, + border-color 0.15s ease; + } + .hub-get-started .hub-get-started-option-btn:hover, + .hub-get-started .hub-get-started-option-btn:focus-visible { + background: var(--color-grey-10); + border-color: var(--color-ink-dark); + color: var(--color-ink-dark); + } + .hub-get-started .hub-get-started-option-link { + @apply inline-flex items-center gap-1; + align-self: flex-start; + font-size: var(--text-base); + font-weight: 600; + color: var(--color-blue-elastic-100, #0b64dd); + } + .hub-get-started .hub-get-started-option-link:hover, + .hub-get-started .hub-get-started-option-link:focus-visible { + text-decoration: underline; + } + @container (max-width: 560px) { + .hub-get-started .hub-get-started-options { + grid-template-columns: 1fr; + } + .hub-get-started .hub-get-started-option { + padding-right: 0; + } + .hub-get-started .hub-get-started-option + .hub-get-started-option { + padding-left: 0; + padding-top: 16px; + margin-top: 4px; + border-left: 0; + border-top: 1px solid var(--color-grey-20); + } + } + + /* Steps 02/03 are clickable link cards. */ + .hub-get-started .hub-get-started-step-link { + padding: 0; + transition: + box-shadow 0.15s, + border-color 0.15s; + } + /* Same hover treatment as every other card that is itself a link. Blue would make + one step look like the call to action when they are a sequence. */ + .hub-get-started .hub-get-started-step-link:hover, + .hub-get-started .hub-get-started-step-link:focus-visible { + border-color: var(--color-grey-80); + box-shadow: 0 1px 4px rgb(0 0 0 / 0.07); + } + .hub-get-started .hub-get-started-step-anchor { + @apply flex h-full flex-col; + padding: 20px; + color: inherit; + text-decoration: none; + } + .hub-get-started .hub-get-started-step-arrow { + @apply inline-flex items-center gap-1; + margin-top: auto; + padding-top: 14px; + font-size: var(--text-base); + font-weight: 600; + color: var(--color-blue-elastic-100, #0b64dd); + } + /* Zone (section heading) ------------------------------------------ */ .hub-zone { @apply mx-auto w-full max-w-5xl; diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index 565a9ab7c0..add4cf3626 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -150,6 +150,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{link-card}") > 0) return new LinkCardBlock(this, context); + if (info.IndexOf("{get-started}") > 0) + return new GetStartedBlock(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 7c4f5f6f1e..31057f6bd0 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -117,6 +117,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case LinkCardBlock linkCardBlock: WriteLinkCard(renderer, linkCardBlock); return; + case GetStartedBlock getStartedBlock: + WriteGetStarted(renderer, getStartedBlock); + return; case PageCardBlock pageCardBlock: WritePageCard(renderer, pageCardBlock); return; @@ -267,6 +270,43 @@ private static void WriteLinkCard(HtmlRenderer renderer, LinkCardBlock block) RenderRazorSlice(slice, renderer); } + private static void WriteGetStarted(HtmlRenderer renderer, GetStartedBlock block) + { + var data = block.Data; + var steps = new List(data.Steps.Length); + for (var i = 0; i < data.Steps.Length; i++) + { + var step = data.Steps[i]; + steps.Add(new GetStartedStepViewModel + { + Number = i + 1, + Title = step.Title, + DescriptionHtml = RenderInlineMarkdown(step.Description), + Link = step.Link, + LinkLabel = step.LinkLabel, + Options = [.. step.Options.Select(option => new GetStartedOptionViewModel + { + Label = option.Label, + DescriptionHtml = RenderInlineMarkdown(option.Description), + Code = option.Code, + Language = option.Language, + Url = option.Url, + UrlLabel = option.UrlLabel + })] + }); + } + + var slice = GetStartedView.Create(new GetStartedViewModel + { + DirectiveBlock = block, + Title = data.Title, + IntroHtml = RenderInlineMarkdown(data.Intro), + Steps = steps, + SitePathPrefix = block.Build.UrlPathPrefix + }); + RenderRazorSlice(slice, renderer); + } + // The hero description is a directive option, not a body block, so it never reaches // the document pipeline. Render it with the default Markdig pipeline for basic inline // markup. Substitutions, roles, and link validation do not apply inside this option. diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs new file mode 100644 index 0000000000..d2915faa2a --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs @@ -0,0 +1,121 @@ +// 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; + +/// +/// The first hub-body section: a short onboarding funnel with an intro line, an +/// optional install snippet, an optional tutorial link, and a set of numbered +/// steps. The schema is YAML-formatted in the directive body for predictable +/// structure. +/// +/// +/// +/// :::{get-started} +/// title: Get started in 3 steps +/// intro: Spin up Kibana, connect your data, and start exploring in minutes. +/// ::: +/// +/// +public class GetStartedBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + public override string Directive => "get-started"; + + public GetStartedData Data { get; private set; } = GetStartedData.Empty; + + public override void FinalizeAndValidate(ParserContext context) + { + var yaml = HubYamlBody.Extract(this, new BuildContextFileReader(Build.ReadFileSystem)); + if (yaml is null) + { + this.EmitError("{get-started} requires a YAML body. See the get-started directive docs."); + return; + } + + try + { + Data = YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration) ?? GetStartedData.Empty; + } + catch (YamlException ex) + { + this.EmitError($"{{get-started}} YAML parse error: {ex.Message}"); + return; + } + + if (string.IsNullOrWhiteSpace(Data.Title)) + this.EmitError("{get-started} requires a `title` field in its YAML body."); + + + foreach (var step in Data.Steps) + { + if (!string.IsNullOrWhiteSpace(step.Link)) + step.Link = DirectiveLinkValidator.ValidateAndResolve(step.Link, this, context); + foreach (var option in step.Options) + option.Url = DirectiveLinkValidator.ValidateAndResolve(option.Url, this, context); + } + } +} + +[YamlSerializable] +public record GetStartedData +{ + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "intro")] + public string? Intro { get; set; } + + [YamlMember(Alias = "steps")] + public GetStartedStep[] Steps { get; set; } = []; + + public static GetStartedData Empty { get; } = new(); +} + +[YamlSerializable] +public record GetStartedStep +{ + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "description")] + public string? Description { get; set; } + + /// When set, the whole step card links here. + [YamlMember(Alias = "link")] + public string? Link { get; set; } + + [YamlMember(Alias = "link-label")] + public string? LinkLabel { get; set; } + + /// Two-or-more equally-weighted start options rendered side by side. + [YamlMember(Alias = "options")] + public GetStartedStepOption[] Options { get; set; } = []; +} + +[YamlSerializable] +public record GetStartedStepOption +{ + [YamlMember(Alias = "label")] + public string? Label { get; set; } + + [YamlMember(Alias = "description")] + public string? Description { get; set; } + + [YamlMember(Alias = "code")] + public string? Code { get; set; } + + [YamlMember(Alias = "language")] + public string? Language { get; set; } + + [YamlMember(Alias = "url")] + public string? Url { get; set; } + + [YamlMember(Alias = "url-label")] + public string? UrlLabel { get; set; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedView.cshtml new file mode 100644 index 0000000000..3022ef2479 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedView.cshtml @@ -0,0 +1,116 @@ +@inherits RazorSlice + +
    +
    + @if (!string.IsNullOrWhiteSpace(Model.Title)) + { +

    @Model.Title

    + } + @if (!string.IsNullOrWhiteSpace(Model.IntroHtml)) + { +

    @(new HtmlString(Model.IntroHtml))

    + } +
    + + @if (Model.Steps.Count > 0) + { +
      + @foreach (var step in Model.Steps) + { + @if (step.Options.Count > 0) + { +
    1. +
      + + @if (!string.IsNullOrWhiteSpace(step.Title)) + { +

      @step.Title

      + } +
      + @if (!string.IsNullOrWhiteSpace(step.DescriptionHtml)) + { +

      @(new HtmlString(step.DescriptionHtml))

      + } +
      + @foreach (var option in step.Options) + { +
      + @if (!string.IsNullOrWhiteSpace(option.Label)) + { + @option.Label + } + @if (!string.IsNullOrWhiteSpace(option.DescriptionHtml)) + { +

      @(new HtmlString(option.DescriptionHtml))

      + } + @if (!string.IsNullOrWhiteSpace(option.Code)) + { +
      +
      @option.Code
      +
      + } + @if (!string.IsNullOrWhiteSpace(option.Url)) + { + @if (!string.IsNullOrWhiteSpace(option.Code)) + { + + @(string.IsNullOrWhiteSpace(option.UrlLabel) ? "Learn more" : option.UrlLabel) + + + } + else + { + @(string.IsNullOrWhiteSpace(option.UrlLabel) ? "Get started" : option.UrlLabel) + } + } +
      + } +
      +
    2. + } + else if (!string.IsNullOrWhiteSpace(step.Link)) + { + + } + else + { +
    3. +
      + + @if (!string.IsNullOrWhiteSpace(step.Title)) + { +

      @step.Title

      + } +
      + @if (!string.IsNullOrWhiteSpace(step.DescriptionHtml)) + { +

      @(new HtmlString(step.DescriptionHtml))

      + } +
    4. + } + } +
    + } +
    diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedViewModel.cs new file mode 100644 index 0000000000..7c6c8faa85 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedViewModel.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 + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class GetStartedViewModel : HubDirectiveViewModel +{ + public required string? Title { get; init; } + public required string? IntroHtml { get; init; } + public required IReadOnlyList Steps { get; init; } + + /// + /// Track count for the step grid. A step carrying options spans the full row, so only the + /// remaining steps compete for columns. The count is picked to divide them evenly and leave + /// no short last row: three across when they divide by three, two when they are even, + /// otherwise as many as there are, up to three. + /// + public int StepColumns + { + get + { + var inFlow = Steps.Count(s => s.Options.Count == 0); + if (inFlow == 0) + return 1; + if (inFlow % 3 == 0) + return 3; + return inFlow % 2 == 0 ? 2 : inFlow < 3 ? inFlow : 3; + } + } +} + +public sealed record GetStartedStepViewModel +{ + public required int Number { get; init; } + public required string? Title { get; init; } + public required string? DescriptionHtml { get; init; } + public required string? Link { get; init; } + public required string? LinkLabel { get; init; } + public required IReadOnlyList Options { get; init; } +} + +public sealed record GetStartedOptionViewModel +{ + public required string? Label { get; init; } + public required string? DescriptionHtml { get; init; } + public required string? Code { get; init; } + public required string? Language { get; init; } + public required string? Url { get; init; } + public required string? UrlLabel { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index a8de739529..0a40733226 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -514,6 +514,9 @@ protected override void Write(LlmMarkdownRenderer renderer, DirectiveBlock obj) case LinkCardBlock linkCardBlock: WriteLinkCardBlock(renderer, linkCardBlock); return; + case GetStartedBlock getStartedBlock: + WriteGetStartedBlock(renderer, getStartedBlock); + return; } // Ensure single empty line before directive @@ -580,6 +583,51 @@ private static void WriteHeroBlock(LlmMarkdownRenderer renderer, HeroBlock heroB renderer.EnsureLine(); } + // The onboarding path is a sequence, so it exports as an ordered list. Options under a step + // become sub-items, each with its command or its link. + private static void WriteGetStartedBlock(LlmMarkdownRenderer renderer, GetStartedBlock block) + { + var data = block.Data; + renderer.EnsureBlockSpacing(); + + if (!string.IsNullOrEmpty(data.Title)) + { + renderer.WriteLine($"## {data.Title}"); + renderer.EnsureLine(); + } + if (!string.IsNullOrEmpty(data.Intro)) + { + renderer.WriteLine(data.Intro); + renderer.EnsureLine(); + } + + for (var i = 0; i < data.Steps.Length; i++) + WriteGetStartedStep(renderer, data.Steps[i], i + 1); + + renderer.EnsureLine(); + } + + private static void WriteGetStartedStep(LlmMarkdownRenderer renderer, GetStartedStep step, int number) + { + renderer.EnsureLine(); + var title = string.IsNullOrEmpty(step.Link) + ? step.Title + : $"[{step.Title}]({HubLinkForLlm(renderer, step.Link)})"; + renderer.WriteLine($"{number}. {title}"); + + if (!string.IsNullOrEmpty(step.Description)) + renderer.WriteLine($" {step.Description}"); + + foreach (var option in step.Options) + { + renderer.WriteLine($" - {option.Label}: {option.Description}"); + if (!string.IsNullOrEmpty(option.Code)) + renderer.WriteLine($" `{option.Code}`"); + if (!string.IsNullOrEmpty(option.Url)) + renderer.WriteLine($" [{option.UrlLabel ?? "Get started"}]({HubLinkForLlm(renderer, option.Url)})"); + } + } + // 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) diff --git a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs index 6e60bfbe24..3b01468f1d 100644 --- a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs @@ -291,6 +291,7 @@ protected override void Write(PlainTextRenderer renderer, DirectiveBlock obj) case ExploreBlock: case CardGroupBlock: case LinkCardBlock: + case GetStartedBlock: return; case AgentSkillBlock agentSkillBlock: diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 6dbe9b5933..36bb30ce0a 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -83,4 +83,7 @@ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializ [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.GetStartedData))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedStep))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedStepOption))] public partial class DocsBuilderYamlStaticContext; diff --git a/tests/authoring/Blocks/Hub/GetStarted.fs b/tests/authoring/Blocks/Hub/GetStarted.fs new file mode 100644 index 0000000000..7ff8dd1eb0 --- /dev/null +++ b/tests/authoring/Blocks/Hub/GetStarted.fs @@ -0,0 +1,154 @@ +// 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``.``get started elements`` + +open Xunit +open authoring + +type ``get started with a title and intro`` () = + static let markdown = Setup.Markdown """ +:::{get-started} +title: Get started in 3 steps +intro: Install, write, preview. +steps: + - title: Install + description: Install the CLI. +::: +""" + + [] + let ``renders the heading`` () = + markdown |> convertsToContainingHtml """

    Get started in 3 steps

    """ + + [] + let ``numbers steps from one, zero padded`` () = + markdown |> convertsToContainingRawHtml """""" + + // Nothing renders between the intro and the numbered list. The section is the steps. + [] + let ``renders nothing above the steps`` () = + markdown |> doesNotConvertToContainingHtml "hub-get-started-actions" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``get started with a link step`` () = + static let markdown = Setup.Markdown """ +:::{get-started} +title: Get started +steps: + - title: Write your first page + description: Author markdown. + link: /index.md + link-label: Start writing +::: +""" + + [] + let ``makes the whole step clickable`` () = + markdown |> convertsToContainingHtml """Start writing""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``get started with option steps`` () = + static let markdown = Setup.Markdown """ +:::{get-started} +title: Get started +steps: + - title: Preview and publish + options: + - label: Preview locally + description: Serve with live reload. + code: docs-builder serve + language: sh + - label: Publish + description: Build and publish. + url: /index.md + url-label: How to publish +::: +""" + + [] + let ``renders both options`` () = + markdown |> convertsToContainingHtml """Preview locally""" + + [] + let ``renders the option command`` () = + markdown |> convertsToContainingHtml """docs-builder serve""" + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``get started with a relative step link`` () = + static let markdown = Setup.Markdown """ +:::{get-started} +title: Get started +steps: + - title: Broken + link: nope.md +::: +""" + + [] + let ``rejects a relative path`` () = + markdown |> hasError "must be an absolute path starting with `/`" + +type ``get started without a body`` () = + static let markdown = Setup.Markdown """ +:::{get-started} +::: +""" + + [] + let ``errors`` () = + markdown |> hasError "{get-started}" + +// The section is not fixed at three steps. The track count follows the steps that flow in +// columns, so the last row is never short. A step carrying options spans the full row and +// takes no track. +type ``get started with four steps`` () = + static let markdown = Setup.Markdown """ +:::{get-started} +title: Get started +steps: + - title: Install + options: + - label: Source + code: dotnet build + - label: Container + url: /index.md + - title: Write + - title: Preview + - title: Validate +::: +""" + + [] + let ``lays the three remaining steps across three tracks`` () = + markdown |> convertsToContainingRawHtml """
      """ + + [] + let ``has no errors`` () = markdown |> hasNoErrors + +type ``get started with five steps`` () = + static let markdown = Setup.Markdown """ +:::{get-started} +title: Get started +steps: + - title: One + - title: Two + - title: Three + - title: Four +::: +""" + + // Four steps divide evenly into two rows of two, so they take two tracks rather than + // three with a single step stranded on the last row. + [] + let ``pairs four steps into two tracks`` () = + markdown |> convertsToContainingRawHtml """
        """ + + [] + let ``has no errors`` () = markdown |> hasNoErrors diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index 723a8d7ba8..4bd3e42fe5 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -57,6 +57,7 @@ + From 5fabcbb967d5acad933820c40c1da90c901a52e8 Mon Sep 17 00:00:00 2001 From: Florent LB Date: Fri, 14 Aug 2026 12:20:32 +0200 Subject: [PATCH 07/11] Hub pages 4/4: {whats-new} (#3829) * Add {whats-new} A recency panel for a hub page. A reader who bookmarks a hub wants a quick answer to "what changed recently" without hunting through release notes. The panel content moves out of the build tool. The prototype reads config/whats-new.yml from docs-builder, which means a writer editing a highlight card opens a pull request against the build tool and waits for a release. It now reads hub-whats-new.yml from the root of the current documentation set, beside changelog.yml and redirects.yml. That makes the directive documentation-set scoped. It cannot render another repository's panel, and this is a constraint rather than a syntax gap. Cross-link resolution maps pages through the link index, and a YAML data file is not a page. In an isolated build the other repository is not checked out, so there would be no file to read. Every hub page lives in the same repository as its content file, so the limit costs nothing today. The common case stays one line, `:product: `. Omitting it accepts the same schema as an inline YAML body, for a one-off panel. Adds containsRawHtml to the authoring assertions. The pretty-printed comparison only matches a childless element or a snapshot taken from the outermost element of the directive output, so asserting a class on a container previously meant snapshotting its whole subtree. Follow-up, not in this change: a skill in docs-content that updates hub-whats-new.yml from the release notes of each product that has a hub page. Co-Authored-By: Claude Opus 5 * Apply review feedback to the what's new section Martijn's review on #3825 covered the whole page, so the same rule applies here. What's new type moves onto the site's scale. Every raw font size in hub.css is now a scale token. Co-Authored-By: Claude Opus 5 * Raise what's new text to body size, and stop shouting the date Martijn's point about tiny fonts applies to every piece of hub text. Card descriptions, release note links, and the footer now read at the body size. Card meta sits one step down at 14px. No hub text renders below 14px, and nothing uses the smallest step of the scale. The card date no longer forces uppercase. It is authored, so it keeps the casing the writer chose. The example data and the syntax page now use sentence case to match. Co-Authored-By: Claude Opus 5 * Use the shared arrow in what's new The card link and the upgrade link both send the reader onward, which is one of the two cases the site marks with an arrow. They now use the same glyph and hover slide as every other hub arrow. No hub directive draws its own arrow any more. Co-Authored-By: Claude Opus 5 * Give a what's new card the shared card hover The card hovered to a pale blue border reading 2.12:1 against white, so the state was barely visible. It now uses grey-80 at 4.15:1, the same treatment as every other card on a hub page that is itself a link. Every card that is a link now hovers the same way, and every state border in the hub clears the 3:1 ratio. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- docs/_docset.yml | 1 + docs/examples/products/docs-builder.md | 4 + docs/hub-whats-new.yml | 49 +++++ docs/syntax/hub-pages.md | 1 + docs/syntax/whats-new.md | 81 ++++++++ .../Assets/markdown/hub.css | 159 ++++++++++++++++ .../Myst/Directives/DirectiveBlockParser.cs | 3 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 14 ++ .../Myst/Directives/Hub/WhatsNewBlock.cs | 173 ++++++++++++++++++ .../Myst/Directives/Hub/WhatsNewView.cshtml | 117 ++++++++++++ .../Myst/Directives/Hub/WhatsNewViewModel.cs | 10 + .../LlmMarkdown/LlmBlockRenderers.cs | 54 ++++++ .../PlainText/PlainTextBlockRenderers.cs | 1 + .../Myst/YamlSerialization.cs | 3 + tests/authoring/Blocks/Hub/WhatsNew.fs | 110 +++++++++++ tests/authoring/authoring.fsproj | 1 + 16 files changed, 781 insertions(+) create mode 100644 docs/hub-whats-new.yml create mode 100644 docs/syntax/whats-new.md create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs create mode 100644 tests/authoring/Blocks/Hub/WhatsNew.fs diff --git a/docs/_docset.yml b/docs/_docset.yml index d18d7681cc..89f358d3a2 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -146,6 +146,7 @@ toc: - file: tables.md - file: tabs.md - file: titles.md + - file: whats-new.md # Examples - folder: examples diff --git a/docs/examples/products/docs-builder.md b/docs/examples/products/docs-builder.md index 1c15e9c8fb..2457e2067a 100644 --- a/docs/examples/products/docs-builder.md +++ b/docs/examples/products/docs-builder.md @@ -38,6 +38,10 @@ steps: description: Check links, syntax, and frontmatter before you open a pull request. ::: +:::{whats-new} +:product: docs-builder +::: + ::::{card-group} :title: Get hands-on :id: hands-on diff --git a/docs/hub-whats-new.yml b/docs/hub-whats-new.yml new file mode 100644 index 0000000000..450b3a6c48 --- /dev/null +++ b/docs/hub-whats-new.yml @@ -0,0 +1,49 @@ +# Content for the {whats-new} panel on hub pages, keyed by product. +# +# This file lives at the root of the documentation set, alongside changelog.yml and +# redirects.yml, so a writer edits it without touching the build tool. A hub page renders +# a panel from it with one line: `:product: `. +# +# The directive reads this file from the current documentation set only. It cannot render +# another repository's panel. +products: + docs-builder: + title: What's new in docs-builder + id: whats-new + intro: Recent additions to the toolchain and the syntax it understands. + # More than one release stream can be listed here. + release-links: + - label: Release notes + url: /data/release-notes/index.md + - label: Changelog reference + url: /data/release-notes/overview.md + upgrade-link: + label: Upgrade docs-builder + url: /getting-started/installation.md + items: + - title: Hub pages + description: A product-scoped landing page composed entirely from directives. + link: /syntax/hub-pages.md + date: Aug 2026 + tag: Syntax + featured: true + - title: Explore sections + description: Collapse a long link list into a stack of accordions. + link: /syntax/explore.md + date: Aug 2026 + tag: Syntax + - title: Get started sections + description: One opinionated onboarding path, with a copyable install command. + link: /syntax/get-started.md + date: Aug 2026 + tag: Syntax + - title: Link cards + description: A card with a title, a description, and a validated link list. + link: /syntax/link-card.md + date: Aug 2026 + tag: Syntax + - title: Card groups + description: Group related cards under a heading, or as one accordion. + link: /syntax/card-group.md + date: Aug 2026 + tag: Syntax diff --git a/docs/syntax/hub-pages.md b/docs/syntax/hub-pages.md index e01bec4672..431f8d8e5c 100644 --- a/docs/syntax/hub-pages.md +++ b/docs/syntax/hub-pages.md @@ -57,6 +57,7 @@ Write both deliberately. The search body indexes the hero title and description | [`{card-group}`](card-group.md) | Section heading and card grid. Renders as an accordion inside `{explore}`. | | [`{link-card}`](link-card.md) | One card: title, description, and a list of links. Renders as a link column inside `{explore}`. | | [`{get-started}`](get-started.md) | Onboarding funnel. An install command, a tutorial link, and numbered steps. | +| [`{whats-new}`](whats-new.md) | Recency panel. Dated highlight cards, authored once in `hub-whats-new.yml`. | | [`{explore}`](explore.md) | The browse-everything section. A stack of collapsible accordions. | ## Page skeleton diff --git a/docs/syntax/whats-new.md b/docs/syntax/whats-new.md new file mode 100644 index 0000000000..27c1c1caf1 --- /dev/null +++ b/docs/syntax/whats-new.md @@ -0,0 +1,81 @@ +# What's new + +A recency panel for a [hub page](hub-pages.md). A reader who bookmarks a hub wants a quick answer to "what changed recently" without hunting through release notes. + +See the [docs-builder documentation hub](../examples/products/docs-builder.md) for a rendered panel. + +## Basic + +The common case is one line: + +```markdown +:::{whats-new} +:product: docs-builder +::: +``` + +`:product:` looks the key up in `hub-whats-new.yml` at the root of the documentation set. The content is authored once there and every page that names the same product renders the same panel. One edit updates them all. + +## Where the content lives + +`hub-whats-new.yml` sits beside `changelog.yml` and `redirects.yml`, at the root of the content repository rather than in the build tool. A writer edits the panel without opening docs-builder, and without waiting for a docs-builder release. + +```yaml +products: + docs-builder: + title: What's new in docs-builder + id: whats-new + intro: Recent additions to the toolchain. + release-links: + - label: View release notes + url: /data/release-notes/index.md + items: + - title: Hub pages + description: A product-scoped landing page composed entirely from directives. + link: /syntax/hub-pages.md + date: Aug 2026 + tag: Syntax + featured: true +``` + +| Field | Notes | +|---|---| +| `title` | H2 heading. | +| `id` | Section anchor. Use `whats-new` so `{hero}`'s secondary action can jump to it. | +| `intro` | One-line lead. | +| `release-links` | Links to the full release notes, shown beside the heading. List more than one when a product has several release streams. | +| `upgrade-link` | An upgrade prompt below the grid. Takes `label` and `url`. | +| `items` | The highlight cards. | + +Each item takes a `title`, a `description`, a `link`, a `date` and a `tag`. Mark one item `featured: true` to span two columns. + +The `date` renders as you write it. Use sentence case, for example `Aug 2026`. + +Every field except `title` is optional. The example file uses each one once, so you can start from it and delete what you do not need. + +## Inline body + +Omit `:product:` and give the directive the same schema as a YAML body, for a one-off panel that does not belong in the shared file: + +```markdown +:::{whats-new} +title: What's new +items: + - title: Hub pages + description: A product-scoped landing page. + link: /syntax/hub-pages.md + date: Aug 2026 +::: +``` + +## Scope limit + +The directive reads the file in the current documentation set. It cannot render another repository's panel. + +That is not a syntax gap. Cross-link resolution maps pages through the link index, and a YAML data file is not a page. In an isolated build the other repository is not checked out, so there is no file to read at all. + +Every hub page lives in the same repository as its content file, so this costs nothing today. + +## Links + +Every `release-links[].url`, `upgrade-link.url`, and `items[].link` validates at build time, using the same forms as [`{link-card}`](link-card.md#links). diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css index 564cb73ee3..1701003ba1 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css @@ -370,6 +370,165 @@ color: var(--color-blue-elastic-100, #0b64dd); } + /* What's new ------------------------------------------------------- */ + .hub-whats-new { + @apply mx-auto w-full max-w-5xl; + margin-bottom: 56px; + scroll-margin-top: 120px; + } + .hub-whats-new .hub-wn-header { + @apply mb-6 flex flex-wrap items-end justify-between gap-4; + } + .hub-whats-new .hub-wn-title { + font-size: var(--text-2xl); + font-weight: 700; + color: var(--color-ink-dark); + line-height: 1.2; + margin: 0; + } + .hub-whats-new .hub-wn-intro { + color: var(--color-ink-light); + margin-top: 8px; + max-width: 60ch; + } + .hub-whats-new .hub-wn-rn { + @apply flex flex-shrink-0 flex-wrap items-center gap-x-2 gap-y-1; + } + .hub-whats-new .hub-wn-footer { + @apply mt-4 flex flex-wrap items-center justify-center gap-x-2 gap-y-1; + } + .hub-whats-new .hub-wn-footer-text { + font-size: var(--text-base); + color: var(--color-ink-light); + } + .hub-whats-new .hub-wn-upgrade { + @apply inline-flex items-center gap-1.5; + font-size: var(--text-base); + font-weight: 600; + color: var(--color-blue-elastic-100); + text-decoration: none; + } + .hub-whats-new .hub-wn-upgrade:hover, + .hub-whats-new .hub-wn-upgrade:focus-visible { + text-decoration: underline; + } + .hub-whats-new .hub-wn-upgrade svg { + flex-shrink: 0; + } + .hub-whats-new .hub-wn-rn-label { + font-size: var(--text-base); + font-weight: 600; + color: var(--color-ink-light); + } + .hub-whats-new .hub-wn-rn-list { + @apply m-0 flex flex-wrap items-center p-0; + list-style: none; + } + .hub-whats-new .hub-wn-rn-list li { + @apply inline-flex items-center; + } + .hub-whats-new .hub-wn-rn-list li:not(:last-child)::after { + content: '·'; + color: var(--color-grey-40); + margin: 0 10px; + font-weight: 700; + } + .hub-whats-new .hub-wn-rn-link { + font-size: var(--text-base); + font-weight: 600; + color: var(--color-blue-elastic-100); + text-decoration: none; + } + .hub-whats-new .hub-wn-rn-link:hover, + .hub-whats-new .hub-wn-rn-link:focus-visible { + text-decoration: underline; + } + + /* What's new card grid */ + .hub-whats-new .hub-wn-grid { + @apply m-0 grid list-none p-0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + } + .hub-whats-new .hub-wn-card { + margin: 0; + } + .hub-whats-new .hub-wn-card-featured { + grid-column: span 2; + } + .hub-whats-new .hub-wn-card-link { + @apply flex h-full flex-col gap-2.5; + padding: 18px 20px; + background: var(--color-white); + border: 1px solid var(--color-grey-20); + border-radius: 14px; + text-decoration: none; + color: inherit; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + } + /* Same hover treatment as every other card that is itself a link. The pale blue this + replaced read at 2.12:1 against white, so the state was barely visible. */ + .hub-whats-new a.hub-wn-card-link:hover, + .hub-whats-new .hub-wn-card-link:focus-visible { + border-color: var(--color-grey-80); + box-shadow: 0 2px 10px rgb(0 0 0 / 0.06); + } + .hub-whats-new .hub-wn-card-meta { + @apply flex items-center justify-between gap-2; + font-size: var(--text-sm); + } + .hub-whats-new .hub-wn-card-meta-left { + @apply inline-flex items-center gap-2; + } + /* The date is authored, so it keeps the casing the writer chose. Forcing + uppercase shouts, and it mangles a month name in any language. */ + .hub-whats-new .hub-wn-card-date { + font-weight: 600; + color: var(--color-ink-light); + letter-spacing: 0.03em; + } + .hub-whats-new .hub-wn-card-tag { + color: var(--color-grey-80, #69707d); + font-weight: 500; + } + .hub-whats-new .hub-wn-card-title { + font-size: var(--text-base); + font-weight: 700; + color: var(--color-ink-dark); + line-height: 1.3; + margin: 0; + } + .hub-whats-new .hub-wn-card-featured .hub-wn-card-title { + font-size: var(--text-lg); + } + .hub-whats-new .hub-wn-card-desc { + font-size: var(--text-base); + color: var(--color-ink-light); + line-height: 1.45; + margin: 0; + } + .hub-whats-new .hub-wn-card-more { + @apply mt-auto inline-flex items-center gap-1.5; + font-size: var(--text-base); + font-weight: 600; + color: var(--color-blue-elastic-100); + } + @media (max-width: 900px) { + .hub-whats-new .hub-wn-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + @media (max-width: 640px) { + .hub-whats-new .hub-wn-grid { + grid-template-columns: 1fr; + } + .hub-whats-new .hub-wn-card-featured { + grid-column: span 1; + } + } + /* Zone (section heading) ------------------------------------------ */ .hub-zone { @apply mx-auto w-full max-w-5xl; diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index add4cf3626..d9e56299ad 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -153,6 +153,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{get-started}") > 0) return new GetStartedBlock(this, context); + if (info.IndexOf("{whats-new}") > 0) + return new WhatsNewBlock(this, context); + if (info.IndexOf("{agent-skill}") > 0) return new AgentSkillBlock(this, context); diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 31057f6bd0..904c963f35 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -120,6 +120,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case GetStartedBlock getStartedBlock: WriteGetStarted(renderer, getStartedBlock); return; + case WhatsNewBlock whatsNewBlock: + WriteWhatsNew(renderer, whatsNewBlock); + return; case PageCardBlock pageCardBlock: WritePageCard(renderer, pageCardBlock); return; @@ -270,6 +273,17 @@ private static void WriteLinkCard(HtmlRenderer renderer, LinkCardBlock block) RenderRazorSlice(slice, renderer); } + private static void WriteWhatsNew(HtmlRenderer renderer, WhatsNewBlock block) + { + var slice = WhatsNewView.Create(new WhatsNewViewModel + { + DirectiveBlock = block, + Data = block.Data, + SitePathPrefix = block.Build.UrlPathPrefix + }); + RenderRazorSlice(slice, renderer); + } + private static void WriteGetStarted(HtmlRenderer renderer, GetStartedBlock block) { var data = block.Data; diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs new file mode 100644 index 0000000000..1c54c33e2c --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewBlock.cs @@ -0,0 +1,173 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Collections.Concurrent; +using Elastic.Markdown.Diagnostics; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace Elastic.Markdown.Myst.Directives.Hub; + +/// +/// Renders the "What's new" panel for a product. Two usage shapes: +/// +/// Centralized lookup (preferred): +/// +/// :::{whats-new} +/// :product: kibana +/// ::: +/// +/// The directive looks the product key up in config/whats-new.yml +/// and renders the data declared there. Authors edit one file; any page can +/// surface the panel. +/// +/// Inline override: if no :product: option is provided, +/// the directive expects a YAML body declaring title, items, +/// etc. directly. Useful for one-offs that don't belong in the central +/// feed. +/// +public class WhatsNewBlock(DirectiveBlockParser parser, ParserContext context) + : DirectiveBlock(parser, context) +{ + private const string WhatsNewFileName = "hub-whats-new.yml"; + + private static readonly ConcurrentDictionary CentralConfigCache = new(); + + public override string Directive => "whats-new"; + + public WhatsNewData Data { get; private set; } = WhatsNewData.Empty; + + public override void FinalizeAndValidate(ParserContext context) + { + var product = Prop("product"); + + if (!string.IsNullOrWhiteSpace(product)) + { + var resolved = LoadFromCentralConfig(product); + if (resolved is null) + { + this.EmitError($"{{whats-new}} :product: '{product}' was not found in {WhatsNewFileName} at the root of this documentation set."); + return; + } + Data = resolved; + ValidateLinks(context); + return; + } + + var yaml = HubYamlBody.Extract(this, new BuildContextFileReader(Build.ReadFileSystem)); + if (yaml is null) + { + this.EmitError("{whats-new} requires either a `:product:` option or a YAML body."); + return; + } + + try + { + Data = YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration) ?? WhatsNewData.Empty; + } + catch (YamlException ex) + { + this.EmitError($"{{whats-new}} YAML parse error: {ex.Message}"); + return; + } + + ValidateLinks(context); + } + + private void ValidateLinks(ParserContext context) + { + foreach (var link in Data.ReleaseLinks) + link.Url = DirectiveLinkValidator.ValidateAndResolve(link.Url, this, context); + if (Data.UpgradeLink is { } upgrade) + upgrade.Url = DirectiveLinkValidator.ValidateAndResolve(upgrade.Url, this, context); + foreach (var item in Data.Items) + item.Link = DirectiveLinkValidator.ValidateAndResolve(item.Link, this, context); + } + + /// + /// The panel content lives in the content repository, next to the pages that use it, so a + /// writer edits it without touching the build tool. It is read from the current documentation + /// set, which means the directive cannot render another repository's panel. In an isolated + /// build the other repository is not checked out at all, so there would be no file to read. + /// + private WhatsNewData? LoadFromCentralConfig(string productKey) + { + var path = Path.Combine(Build.DocumentationSourceDirectory.FullName, WhatsNewFileName); + if (!Build.ReadFileSystem.File.Exists(path)) + return null; + + var config = CentralConfigCache.GetOrAdd(path, p => + { + try + { + var yaml = Build.ReadFileSystem.File.ReadAllText(p); + return YamlSerialization.Deserialize(yaml, Build.ProductsConfiguration); + } + catch + { + return null; + } + }); + + if (config?.Products is null) + return null; + return config.Products.TryGetValue(productKey, out var data) ? data : null; + } + + public override IEnumerable GeneratedAnchors => + string.IsNullOrWhiteSpace(Data.Id) ? [] : [Data.Id]; +} + +[YamlSerializable] +public record WhatsNewConfig +{ + [YamlMember(Alias = "products")] + public Dictionary Products { get; set; } = []; +} + +[YamlSerializable] +public record WhatsNewData +{ + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "id")] + public string? Id { get; set; } + + [YamlMember(Alias = "intro")] + public string? Intro { get; set; } + + [YamlMember(Alias = "release-links")] + public LinkCardLink[] ReleaseLinks { get; set; } = []; + + [YamlMember(Alias = "upgrade-link")] + public LinkCardLink? UpgradeLink { get; set; } + + [YamlMember(Alias = "items")] + public WhatsNewItem[] Items { get; set; } = []; + + public static WhatsNewData Empty { get; } = new(); +} + +[YamlSerializable] +public record WhatsNewItem +{ + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "description")] + public string? Description { get; set; } + + [YamlMember(Alias = "link")] + public string? Link { get; set; } + + [YamlMember(Alias = "date")] + public string? Date { get; set; } + + [YamlMember(Alias = "tag")] + public string? Tag { get; set; } + + [YamlMember(Alias = "featured")] + public bool Featured { get; set; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml new file mode 100644 index 0000000000..d61d05ecbe --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewView.cshtml @@ -0,0 +1,117 @@ +@inherits RazorSlice + +@{ + var d = Model.Data; + var upgrade = d.UpgradeLink; + var hasUpgrade = upgrade is not null && !string.IsNullOrWhiteSpace(upgrade.Url) && !string.IsNullOrWhiteSpace(upgrade.Label); +} + +
        +
        +
        + @if (!string.IsNullOrWhiteSpace(d.Title)) + { +

        @d.Title

        + } + @if (!string.IsNullOrWhiteSpace(d.Intro)) + { +

        @d.Intro

        + } +
        + @if (d.ReleaseLinks.Length > 0) + { +
        + Latest release notes: +
          + @foreach (var link in d.ReleaseLinks) + { + if (string.IsNullOrWhiteSpace(link.Url) || string.IsNullOrWhiteSpace(link.Label)) + { + continue; + } +
        • @link.Label
        • + } +
        +
        + } +
        + + @if (d.Items.Length > 0) + { + + } + + @if (hasUpgrade) + { + + } +
        diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs new file mode 100644 index 0000000000..764c756e46 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Hub/WhatsNewViewModel.cs @@ -0,0 +1,10 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Markdown.Myst.Directives.Hub; + +public class WhatsNewViewModel : HubDirectiveViewModel +{ + public required WhatsNewData Data { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 0a40733226..74265537a6 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -517,6 +517,9 @@ protected override void Write(LlmMarkdownRenderer renderer, DirectiveBlock obj) case GetStartedBlock getStartedBlock: WriteGetStartedBlock(renderer, getStartedBlock); return; + case WhatsNewBlock whatsNewBlock: + WriteWhatsNewBlock(renderer, whatsNewBlock); + return; } // Ensure single empty line before directive @@ -583,6 +586,57 @@ private static void WriteHeroBlock(LlmMarkdownRenderer renderer, HeroBlock heroB renderer.EnsureLine(); } + // Dated highlights, so the export keeps the date and tag alongside each title. A reader + // asking "what changed recently in X" wants exactly this list. + private static void WriteWhatsNewBlock(LlmMarkdownRenderer renderer, WhatsNewBlock block) + { + var data = block.Data; + renderer.EnsureBlockSpacing(); + + if (!string.IsNullOrEmpty(data.Title)) + { + renderer.WriteLine($"## {data.Title}"); + renderer.EnsureLine(); + } + if (!string.IsNullOrEmpty(data.Intro)) + { + renderer.WriteLine(data.Intro); + renderer.EnsureLine(); + } + + foreach (var link in data.ReleaseLinks) + WriteHeroAction(renderer, link.Label, link.Url); + + foreach (var item in data.Items) + WriteWhatsNewItem(renderer, item); + + if (data.UpgradeLink is { } upgrade) + WriteHeroAction(renderer, upgrade.Label, upgrade.Url); + + renderer.EnsureLine(); + } + + private static void WriteWhatsNewItem(LlmMarkdownRenderer renderer, WhatsNewItem item) + { + if (string.IsNullOrEmpty(item.Title)) + return; + + renderer.EnsureLine(); + var title = string.IsNullOrEmpty(item.Link) + ? item.Title + : $"[{item.Title}]({HubLinkForLlm(renderer, item.Link)})"; + + var meta = new List(2); + if (!string.IsNullOrEmpty(item.Date)) + meta.Add(item.Date); + if (!string.IsNullOrEmpty(item.Tag)) + meta.Add(item.Tag); + + renderer.WriteLine(meta.Count > 0 ? $"- {title} ({string.Join(", ", meta)})" : $"- {title}"); + if (!string.IsNullOrEmpty(item.Description)) + renderer.WriteLine($" {item.Description}"); + } + // The onboarding path is a sequence, so it exports as an ordered list. Options under a step // become sub-items, each with its command or its link. private static void WriteGetStartedBlock(LlmMarkdownRenderer renderer, GetStartedBlock block) diff --git a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs index 3b01468f1d..49c8f4dfd3 100644 --- a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs @@ -292,6 +292,7 @@ protected override void Write(PlainTextRenderer renderer, DirectiveBlock obj) case CardGroupBlock: case LinkCardBlock: case GetStartedBlock: + case WhatsNewBlock: return; case AgentSkillBlock agentSkillBlock: diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 36bb30ce0a..65ed9abcf7 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -86,4 +86,7 @@ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializ [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedData))] [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedStep))] [YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.GetStartedStepOption))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewData))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewItem))] +[YamlSerializable(typeof(Elastic.Markdown.Myst.Directives.Hub.WhatsNewConfig))] public partial class DocsBuilderYamlStaticContext; diff --git a/tests/authoring/Blocks/Hub/WhatsNew.fs b/tests/authoring/Blocks/Hub/WhatsNew.fs new file mode 100644 index 0000000000..fd93206767 --- /dev/null +++ b/tests/authoring/Blocks/Hub/WhatsNew.fs @@ -0,0 +1,110 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information +module ``AuthoringTests``.``block elements``.``hub``.``whats new elements`` + +open Xunit +open authoring + +// The :product: path reads hub-whats-new.yml from the documentation set root. The authoring +// harness builds from a temporary set with no such file, so these cover the inline-body path +// and the error raised when a product key cannot be resolved. + +type ``whats new with an inline body`` () = + static let markdown = Setup.Markdown """ +:::{whats-new} +title: What's new in docs-builder +id: whats-new +intro: Recent additions to the toolchain. +items: + - title: Hub pages + description: A product-scoped landing page. + link: /index.md + date: AUG 2026 + tag: Syntax + featured: true + - title: Explore sections + description: Collapse a long link list. + link: /index.md + date: AUG 2026 + tag: Syntax +::: +""" + + [] + let ``renders the heading and intro`` () = + markdown |> convertsToContainingHtml """

        What's new in docs-builder

        """ + + [] + let ``renders a card per item`` () = + markdown |> convertsToContainingHtml """

        Hub pages

        """ + + [] + let ``spans the featured card across two columns`` () = + markdown |> convertsToContainingRawHtml """