From 4b2754c4db78652ca9dd714e00e544cd5ad2b8e6 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 24 Feb 2026 18:29:59 +0100 Subject: [PATCH 1/2] Add codex cross-link resolution with per-build-type URL behavior Introduce useRelativePaths parameter to CodexAwareUriResolver so codex builds produce path-only /r/{repo}/{path} URLs with htmx navigation, while isolated builds produce absolute https://codex.elastic.dev URLs with target=_blank. Assembler builds continue using their own PublishEnvironmentUriResolver with htmx intact. Clear isCrossLink flag only for isolated builds when the resolved URI is absolute, so assembler same-site cross-links keep htmx behavior. Use CodexHtmxAttributeProvider (with #codex-breadcrumbs) for codex builds in ParserContext. Improve cross-link error message to suggest adding the repo to docset.yml cross_links. Co-authored-by: Cursor --- .../Building/CodexBuildService.cs | 26 +++- .../Builder/ConfigurationFile.cs | 69 ++++++++- .../CrossLinkEntry.cs | 12 ++ .../DocSetRegistry.cs | 24 +++ ...Elastic.Documentation.Configuration.csproj | 1 + .../CrossLinks/CrossLinkFetcher.cs | 50 +++++- .../CrossLinks/CrossLinkResolver.cs | 21 ++- .../DocSetConfigurationCrossLinkFetcher.cs | 49 ++++-- .../CrossLinks/IUriEnvironmentResolver.cs | 37 +++-- .../DiagnosticLinkInlineParser.cs | 7 +- src/Elastic.Markdown/Myst/ParserContext.cs | 4 +- .../IsolatedBuildService.cs | 29 +++- .../Http/ReloadableGeneratorState.cs | 15 +- .../CrossLinkRegistryTests.cs | 144 ++++++++++++++++++ .../AssemblerHtmxMarkdownLinkTests.cs | 23 ++- .../Codex/CodexHtmxCrossLinkTests.cs | 88 +++++++++++ .../CrossLinks/UriEnvironmentResolverTests.cs | 125 +++++++++++++++ .../Inline/InlineLinkTests.cs | 10 +- .../Inline/InlneBaseTests.cs | 6 +- .../TestCodexCrossLinkResolver.cs | 77 ++++++++++ tests/authoring/Inline/CrossLinks.fs | 12 +- tests/authoring/Inline/CrossLinksRedirects.fs | 44 ++++-- 22 files changed, 795 insertions(+), 78 deletions(-) create mode 100644 src/Elastic.Documentation.Configuration/CrossLinkEntry.cs create mode 100644 src/Elastic.Documentation.Configuration/DocSetRegistry.cs create mode 100644 tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs create mode 100644 tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs create mode 100644 tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs create mode 100644 tests/Elastic.Markdown.Tests/TestCodexCrossLinkResolver.cs diff --git a/src/Elastic.Codex/Building/CodexBuildService.cs b/src/Elastic.Codex/Building/CodexBuildService.cs index cca5b4ae5..001322ca5 100644 --- a/src/Elastic.Codex/Building/CodexBuildService.cs +++ b/src/Elastic.Codex/Building/CodexBuildService.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Isolated; +using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Isolated.Node; @@ -60,10 +61,13 @@ public async Task BuildAll( var documentationSets = new Dictionary(); var buildContexts = new List(); + var environment = context.Configuration.Environment ?? "internal"; + using var codexLinkIndexReader = new GitLinkIndexReader(environment, context.ReadFileSystem); + // Phase 1: Load and parse all documentation sets foreach (var checkout in cloneResult.Checkouts) { - var buildContext = await LoadDocumentationSet(context, checkout, fileSystem, ctx); + var buildContext = await LoadDocumentationSet(context, checkout, fileSystem, codexLinkIndexReader, ctx); if (buildContext != null) { buildContexts.Add(buildContext); @@ -119,6 +123,7 @@ public async Task BuildAll( CodexContext context, CodexCheckout checkout, IFileSystem fileSystem, + ILinkIndexReader codexLinkIndexReader, Cancel ctx) { _logger.LogInformation("Loading documentation set: {Name}", checkout.Reference.Name); @@ -170,8 +175,23 @@ public async Task BuildAll( BuildType = BuildType.Codex }; - // Create cross-link resolver (simplified for codex - no external links) - var crossLinkResolver = NoopCrossLinkResolver.Instance; + ICrossLinkResolver crossLinkResolver; + if (buildContext.Configuration.CrossLinkEntries.Length > 0) + { + var fetcher = new DocSetConfigurationCrossLinkFetcher( + logFactory, + buildContext.Configuration, + codexLinkIndexReader: buildContext.Configuration.Registry != DocSetRegistry.Public ? codexLinkIndexReader : null); + var crossLinks = await fetcher.FetchCrossLinks(ctx); + IUriEnvironmentResolver? uriResolver = crossLinks.CodexRepositories is not null + ? new CodexAwareUriResolver(crossLinks.CodexRepositories, useRelativePaths: true) + : null; + crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver); + } + else + { + crossLinkResolver = NoopCrossLinkResolver.Instance; + } // Create documentation set var documentationSet = new DocumentationSet(buildContext, logFactory, crossLinkResolver); diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index c0ba4caa6..a4f2301b7 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -27,6 +27,16 @@ public record ConfigurationFile public string[] CrossLinkRepositories { get; } = []; + /// + /// Registry for this documentation set. Public uses S3 link index; other values use codex-link-index. + /// + public DocSetRegistry Registry { get; } = DocSetRegistry.Public; + + /// + /// Parsed cross-link entries with registry for each target. + /// + public CrossLinkEntry[] CrossLinkEntries { get; } = []; + /// The maximum depth `toc.yml` files may appear public int MaxTocDepth { get; } = 1; @@ -90,8 +100,28 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte Exclude = [.. docSetFile.Exclude.Where(s => !string.IsNullOrEmpty(s) && !s.StartsWith('!')).Select(Glob.Parse)]; Include = [.. docSetFile.Exclude.Where(s => !string.IsNullOrEmpty(s) && s.StartsWith('!')).Select(s => s.TrimStart('!'))]; - // Set cross link repositories - CrossLinkRepositories = [.. docSetFile.CrossLinks]; + // Parse registry (null/empty/"public" -> Public) + var registry = DocSetRegistry.Public; + if (!string.IsNullOrWhiteSpace(docSetFile.Registry) && + DocSetRegistryExtensions.TryParse(docSetFile.Registry.Trim(), out var parsedRegistry, true)) + registry = parsedRegistry; + + Registry = registry; + + // Parse cross-link entries with optional registry prefix (e.g. public://elasticsearch) + var crossLinkEntries = new List(); + foreach (var raw in docSetFile.CrossLinks) + { + if (string.IsNullOrWhiteSpace(raw)) + continue; + + var entry = ParseCrossLinkEntry(raw.Trim(), registry, context.ConfigurationPath, context); + if (entry != null) + crossLinkEntries.Add(entry); + } + + CrossLinkEntries = [.. crossLinkEntries]; + CrossLinkRepositories = crossLinkEntries.Select(e => e.Repository).ToArray(); // Extensions - assuming they're not in DocumentationSetFile yet Extensions = new EnabledExtensions(docSetFile.Extensions); @@ -159,4 +189,39 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte } } + private static CrossLinkEntry? ParseCrossLinkEntry(string raw, DocSetRegistry docsetRegistry, IFileInfo configPath, IDocumentationContext context) + { + DocSetRegistry entryRegistry; + string repository; + + var colonSlash = raw.IndexOf("://", StringComparison.Ordinal); + if (colonSlash >= 0) + { + var prefix = raw[..colonSlash]; + repository = raw[(colonSlash + 3)..]; + if (string.IsNullOrWhiteSpace(repository)) + { + context.EmitError(configPath, $"Cross-link '{raw}' has empty repository after registry prefix."); + return null; + } + if (!DocSetRegistryExtensions.TryParse(prefix, out entryRegistry, true)) + { + context.EmitError(configPath, $"Cross-link '{raw}' uses unknown registry '{prefix}'. Use 'public' or 'internal'."); + return null; + } + } + else + { + repository = raw; + entryRegistry = docsetRegistry; + } + + if (docsetRegistry == DocSetRegistry.Public && entryRegistry != DocSetRegistry.Public) + { + context.EmitError(configPath, $"Public documentation cannot link to codex docs. Cross-link '{raw}' targets registry '{entryRegistry.ToStringFast()}'. Remove it or use a public docset."); + return null; + } + + return new CrossLinkEntry(repository, entryRegistry); + } } diff --git a/src/Elastic.Documentation.Configuration/CrossLinkEntry.cs b/src/Elastic.Documentation.Configuration/CrossLinkEntry.cs new file mode 100644 index 000000000..9dc66af13 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/CrossLinkEntry.cs @@ -0,0 +1,12 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Configuration; + +/// +/// A parsed cross-link entry from docset.yml, with the target registry for lookup. +/// +/// Repository name (e.g. elasticsearch, docs-eng-team). +/// Registry to use for lookup (public S3 or codex environment). +public record CrossLinkEntry(string Repository, DocSetRegistry Registry); diff --git a/src/Elastic.Documentation.Configuration/DocSetRegistry.cs b/src/Elastic.Documentation.Configuration/DocSetRegistry.cs new file mode 100644 index 000000000..cfcf9bbf2 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/DocSetRegistry.cs @@ -0,0 +1,24 @@ +// 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.ComponentModel.DataAnnotations; +using NetEscapades.EnumGenerators; + +namespace Elastic.Documentation.Configuration; + +/// +/// Registry type for cross-link resolution. Maps to the link index source: +/// Public uses the S3-based public link index; other values use the codex-link-index for that environment. +/// +[EnumExtensions] +public enum DocSetRegistry +{ + /// Public documentation; uses S3-based link index. + [Display(Name = "public")] + Public, + + /// Internal codex environment; uses codex-link-index/internal/. + [Display(Name = "internal")] + Internal, +} diff --git a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj index d112ac408..ff9d136a4 100644 --- a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj +++ b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index 5b62ee2d2..db5615a11 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -20,11 +20,25 @@ public record FetchedCrossLinks public required FrozenDictionary LinkIndexEntries { get; init; } + /// + /// Optional map of repository name to link index registry URL for error messages. + /// When null or missing, falls back to the public S3 URL. + /// + public FrozenDictionary? RegistryUrlsByRepository { get; init; } + + /// + /// Set of repository names that belong to a codex (non-public) registry. + /// Used by the URI resolver to generate codex URLs instead of public preview URLs. + /// + public FrozenSet? CodexRepositories { get; init; } + public static FetchedCrossLinks Empty { get; } = new() { DeclaredRepositories = [], LinkReferences = new Dictionary().ToFrozenDictionary(), - LinkIndexEntries = new Dictionary().ToFrozenDictionary() + LinkIndexEntries = new Dictionary().ToFrozenDictionary(), + RegistryUrlsByRepository = null, + CodexRepositories = null }; } @@ -87,21 +101,47 @@ protected async Task FetchCrossLinks(string repository, string[ throw new Exception($"Repository found in link index however none of: '{string.Join(", ", keys)}' branches found"); } - protected async Task FetchLinkIndexEntry(string repository, LinkRegistryEntry linkRegistryEntry, Cancel ctx) + protected Task FetchLinkIndexEntry(string repository, LinkRegistryEntry linkRegistryEntry, Cancel ctx) => + FetchLinkIndexEntryFromReader(linkIndexProvider, repository, linkRegistryEntry, ctx); + + /// + /// Fetches repository links from a specific reader. Used for dual-registry (public + codex) fetching. + /// + protected async Task FetchLinkIndexEntryFromReader( + ILinkIndexReader reader, + string repository, + LinkRegistryEntry linkRegistryEntry, + Cancel ctx) { var linkReference = await TryGetCachedLinkReference(repository, linkRegistryEntry); if (linkReference is not null) { - Logger.LogInformation("Using locally cached links.json for '{Repository}' from {RegistryUrl}", repository, linkIndexProvider.RegistryUrl); + Logger.LogInformation("Using locally cached links.json for '{Repository}' from {RegistryUrl}", repository, reader.RegistryUrl); return linkReference; } - Logger.LogInformation("Fetching links.json for '{Repository}' from {RegistryUrl}", repository, linkIndexProvider.RegistryUrl); - linkReference = await linkIndexProvider.GetRepositoryLinks(linkRegistryEntry.Path, ctx); + Logger.LogInformation("Fetching links.json for '{Repository}' from {RegistryUrl}", repository, reader.RegistryUrl); + linkReference = await reader.GetRepositoryLinks(linkRegistryEntry.Path, ctx); WriteLinksJsonCachedFile(repository, linkRegistryEntry, linkReference); return linkReference; } + /// + /// Fetches cross-links for a repository from a specific reader. Used for dual-registry fetching. + /// + protected static async Task FetchCrossLinksFromReader( + ILinkIndexReader reader, + string repository, + CrossLinkFetcher fetcher, + Cancel ctx) + { + var linkIndex = await reader.GetRegistry(ctx); + if (!linkIndex.Repositories.TryGetValue(repository, out var repositoryLinks)) + throw new Exception($"Repository {repository} not found in link index"); + var entry = GetNextContentSourceLinkIndexEntry(repositoryLinks, repository); + return await fetcher.FetchLinkIndexEntryFromReader(reader, repository, entry, ctx); + } + private void WriteLinksJsonCachedFile(string repository, LinkRegistryEntry linkRegistryEntry, RepositoryLinks linkReference) { var cachedFileName = $"links-elastic-{repository}-{linkRegistryEntry.Branch}-{linkRegistryEntry.ETag}.json"; diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs index 61ed6657b..a192d3e64 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs @@ -75,7 +75,7 @@ public static bool TryResolve( return true; } - errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index"); + errorEmitter($"'{crossLinkUri.Scheme}' was not found in the cross link index. Ensure it is listed under 'cross_links' in your docset.yml"); return false; } @@ -89,9 +89,12 @@ public static bool TryResolve( if (sourceLinkReference.Links.TryGetValue(originalLookupPath, out var directLinkMetadata)) return ResolveDirectLink(errorEmitter, uriResolver, crossLinkUri, originalLookupPath, directLinkMetadata, out resolvedUri); - var linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/{crossLinkUri.Scheme}/main/links.json"; - if (fetchedCrossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var indexEntry)) - linksJson = $"https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/{indexEntry.Path}"; + var registryUrl = fetchedCrossLinks.RegistryUrlsByRepository?.GetValueOrDefault(crossLinkUri.Scheme) + ?? "https://elastic-docs-link-index.s3.us-east-2.amazonaws.com"; + var baseUrl = GetLinksJsonBaseUrl(registryUrl); + var linksJson = fetchedCrossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var indexEntry) + ? $"{baseUrl}/{indexEntry.Path}" + : $"{baseUrl}/elastic/{crossLinkUri.Scheme}/main/links.json"; errorEmitter($"'{originalLookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link index: {linksJson}"); resolvedUri = null; @@ -231,4 +234,14 @@ public static string ToTargetUrlPath(string lookupPath) path = string.Empty; return path; } + + /// Derives the base URL for links.json from a reader's RegistryUrl (S3 or GitHub). + private static string GetLinksJsonBaseUrl(string registryUrl) + { + if (registryUrl.Contains("github.com", StringComparison.OrdinalIgnoreCase)) + return $"{registryUrl.TrimEnd('/')}/blob/main"; + if (registryUrl.Contains("/link-index.json", StringComparison.OrdinalIgnoreCase)) + return registryUrl.Replace("/link-index.json", "", StringComparison.OrdinalIgnoreCase).TrimEnd('/'); + return registryUrl.TrimEnd('/'); + } } diff --git a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs index be5bde12c..0af5a856b 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.Collections.Frozen; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.LinkIndex; using Microsoft.Extensions.Logging; @@ -10,44 +11,64 @@ namespace Elastic.Documentation.Links.CrossLinks; /// Fetches cross-links from all the declared repositories in the docset.yml configuration see -public class DocSetConfigurationCrossLinkFetcher(ILoggerFactory logFactory, ConfigurationFile configuration, ILinkIndexReader? linkIndexProvider = null) +public class DocSetConfigurationCrossLinkFetcher( + ILoggerFactory logFactory, + ConfigurationFile configuration, + ILinkIndexReader? linkIndexProvider = null, + ILinkIndexReader? codexLinkIndexReader = null) : CrossLinkFetcher(logFactory, linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous()) { private readonly ILogger _logger = logFactory.CreateLogger(nameof(DocSetConfigurationCrossLinkFetcher)); + private readonly ILinkIndexReader? _codexReader = codexLinkIndexReader; public override async Task FetchCrossLinks(Cancel ctx) { Logger.LogInformation("Fetching cross-links for all repositories defined in docset.yml"); var linkReferences = new Dictionary(); var linkIndexEntries = new Dictionary(); + var registryUrlsByRepository = new Dictionary(); + var codexRepositories = new HashSet(); var declaredRepositories = new HashSet(); - foreach (var repository in configuration.CrossLinkRepositories) + var publicReader = linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous(); + var useDualRegistry = configuration.Registry != DocSetRegistry.Public && _codexReader is not null; + + foreach (var entry in configuration.CrossLinkEntries) { - _ = declaredRepositories.Add(repository); + _ = declaredRepositories.Add(entry.Repository); + var isCodexEntry = useDualRegistry && entry.Registry != DocSetRegistry.Public; + var reader = isCodexEntry ? _codexReader! : publicReader; + + if (isCodexEntry) + _ = codexRepositories.Add(entry.Repository); + try { - var linkReference = await FetchCrossLinks(repository, ["main", "master"], ctx); - linkReferences.Add(repository, linkReference); + var linkReference = await FetchCrossLinksFromReader(reader, entry.Repository, this, ctx); + linkReferences.Add(entry.Repository, linkReference); + registryUrlsByRepository[entry.Repository] = reader.RegistryUrl; - var linkIndexReference = await GetLinkIndexEntry(repository, ctx); - linkIndexEntries.Add(repository, linkIndexReference); + var registry = await reader.GetRegistry(ctx); + if (registry.Repositories.TryGetValue(entry.Repository, out var repoBranches)) + { + var linkIndexEntry = GetNextContentSourceLinkIndexEntry(repoBranches, entry.Repository); + linkIndexEntries.Add(entry.Repository, linkIndexEntry); + } } catch (Exception ex) { - // Log the error but continue processing other repositories - _logger.LogWarning(ex, "Error fetching link data for repository '{Repository}'. Cross-links to this repository may not resolve correctly.", repository); + _logger.LogWarning(ex, "Error fetching link data for repository '{Repository}'. Cross-links to this repository may not resolve correctly.", entry.Repository); + _ = registryUrlsByRepository.TryAdd(entry.Repository, reader.RegistryUrl); - // Add an empty entry so we at least recognize the repository exists - if (!linkReferences.ContainsKey(repository)) + if (!linkReferences.ContainsKey(entry.Repository)) { - linkReferences.Add(repository, new RepositoryLinks + linkReferences.Add(entry.Repository, new RepositoryLinks { Links = [], Origin = new GitCheckoutInformation { Branch = "main", - RepositoryName = repository, + RepositoryName = entry.Repository, Remote = "origin", Ref = "refs/heads/main" }, @@ -63,6 +84,8 @@ public override async Task FetchCrossLinks(Cancel ctx) DeclaredRepositories = declaredRepositories, LinkReferences = linkReferences.ToFrozenDictionary(), LinkIndexEntries = linkIndexEntries.ToFrozenDictionary(), + RegistryUrlsByRepository = registryUrlsByRepository.ToFrozenDictionary(), + CodexRepositories = codexRepositories.Count > 0 ? codexRepositories.ToFrozenSet() : null, }; } } diff --git a/src/Elastic.Documentation.Links/CrossLinks/IUriEnvironmentResolver.cs b/src/Elastic.Documentation.Links/CrossLinks/IUriEnvironmentResolver.cs index 81f8f49b0..62ab1bf49 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/IUriEnvironmentResolver.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/IUriEnvironmentResolver.cs @@ -2,6 +2,8 @@ // 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.Frozen; + namespace Elastic.Documentation.Links.CrossLinks; public interface IUriEnvironmentResolver @@ -13,24 +15,31 @@ public class IsolatedBuildEnvironmentUriResolver : IUriEnvironmentResolver { private static Uri BaseUri { get; } = new("https://docs-v3-preview.elastic.dev"); - public Uri Resolve(Uri crossLinkUri, string path) - { - var branch = GetBranch(crossLinkUri); - return new Uri(BaseUri, $"elastic/{crossLinkUri.Scheme}/tree/{branch}/{path}"); - } - - /// Hardcoding these for now, we'll have an index.json pointing to all links.json files - /// at some point from which we can query the branch soon. - public static string GetBranch(Uri crossLinkUri) - { - var branch = crossLinkUri.Scheme switch + public Uri Resolve(Uri crossLinkUri, string path) => + new(BaseUri, $"elastic/{crossLinkUri.Scheme}/tree/{GetBranch(crossLinkUri)}/{path}"); + + public static string GetBranch(Uri crossLinkUri) => + crossLinkUri.Scheme switch { - "docs-content" => "main", "cloud" => "master", _ => "main" }; - return branch; - } +} +/// +/// Resolves cross-link URIs for codex-hosted repos. +/// When is true (codex/assembler builds), produces path-only /r/{repo}/{path} for htmx navigation. +/// When false (isolated builds), produces absolute https://codex.elastic.dev/r/{repo}/{path}. +/// +public class CodexAwareUriResolver(FrozenSet codexRepositories, bool useRelativePaths = false) : IUriEnvironmentResolver +{ + private static readonly Uri CodexBaseUri = new("https://codex.elastic.dev"); + private static readonly IsolatedBuildEnvironmentUriResolver PublicResolver = new(); + public Uri Resolve(Uri crossLinkUri, string path) => + codexRepositories.Contains(crossLinkUri.Scheme) + ? useRelativePaths + ? new Uri($"/r/{crossLinkUri.Scheme}/{path}", UriKind.Relative) + : new Uri(CodexBaseUri, $"r/{crossLinkUri.Scheme}/{path}") + : PublicResolver.Resolve(crossLinkUri, path); } diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 55b562d85..50c8a2651 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -190,9 +190,12 @@ private static void ProcessCrossLink(LinkInline link, InlineProcessor processor, if (context.CrossLinkResolver.TryResolve( s => processor.EmitError(link, s), - uri, out var resolvedUri) - ) + uri, out var resolvedUri)) + { link.Url = resolvedUri.ToString(); + if (resolvedUri.IsAbsoluteUri && context.Build.BuildType == BuildType.Isolated) + link.SetData("isCrossLink", false); + } // Emit error for empty link text in crosslinks if (link.FirstChild == null) diff --git a/src/Elastic.Markdown/Myst/ParserContext.cs b/src/Elastic.Markdown/Myst/ParserContext.cs index 4756a28a0..a4a5fce37 100644 --- a/src/Elastic.Markdown/Myst/ParserContext.cs +++ b/src/Elastic.Markdown/Myst/ParserContext.cs @@ -153,7 +153,9 @@ public ParserContext(ParserState state) ContextSubstitutions = contextSubs; var rootPath = Build.SiteRootPath ?? GetDefaultRootPath(Build.UrlPathPrefix); - Htmx = new DefaultHtmxAttributeProvider(rootPath); + Htmx = Build.BuildType == BuildType.Codex + ? new CodexHtmxAttributeProvider(rootPath) + : new DefaultHtmxAttributeProvider(rootPath); } private static string GetDefaultRootPath(string? urlPathPrefix) diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index c16cec21a..d02ff3656 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -6,8 +6,10 @@ using Actions.Core.Services; using Elastic.ApiExplorer; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation; using Elastic.Documentation.Services; @@ -120,9 +122,30 @@ public async Task Build( } else { - var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(logFactory, context.Configuration); - var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx); - crossLinkResolver = new CrossLinkResolver(crossLinks); + ILinkIndexReader? codexReader = null; + if (context.Configuration.Registry != DocSetRegistry.Public) + { + var environment = context.Configuration.Registry.ToStringFast(true); + codexReader = new GitLinkIndexReader(environment, fileSystem); + } + + try + { + var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher( + logFactory, + context.Configuration, + codexLinkIndexReader: codexReader); + var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx); + IUriEnvironmentResolver? uriResolver = crossLinks.CodexRepositories is not null + ? new CodexAwareUriResolver(crossLinks.CodexRepositories) + : null; + crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver); + } + finally + { + if (codexReader is IDisposable d) + d.Dispose(); + } } // always delete output folder on CI diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index e95cdaa16..e1d0e0c69 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -6,6 +6,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown; using Elastic.Markdown.Exporters; @@ -27,6 +28,7 @@ public class ReloadableGeneratorState : IDisposable private readonly BuildContext _context; private readonly bool _isWatchBuild; private readonly DocSetConfigurationCrossLinkFetcher _crossLinkFetcher; + private readonly ILinkIndexReader? _codexReader; public ReloadableGeneratorState(ILoggerFactory logFactory, IDirectoryInfo sourcePath, @@ -41,7 +43,11 @@ bool isWatchBuild SourcePath = sourcePath; OutputPath = outputPath; ApiPath = context.WriteFileSystem.DirectoryInfo.New(Path.Combine(outputPath.FullName, "api")); - _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(logFactory, _context.Configuration); + + if (context.Configuration.Registry != DocSetRegistry.Public) + _codexReader = new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), context.ReadFileSystem); + + _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(logFactory, _context.Configuration, codexLinkIndexReader: _codexReader); // we pass NoopCrossLinkResolver.Instance here because `ReloadAsync` will always be called when the is started. _generator = new DocumentationGenerator(new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance), logFactory); } @@ -56,7 +62,10 @@ public async Task ReloadAsync(Cancel ctx) SourcePath.Refresh(); OutputPath.Refresh(); var crossLinks = await _crossLinkFetcher.FetchCrossLinks(ctx); - var crossLinkResolver = new CrossLinkResolver(crossLinks); + IUriEnvironmentResolver? uriResolver = crossLinks.CodexRepositories is not null + ? new CodexAwareUriResolver(crossLinks.CodexRepositories) + : null; + var crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver); var docSet = new DocumentationSet(_context, _logFactory, crossLinkResolver); // Add LLM markdown export for dev server @@ -128,7 +137,7 @@ private async Task ReloadApiReferences(IMarkdownStringRenderer markdownStringRen public void Dispose() { - _crossLinkFetcher.Dispose(); + (_codexReader as IDisposable)?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs new file mode 100644 index 000000000..67de8a7c2 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs @@ -0,0 +1,144 @@ +// 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.Frozen; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using Elastic.Documentation; +using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.Diagnostics; +using FluentAssertions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class CrossLinkRegistryTests +{ + [Fact] + public void Registry_NullOrEmpty_ParsesAsPublic() + { + foreach (var registryValue in new[] { null, "", " ", "public" }) + { + var docSet = CreateDocSet(registryValue, ["elasticsearch"]); + var config = CreateConfiguration(docSet); + + config.Registry.Should().Be(DocSetRegistry.Public); + } + } + + [Fact] + public void Registry_Internal_ParsesAsInternal() + { + var docSet = CreateDocSet("internal", ["docs-eng-team"]); + var config = CreateConfiguration(docSet); + + config.Registry.Should().Be(DocSetRegistry.Internal); + } + + [Fact] + public void CrossLinkEntry_BareRepo_InheritsDocsetRegistry() + { + var docSet = CreateDocSet("internal", ["other-internal-repo"]); + var config = CreateConfiguration(docSet); + + config.CrossLinkEntries.Should().ContainSingle() + .Which.Should().Be(new CrossLinkEntry("other-internal-repo", DocSetRegistry.Internal)); + } + + [Fact] + public void CrossLinkEntry_PublicPrefix_UsesPublicRegistry() + { + var docSet = CreateDocSet("internal", ["other-internal-repo", "public://elasticsearch"]); + var config = CreateConfiguration(docSet); + + config.CrossLinkEntries.Should().HaveCount(2); + config.CrossLinkEntries[0].Should().Be(new CrossLinkEntry("other-internal-repo", DocSetRegistry.Internal)); + config.CrossLinkEntries[1].Should().Be(new CrossLinkEntry("elasticsearch", DocSetRegistry.Public)); + } + + [Fact] + public void CrossLinkEntry_PublicDocset_BareReposUsePublic() + { + var docSet = CreateDocSet(null, ["elasticsearch", "kibana"]); + var config = CreateConfiguration(docSet); + + config.CrossLinkEntries.Should().HaveCount(2); + config.CrossLinkEntries[0].Should().Be(new CrossLinkEntry("elasticsearch", DocSetRegistry.Public)); + config.CrossLinkEntries[1].Should().Be(new CrossLinkEntry("kibana", DocSetRegistry.Public)); + } + + [Fact] + public void CrossLinkEntry_PublicDocset_InternalPrefix_ExcludesInvalidEntry() + { + var docSet = CreateDocSet(null, ["elasticsearch", "internal://docs-eng-team"]); + var config = CreateConfiguration(docSet); + + // Public docsets cannot link to internal; the invalid entry is excluded + config.CrossLinkEntries.Should().ContainSingle() + .Which.Should().Be(new CrossLinkEntry("elasticsearch", DocSetRegistry.Public)); + } + + [Fact] + public void CrossLinkRepositories_MatchesCrossLinkEntries() + { + var docSet = CreateDocSet("internal", ["repo-a", "public://repo-b"]); + var config = CreateConfiguration(docSet); + + config.CrossLinkRepositories.Should().BeEquivalentTo(["repo-a", "repo-b"]); + } + + private static DocumentationSetFile CreateDocSet(string? registry, IReadOnlyList crossLinks) + { + var docSet = new DocumentationSetFile + { + Project = "test", + CrossLinks = crossLinks.ToList(), + Registry = registry, + TableOfContents = [] + }; + return docSet; + } + + private static ConfigurationFile CreateConfiguration(DocumentationSetFile docSet) + { + var collector = new DiagnosticsCollector([]); + var root = Paths.WorkingDirectoryRoot.FullName; + var fileSystem = new MockFileSystem(new Dictionary(), root); + + var configPath = fileSystem.FileInfo.New(Path.Combine(root, "docs", "_docset.yml")); + var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(root, "docs")); + + var context = new MockDocumentationSetContext(collector, fileSystem, configPath, docsDir); + var versionsConfig = new VersionsConfiguration + { + VersioningSystems = new Dictionary() + }; + var productsConfig = new ProductsConfiguration + { + Products = new Dictionary().ToFrozenDictionary(), + ProductDisplayNames = new Dictionary().ToFrozenDictionary() + }; + + return new ConfigurationFile(docSet, context, versionsConfig, productsConfig); + } + + private sealed class MockDocumentationSetContext( + IDiagnosticsCollector collector, + IFileSystem fileSystem, + IFileInfo configurationPath, + IDirectoryInfo documentationSourceDirectory) + : IDocumentationSetContext + { + public IDiagnosticsCollector Collector => collector; + public IFileSystem ReadFileSystem => fileSystem; + public IFileSystem WriteFileSystem => fileSystem; + public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); + public IFileInfo ConfigurationPath => configurationPath; + public BuildType BuildType => BuildType.Isolated; + public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory; + public GitCheckoutInformation Git => GitCheckoutInformation.Create(documentationSourceDirectory, fileSystem); + } +} diff --git a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs index 37d7f44f6..2eca4f11b 100644 --- a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs @@ -13,7 +13,7 @@ namespace Elastic.Markdown.Tests.Assembler; -/// Tests that assembler builds produce correct HTMX attributes on markdown links (same as isolated: DefaultHtmxAttributeProvider). +/// Tests that assembler builds produce correct HTMX attributes on markdown cross-links (same-site, not target=_blank). public class AssemblerHtmxMarkdownLinkTests(ITestOutputHelper output) : LinkTestBase(output, "Go to [test](kibana://index.md)") { protected override BuildContext CreateBuildContext( @@ -27,11 +27,16 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void CrossLink_UsesGranularSwap_ForAssembler() - { - // Assembler: cross-links use #content-container,#toc-nav,#nav-tree,#nav-dropdown (same as isolated) + public void CrossLink_UsesGranularSwap_ForAssembler() => Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav,#nav-tree,#nav-dropdown\""); - } + + [Fact] + public void CrossLink_HasPreload() => + Html.Should().Contain("preload=\"mousedown\""); + + [Fact] + public void CrossLink_NoTargetBlank() => + Html.Should().NotContain("target=\"_blank\""); [Fact] public void EmitsCrossLink() @@ -151,10 +156,12 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void EmptyTextCrossLink_UsesGranularSwap_ForAssembler() - { + public void EmptyTextCrossLink_UsesGranularSwap_ForAssembler() => Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav,#nav-tree,#nav-dropdown\""); - } + + [Fact] + public void EmptyTextCrossLink_NoTargetBlank() => + Html.Should().NotContain("target=\"_blank\""); [Fact] public void HasError() => diff --git a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs new file mode 100644 index 000000000..1b9b4c0ee --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs @@ -0,0 +1,88 @@ +// 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 Elastic.Documentation; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Links.CrossLinks; +using Elastic.Markdown.IO; +using Elastic.Markdown.Tests.Inline; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Codex; + +/// Codex cross-links resolve to path-only URLs with htmx attributes including codex-breadcrumbs. +public class CodexHtmxCrossLinkTests(ITestOutputHelper output) : LinkTestBase(output, "Go to [test](kibana://index.md)") +{ + protected override BuildContext CreateBuildContext( + TestDiagnosticsCollector collector, + MockFileSystem fileSystem, + IConfigurationContext configurationContext) => + new(collector, fileSystem, configurationContext) + { + UrlPathPrefix = "/r/codex-environments", + BuildType = BuildType.Codex + }; + + protected override ICrossLinkResolver CreateCrossLinkResolver() => + new TestCodexCrossLinkResolver(useRelativePaths: true); + + [Fact] + public void CrossLink_ProducesPathOnlyHref() + { + Html.Should().Contain("href=\"/r/kibana/\""); + Html.Should().NotContain("https://codex.elastic.dev"); + } + + [Fact] + public void CrossLink_HasHtmxWithCodexBreadcrumbs() => + Html.Should().Contain("#codex-breadcrumbs"); + + [Fact] + public void CrossLink_HasHtmxSelectOob() => + Html.Should().Contain("hx-select-oob="); + + [Fact] + public void CrossLink_HasPreload() => + Html.Should().Contain("preload=\"mousedown\""); + + [Fact] + public void CrossLink_NoTargetBlank() => + Html.Should().NotContain("target=\"_blank\""); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +/// Isolated cross-links resolve to absolute URLs with target=_blank and no htmx. +public class IsolatedCodexCrossLinkTests(ITestOutputHelper output) : LinkTestBase(output, "Go to [test](kibana://index.md)") +{ + protected override BuildContext CreateBuildContext( + TestDiagnosticsCollector collector, + MockFileSystem fileSystem, + IConfigurationContext configurationContext) => + new(collector, fileSystem, configurationContext) + { + UrlPathPrefix = "/docs", + BuildType = BuildType.Isolated + }; + + protected override ICrossLinkResolver CreateCrossLinkResolver() => + new TestCodexCrossLinkResolver(useRelativePaths: false); + + [Fact] + public void IsolatedCrossLink_HasAbsoluteHref() => + Html.Should().Contain("https://codex.elastic.dev/r/kibana/"); + + [Fact] + public void IsolatedCrossLink_HasTargetBlank() => + Html.Should().Contain("target=\"_blank\""); + + [Fact] + public void IsolatedCrossLink_NoHtmx() => + Html.Should().NotContain("hx-select-oob"); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} diff --git a/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs b/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs new file mode 100644 index 000000000..5334acdf7 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs @@ -0,0 +1,125 @@ +// 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.Frozen; +using Elastic.Documentation.Links.CrossLinks; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.CrossLinks; + +public class CodexAwareUriResolverTests +{ + private static readonly FrozenSet CodexRepos = + new HashSet { "observability-robots", "docs-eng-team" }.ToFrozenSet(); + + [Fact] + public void CodexRepo_RelativeMode_ProducesPathOnly() + { + var resolver = new CodexAwareUriResolver(CodexRepos, useRelativePaths: true); + var uri = new Uri("observability-robots://some-page.md", UriKind.Absolute); + + var result = resolver.Resolve(uri, "some-page"); + + result.IsAbsoluteUri.Should().BeFalse(); + result.ToString().Should().Be("/r/observability-robots/some-page"); + } + + [Fact] + public void CodexRepo_AbsoluteMode_ProducesFullUrl() + { + var resolver = new CodexAwareUriResolver(CodexRepos, useRelativePaths: false); + var uri = new Uri("observability-robots://some-page.md", UriKind.Absolute); + + var result = resolver.Resolve(uri, "some-page"); + + result.IsAbsoluteUri.Should().BeTrue(); + result.ToString().Should().Be("https://codex.elastic.dev/r/observability-robots/some-page"); + } + + [Fact] + public void CodexRepo_EmptyPath_RelativeMode() + { + var resolver = new CodexAwareUriResolver(CodexRepos, useRelativePaths: true); + var uri = new Uri("observability-robots://index.md", UriKind.Absolute); + + var result = resolver.Resolve(uri, ""); + + result.IsAbsoluteUri.Should().BeFalse(); + result.ToString().Should().Be("/r/observability-robots/"); + } + + [Fact] + public void CodexRepo_EmptyPath_AbsoluteMode() + { + var resolver = new CodexAwareUriResolver(CodexRepos, useRelativePaths: false); + var uri = new Uri("observability-robots://index.md", UriKind.Absolute); + + var result = resolver.Resolve(uri, ""); + + result.IsAbsoluteUri.Should().BeTrue(); + result.ToString().Should().Be("https://codex.elastic.dev/r/observability-robots/"); + } + + [Fact] + public void NonCodexRepo_FallsBackToPublicResolver() + { + var resolver = new CodexAwareUriResolver(CodexRepos, useRelativePaths: true); + var uri = new Uri("docs-content://get-started/index.md", UriKind.Absolute); + + var result = resolver.Resolve(uri, "get-started"); + + result.IsAbsoluteUri.Should().BeTrue(); + result.ToString().Should().Contain("docs-v3-preview.elastic.dev"); + result.ToString().Should().Contain("docs-content"); + } + + [Fact] + public void DefaultMode_IsAbsolute() + { + var resolver = new CodexAwareUriResolver(CodexRepos); + var uri = new Uri("observability-robots://page.md", UriKind.Absolute); + + var result = resolver.Resolve(uri, "page"); + + result.IsAbsoluteUri.Should().BeTrue(); + result.ToString().Should().Be("https://codex.elastic.dev/r/observability-robots/page"); + } +} + +public class IsolatedBuildEnvironmentUriResolverTests +{ + [Fact] + public void ProducesAbsoluteUrl() + { + var resolver = new IsolatedBuildEnvironmentUriResolver(); + var uri = new Uri("docs-content://get-started/index.md", UriKind.Absolute); + + var result = resolver.Resolve(uri, "get-started"); + + result.IsAbsoluteUri.Should().BeTrue(); + result.ToString().Should().Be("https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/get-started"); + } + + [Fact] + public void CloudRepo_UsesMasterBranch() + { + var resolver = new IsolatedBuildEnvironmentUriResolver(); + var uri = new Uri("cloud://page.md", UriKind.Absolute); + + var result = resolver.Resolve(uri, "page"); + + result.ToString().Should().Contain("/tree/master/"); + } + + [Fact] + public void NonCloudRepo_UsesMainBranch() + { + var resolver = new IsolatedBuildEnvironmentUriResolver(); + var uri = new Uri("elasticsearch://page.md", UriKind.Absolute); + + var result = resolver.Resolve(uri, "page"); + + result.ToString().Should().Contain("/tree/main/"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs index 089a5724e..7e91ed0f6 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineLinkTests.cs @@ -125,7 +125,7 @@ public class CrossLinkReferenceTest(ITestOutputHelper output) : LinkTestBase(out [Fact] public void GeneratesHtml() => Html.ShouldContainHtml( - """

test

""" + """

test

""" ); [Fact] @@ -150,7 +150,7 @@ Go to [test](kibana://index.md) public void GeneratesHtml() => // language=html Html.Should().Contain( - """

Go to test

""" + """

Go to test

""" ); [Fact] @@ -175,7 +175,7 @@ Go to [](kibana://index.md) public void GeneratesHtml() => // language=html - empty crosslinks now emit an error Html.Should().Contain( - """

Go to

""" + """

Go to

""" ); [Fact] @@ -201,9 +201,9 @@ Go to [](kibana://get-started/index.md) { [Fact] public void GeneratesHtml() => - // language=html - empty crosslinks emit an error + // language=html - empty crosslinks emit an error; isolated builds get target=_blank Html.Should().Contain( - """

Go to

""" + """

Go to

""" ); [Fact] diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index 63e58f1a0..a2a0848fb 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions.TestingHelpers; using System.Runtime.InteropServices; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown.IO; using FluentAssertions; using JetBrains.Annotations; @@ -114,7 +115,7 @@ protected InlineTest( Collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var context = CreateBuildContext(Collector, FileSystem, configurationContext); - var linkResolver = new TestCrossLinkResolver(); + var linkResolver = CreateCrossLinkResolver(); Set = new DocumentationSet(context, logger, linkResolver); File = Set.TryFindDocument(FileSystem.FileInfo.New("docs/index.md")) as MarkdownFile ?? throw new NullReferenceException(); Html = default!; //assigned later @@ -123,6 +124,9 @@ protected InlineTest( protected virtual void AddToFileSystem(MockFileSystem fileSystem) { } + /// Override to provide a different cross-link resolver (e.g. codex-aware). + protected virtual ICrossLinkResolver CreateCrossLinkResolver() => new TestCrossLinkResolver(); + /// Override to customize BuildContext (e.g. for codex tests). protected virtual BuildContext CreateBuildContext( TestDiagnosticsCollector collector, diff --git a/tests/Elastic.Markdown.Tests/TestCodexCrossLinkResolver.cs b/tests/Elastic.Markdown.Tests/TestCodexCrossLinkResolver.cs new file mode 100644 index 000000000..a565c6b77 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/TestCodexCrossLinkResolver.cs @@ -0,0 +1,77 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using Elastic.Documentation.Links; +using Elastic.Documentation.Links.CrossLinks; +using Xunit.Internal; + +namespace Elastic.Markdown.Tests; + +/// Cross-link resolver that uses with configurable relative/absolute mode. +public class TestCodexCrossLinkResolver : ICrossLinkResolver +{ + private readonly FetchedCrossLinks _crossLinks; + + public IUriEnvironmentResolver UriResolver { get; } + + public TestCodexCrossLinkResolver(bool useRelativePaths) + { + // language=json + var json = """ + { + "content_source": "current", + "origin": { + "branch": "main", + "remote": " https://github.com/elastic/docs-content", + "ref": "76aac68d066e2af935c38bca8ce04d3ee67a8dd9" + }, + "url_path_prefix": "/elastic/docs-content/tree/main", + "cross_links": [], + "links": { + "index.md": {}, + "get-started/index.md": { + "anchors": [ + "elasticsearch-intro-elastic-stack", + "elasticsearch-intro-use-cases" + ] + }, + "solutions/observability/apps/apm-server-binary.md": { + "anchors": [ "apm-deb" ] + } + } + } + """; + var reference = CrossLinkFetcher.Deserialize(json); + var linkReferences = new Dictionary(); + var declaredRepositories = new HashSet(); + linkReferences.Add("docs-content", reference); + linkReferences.Add("kibana", reference); + declaredRepositories.AddRange(["docs-content", "kibana"]); + + var codexRepositories = new HashSet { "docs-content", "kibana" }.ToFrozenSet(); + + var indexEntries = linkReferences.ToDictionary(e => e.Key, e => new LinkRegistryEntry + { + Repository = e.Key, + Path = $"elastic/docs-builder-tests/{e.Key}/links.json", + Branch = "main", + ETag = Guid.NewGuid().ToString(), + GitReference = Guid.NewGuid().ToString() + }); + _crossLinks = new FetchedCrossLinks + { + DeclaredRepositories = declaredRepositories, + LinkReferences = linkReferences.ToFrozenDictionary(), + LinkIndexEntries = indexEntries.ToFrozenDictionary(), + CodexRepositories = codexRepositories + }; + + UriResolver = new CodexAwareUriResolver(codexRepositories, useRelativePaths); + } + + public bool TryResolve(Action errorEmitter, Uri crossLinkUri, [NotNullWhen(true)] out Uri? resolvedUri) => + CrossLinkResolver.TryResolve(errorEmitter, _crossLinks, UriResolver, crossLinkUri, out resolvedUri); +} diff --git a/tests/authoring/Inline/CrossLinks.fs b/tests/authoring/Inline/CrossLinks.fs index b9bcccd8b..d09fd8886 100644 --- a/tests/authoring/Inline/CrossLinks.fs +++ b/tests/authoring/Inline/CrossLinks.fs @@ -17,7 +17,9 @@ type ``cross-link makes it into html`` () = let ``validate HTML`` () = markdown |> convertsToHtml """

+ href="https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/solutions/observability/apps/apm-server-binary" + target="_blank" + rel="noopener noreferrer"> APM Server binary

@@ -67,7 +69,9 @@ type ``link to valid anchor`` () = let ``validate HTML`` () = markdown |> convertsToHtml """

+ href="https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/solutions/observability/apps/apm-server-binary#apm-deb" + target="_blank" + rel="noopener noreferrer"> APM Server binary

@@ -135,7 +139,9 @@ type ``Using double forward slashes`` () = let ``validate HTML`` () = markdown |> convertsToHtml """

+ href="https://docs-v3-preview.elastic.dev/elastic/docs-content/tree/main/solutions/observability/apps/apm-server-binary#apm-deb" + target="_blank" + rel="noopener noreferrer"> APM Server binary

diff --git a/tests/authoring/Inline/CrossLinksRedirects.fs b/tests/authoring/Inline/CrossLinksRedirects.fs index 1a212a458..f3c7dd4ce 100644 --- a/tests/authoring/Inline/CrossLinksRedirects.fs +++ b/tests/authoring/Inline/CrossLinksRedirects.fs @@ -18,7 +18,9 @@ type ``link to redirected page`` () = [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

+

Was first is now second

""" @@ -38,7 +40,9 @@ type ``link to redirected page with renamed anchor`` () = [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

+

Was first is now second

""" @@ -60,7 +64,9 @@ type ``Scenario 1: Moving a file`` () = [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

+

Scenario 1

""" @@ -72,7 +78,9 @@ type ``Scenario 1 B: Moving a file`` () = [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

+

Scenario 1

""" @@ -90,8 +98,12 @@ type ``Scenario 2: Splitting a page into multiple smaller pages`` () = [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

Scenario 2 - Scenario 2

+

Scenario 2 + Scenario 2

""" @@ -105,7 +117,9 @@ type ``Scenario 3: Deleting a section on a page (removing anchors)`` () = [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

+

Scenario 3

""" @@ -119,7 +133,9 @@ type ``Scenario 3 B: Linking to a removed anchor on a redirected page`` () = [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

+

Scenario 3 B

""" @@ -136,7 +152,9 @@ type ``Scenario 4: Deleting an entire page`` () = [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

+

Scenario 4

""" @@ -148,7 +166,9 @@ type ``Scenario 4 B: Deleting an entire page (short syntax for no anchors)`` () [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

+

Scenario 4

""" @@ -162,5 +182,7 @@ type ``Scenario 5: Deleting an entire page`` () = [] let ``validate HTML`` () = markdown |> convertsToHtml $""" -

Scenario 5

+

Scenario 5

""" From 1eece23d24c0d2680b28c90103a3730c3af32b77 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 24 Feb 2026 19:04:36 +0100 Subject: [PATCH 2/2] Fix errors and codeql suggestions --- .../Builder/ConfigurationFile.cs | 21 ++++------- .../IsolatedBuildService.cs | 37 +++++++------------ .../CrossLinkRegistryTests.cs | 19 +++++----- 3 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index a4f2301b7..21dba9d97 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -109,19 +109,14 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte Registry = registry; // Parse cross-link entries with optional registry prefix (e.g. public://elasticsearch) - var crossLinkEntries = new List(); - foreach (var raw in docSetFile.CrossLinks) - { - if (string.IsNullOrWhiteSpace(raw)) - continue; - - var entry = ParseCrossLinkEntry(raw.Trim(), registry, context.ConfigurationPath, context); - if (entry != null) - crossLinkEntries.Add(entry); - } - - CrossLinkEntries = [.. crossLinkEntries]; - CrossLinkRepositories = crossLinkEntries.Select(e => e.Repository).ToArray(); + CrossLinkEntries = docSetFile.CrossLinks + .Where(raw => !string.IsNullOrWhiteSpace(raw)) + .Select(raw => ParseCrossLinkEntry(raw.Trim(), registry, context.ConfigurationPath, context)) + .Where(entry => entry is not null) + .Select(entry => entry!) + .ToArray(); + + CrossLinkRepositories = CrossLinkEntries.Select(e => e.Repository).ToArray(); // Extensions - assuming they're not in DocumentationSetFile yet Extensions = new EnabledExtensions(docSetFile.Extensions); diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index d02ff3656..be46a7a35 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -122,30 +122,19 @@ public async Task Build( } else { - ILinkIndexReader? codexReader = null; - if (context.Configuration.Registry != DocSetRegistry.Public) - { - var environment = context.Configuration.Registry.ToStringFast(true); - codexReader = new GitLinkIndexReader(environment, fileSystem); - } - - try - { - var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher( - logFactory, - context.Configuration, - codexLinkIndexReader: codexReader); - var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx); - IUriEnvironmentResolver? uriResolver = crossLinks.CodexRepositories is not null - ? new CodexAwareUriResolver(crossLinks.CodexRepositories) - : null; - crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver); - } - finally - { - if (codexReader is IDisposable d) - d.Dispose(); - } + using var codexReader = context.Configuration.Registry != DocSetRegistry.Public + ? new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), fileSystem) + : null; + + var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher( + logFactory, + context.Configuration, + codexLinkIndexReader: codexReader); + var crossLinks = await crossLinkFetcher.FetchCrossLinks(ctx); + IUriEnvironmentResolver? uriResolver = crossLinks.CodexRepositories is not null + ? new CodexAwareUriResolver(crossLinks.CodexRepositories) + : null; + crossLinkResolver = new CrossLinkResolver(crossLinks, uriResolver); } // always delete output folder on CI diff --git a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs index 67de8a7c2..a86a5fdfa 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs @@ -20,13 +20,8 @@ public class CrossLinkRegistryTests [Fact] public void Registry_NullOrEmpty_ParsesAsPublic() { - foreach (var registryValue in new[] { null, "", " ", "public" }) - { - var docSet = CreateDocSet(registryValue, ["elasticsearch"]); - var config = CreateConfiguration(docSet); - + foreach (var config in new[] { null, "", " ", "public" }.Select(v => CreateConfiguration(CreateDocSet(v, ["elasticsearch"])))) config.Registry.Should().Be(DocSetRegistry.Public); - } } [Fact] @@ -106,10 +101,14 @@ private static ConfigurationFile CreateConfiguration(DocumentationSetFile docSet { var collector = new DiagnosticsCollector([]); var root = Paths.WorkingDirectoryRoot.FullName; - var fileSystem = new MockFileSystem(new Dictionary(), root); + var configFilePath = Path.Join(root, "docs", "_docset.yml"); + var fileSystem = new MockFileSystem(new Dictionary + { + { configFilePath, new MockFileData("") } + }, root); - var configPath = fileSystem.FileInfo.New(Path.Combine(root, "docs", "_docset.yml")); - var docsDir = fileSystem.DirectoryInfo.New(Path.Combine(root, "docs")); + var configPath = fileSystem.FileInfo.New(configFilePath); + var docsDir = fileSystem.DirectoryInfo.New(Path.Join(root, "docs")); var context = new MockDocumentationSetContext(collector, fileSystem, configPath, docsDir); var versionsConfig = new VersionsConfiguration @@ -135,7 +134,7 @@ private sealed class MockDocumentationSetContext( public IDiagnosticsCollector Collector => collector; public IFileSystem ReadFileSystem => fileSystem; public IFileSystem WriteFileSystem => fileSystem; - public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); + public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory;