From 7b31304d3c2d93810cbb82ac49e603c26e701114 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 14 Aug 2026 17:03:09 -0500 Subject: [PATCH 1/5] Add catalogue of related learning links --- config/related-learning.yml | 21 +++ docs/_docset.yml | 1 + docs/documentation/catalog/index.md | 1 + .../documentation/catalog/related-learning.md | 49 ++++++ src/Elastic.Codex/Page/Index.cshtml | 1 + .../BuildContext.cs | 3 + .../ConfigurationFileProvider.cs | 6 +- ...Elastic.Documentation.Configuration.csproj | 1 + .../RelatedLearningConfiguration.cs | 134 +++++++++++++++++ .../Serialization/YamlStaticContext.cs | 4 + .../Assets/markdown/related-learning.css | 23 +++ .../Assets/styles.css | 1 + src/Elastic.Markdown/HtmlWriter.cs | 2 +- src/Elastic.Markdown/IO/MarkdownFile.cs | 7 + .../Directives/DirectiveMarkdownExtension.cs | 4 + .../RelatedLearning/RelatedLearningBlock.cs | 58 +++++++ .../RelatedLearningHtmlRenderer.cs | 22 +++ .../LlmMarkdown/LlmBlockRenderers.cs | 11 ++ .../LlmMarkdown/LlmMarkdownRenderer.cs | 1 + .../PlainText/PlainTextBlockRenderers.cs | 14 ++ .../Renderers/PlainText/PlainTextRenderer.cs | 1 + src/Elastic.Markdown/Page/Index.cshtml | 1 + .../Page/RelatedLearningView.cshtml | 14 ++ .../Page/RelatedLearningViewModel.cs | 12 ++ .../Elastic.ApiExplorer.Tests/TestHelpers.cs | 2 +- .../RelatedLearningConfigurationTests.cs | 97 ++++++++++++ .../RelatedLearningMarkdownTests.cs | 142 ++++++++++++++++++ .../RelatedLearningViewTests.cs | 50 ++++++ 28 files changed, 680 insertions(+), 3 deletions(-) create mode 100644 config/related-learning.yml create mode 100644 docs/documentation/catalog/related-learning.md create mode 100644 src/Elastic.Documentation.Configuration/RelatedLearning/RelatedLearningConfiguration.cs create mode 100644 src/Elastic.Documentation.Site/Assets/markdown/related-learning.css create mode 100644 src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningBlock.cs create mode 100644 src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningHtmlRenderer.cs create mode 100644 src/Elastic.Markdown/Page/RelatedLearningView.cshtml create mode 100644 src/Elastic.Markdown/Page/RelatedLearningViewModel.cs create mode 100644 tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs create mode 100644 tests/Elastic.Markdown.Tests/RelatedLearningMarkdownTests.cs create mode 100644 tests/Elastic.Markdown.Tests/RelatedLearningViewTests.cs diff --git a/config/related-learning.yml b/config/related-learning.yml new file mode 100644 index 0000000000..876f88e853 --- /dev/null +++ b/config/related-learning.yml @@ -0,0 +1,21 @@ +links: + apm-with-elastic: + title: APM with Elastic + url: https://www.elastic.co/training/apm-with-elastic + pages: + - docs-content://solutions/observability/apm/index.md + elastic-agent: + title: Elastic Agent + url: https://www.elastic.co/training/elastic-agent + pages: + - docs-content://reference/fleet/index.md + index-basics: + title: Index Basics + url: https://www.elastic.co/training/index-basics + pages: + - docs-content://manage-data/data-store/index-basics.md + data-types-and-mappings: + title: Data Types and Mappings + url: https://www.elastic.co/training/data-types-and-mappings + pages: + - docs-content://manage-data/data-store/mapping.md diff --git a/docs/_docset.yml b/docs/_docset.yml index 89f358d3a2..67b9bc0f92 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -209,6 +209,7 @@ toc: - file: versions.md - file: synonyms.md - file: legacy-url-mappings.md + - file: related-learning.md - file: redirects.md # Structured diff --git a/docs/documentation/catalog/index.md b/docs/documentation/catalog/index.md index 75846c9591..12fcb56edb 100644 --- a/docs/documentation/catalog/index.md +++ b/docs/documentation/catalog/index.md @@ -9,3 +9,4 @@ The catalog defines the global product and versioning metadata used across docum - **[products.yml](./products.md)** — the product catalog: identifiers, display names, and feature flags - **[versions.yml](./versions.md)** — versioning schemes that tie products to version sets - **[search.yml](./synonyms.md)** — search synonyms and query rules for the Elasticsearch exporter +- **[related-learning.yml](./related-learning.md)** — learning destinations shown as a **Related learning** section on mapped docs pages diff --git a/docs/documentation/catalog/related-learning.md b/docs/documentation/catalog/related-learning.md new file mode 100644 index 0000000000..414cdbf1d3 --- /dev/null +++ b/docs/documentation/catalog/related-learning.md @@ -0,0 +1,49 @@ +--- +navigation_title: related-learning.yml +--- + +# Related learning + +The [`related-learning.yml`](https://github.com/elastic/docs-builder/blob/main/config/related-learning.yml) file is a global catalog of learning destinations (training modules, labs, and similar). When a documentation page matches an entry's `pages` list, {{dbuild}} appends a **Related learning** heading and list to that page automatically. The heading is a normal H2, so it appears in **On this page**. + +This catalog ships with docs-builder and is available in both isolated and assembler builds. Content repositories pick up catalog changes on the next docs-builder version. + +## Example + +```yml +links: + apm-with-elastic: + title: APM with Elastic + url: https://www.elastic.co/training/apm-with-elastic + pages: + - docs-content://solutions/observability/apm/index.md + index-basics: + title: Index Basics + url: https://www.elastic.co/training/index-basics + pages: + - docs-content://manage-data/data-store/index-basics.md +``` + +## Structure + +`links` +: A YAML mapping where each key is a stable link ID (typically the training URL slug). Each value is a mapping with: +* `title` (required): Link text shown under the **Related learning** heading. +* `url` (required): Absolute `https://` destination. +* `pages` (optional): List of documentation pages that should show this link. Each entry **must** be a qualified cross-link of the form `{repository}://path.md` (same scheme as TOC and cross-links). Unqualified paths are rejected when the catalog loads. + +## How matching works + +For each page, {{dbuild}} builds `{current-repository}://{path-relative-to-docset}` and looks for catalog entries whose `pages` list contains that exact cross-link. + +- Matching is case-sensitive and uses forward slashes. +- A page can match more than one link. Matching links appear in **catalog file order**. +- If a listed file is missing from the named repository, the build does not fail; that link simply does not appear for any rendered page. + +## Add a learning module + +1. Open [`config/related-learning.yml`](https://github.com/elastic/docs-builder/blob/main/config/related-learning.yml) in docs-builder. +2. Add a new key under `links` with `title`, `url`, and one or more `pages` cross-links. +3. Open a pull request. After the next docs-builder release, assembler and isolated builds that use that version show the section on the mapped pages. + +To stop showing a link on a page, remove that page from the entry's `pages` list. diff --git a/src/Elastic.Codex/Page/Index.cshtml b/src/Elastic.Codex/Page/Index.cshtml index 4529b9b85b..f44f978eda 100644 --- a/src/Elastic.Codex/Page/Index.cshtml +++ b/src/Elastic.Codex/Page/Index.cshtml @@ -1,3 +1,4 @@ +@using Elastic.Markdown.Page @using System.Text.Json @using Elastic.Documentation @using Elastic.Documentation.Configuration diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 99835cdd01..a33b3c3d2f 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.RelatedLearning; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; @@ -52,6 +53,7 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public ProductsConfiguration ProductsConfiguration { get; } public LegacyUrlMappingConfiguration LegacyUrlMappings { get; } public SearchConfiguration SearchConfiguration { get; } + public RelatedLearningConfiguration RelatedLearningConfiguration { get; } public IEnvironmentVariables Environment { get; } public IDiagnosticsCollector Collector { get; } public bool Force { get; init; } @@ -90,6 +92,7 @@ public BuildContext( ConfigurationFileProvider = configurationContext.ConfigurationFileProvider; ProductsConfiguration = configurationContext.ProductsConfiguration; LegacyUrlMappings = configurationContext.LegacyUrlMappings; + RelatedLearningConfiguration = configurationContext.ConfigurationFileProvider.CreateRelatedLearningConfiguration(); Endpoints = configurationContext.Endpoints; GoogleTagManager = new GoogleTagManagerConfiguration { Enabled = false }; diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index 64c1268a0a..d8521c453c 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -62,7 +62,7 @@ public ConfigurationFileProvider( ConfigurationSource = source; else { - string[] spotChecks = ["navigation.yml", "versions.yml", "products.yml", "assembler.yml", "search.yml"]; + string[] spotChecks = ["navigation.yml", "versions.yml", "products.yml", "assembler.yml", "search.yml", "related-learning.yml"]; var defaultSource = fileSystem.Directory.Exists(LocalConfigurationDirectory) && spotChecks.All(f => fileSystem.File.Exists(Path.Join(LocalConfigurationDirectory, f))) @@ -107,6 +107,7 @@ public ConfigurationFileProvider( LegacyUrlMappingsFile = CreateTemporaryConfigurationFile("legacy-url-mappings.yml"); // reading from synonyms.yml is temporary. If you spot this again as a future reader, feel free to remove it. SearchFile = CreateTemporaryConfigurationFile("search.yml", "synonyms.yml"); + RelatedLearningFile = CreateTemporaryConfigurationFile("related-learning.yml"); } public bool SkipPrivateRepositories { get; } @@ -124,6 +125,9 @@ public ConfigurationFileProvider( public IFileInfo LegacyUrlMappingsFile { get; } public IFileInfo SearchFile { get; } + + public IFileInfo RelatedLearningFile { get; } + /// /// Repoints at config/navigation_preview.yml. /// Must be called before any reader accesses — diff --git a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj index 0639e36105..b410962d2a 100644 --- a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj +++ b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj @@ -41,5 +41,6 @@ + diff --git a/src/Elastic.Documentation.Configuration/RelatedLearning/RelatedLearningConfiguration.cs b/src/Elastic.Documentation.Configuration/RelatedLearning/RelatedLearningConfiguration.cs new file mode 100644 index 0000000000..7157f5be9d --- /dev/null +++ b/src/Elastic.Documentation.Configuration/RelatedLearning/RelatedLearningConfiguration.cs @@ -0,0 +1,134 @@ +// 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.Immutable; +using Elastic.Documentation.Links; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.Configuration.RelatedLearning; + +/// +/// Global catalog of elastic.co learning links and the docs pages that should show them. +/// +public record RelatedLearningConfiguration +{ + public static RelatedLearningConfiguration Empty { get; } = new() { Links = [] }; + + /// Catalog entries in file order. + public required IReadOnlyList Links { get; init; } + + /// + /// Returns catalog links whose include + /// {repositoryName}://{relativePath}, preserving catalog file order. + /// + public IReadOnlyList GetLinksForPage(string repositoryName, string relativePath) + { + if (Links.Count == 0) + return []; + + var crossLink = $"{repositoryName}://{relativePath.Replace('\\', '/')}"; + return Links.Where(l => l.Pages.Contains(crossLink)).ToArray(); + } +} + +/// A single named learning destination from related-learning.yml. +public record RelatedLearningLink +{ + public required string Id { get; init; } + public required string Title { get; init; } + public required string Url { get; init; } + + /// Qualified page cross-links ({repo}://path.md) that show this link. + public IReadOnlyList Pages { get; init; } = []; +} + +[YamlSerializable] +internal sealed class RelatedLearningConfigDto +{ + [YamlMember(Alias = "links")] + public Dictionary Links { get; set; } = []; +} + +[YamlSerializable] +internal sealed class RelatedLearningLinkDto +{ + [YamlMember(Alias = "title")] + public string Title { get; set; } = string.Empty; + + [YamlMember(Alias = "url")] + public string Url { get; set; } = string.Empty; + + [YamlMember(Alias = "pages")] + public List Pages { get; set; } = []; +} + +public static class RelatedLearningConfigurationExtensions +{ + public static RelatedLearningConfiguration CreateRelatedLearningConfiguration(this ConfigurationFileProvider provider) + { + var file = provider.RelatedLearningFile; + if (!file.Exists) + return RelatedLearningConfiguration.Empty; + + using var reader = file.OpenText(); + return Parse(reader.ReadToEnd()); + } + + /// Parses and validates a related-learning.yml document. + public static RelatedLearningConfiguration Parse(string yaml) + { + var dto = ConfigurationFileProvider.Deserializer.Deserialize(yaml) + ?? throw new InvalidOperationException("related-learning.yml deserialized to null."); + return FromDto(dto); + } + + /// Parses and validates a catalog DTO. Used by tests and the file loader. + internal static RelatedLearningConfiguration FromDto(RelatedLearningConfigDto dto) + { + var links = new List(dto.Links.Count); + foreach (var (id, linkDto) in dto.Links) + { + if (string.IsNullOrWhiteSpace(linkDto.Title)) + throw new InvalidOperationException($"related-learning.yml link '{id}' is missing required 'title'."); + if (string.IsNullOrWhiteSpace(linkDto.Url)) + throw new InvalidOperationException($"related-learning.yml link '{id}' is missing required 'url'."); + + var pages = new List(linkDto.Pages.Count); + foreach (var page in linkDto.Pages) + { + if (!IsQualifiedPageCrossLink(page)) + { + throw new InvalidOperationException( + $"related-learning.yml link '{id}' has unqualified page '{page}'. " + + "Every pages entry must be a cross-link of the form '{{repo}}://path.md'."); + } + pages.Add(page.Replace('\\', '/')); + } + + links.Add(new RelatedLearningLink + { + Id = id, + Title = linkDto.Title, + Url = linkDto.Url, + Pages = pages.ToImmutableArray() + }); + } + + return new RelatedLearningConfiguration { Links = links.ToImmutableArray() }; + } + + /// + /// A qualified page cross-link is {repository}://{relativePath} — same form as TOC/cross-links. + /// + internal static bool IsQualifiedPageCrossLink(string page) + { + if (!CrossLinkValidator.IsValidCrossLink(page, out _)) + return false; + var separator = page.IndexOf("://", StringComparison.Ordinal); + if (separator <= 0) + return false; + var path = page.AsSpan(separator + 3).Trim(); + return !path.IsEmpty; + } +} diff --git a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs index d460a7298c..9c63851b9d 100644 --- a/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs +++ b/src/Elastic.Documentation.Configuration/Serialization/YamlStaticContext.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.RelatedLearning; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Toc; @@ -51,6 +52,9 @@ namespace Elastic.Documentation.Configuration.Serialization; [YamlSerializable(typeof(QueryRuleDto))] [YamlSerializable(typeof(QueryRuleCriteriaDto))] [YamlSerializable(typeof(QueryRuleActionsDto))] +// Related learning catalog +[YamlSerializable(typeof(RelatedLearningConfigDto))] +[YamlSerializable(typeof(RelatedLearningLinkDto))] // Release notes / changelog YAML DTOs [YamlSerializable(typeof(ChangelogEntryDto))] [YamlSerializable(typeof(ProductInfoDto))] diff --git a/src/Elastic.Documentation.Site/Assets/markdown/related-learning.css b/src/Elastic.Documentation.Site/Assets/markdown/related-learning.css new file mode 100644 index 0000000000..e9f4fe6fae --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/related-learning.css @@ -0,0 +1,23 @@ +.heading-wrapper:has(+ .related-learning) { + margin-block-start: 2.5rem; + padding-block-start: 1.5rem; + border-top: 1px solid var(--color-grey-20); +} + +.related-learning { + margin-block-start: 0.75rem; +} + +.related-learning__list { + list-style: disc; + padding-inline-start: 1.25rem; + margin: 0; +} + +.related-learning__item { + margin-block: 0.35rem; +} + +.related-learning__link { + @apply text-blue-elastic hover:underline; +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index db3847d472..8ecc44d9fb 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -31,6 +31,7 @@ @import './markdown/contributors.css'; @import './markdown/storybook.css'; @import './markdown/hub.css'; +@import './markdown/related-learning.css'; @import './api-docs.css'; @import 'tippy.js/dist/tippy.css'; diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 40cd683c0e..8579f40304 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -214,7 +214,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc GitHubRef = DocumentationSet.Context.Git.GitHubRef, Branding = DocumentationSet.Configuration.Branding, RedirectUrl = markdown.RedirectUrl, - Cta = cta + Cta = cta, }); return new RenderResult diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index ac18e68285..dba146c45a 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -6,6 +6,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.RelatedLearning; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Navigation; using Elastic.Markdown.Helpers; @@ -18,6 +19,7 @@ using Elastic.Markdown.Myst.Directives.Stepper; using Elastic.Markdown.Myst.FrontMatter; using Elastic.Markdown.Myst.InlineParsers; +using Elastic.Markdown.Myst.RelatedLearning; using Markdig; using Markdig.Extensions.Yaml; using Markdig.Syntax; @@ -29,6 +31,7 @@ public record MarkdownFile : DocumentationFile, ITableOfContentsScope, IDocument private readonly IFileInfo _configurationFile; private readonly IReadOnlyDictionary _globalSubstitutions; + private readonly RelatedLearningConfiguration _relatedLearning; public MarkdownFile( IFileInfo sourceFile, @@ -46,6 +49,7 @@ BuildContext build Collector = build.Collector; _configurationFile = build.Configuration.SourceFile; _globalSubstitutions = build.Configuration.Substitutions; + _relatedLearning = build.RelatedLearningConfiguration; //may be updated by DocumentationGroup.ProcessTocItems //todo refactor mutability of MarkdownFile as a whole ScopeDirectory = build.Configuration.ScopeDirectory; @@ -138,6 +142,7 @@ public async Task ParseFullAsync(Func(new SectionedHeadingRenderer()); + if (!renderer.ObjectRenderers.Contains()) + _ = renderer.ObjectRenderers.Add(new RelatedLearningHtmlRenderer()); + _ = renderer.ObjectRenderers.Replace(new WrappedTableRenderer()); } } diff --git a/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningBlock.cs b/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningBlock.cs new file mode 100644 index 0000000000..55dca1eaf8 --- /dev/null +++ b/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningBlock.cs @@ -0,0 +1,58 @@ +// 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.Configuration.RelatedLearning; +using Markdig.Extensions.Footnotes; +using Markdig.Parsers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace Elastic.Markdown.Myst.RelatedLearning; + +public sealed class RelatedLearningBlock : LeafBlock +{ + public const string Heading = "Related learning"; + public const string Anchor = "related-learning-heading"; + + public RelatedLearningBlock(BlockParser? parser) : base(parser) { } + + public required IReadOnlyList Links { get; init; } + + public static void Append(MarkdownDocument document, IReadOnlyList links) + { + if (links.Count == 0) + return; + if (document.Any(static b => b is RelatedLearningBlock)) + return; + + var heading = new HeadingBlock(null) + { + Level = 2, + Line = int.MaxValue - 1 + }; + heading.SetData("header", Heading); + heading.SetData("anchor", Anchor); + heading.Inline = new ContainerInline(); + heading.Inline.AppendChild(new LiteralInline(Heading)); + + var list = new RelatedLearningBlock(null) + { + Links = links, + Line = int.MaxValue + }; + + var insertAt = document.Count; + for (var i = 0; i < document.Count; i++) + { + if (document[i] is FootnoteGroup) + { + insertAt = i; + break; + } + } + + document.Insert(insertAt, heading); + document.Insert(insertAt + 1, list); + } +} diff --git a/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningHtmlRenderer.cs b/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningHtmlRenderer.cs new file mode 100644 index 0000000000..6d4a1230c8 --- /dev/null +++ b/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningHtmlRenderer.cs @@ -0,0 +1,22 @@ +// 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.Diagnostics.CodeAnalysis; +using Elastic.Markdown.Page; +using Markdig.Renderers; +using Markdig.Renderers.Html; +using RazorSlices; + +namespace Elastic.Markdown.Myst.RelatedLearning; + +public sealed class RelatedLearningHtmlRenderer : HtmlObjectRenderer +{ + [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly")] + protected override void Write(HtmlRenderer renderer, RelatedLearningBlock obj) + { + var slice = RelatedLearningView.Create(new RelatedLearningViewModel { Links = obj.Links }); + var html = slice.RenderAsync().GetAwaiter().GetResult(); + _ = renderer.Write(html); + } +} diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 74265537a6..c96aae14d7 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -17,6 +17,7 @@ using Elastic.Markdown.Myst.Directives.Math; using Elastic.Markdown.Myst.Directives.Settings; using Elastic.Markdown.Myst.Directives.Storybook; +using Elastic.Markdown.Myst.RelatedLearning; using Markdig.Extensions.DefinitionLists; using Markdig.Extensions.Tables; using Markdig.Extensions.Yaml; @@ -183,6 +184,16 @@ protected override void Write(LlmMarkdownRenderer renderer, HeadingBlock obj) } } +public class LlmRelatedLearningRenderer : MarkdownObjectRenderer +{ + protected override void Write(LlmMarkdownRenderer renderer, RelatedLearningBlock obj) + { + renderer.EnsureBlockSpacing(); + foreach (var link in obj.Links) + renderer.WriteLine($"- [{link.Title}]({link.Url})"); + } +} + public class LlmParagraphRenderer : MarkdownObjectRenderer { protected override void Write(LlmMarkdownRenderer renderer, ParagraphBlock obj) diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmMarkdownRenderer.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmMarkdownRenderer.cs index bf6a9700f7..d8834a78d8 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmMarkdownRenderer.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmMarkdownRenderer.cs @@ -91,6 +91,7 @@ public LlmMarkdownRenderer(TextWriter writer) : base(writer) // Add default object renderers for CommonMark elements ObjectRenderers.Add(new LlmHeadingRenderer()); + ObjectRenderers.Add(new LlmRelatedLearningRenderer()); ObjectRenderers.Add(new LlmParagraphRenderer()); ObjectRenderers.Add(new LlmDefinitionItemRenderer()); ObjectRenderers.Add(new LlmDefinitionListRenderer()); diff --git a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs index 49c8f4dfd3..af0ed14034 100644 --- a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextBlockRenderers.cs @@ -16,6 +16,7 @@ using Elastic.Markdown.Myst.Directives.Math; using Elastic.Markdown.Myst.Directives.Settings; using Elastic.Markdown.Myst.Directives.Tabs; +using Elastic.Markdown.Myst.RelatedLearning; using Elastic.Markdown.Myst.Renderers.LlmMarkdown; using Markdig.Extensions.DefinitionLists; using Markdig.Extensions.Tables; @@ -50,6 +51,19 @@ protected override void Write(PlainTextRenderer renderer, HeadingBlock obj) } } +public class PlainTextRelatedLearningRenderer : MarkdownObjectRenderer +{ + protected override void Write(PlainTextRenderer renderer, RelatedLearningBlock obj) + { + renderer.EnsureBlockSpacing(); + foreach (var link in obj.Links) + { + renderer.Write(link.Title); + renderer.EnsureLine(); + } + } +} + /// /// Renders paragraphs as plain text /// diff --git a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextRenderer.cs b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextRenderer.cs index cd594eceb9..3376eb743c 100644 --- a/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextRenderer.cs +++ b/src/Elastic.Markdown/Myst/Renderers/PlainText/PlainTextRenderer.cs @@ -82,6 +82,7 @@ public PlainTextRenderer(TextWriter writer) : base(writer) ObjectRenderers.Add(new PlainTextDirectiveRenderer()); ObjectRenderers.Add(new PlainTextCodeBlockRenderer()); ObjectRenderers.Add(new PlainTextHeadingRenderer()); + ObjectRenderers.Add(new PlainTextRelatedLearningRenderer()); ObjectRenderers.Add(new PlainTextParagraphRenderer()); ObjectRenderers.Add(new PlainTextListRenderer()); ObjectRenderers.Add(new PlainTextQuoteBlockRenderer()); diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index d7040b304f..fdc7fcb0a9 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -1,3 +1,4 @@ +@using Elastic.Markdown.Page @using System.Text.Json @using Elastic.Documentation @using Elastic.Documentation.Configuration diff --git a/src/Elastic.Markdown/Page/RelatedLearningView.cshtml b/src/Elastic.Markdown/Page/RelatedLearningView.cshtml new file mode 100644 index 0000000000..95b58d6e41 --- /dev/null +++ b/src/Elastic.Markdown/Page/RelatedLearningView.cshtml @@ -0,0 +1,14 @@ +@inherits RazorSlice +@if (Model.Links is { Count: > 0 }) +{ + +} diff --git a/src/Elastic.Markdown/Page/RelatedLearningViewModel.cs b/src/Elastic.Markdown/Page/RelatedLearningViewModel.cs new file mode 100644 index 0000000000..ac2cbddbb0 --- /dev/null +++ b/src/Elastic.Markdown/Page/RelatedLearningViewModel.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 + +using Elastic.Documentation.Configuration.RelatedLearning; + +namespace Elastic.Markdown.Page; + +public class RelatedLearningViewModel +{ + public required IReadOnlyList Links { get; init; } +} diff --git a/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs b/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs index 3b03b19924..6280c61ad5 100644 --- a/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs +++ b/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs @@ -56,7 +56,7 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, LegacyUrlMappings = new LegacyUrlMappingConfiguration { Mappings = [] }, - SearchConfiguration = search + SearchConfiguration = search, }; } diff --git a/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs new file mode 100644 index 0000000000..4e1b8269b5 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs @@ -0,0 +1,97 @@ +// 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.IO.Abstractions; +using AwesomeAssertions; +using Elastic.Documentation.Configuration.RelatedLearning; +using Elastic.Documentation.FileSystems; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class RelatedLearningConfigurationTests +{ + [Fact] + public void EmbeddedCatalog_LoadsFourTrainingModules() + { + var config = LoadActualCatalog(); + + config.Links.Should().HaveCount(4); + config.Links.Select(l => l.Id).Should().Equal( + "apm-with-elastic", + "elastic-agent", + "index-basics", + "data-types-and-mappings"); + config.Links[0].Title.Should().Be("APM with Elastic"); + config.Links[0].Url.Should().Be("https://www.elastic.co/training/apm-with-elastic"); + config.Links[0].Pages.Should().Equal("docs-content://solutions/observability/apm/index.md"); + } + + [Fact] + public void Parse_UnqualifiedPage_Throws() + { + const string yaml = + """ + links: + index-basics: + title: Index Basics + url: https://www.elastic.co/training/index-basics + pages: + - manage-data/data-store/index-basics.md + """; + + var act = () => RelatedLearningConfigurationExtensions.Parse(yaml); + + act.Should().Throw() + .WithMessage("*unqualified page*manage-data/data-store/index-basics.md*"); + } + + [Fact] + public void GetLinksForPage_MatchingDocsContentPath_ReturnsLink() + { + var config = LoadActualCatalog(); + + var links = config.GetLinksForPage("docs-content", "manage-data/data-store/index-basics.md"); + + links.Should().ContainSingle() + .Which.Id.Should().Be("index-basics"); + } + + [Fact] + public void GetLinksForPage_SamePathDifferentRepository_ReturnsEmpty() + { + var config = LoadActualCatalog(); + + var links = config.GetLinksForPage("elasticsearch", "manage-data/data-store/index-basics.md"); + + links.Should().BeEmpty(); + } + + [Fact] + public void GetLinksForPage_UnmappedPath_ReturnsEmpty() + { + var config = LoadActualCatalog(); + + var links = config.GetLinksForPage("docs-content", "getting-started/index.md"); + + links.Should().BeEmpty(); + } + + [Fact] + public void GetLinksForPage_DoesNotReturnUnrelatedMappedLinks() + { + var config = LoadActualCatalog(); + + var links = config.GetLinksForPage("docs-content", "manage-data/data-store/index-basics.md"); + + links.Should().NotContain(l => l.Id == "apm-with-elastic"); + } + + private static RelatedLearningConfiguration LoadActualCatalog() + { + var fileSystem = new FileSystem(); + var provider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem)); + return provider.CreateRelatedLearningConfiguration(); + } +} diff --git a/tests/Elastic.Markdown.Tests/RelatedLearningMarkdownTests.cs b/tests/Elastic.Markdown.Tests/RelatedLearningMarkdownTests.cs new file mode 100644 index 0000000000..82f067bb42 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/RelatedLearningMarkdownTests.cs @@ -0,0 +1,142 @@ +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; +using Elastic.Markdown.IO; +using Elastic.Markdown.Myst.RelatedLearning; +using Markdig.Syntax; + +namespace Elastic.Markdown.Tests; + +public class RelatedLearningMappedPageTests(ITestOutputHelper output) + : RelatedLearningPageTest( + output, + "docs/manage-data/data-store/index-basics.md", + """ + # Index basics + + Index documents into Elasticsearch. + """, + repositoryName: "docs-content") +{ + [Fact] + public void InjectsHeadingAndLinks() + { + Document.Descendants() + .Should() + .Contain(h => h.GetData("anchor") as string == RelatedLearningBlock.Anchor); + Document.Descendants().Should().ContainSingle(); + + Html.Should().Contain("id=\"related-learning-heading\""); + Html.Should().Contain("class=\"related-learning\""); + Html.Should().Contain("href=\"https://www.elastic.co/training/index-basics\""); + Html.Should().Contain("target=\"_blank\""); + Html.Should().Contain(">Index Basics"); + } + + [Fact] + public void AddsHeadingToOnThisPage() + { + File.PageTableOfContent.Should().ContainKey("related-learning-heading"); + File.PageTableOfContent["related-learning-heading"].Heading.Should().Be("Related learning"); + File.PageTableOfContent["related-learning-heading"].Level.Should().Be(2); + } +} + +public class RelatedLearningUnmappedPageTests(ITestOutputHelper output) + : RelatedLearningPageTest( + output, + "docs/getting-started/index.md", + """ + # Getting started + + No matching catalog entry. + """, + repositoryName: "docs-content") +{ + [Fact] + public void DoesNotInjectSection() + { + Document.Descendants().Should().BeEmpty(); + File.PageTableOfContent.Should().NotContainKey("related-learning-heading"); + Html.Should().NotContain("class=\"related-learning\""); + Html.Should().NotContain("id=\"related-learning-heading\""); + } +} + +public class RelatedLearningWrongRepositoryTests(ITestOutputHelper output) + : RelatedLearningPageTest( + output, + "docs/manage-data/data-store/index-basics.md", + """ + # Index basics + + Same path, different repository. + """, + repositoryName: "docs-builder") +{ + [Fact] + public void DoesNotInjectSection() + { + Document.Descendants().Should().BeEmpty(); + File.PageTableOfContent.Should().NotContainKey("related-learning-heading"); + Html.Should().NotContain("class=\"related-learning\""); + } +} + +public abstract class RelatedLearningPageTest : IAsyncLifetime +{ + protected MarkdownFile File { get; } + protected string Html { get; private set; } + protected MarkdownDocument Document { get; private set; } + private TestDiagnosticsCollector Collector { get; } + private DocumentationSet Set { get; } + + protected RelatedLearningPageTest(ITestOutputHelper output, string relativePath, string content, string repositoryName) + { + var fileSystem = new MockFileSystem(new Dictionary + { + { relativePath, new MockFileData(content) } + }, new MockFileSystemOptions + { + CurrentDirectory = Paths.WorkingDirectoryRoot.FullName + }); + + var root = fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/")); + fileSystem.GenerateDocSetYaml(root); + Collector = new TestDiagnosticsCollector(output); + var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); + var git = new GitCheckoutInformation + { + Branch = "main", + Remote = $"elastic/{repositoryName}", + Ref = "test", + RepositoryName = repositoryName + }; + var context = new BuildContext(Collector, TestHelpers.CreateDocumentationFileSystem(fileSystem, root, git), configurationContext); + Set = new DocumentationSet(context, new TestLoggerFactory(output), new TestCrossLinkResolver()); + File = Set.TryFindDocument(fileSystem.FileInfo.New(relativePath)) as MarkdownFile + ?? throw new NullReferenceException(); + Html = default!; + Document = default!; + } + + public async ValueTask InitializeAsync() + { + _ = Collector.StartAsync(TestContext.Current.CancellationToken); + await Set.ResolveDirectoryTree(TestContext.Current.CancellationToken); + Document = await File.ParseFullAsync(Set.TryFindDocumentByRelativePath, TestContext.Current.CancellationToken); + Html = MarkdownFile.CreateHtml(Document); + await Collector.StopAsync(TestContext.Current.CancellationToken); + } + + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } +} diff --git a/tests/Elastic.Markdown.Tests/RelatedLearningViewTests.cs b/tests/Elastic.Markdown.Tests/RelatedLearningViewTests.cs new file mode 100644 index 0000000000..a2ab6d615e --- /dev/null +++ b/tests/Elastic.Markdown.Tests/RelatedLearningViewTests.cs @@ -0,0 +1,50 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Configuration.RelatedLearning; +using Elastic.Markdown.Page; +using RazorSlices; + +namespace Elastic.Markdown.Tests; + +public class RelatedLearningViewTests +{ + [Fact] + public async Task RendersExternalLinksWithBlankTarget() + { + var slice = RelatedLearningView.Create(new RelatedLearningViewModel + { + Links = + [ + new RelatedLearningLink + { + Id = "index-basics", + Title = "Index Basics", + Url = "https://www.elastic.co/training/index-basics", + Pages = ["docs-content://manage-data/data-store/index-basics.md"] + } + ] + }); + + var html = await slice.RenderAsync(cancellationToken: TestContext.Current.CancellationToken); + + html.Should().Contain("href=\"https://www.elastic.co/training/index-basics\""); + html.Should().Contain("target=\"_blank\""); + html.Should().Contain("rel=\"noopener noreferrer\""); + html.Should().Contain(">Index Basics"); + html.Should().NotContain(" Date: Fri, 14 Aug 2026 17:12:32 -0500 Subject: [PATCH 2/5] Lint --- .../Myst/Directives/DirectiveMarkdownExtension.cs | 2 +- .../Myst/RelatedLearning/RelatedLearningBlock.cs | 8 +++----- .../RelatedLearningMarkdownTests.cs | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs index b1f4ee1381..14ad072391 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs @@ -63,7 +63,7 @@ public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) _ = renderer.ObjectRenderers.Replace(new SectionedHeadingRenderer()); if (!renderer.ObjectRenderers.Contains()) - _ = renderer.ObjectRenderers.Add(new RelatedLearningHtmlRenderer()); + renderer.ObjectRenderers.Add(new RelatedLearningHtmlRenderer()); _ = renderer.ObjectRenderers.Replace(new WrappedTableRenderer()); } diff --git a/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningBlock.cs b/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningBlock.cs index 55dca1eaf8..a2002765a1 100644 --- a/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningBlock.cs +++ b/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningBlock.cs @@ -10,13 +10,11 @@ namespace Elastic.Markdown.Myst.RelatedLearning; -public sealed class RelatedLearningBlock : LeafBlock +public sealed class RelatedLearningBlock(BlockParser? parser) : LeafBlock(parser) { public const string Heading = "Related learning"; public const string Anchor = "related-learning-heading"; - public RelatedLearningBlock(BlockParser? parser) : base(parser) { } - public required IReadOnlyList Links { get; init; } public static void Append(MarkdownDocument document, IReadOnlyList links) @@ -26,7 +24,7 @@ public static void Append(MarkdownDocument document, IReadOnlyList b is RelatedLearningBlock)) return; - var heading = new HeadingBlock(null) + var heading = new HeadingBlock(null!) { Level = 2, Line = int.MaxValue - 1 @@ -34,7 +32,7 @@ public static void Append(MarkdownDocument document, IReadOnlyList() .Should() - .Contain(h => h.GetData("anchor") as string == RelatedLearningBlock.Anchor); + .Contain(h => (h.GetData("anchor") as string) == RelatedLearningBlock.Anchor); Document.Descendants().Should().ContainSingle(); Html.Should().Contain("id=\"related-learning-heading\""); From 617c4454a907d56b762301b6b6ca53edaccf508f Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 14 Aug 2026 17:25:32 -0500 Subject: [PATCH 3/5] Fix failing CI --- .../ConfigurationFileProvider.cs | 14 ++++++--- .../RelatedLearningConfigurationTests.cs | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index d8521c453c..63f64e65ad 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -62,7 +62,7 @@ public ConfigurationFileProvider( ConfigurationSource = source; else { - string[] spotChecks = ["navigation.yml", "versions.yml", "products.yml", "assembler.yml", "search.yml", "related-learning.yml"]; + string[] spotChecks = ["navigation.yml", "versions.yml", "products.yml", "assembler.yml", "search.yml"]; var defaultSource = fileSystem.Directory.Exists(LocalConfigurationDirectory) && spotChecks.All(f => fileSystem.File.Exists(Path.Join(LocalConfigurationDirectory, f))) @@ -107,7 +107,7 @@ public ConfigurationFileProvider( LegacyUrlMappingsFile = CreateTemporaryConfigurationFile("legacy-url-mappings.yml"); // reading from synonyms.yml is temporary. If you spot this again as a future reader, feel free to remove it. SearchFile = CreateTemporaryConfigurationFile("search.yml", "synonyms.yml"); - RelatedLearningFile = CreateTemporaryConfigurationFile("related-learning.yml"); + RelatedLearningFile = CreateTemporaryConfigurationFile("related-learning.yml", fallbackToEmbedded: true); } public bool SkipPrivateRepositories { get; } @@ -216,16 +216,16 @@ public IFileInfo CreateNavigationFile(AssemblyConfiguration configuration) } - private IFileInfo CreateTemporaryConfigurationFile(string fileName, string? fallback = null) + private IFileInfo CreateTemporaryConfigurationFile(string fileName, string? fallback = null, bool fallbackToEmbedded = false) { - using var stream = GetLocalOrEmbedded(fileName, fallback); + using var stream = GetLocalOrEmbedded(fileName, fallback, fallbackToEmbedded: fallbackToEmbedded); var context = stream.ReadToEnd(); var fi = _fileSystem.FileInfo.New(Path.Join(TemporaryDirectory.FullName, fileName)); _fileSystem.File.WriteAllText(fi.FullName, context); return fi; } - private StreamReader GetLocalOrEmbedded(string fileName, string? fallback = null) + private StreamReader GetLocalOrEmbedded(string fileName, string? fallback = null, bool fallbackToEmbedded = false) { var localPath = GetLocalPath(fileName); if (ConfigurationSource == ConfigurationSource.Local) @@ -241,6 +241,8 @@ private StreamReader GetLocalOrEmbedded(string fileName, string? fallback = null var reader = _fileSystem.File.OpenText(fallbackPath); return reader; } + if (fallbackToEmbedded) + return GetEmbeddedStream(fileName, fallback); throw new Exception($"Can not read {fileName} in directory {LocalConfigurationDirectory}"); } @@ -258,6 +260,8 @@ private StreamReader GetLocalOrEmbedded(string fileName, string? fallback = null var reader = _fileSystem.File.OpenText(fallbackPath); return reader; } + if (fallbackToEmbedded) + return GetEmbeddedStream(fileName, fallback); throw new Exception($"Can not read {fileName} in directory {AppDataConfigurationDirectory}"); } return GetEmbeddedStream(fileName, fallback); diff --git a/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs index 4e1b8269b5..8304522e92 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Configuration.RelatedLearning; using Elastic.Documentation.FileSystems; @@ -88,6 +89,36 @@ public void GetLinksForPage_DoesNotReturnUnrelatedMappedLinks() links.Should().NotContain(l => l.Id == "apm-with-elastic"); } + [Fact] + public void LocalConfigMissingCatalog_FallsBackToEmbedded() + { + var configDir = ConfigurationFileProvider.LocalConfigurationDirectory; + var fileSystem = new MockFileSystem(new MockFileSystemOptions + { + CurrentDirectory = Directory.GetCurrentDirectory() + }); + fileSystem.Directory.CreateDirectory(configDir); + foreach (var name in new[] + { + "versions.yml", + "products.yml", + "assembler.yml", + "navigation.yml", + "legacy-url-mappings.yml", + "search.yml" + }) + fileSystem.File.WriteAllText(Path.Join(configDir, name), "placeholder: true\n"); + + var provider = new ConfigurationFileProvider( + NullLoggerFactory.Instance, + new ConfigurationFileSystem(fileSystem), + configurationSource: ConfigurationSource.Local); + + var config = provider.CreateRelatedLearningConfiguration(); + + config.Links.Should().HaveCount(4); + } + private static RelatedLearningConfiguration LoadActualCatalog() { var fileSystem = new FileSystem(); From 1d19f44439f58becd3ca3bc44fe8df2007ae3ca4 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 14 Aug 2026 17:31:06 -0500 Subject: [PATCH 4/5] Fix tests --- .../RelatedLearningConfigurationTests.cs | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs index 8304522e92..4e1b8269b5 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/RelatedLearningConfigurationTests.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Configuration.RelatedLearning; using Elastic.Documentation.FileSystems; @@ -89,36 +88,6 @@ public void GetLinksForPage_DoesNotReturnUnrelatedMappedLinks() links.Should().NotContain(l => l.Id == "apm-with-elastic"); } - [Fact] - public void LocalConfigMissingCatalog_FallsBackToEmbedded() - { - var configDir = ConfigurationFileProvider.LocalConfigurationDirectory; - var fileSystem = new MockFileSystem(new MockFileSystemOptions - { - CurrentDirectory = Directory.GetCurrentDirectory() - }); - fileSystem.Directory.CreateDirectory(configDir); - foreach (var name in new[] - { - "versions.yml", - "products.yml", - "assembler.yml", - "navigation.yml", - "legacy-url-mappings.yml", - "search.yml" - }) - fileSystem.File.WriteAllText(Path.Join(configDir, name), "placeholder: true\n"); - - var provider = new ConfigurationFileProvider( - NullLoggerFactory.Instance, - new ConfigurationFileSystem(fileSystem), - configurationSource: ConfigurationSource.Local); - - var config = provider.CreateRelatedLearningConfiguration(); - - config.Links.Should().HaveCount(4); - } - private static RelatedLearningConfiguration LoadActualCatalog() { var fileSystem = new FileSystem(); From d22a0c7355d21cee0185632972605d9bd97eae41 Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 17 Aug 2026 15:56:53 -0500 Subject: [PATCH 5/5] Fix RelatedLearningMappedPageTests --- .../BuildContext.cs | 2 +- .../RelatedLearningMarkdownTests.cs | 29 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index a33b3c3d2f..fb21071228 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -53,7 +53,7 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public ProductsConfiguration ProductsConfiguration { get; } public LegacyUrlMappingConfiguration LegacyUrlMappings { get; } public SearchConfiguration SearchConfiguration { get; } - public RelatedLearningConfiguration RelatedLearningConfiguration { get; } + public RelatedLearningConfiguration RelatedLearningConfiguration { get; init; } public IEnvironmentVariables Environment { get; } public IDiagnosticsCollector Collector { get; } public bool Force { get; init; } diff --git a/tests/Elastic.Markdown.Tests/RelatedLearningMarkdownTests.cs b/tests/Elastic.Markdown.Tests/RelatedLearningMarkdownTests.cs index d6b6c34773..6de6bfa6ac 100644 --- a/tests/Elastic.Markdown.Tests/RelatedLearningMarkdownTests.cs +++ b/tests/Elastic.Markdown.Tests/RelatedLearningMarkdownTests.cs @@ -6,6 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.RelatedLearning; using Elastic.Markdown.IO; using Elastic.Markdown.Myst.RelatedLearning; using Markdig.Syntax; @@ -26,6 +27,11 @@ Index documents into Elasticsearch. [Fact] public void InjectsHeadingAndLinks() { + File.Repository.Should().Be("docs-content"); + Set.Context.RelatedLearningConfiguration.GetLinksForPage(File.Repository, File.RelativePath) + .Should() + .NotBeEmpty(); + Document.Descendants() .Should() .Contain(h => (h.GetData("anchor") as string) == RelatedLearningBlock.Anchor); @@ -41,6 +47,7 @@ public void InjectsHeadingAndLinks() [Fact] public void AddsHeadingToOnThisPage() { + File.Repository.Should().Be("docs-content"); File.PageTableOfContent.Should().ContainKey("related-learning-heading"); File.PageTableOfContent["related-learning-heading"].Heading.Should().Be("Related learning"); File.PageTableOfContent["related-learning-heading"].Level.Should().Be(2); @@ -82,6 +89,7 @@ public class RelatedLearningWrongRepositoryTests(ITestOutputHelper output) [Fact] public void DoesNotInjectSection() { + File.Repository.Should().Be("docs-builder"); Document.Descendants().Should().BeEmpty(); File.PageTableOfContent.Should().NotContainKey("related-learning-heading"); Html.Should().NotContain("class=\"related-learning\""); @@ -90,11 +98,21 @@ public void DoesNotInjectSection() public abstract class RelatedLearningPageTest : IAsyncLifetime { + private static readonly RelatedLearningConfiguration Catalog = RelatedLearningConfigurationExtensions.Parse( + """ + links: + index-basics: + title: Index Basics + url: https://www.elastic.co/training/index-basics + pages: + - docs-content://manage-data/data-store/index-basics.md + """); + protected MarkdownFile File { get; } protected string Html { get; private set; } protected MarkdownDocument Document { get; private set; } + protected DocumentationSet Set { get; } private TestDiagnosticsCollector Collector { get; } - private DocumentationSet Set { get; } protected RelatedLearningPageTest(ITestOutputHelper output, string relativePath, string content, string repositoryName) { @@ -117,7 +135,10 @@ protected RelatedLearningPageTest(ITestOutputHelper output, string relativePath, Ref = "test", RepositoryName = repositoryName }; - var context = new BuildContext(Collector, TestHelpers.CreateDocumentationFileSystem(fileSystem, root, git), configurationContext); + var context = new BuildContext(Collector, TestHelpers.CreateDocumentationFileSystem(fileSystem, root, git), configurationContext) + { + RelatedLearningConfiguration = Catalog + }; Set = new DocumentationSet(context, new TestLoggerFactory(output), new TestCrossLinkResolver()); File = Set.TryFindDocument(fileSystem.FileInfo.New(relativePath)) as MarkdownFile ?? throw new NullReferenceException(); @@ -130,7 +151,9 @@ public async ValueTask InitializeAsync() _ = Collector.StartAsync(TestContext.Current.CancellationToken); await Set.ResolveDirectoryTree(TestContext.Current.CancellationToken); Document = await File.ParseFullAsync(Set.TryFindDocumentByRelativePath, TestContext.Current.CancellationToken); - Html = MarkdownFile.CreateHtml(Document); + // CreateHtml strips the page H1 from the document it receives — use a second parse for HTML. + var htmlDocument = await File.ParseFullAsync(Set.TryFindDocumentByRelativePath, TestContext.Current.CancellationToken); + Html = MarkdownFile.CreateHtml(htmlDocument); await Collector.StopAsync(TestContext.Current.CancellationToken); }