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 0f27dcbb98..7472fd3360 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -179,6 +179,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..fb21071228 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; init; } 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..63f64e65ad 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -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", fallbackToEmbedded: true); } 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 — @@ -212,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) @@ -237,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}"); } @@ -254,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/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..a2002765a1 --- /dev/null +++ b/src/Elastic.Markdown/Myst/RelatedLearning/RelatedLearningBlock.cs @@ -0,0 +1,56 @@ +// 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(BlockParser? parser) : LeafBlock(parser) +{ + public const string Heading = "Related learning"; + public const string Anchor = "related-learning-heading"; + + 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..6de6bfa6ac --- /dev/null +++ b/tests/Elastic.Markdown.Tests/RelatedLearningMarkdownTests.cs @@ -0,0 +1,165 @@ +// 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.Documentation.Configuration.RelatedLearning; +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() + { + 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); + 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.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); + } +} + +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() + { + File.Repository.Should().Be("docs-builder"); + Document.Descendants().Should().BeEmpty(); + File.PageTableOfContent.Should().NotContainKey("related-learning-heading"); + Html.Should().NotContain("class=\"related-learning\""); + } +} + +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; } + + 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) + { + RelatedLearningConfiguration = Catalog + }; + 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); + // 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); + } + + 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("