diff --git a/config/assembler.yml b/config/assembler.yml
index a53be2be09..e833d5fe1f 100644
--- a/config/assembler.yml
+++ b/config/assembler.yml
@@ -58,6 +58,7 @@ environments:
enabled: false
feature_flags:
NAVIGATION_PREVIEW: true
+ ASSEMBLER_API_EXPLORER: true
air-gapped:
uri: http://localhost:8080
path_prefix: docs
diff --git a/docs/_docset.yml b/docs/_docset.yml
index 0f27dcbb98..354382c7f5 100644
--- a/docs/_docset.yml
+++ b/docs/_docset.yml
@@ -38,6 +38,18 @@ subs:
features:
primary-nav: false
+# Local fixture for ApiExplorer and supplemental files. The URL key must not collide
+# with docs-content's `elasticsearch` key: assembler preview still builds this checkout
+# even though the repo is `skip: true` in assembler.yml.
+# `repository:` matches the version-index publisher; without it, lookup uses this
+# checkout (`elastic/docs-builder`) and `--strict` fails. Isolated serve (no
+# `--watch`): /api/doc/docs-builder-elasticsearch/
+api:
+ docs-builder-elasticsearch:
+ - spec: elasticsearch.json
+ product: elasticsearch
+ repository: elastic/elasticsearch-specification
+
cta:
docs-builder:
button:
diff --git a/docs/api/docs-builder-elasticsearch/op-async-search-get.md b/docs/api/docs-builder-elasticsearch/op-async-search-get.md
new file mode 100644
index 0000000000..fc77406fa1
--- /dev/null
+++ b/docs/api/docs-builder-elasticsearch/op-async-search-get.md
@@ -0,0 +1,5 @@
+# Async search get supplemental fixture
+
+This file is a local fixture for docs-builder development.
+
+Discovery should associate it with the `async-search-get` operation.
diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md
index 63ab48bef3..fbf12daf8f 100644
--- a/docs/data/openapi/api-explorer.md
+++ b/docs/data/openapi/api-explorer.md
@@ -226,15 +226,18 @@ api:
## When the API Explorer runs
-The API Explorer generates documentation in two scenarios:
+The API Explorer generates documentation in these scenarios:
- **`docs-builder build`**: API docs are generated as part of the standard build. Use `--skip-api` to skip generation for faster iteration on content.
- **`docs-builder serve`**: API docs are generated on startup and regenerated automatically when spec files change.
+- **Assembler builds**: API docs are generated when the `ASSEMBLER_API_EXPLORER` feature flag is on. That flag is on for the `staging` and `preview` environments. Production stays off until cutover.
:::{note}
API generation is skipped when running `docs-builder serve --watch`. This is a performance optimization for `dotnet watch` workflows. Run `serve` without `--watch` to include API docs in your local preview.
:::
+This repository's own `_docset.yml` declares a local `docs-builder-elasticsearch` API that reads `elasticsearch.json` and sets `repository: elastic/elasticsearch-specification`. Use that entry to preview ApiExplorer and supplemental files during isolated `docs-builder serve` (open `/api/doc/docs-builder-elasticsearch/`). The key is not `elasticsearch`, so assembler preview does not collide with docs-content.
+
## Link to API pages in navigation
You can reference API pages in your `toc.yml` or `docset.yml` navigation using cross-link syntax:
diff --git a/docs/elasticsearch-openapi-docs.json b/docs/elasticsearch.json
similarity index 100%
rename from docs/elasticsearch-openapi-docs.json
rename to docs/elasticsearch.json
diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs
index 7401b2fb52..bf95efbce9 100644
--- a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs
+++ b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs
@@ -42,11 +42,14 @@ public static string OperationMoniker(string? operationId, string route)
public static string SchemaMoniker(string schemaId) =>
schemaId.Replace('.', '-').ToLowerInvariant();
- /// Deterministic URL leaf for .../group/{segment} from the canonical tag name.
- public static string TagMoniker(string? tagName)
+ ///
+ /// URL slug for a tag, without the endpoint- prefix. Spaces become hyphens and the
+ /// result is lowercased; underscores are kept. Empty or whitespace names become unknown.
+ ///
+ public static string TagSlug(string? tagName)
{
if (string.IsNullOrWhiteSpace(tagName))
- return "endpoint-unknown";
+ return "unknown";
var s = tagName.Trim();
s = string.Join(" ", s.Split(' ', StringSplitOptions.RemoveEmptyEntries));
@@ -56,12 +59,12 @@ public static string TagMoniker(string? tagName)
s = s.Replace("/", "-", StringComparison.Ordinal);
s = s.Replace(" ", "-", StringComparison.Ordinal);
s = s.ToLowerInvariant();
- if (string.IsNullOrEmpty(s))
- return "endpoint-unknown";
-
- return $"endpoint-{s}";
+ return string.IsNullOrEmpty(s) ? "unknown" : s;
}
+ /// Deterministic URL leaf for .../group/{segment} from the canonical tag name.
+ public static string TagMoniker(string? tagName) => $"endpoint-{TagSlug(tagName)}";
+
[GeneratedRegex(@"\s*\(([^)]+)\)")]
private static partial Regex ParentheticalSuffixPattern();
}
diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs
index 1508f00e69..5f8d664a1f 100644
--- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs
+++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs
@@ -9,6 +9,7 @@
using Elastic.ApiExplorer.Model;
using Elastic.ApiExplorer.Navigation;
using Elastic.ApiExplorer.Operations;
+using Elastic.ApiExplorer.Supplemental;
using Elastic.Documentation;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Products;
@@ -228,6 +229,7 @@ private async Task GenerateApiProduct(
IReadOnlyList versionSwitcherItems,
Cancel ctx)
{
+ _ = DiscoverSupplemental(openApiDocument, apiConfig);
var navigation = CreateNavigation(prefix, openApiDocument, apiConfig);
_logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? "");
@@ -245,6 +247,27 @@ private async Task GenerateApiProduct(
await RenderNavigationItems(renderContext, navigationRenderer, navigation, ctx).ConfigureAwait(false);
}
+ ///
+ /// Associates op-*.md / tag-*.md files under api/<key>/ with this
+ /// document. Merge into page models is a later change; this call logs and exposes the result.
+ ///
+ internal ApiSupplementalDiscoveryResult DiscoverSupplemental(
+ OpenApiDocument openApiDocument,
+ ResolvedApiConfiguration? apiConfig)
+ {
+ var result = ApiSupplementalDiscovery.Discover(apiConfig?.ApiContentDirectory, openApiDocument);
+ if (result.Operations.Count == 0 && result.Tags.Count == 0 && result.Unmatched.Count == 0)
+ return result;
+
+ _logger.LogInformation(
+ "API '{ApiKey}' supplemental files: {Operations} operations, {Tags} tags, {Unmatched} unmatched",
+ apiConfig?.ProductKey ?? "unknown",
+ result.Operations.Count,
+ result.Tags.Count,
+ result.Unmatched.Count);
+ return result;
+ }
+
private async Task RenderNavigationItems(
ApiRenderContext renderContext,
IsolatedBuildNavigationHtmlWriter navigationRenderer,
diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs
new file mode 100644
index 0000000000..3274231572
--- /dev/null
+++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs
@@ -0,0 +1,182 @@
+// 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 Elastic.ApiExplorer.Infrastructure;
+using Microsoft.OpenApi;
+
+namespace Elastic.ApiExplorer.Supplemental;
+
+public sealed record TagSlugCollision(string Slug, IReadOnlyList TagNames);
+
+public sealed record ApiSupplementalVersionedFile(IFileInfo File, ApiSupplementalFileName Name);
+
+public sealed class ApiSupplementalDiscoveryResult
+{
+ public required IReadOnlyDictionary Operations { get; init; }
+ public required IReadOnlyDictionary Tags { get; init; }
+ public required IReadOnlyList Unmatched { get; init; }
+ public required IReadOnlyList Ignored { get; init; }
+ public required IReadOnlyList VersionSuffixed { get; init; }
+ public required IReadOnlyList TagSlugCollisions { get; init; }
+}
+
+///
+/// Discovers top-level op-*.md / tag-*.md files under api/<key>/.
+/// Does not emit diagnostics; unmatched convention files are returned for later validation.
+///
+public static class ApiSupplementalDiscovery
+{
+ public static ApiSupplementalDiscoveryResult Discover(
+ IDirectoryInfo? folder,
+ IReadOnlyCollection operationIds,
+ IReadOnlyCollection tagNames)
+ {
+ var (tagBySlug, collisions) = IndexTags(tagNames);
+ return MatchFiles(folder, operationIds.ToHashSet(StringComparer.Ordinal), tagBySlug, collisions);
+ }
+
+ public static ApiSupplementalDiscoveryResult Discover(IDirectoryInfo? folder, OpenApiDocument document)
+ {
+ var (operationIds, tagNames) = CollectEntities(document);
+ var (tagBySlug, collisions) = IndexTags(tagNames);
+ return MatchFiles(folder, operationIds, tagBySlug, collisions);
+ }
+
+ private static ApiSupplementalDiscoveryResult MatchFiles(
+ IDirectoryInfo? folder,
+ HashSet operationIds,
+ Dictionary tagBySlug,
+ IReadOnlyList collisions)
+ {
+ if (folder is null || !folder.Exists)
+ return NoFiles(collisions);
+
+ var operations = new Dictionary(StringComparer.Ordinal);
+ var tags = new Dictionary(StringComparer.Ordinal);
+ var unmatched = new List();
+ var ignored = new List();
+ var versionSuffixed = new List();
+
+ foreach (var file in folder.EnumerateFiles("*.md"))
+ {
+ if (!ApiSupplementalName.TryParse(file.Name, out var name))
+ {
+ ignored.Add(file);
+ continue;
+ }
+
+ if (name.IsVersionSuffixed)
+ {
+ versionSuffixed.Add(new ApiSupplementalVersionedFile(file, name));
+ continue;
+ }
+
+ if (name.Kind == ApiSupplementalKind.Operation)
+ {
+ if (operationIds.Contains(name.Stem) && operations.TryAdd(name.Stem, file))
+ continue;
+ unmatched.Add(file);
+ continue;
+ }
+
+ if (tagBySlug.TryGetValue(name.Stem, out var tagName) && tags.TryAdd(tagName, file))
+ continue;
+
+ unmatched.Add(file);
+ }
+
+ return new ApiSupplementalDiscoveryResult
+ {
+ Operations = operations,
+ Tags = tags,
+ Unmatched = unmatched,
+ Ignored = ignored,
+ VersionSuffixed = versionSuffixed,
+ TagSlugCollisions = collisions
+ };
+ }
+
+ private static (HashSet OperationIds, HashSet TagNames) CollectEntities(OpenApiDocument document)
+ {
+ var operations = new HashSet(StringComparer.Ordinal);
+ var tags = new HashSet(StringComparer.Ordinal);
+
+ if (document.Tags is not null)
+ {
+ foreach (var tag in document.Tags)
+ {
+ if (!string.IsNullOrEmpty(tag.Name))
+ _ = tags.Add(tag.Name);
+ }
+ }
+
+ foreach (var path in document.Paths ?? [])
+ {
+ if (path.Value.Operations is null)
+ continue;
+
+ foreach (var operation in path.Value.Operations.Values)
+ {
+ if (!string.IsNullOrWhiteSpace(operation.OperationId))
+ _ = operations.Add(operation.OperationId);
+
+ if (operation.Tags is null)
+ continue;
+
+ foreach (var tagRef in operation.Tags)
+ {
+ var name = OperationTagName(tagRef);
+ if (!string.IsNullOrEmpty(name))
+ _ = tags.Add(name);
+ }
+ }
+ }
+
+ return (operations, tags);
+ }
+
+ private static string? OperationTagName(OpenApiTagReference tagRef) =>
+ !string.IsNullOrEmpty(tagRef.Name) ? tagRef.Name : tagRef.Reference?.Id;
+
+ private static (Dictionary UniqueBySlug, IReadOnlyList Collisions) IndexTags(
+ IReadOnlyCollection tagNames)
+ {
+ var bySlug = new Dictionary>(StringComparer.Ordinal);
+ foreach (var tagName in tagNames)
+ {
+ var slug = ApiUrlBuilder.TagSlug(tagName);
+ if (!bySlug.TryGetValue(slug, out var names))
+ {
+ names = [];
+ bySlug[slug] = names;
+ }
+
+ if (!names.Contains(tagName, StringComparer.Ordinal))
+ names.Add(tagName);
+ }
+
+ var unique = new Dictionary(StringComparer.Ordinal);
+ var collisions = new List();
+ foreach (var (slug, names) in bySlug)
+ {
+ if (names.Count == 1)
+ unique[slug] = names[0];
+ else
+ collisions.Add(new TagSlugCollision(slug, names));
+ }
+
+ return (unique, collisions);
+ }
+
+ private static ApiSupplementalDiscoveryResult NoFiles(IReadOnlyList collisions) => new()
+ {
+ Operations = new Dictionary(),
+ Tags = new Dictionary(),
+ Unmatched = [],
+ Ignored = [],
+ VersionSuffixed = [],
+ TagSlugCollisions = collisions
+ };
+}
diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs
new file mode 100644
index 0000000000..991cffe1aa
--- /dev/null
+++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs
@@ -0,0 +1,57 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.Text.RegularExpressions;
+
+namespace Elastic.ApiExplorer.Supplemental;
+
+public enum ApiSupplementalKind
+{
+ Operation,
+ Tag
+}
+
+public readonly record struct ApiSupplementalFileName(
+ ApiSupplementalKind Kind,
+ string Stem,
+ int? VersionMajor)
+{
+ public bool IsVersionSuffixed => VersionMajor is not null;
+}
+
+///
+/// Parses op-*.md / tag-*.md filenames. Operation stems are the spec
+/// operationId with no rewriting. Tag stems are .
+///
+public static partial class ApiSupplementalName
+{
+ public static bool TryParse(string fileName, out ApiSupplementalFileName parsed)
+ {
+ parsed = default;
+ var match = FileNamePattern().Match(fileName);
+ if (!match.Success)
+ return false;
+
+ var kind = match.Groups[1].Value.Equals("op", StringComparison.OrdinalIgnoreCase)
+ ? ApiSupplementalKind.Operation
+ : ApiSupplementalKind.Tag;
+ var stem = match.Groups[2].Value;
+ if (stem.Length == 0)
+ return false;
+
+ int? version = null;
+ if (match.Groups[3].Success)
+ {
+ if (!int.TryParse(match.Groups[3].Value, out var major))
+ return false;
+ version = major;
+ }
+
+ parsed = new ApiSupplementalFileName(kind, stem, version);
+ return true;
+ }
+
+ [GeneratedRegex(@"^(op|tag)-(.+?)(?:\.v(\d+))?\.md$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex FileNamePattern();
+}
diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs
index dd9b9a9e1d..94f71f9aaf 100644
--- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs
+++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs
@@ -563,7 +563,9 @@ private static bool IsValidProductId(string product) =>
repository = candidate;
}
- var children = ResolveApiChildren(productKey, entry.Children, context);
+ var apiContentDirectory = context.ReadFileSystem.DirectoryInfo.New(
+ Path.Join(context.DocumentationSourceDirectory.FullName, "api", productKey));
+ var children = ResolveApiChildren(productKey, entry.Children, context, apiContentDirectory);
return new ResolvedApiConfiguration
{
@@ -572,20 +574,22 @@ private static bool IsValidProductId(string product) =>
SpecFileName = specFileName,
LocalSpecFile = localSpecFile,
Repository = repository,
- Children = children
+ Children = children,
+ ApiContentDirectory = apiContentDirectory
};
}
/// Children resolve only under 'api/<key>/'; escaping paths and symlinks are rejected the
/// same way branding image paths are (see ).
- private static List ResolveApiChildren(string productKey, List children, IDocumentationSetContext context)
+ private static List ResolveApiChildren(
+ string productKey,
+ List children,
+ IDocumentationSetContext context,
+ IDirectoryInfo childrenDirectory)
{
if (children.Count == 0)
return [];
- var childrenDirectory = context.ReadFileSystem.DirectoryInfo.New(
- Path.Join(context.DocumentationSourceDirectory.FullName, "api", productKey));
-
var resolved = new List();
foreach (var child in children)
{
@@ -620,12 +624,24 @@ private static List ResolveApiChildren(string productKey, List
+ childFile.Directory is not null
+ && string.Equals(childFile.Directory.FullName, apiDirectory.FullName, StringComparison.OrdinalIgnoreCase)
+ && ResolvedApiConfiguration.IsSupplementalFileName(childFile.Name);
+
private static CrossLinkEntry? ParseCrossLinkEntry(string raw, DocSetRegistry docsetRegistry, IFileInfo configPath, IDocumentationContext context)
{
DocSetRegistry entryRegistry;
diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs
index 43158b5d47..ebdd052e78 100644
--- a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs
+++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs
@@ -180,11 +180,60 @@ public class ResolvedApiConfiguration
public List Children { get; init; } = [];
///
- /// Gets all child Markdown file paths that should be excluded from normal HTML generation.
+ /// The api/<key>/ directory for this product, whether or not it exists yet.
+ /// Supplemental op-*.md / tag-*.md files are discovered from here.
+ ///
+ public IDirectoryInfo? ApiContentDirectory { get; init; }
+
+ ///
+ /// Whether is an auto-discovered supplemental file
+ /// (op-*.md or tag-*.md), including version-suffixed names.
+ ///
+ public static bool IsSupplementalFileName(string fileName)
+ {
+ var name = Path.GetFileName(fileName);
+ return name.StartsWith("op-", StringComparison.OrdinalIgnoreCase)
+ || name.StartsWith("tag-", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Markdown paths that must not be rendered by the normal HTML pipeline:
+ /// explicit children: pages and convention supplemental files.
///
public IEnumerable GetMarkdownPathsToExclude(string documentationSourceDirectoryFullName)
{
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var file in Children)
- yield return Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/');
+ {
+ var relative = ToRelativeMarkdownPath(file, documentationSourceDirectoryFullName);
+ if (seen.Add(relative))
+ yield return relative;
+ }
+
+ foreach (var file in EnumerateApiMarkdownFiles())
+ {
+ if (!IsSupplementalFileName(file.Name))
+ continue;
+ var relative = ToRelativeMarkdownPath(file, documentationSourceDirectoryFullName);
+ if (seen.Add(relative))
+ yield return relative;
+ }
}
+
+ /// Top-level Markdown files under , when the folder exists.
+ public IEnumerable EnumerateApiMarkdownFiles()
+ {
+ if (ApiContentDirectory is not { } dir)
+ yield break;
+
+ dir.Refresh();
+ if (!dir.Exists)
+ yield break;
+
+ foreach (var file in dir.EnumerateFiles("*.md"))
+ yield return file;
+ }
+
+ private static string ToRelativeMarkdownPath(IFileInfo file, string documentationSourceDirectoryFullName) =>
+ Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/');
}
diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs
index 9853605938..2d3a37558a 100644
--- a/src/Elastic.Markdown/DocumentationGenerator.cs
+++ b/src/Elastic.Markdown/DocumentationGenerator.cs
@@ -53,6 +53,7 @@ public partial class DocumentationGenerator
public DocumentationSet DocumentationSet { get; }
public BuildContext Context { get; }
public IMarkdownStringRenderer MarkdownStringRenderer => HtmlWriter;
+ private HashSet ApiMarkdownExcludePaths { get; }
public DocumentationGenerator(
DocumentationSet docSet,
@@ -76,6 +77,7 @@ public DocumentationGenerator(
DocumentationSet = docSet;
PositionalNavigation = positionalNavigation ?? docSet;
Context = docSet.Context;
+ ApiMarkdownExcludePaths = BuildApiMarkdownExcludePaths();
// Use the provided inferrer or create a default one
_documentInferrer = documentInferrer ?? new DocumentInferrerService(
@@ -502,26 +504,29 @@ public async Task RenderLayout(MarkdownFile markdown, Cancel ctx)
}
///
- /// Checks if a file path is registered as an explicit children: page in any API
- /// configuration. These files render via the API pipeline rather than normal HTML generation.
+ /// True when the file is an API children: page or a convention supplemental file.
+ /// Those files skip the normal HTML pipeline.
///
private bool IsApiMarkdownFile(string relativePath)
{
var normalized = relativePath.Replace(Path.DirectorySeparatorChar, '/');
+ return ApiMarkdownExcludePaths.Contains(normalized);
+ }
- if (Context.Configuration.ApiConfigurations == null)
- return false;
+ private HashSet BuildApiMarkdownExcludePaths()
+ {
+ var set = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (Context.Configuration.ApiConfigurations is null)
+ return set;
+ var docsRoot = Context.DocumentationSourceDirectory.FullName;
foreach (var apiConfig in Context.Configuration.ApiConfigurations.Values)
{
- foreach (var childPath in apiConfig.GetMarkdownPathsToExclude(Context.DocumentationSourceDirectory.FullName))
- {
- if (string.Equals(normalized, childPath, StringComparison.OrdinalIgnoreCase))
- return true;
- }
+ foreach (var path in apiConfig.GetMarkdownPathsToExclude(docsRoot))
+ _ = set.Add(path);
}
- return false;
+ return set;
}
}
diff --git a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs
index 06130da5be..6ba4d442b8 100644
--- a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs
+++ b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs
@@ -30,7 +30,7 @@ public static SitemapResult Generate(
IDirectoryInfo outputFolder
)
{
- // API pages are generated only on staging (assembler-api-explorer flag) and /docs/api/* is still
+ // API pages are generated on staging and preview (assembler-api-explorer flag). /docs/api/* is still
// proxied to bump.sh at the edge (#725). Keep them out of the sitemap until cutover.
var filtered = entries
.Where(e => !e.Key.StartsWith("/docs/api/", StringComparison.Ordinal))
diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs
index 7e667d6600..77428c8ad4 100644
--- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs
+++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs
@@ -60,7 +60,7 @@ bool isWatchBuild
// Track OpenAPI spec file modification times to detect changes
private readonly Dictionary _openApiSpecLastModified = [];
- // Track intro/outro markdown file modification times to detect changes
+ // Track API markdown modification times so serve reloads on overlay and children: edits.
private readonly Dictionary _apiMarkdownFilesLastModified = [];
private volatile bool _apiReferencesStale = true;
@@ -71,8 +71,13 @@ public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true)
{
// Content-only changes (e.g. .md edits) don't need a full rebuild:
// RenderLayout -> ParseFullAsync reads fresh content from disk on each request.
+ // API overlay files are an exception: they are baked into generated HTML, so mark
+ // refs stale and let EnsureApiReferencesAsync re-check timestamps on the next /api request.
if (!reloadConfiguration && _cachedCrossLinks is not null)
+ {
+ _apiReferencesStale = true;
return;
+ }
SourcePath.Refresh();
OutputPath.Refresh();
@@ -151,35 +156,55 @@ private bool HaveOpenApiSpecsChanged(ConfigurationFile config)
if (config.ApiConfigurations is null)
return false;
- // First run - no timestamps yet
if (_openApiSpecLastModified.Count == 0 && _apiMarkdownFilesLastModified.Count == 0)
return true;
foreach (var apiConfig in config.ApiConfigurations.Values)
{
- // The local spec override, when present. A spec with no local file resolves
- // remotely and has nothing on disk to watch here.
if (apiConfig.LocalSpecFile is { } specFile)
{
specFile.Refresh();
if (!_openApiSpecLastModified.TryGetValue(specFile.FullName, out var lastModified))
- return true; // New file
+ return true;
if (specFile.LastWriteTimeUtc > lastModified)
- return true; // File modified
+ return true;
}
+ }
+
+ return HaveApiMarkdownFilesChanged(config);
+ }
+
+ private bool HaveApiMarkdownFilesChanged(ConfigurationFile config)
+ {
+ var current = CurrentApiMarkdownTimestamps(config);
+ if (current.Count != _apiMarkdownFilesLastModified.Count)
+ return true;
+
+ foreach (var (path, time) in current)
+ {
+ if (!_apiMarkdownFilesLastModified.TryGetValue(path, out var lastModified) || time > lastModified)
+ return true;
+ }
+
+ return false;
+ }
+
+ private static Dictionary CurrentApiMarkdownTimestamps(ConfigurationFile config)
+ {
+ var current = new Dictionary(StringComparer.Ordinal);
+ if (config.ApiConfigurations is null)
+ return current;
- // Explicit children declared via 'children:'
- foreach (var childFile in apiConfig.Children)
+ foreach (var apiConfig in config.ApiConfigurations.Values)
+ {
+ foreach (var file in apiConfig.EnumerateApiMarkdownFiles().Concat(apiConfig.Children))
{
- childFile.Refresh();
- if (!_apiMarkdownFilesLastModified.TryGetValue(childFile.FullName, out var lastModified))
- return true; // New file
- if (childFile.LastWriteTimeUtc > lastModified)
- return true; // File modified
+ file.Refresh();
+ current[file.FullName] = file.LastWriteTimeUtc;
}
}
- return false;
+ return current;
}
private void UpdateOpenApiSpecTimestamps(ConfigurationFile config)
@@ -197,13 +222,10 @@ private void UpdateOpenApiSpecTimestamps(ConfigurationFile config)
specFile.Refresh();
_openApiSpecLastModified[specFile.FullName] = specFile.LastWriteTimeUtc;
}
-
- foreach (var childFile in apiConfig.Children)
- {
- childFile.Refresh();
- _apiMarkdownFilesLastModified[childFile.FullName] = childFile.LastWriteTimeUtc;
- }
}
+
+ foreach (var (path, time) in CurrentApiMarkdownTimestamps(config))
+ _apiMarkdownFilesLastModified[path] = time;
}
public async Task ReloadApiReferences(Cancel ctx) => await ReloadApiReferences(_generator.MarkdownStringRenderer, ctx);
diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs
index 22c4dba606..17205421f3 100644
--- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs
+++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs
@@ -96,11 +96,18 @@ public void ReadsContentSource()
}
[Fact]
- public void StagingEnvironment_EnablesAssemblerApiExplorerFlag()
+ public void StagingEnvironment_EnablesAssemblerApiExplorerFlag() =>
+ AssertEnvironmentEnablesAssemblerApiExplorer("staging");
+
+ [Fact]
+ public void PreviewEnvironment_EnablesAssemblerApiExplorerFlag() =>
+ AssertEnvironmentEnablesAssemblerApiExplorer("preview");
+
+ private void AssertEnvironmentEnablesAssemblerApiExplorer(string environmentName)
{
- var staging = Context.Configuration.Environments["staging"];
+ var environment = Context.Configuration.Environments[environmentName];
- staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER")
+ environment.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER")
.WhoseValue.Should().BeTrue();
}
diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs
index 6bad9da9f8..a7e8404e7f 100644
--- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs
+++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs
@@ -57,7 +57,7 @@ public async Task ResolveDocumentsForProduct_VersionlessLocalSpec_RendersLocalFi
};
var context = CreateContext(collector, versionless, products);
var localFile = new FileSystem().FileInfo.New(
- Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json"));
+ Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch.json"));
var expectedDocument = SpecDocument();
var reader = A.Fake();
A.CallTo(() => reader.ReadAsync(localFile)).Returns(expectedDocument);
diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs
index 7eff1c5823..1f85d086fa 100644
--- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs
+++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs
@@ -137,7 +137,7 @@ public async Task ResolveDocumentsForProduct_LocalMainAndRemoteHistoricalVersion
var stack = TestHelpers.CreateStackVersionsConfiguration(currentMajor: 9);
var product = TestHelpers.CreateProduct("elasticsearch", stack.GetVersioningSystem(VersioningSystemId.Stack));
var context = CreateContext(collector, stack, ProductsFor(product), GitForElasticsearch());
- var localFile = new FileSystem().FileInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json"));
+ var localFile = new FileSystem().FileInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch.json"));
var localDocument = SpecDocument("Elasticsearch local main");
using var versionIndexClient = new VersionIndexClient(BaseUri, MultiVersionHandler(), sleep: (_, _) => Task.CompletedTask);
var reader = A.Fake();
diff --git a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs
index 88672a7060..635386e235 100644
--- a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs
+++ b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs
@@ -21,7 +21,7 @@ public class ReaderTests
private static IFileInfo LocalSpecFile()
{
var fileSystem = new FileSystem();
- var path = fileSystem.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json");
+ var path = fileSystem.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch.json");
return fileSystem.FileInfo.New(path);
}
diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs
new file mode 100644
index 0000000000..64c5929da9
--- /dev/null
+++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs
@@ -0,0 +1,188 @@
+// 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.ApiExplorer.Model;
+using Elastic.ApiExplorer.Supplemental;
+
+namespace Elastic.ApiExplorer.Tests.Supplemental;
+
+public class ApiSupplementalDiscoveryTests
+{
+ private const string Folder = "/docs/api/fixture";
+
+ [Fact]
+ public void Discover_MissingFolder_ReturnsEmpty()
+ {
+ var fs = new MockFileSystem();
+ var folder = fs.DirectoryInfo.New("/docs/api/missing");
+
+ var result = ApiSupplementalDiscovery.Discover(folder, ["search"], ["search"]);
+
+ result.Operations.Should().BeEmpty();
+ result.Tags.Should().BeEmpty();
+ result.Unmatched.Should().BeEmpty();
+ result.Ignored.Should().BeEmpty();
+ result.VersionSuffixed.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Discover_NullFolder_ReturnsEmpty()
+ {
+ var result = ApiSupplementalDiscovery.Discover(null, ["search"], ["search"]);
+
+ result.Operations.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Discover_MatchesExactOperationId()
+ {
+ var folder = FolderWith(
+ "op-search.md",
+ "op-getAlertingHealth.md",
+ "op-cluster-health.md");
+
+ var result = ApiSupplementalDiscovery.Discover(
+ folder,
+ ["search", "getAlertingHealth", "cluster.health"],
+ []);
+
+ result.Operations.Keys.Should().BeEquivalentTo("search", "getAlertingHealth");
+ result.Unmatched.Select(f => f.Name).Should().ContainSingle().Which.Should().Be("op-cluster-health.md");
+ }
+
+ [Fact]
+ public void Discover_DoesNotMatchDifferentCasing()
+ {
+ var folder = FolderWith("op-getalertinghealth.md");
+
+ var result = ApiSupplementalDiscovery.Discover(folder, ["getAlertingHealth"], []);
+
+ result.Operations.Should().BeEmpty();
+ result.Unmatched.Select(f => f.Name).Should().ContainSingle().Which.Should().Be("op-getalertinghealth.md");
+ }
+
+ [Fact]
+ public void Discover_MatchesTagUrlSlug()
+ {
+ var folder = FolderWith(
+ "tag-ml-anomaly.md",
+ "tag-health_report.md",
+ "tag-apm-agent-configuration.md");
+
+ var result = ApiSupplementalDiscovery.Discover(
+ folder,
+ [],
+ ["ml anomaly", "health_report", "APM agent configuration"]);
+
+ result.Tags.Should().ContainKey("ml anomaly");
+ result.Tags.Should().ContainKey("health_report");
+ result.Tags.Should().ContainKey("APM agent configuration");
+ result.Unmatched.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Discover_IgnoresNonConventionFiles()
+ {
+ var folder = FolderWith("random-notes.md", "getting-started.md", "op-search.md");
+
+ var result = ApiSupplementalDiscovery.Discover(folder, ["search"], []);
+
+ result.Ignored.Select(f => f.Name).Should().BeEquivalentTo("random-notes.md", "getting-started.md");
+ result.Operations.Should().ContainKey("search");
+ }
+
+ [Fact]
+ public void Discover_UnmatchedConventionFile_IsNotAnError()
+ {
+ var folder = FolderWith("op-does-not-exist.md");
+
+ var result = ApiSupplementalDiscovery.Discover(folder, ["search"], []);
+
+ result.Unmatched.Should().ContainSingle(f => f.Name == "op-does-not-exist.md");
+ result.Operations.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Discover_VersionSuffixedFile_IsClassifiedSeparately()
+ {
+ var folder = FolderWith("op-search.v8.md", "op-search.md");
+
+ var result = ApiSupplementalDiscovery.Discover(folder, ["search"], []);
+
+ result.Operations.Should().ContainKey("search");
+ result.VersionSuffixed.Should().ContainSingle(v => v.File.Name == "op-search.v8.md" && v.Name.VersionMajor == 8);
+ result.Unmatched.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Discover_TagSlugCollision_IsRecordedAndFileUnmatched()
+ {
+ var folder = FolderWith("tag-foo-bar.md");
+
+ var result = ApiSupplementalDiscovery.Discover(folder, [], ["foo bar", "foo-bar"]);
+
+ result.TagSlugCollisions.Should().ContainSingle(c => c.Slug == "foo-bar");
+ result.TagSlugCollisions[0].TagNames.Should().BeEquivalentTo("foo bar", "foo-bar");
+ result.Tags.Should().BeEmpty();
+ result.Unmatched.Should().ContainSingle(f => f.Name == "tag-foo-bar.md");
+ }
+
+ [Fact]
+ public async Task Discover_FixtureDocument_MatchesSearchAndDocsGet()
+ {
+ var folder = FolderWith("op-search.md", "op-docs-get.md", "tag-search.md", "random-notes.md");
+ var path = Path.Combine(AppContext.BaseDirectory, "TestData", "api-explorer-fixture.json");
+ var document = await OpenApiReader.Instance.ReadAsync(new System.IO.Abstractions.FileSystem().FileInfo.New(path))
+ ?? throw new InvalidOperationException("Could not read fixture spec");
+
+ var result = ApiSupplementalDiscovery.Discover(folder, document);
+
+ result.Operations.Should().ContainKey("search");
+ result.Operations.Should().ContainKey("docs-get");
+ result.Tags.Should().ContainKey("search");
+ result.Ignored.Should().ContainSingle(f => f.Name == "random-notes.md");
+ }
+
+ [Fact]
+ public async Task Discover_InlineOperationTagsWithoutDocumentTags_MatchTagFile()
+ {
+ var folder = FolderWith("tag-search.md");
+ var specJson = /*lang=json,strict*/ """
+ {
+ "openapi": "3.0.3",
+ "info": { "title": "t", "version": "1" },
+ "paths": {
+ "/search": {
+ "get": {
+ "operationId": "search",
+ "tags": ["search"]
+ }
+ }
+ }
+ }
+ """;
+ var fs = new MockFileSystem(new Dictionary
+ {
+ ["/spec.json"] = new MockFileData(specJson)
+ });
+ var document = await OpenApiReader.Instance.ReadAsync(fs.FileInfo.New("/spec.json"))
+ ?? throw new InvalidOperationException("Could not read spec");
+
+ var result = ApiSupplementalDiscovery.Discover(folder, document);
+
+ result.Tags.Should().ContainKey("search");
+ result.Unmatched.Should().BeEmpty();
+ }
+
+ private static System.IO.Abstractions.IDirectoryInfo FolderWith(params string[] fileNames)
+ {
+ var files = fileNames.ToDictionary(
+ name => $"{Folder}/{name}",
+ _ => new MockFileData("# supplemental"));
+ var fs = new MockFileSystem(files);
+ return fs.DirectoryInfo.New(Folder);
+ }
+}
diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs
new file mode 100644
index 0000000000..1521604e44
--- /dev/null
+++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs
@@ -0,0 +1,59 @@
+// 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.ApiExplorer.Infrastructure;
+using Elastic.ApiExplorer.Supplemental;
+
+namespace Elastic.ApiExplorer.Tests.Supplemental;
+
+public class ApiSupplementalNameTests
+{
+ [Theory]
+ [InlineData("op-search.md", ApiSupplementalKind.Operation, "search", null)]
+ [InlineData("op-getAlertingHealth.md", ApiSupplementalKind.Operation, "getAlertingHealth", null)]
+ [InlineData("op-search.v8.md", ApiSupplementalKind.Operation, "search", 8)]
+ [InlineData("tag-ml-anomaly.md", ApiSupplementalKind.Tag, "ml-anomaly", null)]
+ [InlineData("tag-health_report.md", ApiSupplementalKind.Tag, "health_report", null)]
+ [InlineData("tag-apm-agent-configuration.v9.md", ApiSupplementalKind.Tag, "apm-agent-configuration", 9)]
+ public void TryParse_ConventionFile_ReturnsKindStemAndVersion(
+ string fileName, ApiSupplementalKind kind, string stem, int? version)
+ {
+ ApiSupplementalName.TryParse(fileName, out var parsed).Should().BeTrue();
+ parsed.Kind.Should().Be(kind);
+ parsed.Stem.Should().Be(stem);
+ parsed.VersionMajor.Should().Be(version);
+ parsed.IsVersionSuffixed.Should().Be(version is not null);
+ }
+
+ [Theory]
+ [InlineData("random-notes.md")]
+ [InlineData("getting-started.md")]
+ [InlineData("index.md")]
+ [InlineData("op-.md")]
+ [InlineData("search.md")]
+ [InlineData("op-search.txt")]
+ public void TryParse_NonConventionFile_ReturnsFalse(string fileName)
+ {
+ ApiSupplementalName.TryParse(fileName, out _).Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("APM agent configuration", "apm-agent-configuration")]
+ [InlineData("health_report", "health_report")]
+ [InlineData("ml anomaly", "ml-anomaly")]
+ public void TagSlug_MatchesExpectedFileStem(string tagName, string expectedStem)
+ {
+ ApiUrlBuilder.TagSlug(tagName).Should().Be(expectedStem);
+ ApiUrlBuilder.TagMoniker(tagName).Should().Be($"endpoint-{expectedStem}");
+ }
+
+ [Fact]
+ public void TagSlug_EmptyName_IsUnknown()
+ {
+ ApiUrlBuilder.TagSlug("").Should().Be("unknown");
+ ApiUrlBuilder.TagMoniker("").Should().Be("endpoint-unknown");
+ ApiUrlBuilder.TagMoniker(null).Should().Be("endpoint-unknown");
+ }
+}
diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs
new file mode 100644
index 0000000000..08acc1614d
--- /dev/null
+++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs
@@ -0,0 +1,46 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions.TestingHelpers;
+using AwesomeAssertions;
+using Elastic.ApiExplorer.Supplemental;
+using Elastic.Documentation.Configuration.Products;
+using Elastic.Documentation.Configuration.Toc;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Elastic.ApiExplorer.Tests.Supplemental;
+
+public class OpenApiGeneratorSupplementalTests(ApiExplorerFixture fixture) : IClassFixture
+{
+ [Fact]
+ public void DiscoverSupplemental_MatchesFixtureFilesAndLeavesHtmlUnchanged()
+ {
+ var folder = "/docs/api/fixture";
+ var fs = new MockFileSystem(new Dictionary
+ {
+ [$"{folder}/op-search.md"] = new("# search"),
+ [$"{folder}/tag-search.md"] = new("# tag"),
+ [$"{folder}/random-notes.md"] = new("# notes"),
+ [$"{folder}/op-nope.md"] = new("# unmatched")
+ });
+ var apiConfig = new ResolvedApiConfiguration
+ {
+ ProductKey = "fixture",
+ Product = new Product { Id = "elasticsearch", DisplayName = "Elasticsearch" },
+ SpecFileName = "api-explorer-fixture.json",
+ ApiContentDirectory = fs.DirectoryInfo.New(folder)
+ };
+
+ var generator = new OpenApiGenerator(NullLoggerFactory.Instance, fixture.Context, PassthroughMarkdownRenderer.Instance);
+ var result = generator.DiscoverSupplemental(fixture.Document, apiConfig);
+
+ result.Operations.Should().ContainKey("search");
+ result.Tags.Should().ContainKey("search");
+ result.Ignored.Should().ContainSingle(f => f.Name == "random-notes.md");
+ result.Unmatched.Should().ContainSingle(f => f.Name == "op-nope.md");
+
+ var navigation = generator.CreateNavigation("fixture", fixture.Document, apiConfig);
+ navigation.Should().NotBeNull();
+ }
+}
diff --git a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs
index eb034e3a48..2b85421268 100644
--- a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs
+++ b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs
@@ -50,16 +50,23 @@ public void AssemblerApiExplorerEnabled_EnvironmentVariableOverridesYaml()
}
[Fact]
- public void StagingEnvironment_EnablesAssemblerApiExplorer()
+ public void StagingEnvironment_EnablesAssemblerApiExplorer() =>
+ AssertEnvironmentEnablesAssemblerApiExplorer("staging");
+
+ [Fact]
+ public void PreviewEnvironment_EnablesAssemblerApiExplorer() =>
+ AssertEnvironmentEnablesAssemblerApiExplorer("preview");
+
+ private static void AssertEnvironmentEnablesAssemblerApiExplorer(string environmentName)
{
var config = AssemblyConfiguration.Create(new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem()));
- var staging = config.Environments["staging"];
+ var environment = config.Environments[environmentName];
- staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER")
+ environment.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER")
.WhoseValue.Should().BeTrue();
var features = new FeatureFlags([]);
- foreach (var (key, value) in staging.FeatureFlags)
+ foreach (var (key, value) in environment.FeatureFlags)
features.Set(key, value);
features.AssemblerApiExplorerEnabled.Should().BeTrue();
}
diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs
index 14bb6a8923..862882d862 100644
--- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs
+++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs
@@ -588,10 +588,125 @@ public void EmitsError_WhenChildPathEscapesApiKeyDirectory()
config.ApiConfigurations!["elasticsearch"].Children.Should().BeEmpty();
}
+ [Fact]
+ public void EmitsError_WhenChildFileUsesSupplementalName()
+ {
+ var docSetFile = new DocumentationSetFile
+ {
+ Api = new Dictionary
+ {
+ ["elasticsearch"] = new()
+ {
+ Entries =
+ [
+ new ApiProductEntry
+ {
+ Spec = "elasticsearch-openapi.json",
+ Product = "elasticsearch",
+ Children = [new ApiEntryChild { File = "op-search.md" }]
+ }
+ ]
+ }
+ }
+ };
+
+ var (config, collector) = CreateConfiguration(docSetFile, extraMarkdownFiles: ["op-search.md"]);
+
+ collector.Errors.Should().Be(1);
+ config.ApiConfigurations!["elasticsearch"].Children.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void AcceptsNestedChildWhoseBasenameLooksSupplemental()
+ {
+ var docSetFile = new DocumentationSetFile
+ {
+ Api = new Dictionary
+ {
+ ["elasticsearch"] = new()
+ {
+ Entries =
+ [
+ new ApiProductEntry
+ {
+ Spec = "elasticsearch-openapi.json",
+ Product = "elasticsearch",
+ Children = [new ApiEntryChild { File = "guides/op-overview.md" }]
+ }
+ ]
+ }
+ }
+ };
+
+ var (config, collector) = CreateConfiguration(docSetFile, extraMarkdownFiles: ["guides/op-overview.md"]);
+
+ collector.Errors.Should().Be(0);
+ config.ApiConfigurations!["elasticsearch"].Children.Should().ContainSingle(f => f.Name == "op-overview.md");
+ }
+
+ [Fact]
+ public void GetMarkdownPathsToExclude_IncludesChildrenAndSupplementalFiles()
+ {
+ var docSetFile = new DocumentationSetFile
+ {
+ Api = new Dictionary
+ {
+ ["elasticsearch"] = new()
+ {
+ Entries =
+ [
+ new ApiProductEntry
+ {
+ Spec = "elasticsearch-openapi.json",
+ Product = "elasticsearch",
+ Children = [new ApiEntryChild { File = "getting-started.md" }]
+ }
+ ]
+ }
+ }
+ };
+
+ var (config, collector) = CreateConfiguration(
+ docSetFile,
+ extraMarkdownFiles: ["op-search.md", "tag-documents.md", "random-notes.md"]);
+
+ collector.Errors.Should().Be(0);
+ var docsRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs");
+ var excluded = config.ApiConfigurations!["elasticsearch"]
+ .GetMarkdownPathsToExclude(docsRoot)
+ .ToArray();
+
+ excluded.Should().Contain("api/elasticsearch/getting-started.md");
+ excluded.Should().Contain("api/elasticsearch/op-search.md");
+ excluded.Should().Contain("api/elasticsearch/tag-documents.md");
+ excluded.Should().NotContain("api/elasticsearch/random-notes.md");
+ }
+
+ [Fact]
+ public void ApiContentDirectory_IsSetToApiKeyFolder()
+ {
+ var docSetFile = new DocumentationSetFile
+ {
+ Api = new Dictionary
+ {
+ ["elasticsearch"] = new()
+ {
+ Entries = [new ApiProductEntry { Spec = "elasticsearch-openapi.json", Product = "elasticsearch" }]
+ }
+ }
+ };
+
+ var (config, collector) = CreateConfiguration(docSetFile);
+
+ collector.Errors.Should().Be(0);
+ config.ApiConfigurations!["elasticsearch"].ApiContentDirectory.Should().NotBeNull();
+ config.ApiConfigurations["elasticsearch"].ApiContentDirectory!.Name.Should().Be("elasticsearch");
+ }
+
private static readonly string[] DefaultProductIds = ["elasticsearch", "kibana"];
private static (ConfigurationFile Config, DiagnosticsCollector Collector) CreateConfiguration(
- DocumentationSetFile docSet, string[]? extraProducts = null, bool withLocalSpecFile = true)
+ DocumentationSetFile docSet, string[]? extraProducts = null, bool withLocalSpecFile = true, string[]? extraMarkdownFiles = null)
{
var collector = new DiagnosticsCollector([]);
var root = Paths.WorkingDirectoryRoot.FullName;
@@ -604,6 +719,8 @@ private static (ConfigurationFile Config, DiagnosticsCollector Collector) Create
};
if (withLocalSpecFile)
files[Path.Join(root, "docs", "elasticsearch-openapi.json")] = new MockFileData("{}");
+ foreach (var name in extraMarkdownFiles ?? [])
+ files[Path.Join(root, "docs", "api", "elasticsearch", name)] = new MockFileData("# extra");
var fileSystem = new MockFileSystem(files, root);
var configPath = fileSystem.FileInfo.New(configFilePath);
diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs
index 2a8cb49247..3a8a9d67dd 100644
--- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs
+++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs
@@ -48,7 +48,12 @@ public void PhysicalDocsetFileCanBeDeserialized()
docSet.Subs.Should().NotBeEmpty();
docSet.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder");
- docSet.Api.Should().BeNullOrEmpty("API declarations live in docs-content for assembler builds");
+ docSet.Api.Should().ContainKey("docs-builder-elasticsearch");
+ var apiEntry = docSet.Api["docs-builder-elasticsearch"].SingleEntry;
+ apiEntry.Should().NotBeNull();
+ apiEntry.Spec.Should().Be("elasticsearch.json");
+ apiEntry.Product.Should().Be("elasticsearch");
+ apiEntry.Repository.Should().Be("elastic/elasticsearch-specification");
docSet.TableOfContents.Should().NotBeEmpty();