src/tooling/docs-assembler/Navigation/GlobalNavigationFile.cs (280 lines of code) (raw):
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information
using System.Collections.Immutable;
using System.IO.Abstractions;
using Elastic.Documentation;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Assembler;
using Elastic.Documentation.Configuration.TableOfContents;
using Elastic.Documentation.Navigation;
using YamlDotNet.RepresentationModel;
namespace Documentation.Assembler.Navigation;
public record GlobalNavigationFile : ITableOfContentsScope
{
private readonly AssembleContext _context;
private readonly AssembleSources _assembleSources;
public IReadOnlyCollection<TocReference> TableOfContents { get; }
public IReadOnlyCollection<TocReference> Phantoms { get; }
public IDirectoryInfo ScopeDirectory { get; }
public GlobalNavigationFile(AssembleContext context, AssembleSources assembleSources)
{
_context = context;
_assembleSources = assembleSources;
TableOfContents = Deserialize("toc");
Phantoms = Deserialize("phantoms");
ScopeDirectory = _context.NavigationPath.Directory!;
}
public static bool ValidatePathPrefixes(AssembleContext context)
{
var sourcePathPrefixes = GetAllPathPrefixes(context);
var pathPrefixSet = new HashSet<string>();
var valid = true;
foreach (var pathPrefix in sourcePathPrefixes)
{
var prefix = $"{pathPrefix.Host}/{pathPrefix.AbsolutePath.Trim('/')}/";
if (pathPrefixSet.Add(prefix))
continue;
var duplicateOf = sourcePathPrefixes.First(p => p.Host == pathPrefix.Host && p.AbsolutePath == pathPrefix.AbsolutePath);
context.Collector.EmitError(context.NavigationPath.FullName,
$"Duplicate path prefix: {pathPrefix} duplicate: {duplicateOf}"
);
valid = false;
}
return valid;
}
public static ImmutableHashSet<Uri> GetAllPathPrefixes(AssembleContext context) =>
GetSourceUris("toc", context);
public static ImmutableHashSet<Uri> GetPhantomPrefixes(AssembleContext context) =>
GetSourceUris("phantoms", context);
private static ImmutableHashSet<Uri> GetSourceUris(string key, AssembleContext context)
{
var reader = new YamlStreamReader(context.NavigationPath, context.Collector);
var set = new HashSet<Uri>();
foreach (var entry in reader.Read())
{
if (entry.Key == key && key == "toc")
ReadPathPrefixes(reader, entry.Entry, set);
if (entry.Key == key && key == "phantoms")
ReadPhantomTocs(reader, entry.Entry, set);
}
return set.ToImmutableHashSet();
static void ReadPhantomTocs(YamlStreamReader reader, KeyValuePair<YamlNode, YamlNode> entry, HashSet<Uri> hashSet)
{
if (entry.Value is not YamlSequenceNode sequence)
{
reader.EmitWarning($"'{entry.Value}' is not an array");
return;
}
foreach (var tocEntry in sequence.Children.OfType<YamlMappingNode>())
{
foreach (var child in tocEntry.Children)
{
var key = ((YamlScalarNode)child.Key).Value;
switch (key)
{
case "toc":
var source = reader.ReadString(child);
if (source != null && !source.Contains("://"))
source = ContentSourceMoniker.CreateString(NarrativeRepository.RepositoryName, source);
if (source is not null)
_ = hashSet.Add(new Uri(source));
break;
}
}
}
}
static void ReadPathPrefixes(YamlStreamReader reader, KeyValuePair<YamlNode, YamlNode> entry, HashSet<Uri> hashSet, string? parent = null)
{
if (entry.Key is not YamlScalarNode { Value: not null } scalarKey)
{
reader.EmitWarning($"key '{entry.Key}' is not string");
return;
}
if (entry.Value is not YamlSequenceNode sequence)
{
reader.EmitWarning($"'{scalarKey.Value}' is not an array");
return;
}
foreach (var tocEntry in sequence.Children.OfType<YamlMappingNode>())
{
var source = ReadToc(reader, tocEntry, ref parent, out var pathPrefix, out var sourceUri);
if (sourceUri is not null && pathPrefix is not null)
{
var pathUri = new Uri($"{sourceUri.Scheme}://{pathPrefix.TrimEnd('/')}/");
if (!hashSet.Add(pathUri))
reader.EmitError($"Duplicate path prefix in the same repository: {pathUri}", tocEntry);
}
foreach (var child in tocEntry.Children)
{
var key = ((YamlScalarNode)child.Key).Value;
switch (key)
{
case "children":
if (source is null && pathPrefix is null)
{
reader.EmitWarning("toc entry has no toc or path_prefix defined");
continue;
}
ReadPathPrefixes(reader, child, hashSet);
break;
}
}
}
}
}
public void EmitWarning(string message) =>
_context.Collector.EmitWarning(_context.NavigationPath.FullName, message);
public void EmitError(string message) =>
_context.Collector.EmitWarning(_context.NavigationPath.FullName, message);
private IReadOnlyCollection<TocReference> Deserialize(string key)
{
var reader = new YamlStreamReader(_context.NavigationPath, _context.Collector);
try
{
foreach (var entry in reader.Read())
{
if (entry.Key == key)
return ReadChildren(key, reader, entry.Entry, null, 0);
}
}
catch (Exception e)
{
reader.EmitError("Could not load docset.yml", e);
throw;
}
return [];
}
private IReadOnlyCollection<TocReference> ReadChildren(string key, YamlStreamReader reader, KeyValuePair<YamlNode, YamlNode> entry, string? parent,
int depth)
{
var entries = new List<TocReference>();
if (entry.Key is not YamlScalarNode { Value: not null } scalarKey)
{
reader.EmitWarning($"key '{entry.Key}' is not string");
return [];
}
if (entry.Value is not YamlSequenceNode sequence)
{
reader.EmitWarning($"'{scalarKey.Value}' is not an array");
return [];
}
foreach (var tocEntry in sequence.Children.OfType<YamlMappingNode>())
{
var child =
key == "toc"
? ReadTocDefinition(reader, tocEntry, parent, depth)
: ReadPhantomDefinition(reader, tocEntry);
if (child is not null)
entries.Add(child);
}
return entries;
}
private TocReference? ReadPhantomDefinition(YamlStreamReader reader, YamlMappingNode tocEntry)
{
foreach (var entry in tocEntry.Children)
{
var key = ((YamlScalarNode)entry.Key).Value;
switch (key)
{
case "toc":
var source = reader.ReadString(entry);
if (source != null && !source.Contains("://"))
source = ContentSourceMoniker.CreateString(NarrativeRepository.RepositoryName, source);
var sourceUri = new Uri(source!);
var tocReference = new TocReference(sourceUri, this, "", []);
return tocReference;
}
}
return null;
}
private TocReference? ReadTocDefinition(YamlStreamReader reader, YamlMappingNode tocEntry, string? parent, int depth)
{
var source = ReadToc(reader, tocEntry, ref parent, out var pathPrefix, out var sourceUri);
if (sourceUri is null)
return null;
if (!_assembleSources.TocConfigurationMapping.TryGetValue(sourceUri, out var mapping))
{
reader.EmitError($"Toc entry '{sourceUri}' is could not be located", tocEntry);
return null;
}
var navigationItems = new List<ITocItem>();
foreach (var entry in tocEntry.Children)
{
var key = ((YamlScalarNode)entry.Key).Value;
switch (key)
{
case "children":
if (source is null && pathPrefix is null)
{
reader.EmitWarning("toc entry has no toc or path_prefix defined");
continue;
}
var children = ReadChildren("toc", reader, entry, parent, depth + 1);
navigationItems.AddRange(children);
break;
}
}
var rootConfig = mapping.RepositoryConfigurationFile.SourceFile.Directory!;
var path = Path.GetRelativePath(rootConfig.FullName, mapping.TableOfContentsConfiguration.ScopeDirectory.FullName);
var tocReference = new TocReference(sourceUri, mapping.TableOfContentsConfiguration, path, navigationItems);
return tocReference;
}
private static string? ReadTocSourcePathPrefix(YamlStreamReader reader, YamlMappingNode tocEntry, string? source, out Uri? sourceUri, string? pathPrefix)
{
sourceUri = null;
if (source is null)
return pathPrefix;
source = source.EndsWith("://") ? source : source.TrimEnd('/') + "/";
if (!Uri.TryCreate(source, UriKind.Absolute, out sourceUri))
{
reader.EmitError($"Source toc entry is not a valid uri: {source}", tocEntry);
return pathPrefix;
}
var sourcePrefix = $"{sourceUri.Host}/{sourceUri.AbsolutePath.TrimStart('/')}";
if (string.IsNullOrEmpty(pathPrefix))
reader.EmitError($"Path prefix is not defined for: {source}, falling back to {sourcePrefix} which may be incorrect", tocEntry);
pathPrefix ??= sourcePrefix;
return pathPrefix;
}
private static string? ReadToc(
YamlStreamReader reader,
YamlMappingNode tocEntry,
ref string? parent,
out string? pathPrefix,
out Uri? sourceUri
)
{
string? repository = null;
string? source = null;
pathPrefix = null;
foreach (var entry in tocEntry.Children)
{
var key = ((YamlScalarNode)entry.Key).Value;
switch (key)
{
case "toc":
source = reader.ReadString(entry);
if (source != null && !source.Contains("://"))
{
parent = source;
pathPrefix = source;
source = ContentSourceMoniker.CreateString(NarrativeRepository.RepositoryName, source);
}
break;
case "repo":
repository = reader.ReadString(entry);
break;
case "path_prefix":
pathPrefix = reader.ReadString(entry);
break;
}
}
if (repository is not null)
{
if (source is not null)
reader.EmitError($"toc config defines 'repo' can not be combined with 'toc': {source}", tocEntry);
pathPrefix = string.Join("/", [parent, repository]);
source = ContentSourceMoniker.CreateString(repository, parent);
}
pathPrefix = ReadTocSourcePathPrefix(reader, tocEntry, source, out sourceUri, pathPrefix);
return source;
}
}