Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions config/related-learning.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/_docset.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
project: 'doc-builder'
max_toc_depth: 2
dev_docs: true
Expand Down Expand Up @@ -179,6 +179,7 @@
- file: versions.md
- file: synonyms.md
- file: legacy-url-mappings.md
- file: related-learning.md
- file: redirects.md

# Structured
Expand Down
1 change: 1 addition & 0 deletions docs/documentation/catalog/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 49 additions & 0 deletions docs/documentation/catalog/related-learning.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/Elastic.Codex/Page/Index.cshtml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@using Elastic.Markdown.Page
@using System.Text.Json
@using Elastic.Documentation
@using Elastic.Documentation.Configuration
Expand Down
3 changes: 3 additions & 0 deletions src/Elastic.Documentation.Configuration/BuildContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -124,6 +125,9 @@ public ConfigurationFileProvider(
public IFileInfo LegacyUrlMappingsFile { get; }

public IFileInfo SearchFile { get; }

public IFileInfo RelatedLearningFile { get; }

/// <summary>
/// Repoints <see cref="NavigationFile"/> at <c>config/navigation_preview.yml</c>.
/// Must be called before any reader accesses <see cref="NavigationFile"/> —
Expand Down Expand Up @@ -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)
Expand All @@ -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}");
}

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,6 @@
<EmbeddedResource Include="$(SolutionRoot)\config\navigation_preview.yml" />
<EmbeddedResource Include="$(SolutionRoot)\config\legacy-url-mappings.yml" />
<EmbeddedResource Include="$(SolutionRoot)\config\search.yml" />
<EmbeddedResource Include="$(SolutionRoot)\config\related-learning.yml" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Global catalog of elastic.co learning links and the docs pages that should show them.
/// </summary>
public record RelatedLearningConfiguration
{
public static RelatedLearningConfiguration Empty { get; } = new() { Links = [] };

/// <summary>Catalog entries in file order.</summary>
public required IReadOnlyList<RelatedLearningLink> Links { get; init; }

/// <summary>
/// Returns catalog links whose <see cref="RelatedLearningLink.Pages"/> include
/// <c>{repositoryName}://{relativePath}</c>, preserving catalog file order.
/// </summary>
public IReadOnlyList<RelatedLearningLink> GetLinksForPage(string repositoryName, string relativePath)
{
if (Links.Count == 0)
return [];

var crossLink = $"{repositoryName}://{relativePath.Replace('\\', '/')}";
return Links.Where(l => l.Pages.Contains(crossLink)).ToArray();
}
}

/// <summary>A single named learning destination from <c>related-learning.yml</c>.</summary>
public record RelatedLearningLink
{
public required string Id { get; init; }
public required string Title { get; init; }
public required string Url { get; init; }

/// <summary>Qualified page cross-links (<c>{repo}://path.md</c>) that show this link.</summary>
public IReadOnlyList<string> Pages { get; init; } = [];
}

[YamlSerializable]
internal sealed class RelatedLearningConfigDto
{
[YamlMember(Alias = "links")]
public Dictionary<string, RelatedLearningLinkDto> 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<string> 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());
}

/// <summary>Parses and validates a <c>related-learning.yml</c> document.</summary>
public static RelatedLearningConfiguration Parse(string yaml)
{
var dto = ConfigurationFileProvider.Deserializer.Deserialize<RelatedLearningConfigDto>(yaml)
?? throw new InvalidOperationException("related-learning.yml deserialized to null.");
return FromDto(dto);
}

/// <summary>Parses and validates a catalog DTO. Used by tests and the file loader.</summary>
internal static RelatedLearningConfiguration FromDto(RelatedLearningConfigDto dto)
{
var links = new List<RelatedLearningLink>(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<string>(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() };
}

/// <summary>
/// A qualified page cross-link is <c>{repository}://{relativePath}</c> — same form as TOC/cross-links.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/Elastic.Documentation.Site/Assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/Elastic.Markdown/HtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ private async Task<RenderResult> RenderLayout(MarkdownFile markdown, MarkdownDoc
GitHubRef = DocumentationSet.Context.Git.GitHubRef,
Branding = DocumentationSet.Configuration.Branding,
RedirectUrl = markdown.RedirectUrl,
Cta = cta
Cta = cta,
});

return new RenderResult
Expand Down
Loading
Loading