diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 0000000000..8bf7cc27a1 --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/AGENTS.md b/AGENTS.md index 1df1497ed8..59db9c9813 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ Use the `/test` skill to pick the right test project automatically. A change to | `src/Elastic.Documentation.Configuration/` | `dotnet test tests/Elastic.Documentation.Configuration.Tests/` | | `src/Elastic.Documentation.Navigation/` | `dotnet test tests/Navigation.Tests/` (prefix dropped) | | `src/Elastic.Documentation.Indexing/` | `dotnet test tests/Elastic.Documentation.Indexing.Tests/` | +| `src/authoring/Elastic.LegacyDocs.Migration/` | `dotnet test tests/Elastic.LegacyDocs.Migration.Tests/` | | `src/tooling/essc/` | `dotnet test tests/Elastic.SiteSearch.Tests/` (essc's root namespace is `Elastic.SiteSearch.Cli`) | | `src/Elastic.ApiExplorer/` | `dotnet test tests/Elastic.ApiExplorer.Tests/` | | `src/Elastic.Documentation.Site/` | `cd src/Elastic.Documentation.Site && npm run test` | diff --git a/docs-builder.slnx b/docs-builder.slnx index e507aeaa7f..91ad0995dd 100644 --- a/docs-builder.slnx +++ b/docs-builder.slnx @@ -64,6 +64,7 @@ + @@ -86,6 +87,8 @@ + + @@ -111,6 +114,7 @@ + diff --git a/docs/cli-schema.json b/docs/cli-schema.json index abf6eb63b0..9298a52cc8 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -863,6 +863,14 @@ "summary": "Special flag for dotnet watch optimizations during development", "defaultValue": "false" }, + { + "role": "flag", + "name": "no-hud", + "type": "boolean", + "required": false, + "summary": "Disable the diagnostics HUD and background validation builds", + "defaultValue": "false" + }, { "role": "flag", "name": "log-level", diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index d7f56eb8a7..dd9b9a9e1d 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -225,6 +225,8 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte _features["primary-nav"] = docSetFile.Features.PrimaryNav.Value; if (docSetFile.Features.DisableGithubEditLink.HasValue) _features["disable-github-edit-link"] = docSetFile.Features.DisableGithubEditLink.Value; + if (docSetFile.Features.GuideNav.HasValue) + _features["guide-nav"] = docSetFile.Features.GuideNav.Value; // primary-nav requires the Elastic global navigation which is not available for white-label builds if (Branding is not null && docSetFile.Features.PrimaryNav is true) diff --git a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs index 2b0869bccf..ed7b7de9c8 100644 --- a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs +++ b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs @@ -58,6 +58,12 @@ public bool NavigationPreviewEnabled set => _featureFlags["navigation-preview"] = value; } + public bool GuideNavEnabled + { + get => IsEnabled("guide-nav"); + set => _featureFlags["guide-nav"] = value; + } + private bool IsEnabled(string key) { var envKey = $"FEATURE_{key.ToUpperInvariant().Replace('-', '_')}"; diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 695c872eb7..f29db26c9f 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -960,6 +960,8 @@ public class DocumentationSetFeatures public bool? PrimaryNav { get; set; } [YamlMember(Alias = "disable-github-edit-link", ApplyNamingConventions = false)] public bool? DisableGithubEditLink { get; set; } + [YamlMember(Alias = "guide-nav", ApplyNamingConventions = false)] + public bool? GuideNav { get; set; } } [YamlSerializable] diff --git a/src/authoring/Elastic.LegacyDocs.Migration/ArchiveDocsetGenerator.cs b/src/authoring/Elastic.LegacyDocs.Migration/ArchiveDocsetGenerator.cs new file mode 100644 index 0000000000..d6c9a78573 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/ArchiveDocsetGenerator.cs @@ -0,0 +1,203 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.LegacyDocs.Migration.Asciidoc; +using Microsoft.Extensions.Logging; + +namespace Elastic.LegacyDocs.Migration; + +public record ArchiveGeneratorOptions +{ + public required string OutputDirectory { get; init; } + public string? BookFilter { get; init; } + public bool AllVersions { get; init; } + public int? MinMajorVersion { get; init; } + public required SourceRepoManager RepoManager { get; init; } +} + +public class ArchiveDocsetGenerator(ILogger logger) +{ + public async Task GenerateAsync(LegacyConf conf, ArchiveGeneratorOptions options, CancellationToken ct = default) + { + var books = conf.Contents + .SelectMany(c => c.Sections) + .Where(b => options.BookFilter is null || b.Prefix == options.BookFilter) + .ToList(); + + logger.LogInformation("Processing {BookCount} books in archive mode", books.Count); + + var tocRefs = new List(); + + foreach (var book in books) + { + ct.ThrowIfCancellationRequested(); + + var versions = GetVersionsToProcess(book, options.AllVersions, options.MinMajorVersion); + if (versions.Count == 0) + { + logger.LogWarning("No versions to process for {BookPrefix}", book.Prefix); + continue; + } + + logger.LogInformation("Book {Prefix}: {Count} versions to process", book.Prefix, versions.Count); + + var prefixDir = Path.Combine(options.OutputDirectory, book.Prefix); + var versionEntries = new List(); + + foreach (var version in versions) + { + ct.ThrowIfCancellationRequested(); + + var versionLabel = version.VersionLabel; + try + { + var pages = await ProcessBookVersion(book, version, options, ct); + if (pages.Count == 0) + continue; + + var versionDir = Path.Combine(prefixDir, versionLabel); + _ = Directory.CreateDirectory(versionDir); + + var fileEntries = await WritePages(pages, versionDir, ct); + YamlWriter.WriteTocYaml(Path.Combine(versionDir, "toc.yml"), fileEntries); + versionEntries.Add(new TocEntry { Folder = versionLabel }); + + logger.LogInformation("Wrote {PageCount} pages for {Prefix}/{Version}", + pages.Count, book.Prefix, versionLabel); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Failed to process {Prefix}/{Version}", book.Prefix, versionLabel); + } + } + + if (versionEntries.Count > 0) + { + tocRefs.Add(book.Prefix); + YamlWriter.WriteTocYaml(Path.Combine(prefixDir, "toc.yml"), versionEntries); + } + } + + if (tocRefs.Count > 0) + YamlWriter.WriteDocsetYaml(Path.Combine(options.OutputDirectory, "docset.yml"), "guide-archive", tocRefs); + + logger.LogInformation("Archive generation complete: {BookCount} books", tocRefs.Count); + } + + private async Task> ProcessBookVersion( + LegacyBook book, BranchRef version, ArchiveGeneratorOptions options, CancellationToken ct) + { + var versionLabel = version.VersionLabel; + var sources = await options.RepoManager.ResolveSourcesAsync(book, version, ct); + if (sources.Count == 0) + { + logger.LogWarning("No sources resolved for {Prefix} version {Version}", book.Prefix, versionLabel); + return []; + } + + var primarySource = sources[0]; + var indexPath = Path.Combine(primarySource.LocalPath, book.Index); + if (!File.Exists(indexPath)) + { + logger.LogWarning("Index file not found: {IndexPath}", indexPath); + return []; + } + + var content = await File.ReadAllTextAsync(indexPath, ct); + var basePath = Path.GetDirectoryName(indexPath) ?? primarySource.LocalPath; + var parserOptions = new AsciidocParserOptions + { + Attributes = new Dictionary + { + ["branch"] = versionLabel, + ["doc-tests-src"] = primarySource.LocalPath + } + }; + var parser = new AsciidocParser(parserOptions); + var document = parser.Parse(content, basePath); + + var emitterOptions = new MarkdownEmitterOptions + { + BookPrefix = book.Prefix, + Version = versionLabel + }; + var emitter = new MarkdownEmitter(emitterOptions); + + return PageChunker.Chunk(document, book.Chunk, emitter); + } + + private static async Task> WritePages( + IReadOnlyList pages, string directory, CancellationToken ct) + { + var entries = new List(); + foreach (var page in pages) + { + var filename = $"{page.Slug}.md"; + await File.WriteAllTextAsync(Path.Combine(directory, filename), page.MarkdownContent, ct); + entries.Add(new TocEntry { File = filename }); + } + return entries; + } + + internal static List GetVersionsToProcess(LegacyBook book, bool allVersions, int? minMajorVersion = null) + { + var branches = FilterByMinVersion(book.Branches, minMajorVersion); + + if (allVersions) + return SortBranchesDescending(branches); + + var selected = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrEmpty(book.Current)) + _ = selected.Add(book.Current); + + var grouped = branches + .Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) + .Where(x => x.Parsed.HasValue) + .GroupBy(x => x.Parsed!.Value.Major); + + foreach (var group in grouped) + { + var topTwo = group + .OrderByDescending(x => x.Parsed!.Value.Minor) + .Take(2); + + foreach (var (branch, _) in topTwo) + _ = selected.Add(branch.VersionLabel); + } + + return SortBranchesDescending(branches.Where(b => selected.Contains(b.VersionLabel))); + } + + private static List FilterByMinVersion(IEnumerable branches, int? minMajor) + { + if (minMajor is null) + return branches.ToList(); + + return branches + .Where(b => + { + var parsed = TryParseMajorMinor(b.VersionLabel); + return parsed.HasValue && parsed.Value.Major >= minMajor; + }) + .ToList(); + } + + private static List SortBranchesDescending(IEnumerable branches) => + branches + .Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) + .OrderByDescending(x => x.Parsed?.Major ?? 0) + .ThenByDescending(x => x.Parsed?.Minor ?? 0) + .Select(x => x.Branch) + .ToList(); + + private static (int Major, int Minor)? TryParseMajorMinor(string version) + { + var parts = version.Split('.'); + if (parts.Length >= 2 && int.TryParse(parts[0], out var major) && int.TryParse(parts[1], out var minor)) + return (major, minor); + + return null; + } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocLexer.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocLexer.cs new file mode 100644 index 0000000000..9e3f5e993f --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocLexer.cs @@ -0,0 +1,616 @@ +// 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.Globalization; +using System.Text.RegularExpressions; + +namespace Elastic.LegacyDocs.Migration.Asciidoc; + +public enum TokenType +{ + SectionTitle, + AttributeEntry, + AttributeUnset, + BlockDelimiter, + BlockAnchor, + BlockTitle, + BlockAttribute, + ListItemUnordered, + ListItemOrdered, + ListContinuation, + DescriptionListItem, + TableDelimiter, + TableRow, + IncludeDirective, + ConditionalStart, + ConditionalEnd, + ImageBlock, + AdmonitionParagraph, + Comment, + CommentBlockDelim, + PageBreak, + ThematicBreak, + Blank, + Text +} + +public record Token(TokenType Type, string Raw, int LineNumber, TokenMetadata? Metadata = null); + +public record TokenMetadata +{ + public int? Level { get; init; } + public string? Id { get; init; } + public string? Language { get; init; } + public string? Path { get; init; } + public string? Title { get; init; } + public string? Content { get; init; } + public string? DelimiterChar { get; init; } + public string? AttributeName { get; init; } + public string? AttributeValue { get; init; } + public string? Condition { get; init; } + public string? BlockStyle { get; init; } + public Dictionary? NamedAttributes { get; init; } +} + +public static partial class AsciidocLexer +{ + private static readonly Regex SectionRegex = GetSectionRegex(); + private static readonly Regex AttributeEntryRegex = GetAttributeEntryRegex(); + private static readonly Regex AttributeUnsetRegex = GetAttributeUnsetRegex(); + private static readonly Regex BlockAnchorRegex = GetBlockAnchorRegex(); + private static readonly Regex BlockTitleRegex = GetBlockTitleRegex(); + private static readonly Regex BlockAttributeRegex = GetBlockAttributeRegex(); + private static readonly Regex BlockDelimiterRegex = GetBlockDelimiterRegex(); + private static readonly Regex UnorderedListRegex = GetUnorderedListRegex(); + private static readonly Regex OrderedListRegex = GetOrderedListRegex(); + private static readonly Regex ListContinuationRegex = GetListContinuationRegex(); + private static readonly Regex DescriptionListRegex = GetDescriptionListRegex(); + private static readonly Regex TableDelimiterRegex = GetTableDelimiterRegex(); + private static readonly Regex IncludeRegex = GetIncludeRegex(); + private static readonly Regex ConditionalStartRegex = GetConditionalStartRegex(); + private static readonly Regex ConditionalEndRegex = GetConditionalEndRegex(); + private static readonly Regex ImageBlockRegex = GetImageBlockRegex(); + private static readonly Regex AdmonitionRegex = GetAdmonitionRegex(); + private static readonly Regex CommentBlockDelimRegex = GetCommentBlockDelimRegex(); + private static readonly Regex CommentRegex = GetCommentRegex(); + private static readonly Regex PageBreakRegex = GetPageBreakRegex(); + private static readonly Regex ThematicBreakRegex = GetThematicBreakRegex(); + private static readonly Regex BlankRegex = GetBlankRegex(); + + [GeneratedRegex(@"^(={1,6})\s+(.+)$")] + private static partial Regex GetSectionRegex(); + + [GeneratedRegex(@"^:([^!:][^:]*?):\s*(.*)$")] + private static partial Regex GetAttributeEntryRegex(); + + [GeneratedRegex(@"^:!([^:]+):$")] + private static partial Regex GetAttributeUnsetRegex(); + + [GeneratedRegex(@"^\[\[([^\]]+)\]\]$")] + private static partial Regex GetBlockAnchorRegex(); + + [GeneratedRegex(@"^\.(\S.*)$")] + private static partial Regex GetBlockTitleRegex(); + + [GeneratedRegex(@"^\[(.+)\]\s*$")] + private static partial Regex GetBlockAttributeRegex(); + + [GeneratedRegex(@"^(-{4,}|\.{4,}|={4,}|\*{4,}|\+{4,}|/{4,}|-{2})\s*$")] + private static partial Regex GetBlockDelimiterRegex(); + + [GeneratedRegex(@"^(\*{1,5})\s+(.+)$")] + private static partial Regex GetUnorderedListRegex(); + + [GeneratedRegex(@"^(\.{1,5})\s+(.+)$")] + private static partial Regex GetOrderedListRegex(); + + [GeneratedRegex(@"^\+\s*$")] + private static partial Regex GetListContinuationRegex(); + + [GeneratedRegex(@"^(.+?)(:{2,4})\s*(.*)$")] + private static partial Regex GetDescriptionListRegex(); + + [GeneratedRegex(@"^\|={3,}\s*$")] + private static partial Regex GetTableDelimiterRegex(); + + [GeneratedRegex(@"^include::(.+?)\[(.*?)?\]$")] + private static partial Regex GetIncludeRegex(); + + [GeneratedRegex(@"^(ifdef|ifndef|ifeval)::(.*?)\[(.*?)?\]$")] + private static partial Regex GetConditionalStartRegex(); + + [GeneratedRegex(@"^endif::(.*?)?\[(.*?)?\]$")] + private static partial Regex GetConditionalEndRegex(); + + [GeneratedRegex(@"^image::(.+?)\[(.*?)?\]$")] + private static partial Regex GetImageBlockRegex(); + + [GeneratedRegex(@"^(NOTE|TIP|WARNING|IMPORTANT|CAUTION):\s+(.+)$")] + private static partial Regex GetAdmonitionRegex(); + + [GeneratedRegex(@"^/{4,}$")] + private static partial Regex GetCommentBlockDelimRegex(); + + [GeneratedRegex(@"^//\s*(.*)$")] + private static partial Regex GetCommentRegex(); + + [GeneratedRegex(@"^<<<\s*$")] + private static partial Regex GetPageBreakRegex(); + + [GeneratedRegex(@"^'{3,}\s*$")] + private static partial Regex GetThematicBreakRegex(); + + [GeneratedRegex(@"^\s*$")] + private static partial Regex GetBlankRegex(); + + public static IReadOnlyList Tokenize(string content) + { + var lines = content.Split('\n'); + var tokens = new List(); + var inCommentBlock = false; + var inVerbatimBlock = false; + var verbatimDelimiter = ""; + var inTable = false; + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i].TrimEnd('\r'); + var lineNumber = i + 1; + + if (inCommentBlock) + { + if (CommentBlockDelimRegex.IsMatch(line)) + inCommentBlock = false; + continue; + } + + if (inVerbatimBlock) + { + if (IsMatchingDelimiter(line, verbatimDelimiter)) + { + inVerbatimBlock = false; + tokens.Add(new Token(TokenType.BlockDelimiter, line, lineNumber, new TokenMetadata { DelimiterChar = verbatimDelimiter[..1] })); + } + else + { + tokens.Add(new Token(TokenType.Text, line, lineNumber)); + } + continue; + } + + if (CommentBlockDelimRegex.IsMatch(line)) + { + inCommentBlock = true; + continue; + } + + var match = BlockDelimiterRegex.Match(line); + if (match.Success) + { + var delim = match.Groups[1].Value; + var delimChar = delim[..1]; + + if (delimChar is "-" or "." or "/" or "+") + { + // `--` (length 2) is an open block delimiter, not verbatim. + // Only `----`, `....`, `++++`, `////` (length >= 4) are verbatim. + if (delim.Length < 4) + { + tokens.Add(new Token(TokenType.BlockDelimiter, line, lineNumber, new TokenMetadata { DelimiterChar = delimChar })); + continue; + } + + if (delimChar == "/") + { + inCommentBlock = true; + continue; + } + + inVerbatimBlock = true; + verbatimDelimiter = delim; + tokens.Add(new Token(TokenType.BlockDelimiter, line, lineNumber, new TokenMetadata { DelimiterChar = delimChar })); + continue; + } + + if (delimChar is "=" or "*") + { + tokens.Add(new Token(TokenType.BlockDelimiter, line, lineNumber, new TokenMetadata { DelimiterChar = delimChar })); + continue; + } + } + + if (inTable) + { + if (TableDelimiterRegex.IsMatch(line)) + { + inTable = false; + tokens.Add(new Token(TokenType.TableDelimiter, line, lineNumber)); + continue; + } + + if (BlankRegex.IsMatch(line)) + { + tokens.Add(new Token(TokenType.Blank, line, lineNumber)); + continue; + } + + var condStart = ConditionalStartRegex.Match(line); + if (condStart.Success) + { + tokens.Add(new Token(TokenType.ConditionalStart, line, lineNumber, new TokenMetadata + { + Condition = condStart.Groups[2].Value, + Content = condStart.Groups[3].Value, + BlockStyle = condStart.Groups[1].Value + })); + continue; + } + + var condEnd = ConditionalEndRegex.Match(line); + if (condEnd.Success) + { + tokens.Add(new Token(TokenType.ConditionalEnd, line, lineNumber, new TokenMetadata + { + Condition = condEnd.Groups[1].Value + })); + continue; + } + + if (CommentRegex.IsMatch(line)) + { + tokens.Add(new Token(TokenType.Comment, line, lineNumber)); + continue; + } + + if (line.StartsWith('|')) + { + tokens.Add(new Token(TokenType.TableRow, line, lineNumber, new TokenMetadata { Content = line[1..] })); + continue; + } + + tokens.Add(new Token(TokenType.Text, line, lineNumber)); + continue; + } + + if (TableDelimiterRegex.IsMatch(line)) + { + inTable = true; + tokens.Add(new Token(TokenType.TableDelimiter, line, lineNumber)); + continue; + } + + if (TryMatchToken(line, lineNumber, out var token)) + { + tokens.Add(token); + continue; + } + + tokens.Add(new Token(TokenType.Text, line, lineNumber)); + } + + return tokens; + } + + private static bool IsMatchingDelimiter(string line, string openDelimiter) + { + if (openDelimiter.Length < 2) + return false; + + var delimChar = openDelimiter[0]; + // Trim trailing whitespace so source bugs like `---- ` still close a `----` block + var trimmed = line.TrimEnd(); + // Require exact length so e.g. `--------` does not close a `----` block + return trimmed.Length == openDelimiter.Length && trimmed.All(c => c == delimChar); + } + + private static bool TryMatchToken(string line, int lineNumber, out Token token) + { + token = default!; + + var m = SectionRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.SectionTitle, line, lineNumber, new TokenMetadata + { + Level = m.Groups[1].Value.Length - 1, + Title = m.Groups[2].Value.Trim() + }); + return true; + } + + m = AttributeUnsetRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.AttributeUnset, line, lineNumber, new TokenMetadata + { + AttributeName = m.Groups[1].Value + }); + return true; + } + + m = AttributeEntryRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.AttributeEntry, line, lineNumber, new TokenMetadata + { + AttributeName = m.Groups[1].Value, + AttributeValue = m.Groups[2].Value + }); + return true; + } + + m = BlockAnchorRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.BlockAnchor, line, lineNumber, new TokenMetadata + { + Id = m.Groups[1].Value + }); + return true; + } + + m = ConditionalStartRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.ConditionalStart, line, lineNumber, new TokenMetadata + { + Condition = m.Groups[2].Value, + Content = m.Groups[3].Value, + BlockStyle = m.Groups[1].Value + }); + return true; + } + + m = ConditionalEndRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.ConditionalEnd, line, lineNumber, new TokenMetadata + { + Condition = m.Groups[1].Value + }); + return true; + } + + m = IncludeRegex.Match(line); + if (m.Success) + { + var attrs = ParseBlockAttributeContent(m.Groups[2].Value); + token = new Token(TokenType.IncludeDirective, line, lineNumber, new TokenMetadata + { + Path = m.Groups[1].Value, + NamedAttributes = attrs + }); + return true; + } + + m = ImageBlockRegex.Match(line); + if (m.Success) + { + var attrs = ParseInlineAttributes(m.Groups[2].Value); + token = new Token(TokenType.ImageBlock, line, lineNumber, new TokenMetadata + { + Path = m.Groups[1].Value, + Title = attrs.GetValueOrDefault("alt") ?? attrs.GetValueOrDefault("0"), + NamedAttributes = attrs + }); + return true; + } + + m = AdmonitionRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.AdmonitionParagraph, line, lineNumber, new TokenMetadata + { + BlockStyle = m.Groups[1].Value, + Content = m.Groups[2].Value + }); + return true; + } + + if (PageBreakRegex.IsMatch(line)) + { + token = new Token(TokenType.PageBreak, line, lineNumber); + return true; + } + + if (ThematicBreakRegex.IsMatch(line)) + { + token = new Token(TokenType.ThematicBreak, line, lineNumber); + return true; + } + + m = ListContinuationRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.ListContinuation, line, lineNumber); + return true; + } + + m = UnorderedListRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.ListItemUnordered, line, lineNumber, new TokenMetadata + { + Level = m.Groups[1].Value.Length, + Content = m.Groups[2].Value + }); + return true; + } + + m = OrderedListRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.ListItemOrdered, line, lineNumber, new TokenMetadata + { + Level = m.Groups[1].Value.Length, + Content = m.Groups[2].Value + }); + return true; + } + + m = CommentRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.Comment, line, lineNumber, new TokenMetadata + { + Content = m.Groups[1].Value + }); + return true; + } + + if (BlankRegex.IsMatch(line)) + { + token = new Token(TokenType.Blank, line, lineNumber); + return true; + } + + m = BlockAttributeRegex.Match(line); + if (m.Success && !BlockAnchorRegex.IsMatch(line)) + { + var content = m.Groups[1].Value; + var parsed = ParseBlockAttributeContent(content); + string? style = null; + string? language = null; + + var positional = content.Split(','); + if (positional.Length > 0) + { + var first = positional[0].Trim().Trim('"'); + if (!first.Contains('=')) + style = first; + } + if (positional.Length > 1 && style?.Equals("source", StringComparison.OrdinalIgnoreCase) == true) + { + var second = positional[1].Trim().Trim('"'); + if (!second.Contains('=')) + language = second; + } + + token = new Token(TokenType.BlockAttribute, line, lineNumber, new TokenMetadata + { + BlockStyle = style, + Language = language, + Content = content, + NamedAttributes = parsed + }); + return true; + } + + m = BlockTitleRegex.Match(line); + if (m.Success) + { + token = new Token(TokenType.BlockTitle, line, lineNumber, new TokenMetadata + { + Title = m.Groups[1].Value + }); + return true; + } + + m = DescriptionListRegex.Match(line); + if (m.Success) + { + var term = m.Groups[1].Value; + var separator = m.Groups[2].Value; + var desc = m.Groups[3].Value; + if (!term.Contains("://") && !term.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + token = new Token(TokenType.DescriptionListItem, line, lineNumber, new TokenMetadata + { + Title = term, + Content = desc, + Level = separator.Length + }); + return true; + } + } + + return false; + } + + private static Dictionary ParseBlockAttributeContent(string content) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(content)) + return result; + + var parts = SplitRespectingQuotes(content); + var positionalIndex = 0; + foreach (var part in parts) + { + var trimmed = part.Trim(); + var eqIndex = trimmed.IndexOf('='); + if (eqIndex > 0) + { + var key = trimmed[..eqIndex].Trim(); + var value = trimmed[(eqIndex + 1)..].Trim().Trim('"'); + result[key] = value; + } + else + { + result[positionalIndex.ToString(CultureInfo.InvariantCulture)] = trimmed.Trim('"'); + positionalIndex++; + } + } + return result; + } + + private static Dictionary ParseInlineAttributes(string content) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(content)) + return result; + + var parts = SplitRespectingQuotes(content); + var positionalIndex = 0; + foreach (var part in parts) + { + var trimmed = part.Trim(); + var eqIndex = trimmed.IndexOf('='); + if (eqIndex > 0) + { + var key = trimmed[..eqIndex].Trim(); + var value = trimmed[(eqIndex + 1)..].Trim().Trim('"'); + result[key] = value; + } + else + { + if (positionalIndex == 0) + result["alt"] = trimmed.Trim('"'); + result[positionalIndex.ToString(CultureInfo.InvariantCulture)] = trimmed.Trim('"'); + positionalIndex++; + } + } + return result; + } + + private static List SplitRespectingQuotes(string input) + { + var parts = new List(); + var current = new System.Text.StringBuilder(); + var inQuotes = false; + + foreach (var c in input) + { + if (c == '"') + { + inQuotes = !inQuotes; + _ = current.Append(c); + } + else if (c == ',' && !inQuotes) + { + parts.Add(current.ToString()); + _ = current.Clear(); + } + else + { + _ = current.Append(c); + } + } + + if (current.Length > 0) + parts.Add(current.ToString()); + + return parts; + } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocParser.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocParser.cs new file mode 100644 index 0000000000..2920809442 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/AsciidocParser.cs @@ -0,0 +1,1561 @@ +// 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.Globalization; +using System.Text.RegularExpressions; +using Elastic.LegacyDocs.Migration.Asciidoc.Ast; + +namespace Elastic.LegacyDocs.Migration.Asciidoc; + +public record AsciidocParserOptions +{ + public Dictionary Attributes { get; init; } = []; + public int MaxIncludeDepth { get; init; } = 64; + public Func? FileReader { get; init; } + public Action? OnDiagnostic { get; init; } +} + +public partial class AsciidocParser(AsciidocParserOptions options) +{ + private readonly Dictionary _attributes = new(options.Attributes, StringComparer.OrdinalIgnoreCase); + + /// + /// Returns the resolved attributes after parsing. Useful for extracting attribute definitions + /// from a shared attributes file to seed into subsequent parsers. + /// + public IReadOnlyDictionary ResolvedAttributes => _attributes; + + /// + /// Parses a shared AsciiDoc attributes file (`:name: value` definitions) with the given + /// seed attributes and returns the fully-resolved attribute map, excluding ProductNames keys. + /// + public static Dictionary LoadAttributeFile(string path, Dictionary seedAttributes) + { + if (!File.Exists(path)) + return []; + var content = File.ReadAllText(path); + var parser = new AsciidocParser(new AsciidocParserOptions { Attributes = seedAttributes }); + _ = parser.Parse(content, Path.GetDirectoryName(path) ?? ""); + return new Dictionary(parser._attributes, StringComparer.OrdinalIgnoreCase); + } + + private IReadOnlyList _tokens = []; + private int _pos; + private int _includeDepth; + private string _basePath = ""; + + public AsciidocDocument Parse(string filePath) + { + var content = ReadFile(filePath) ?? throw new FileNotFoundException($"File not found: {filePath}"); + return Parse(content, Path.GetDirectoryName(filePath) ?? ""); + } + + /// + /// Sets an attribute with eager expansion of its value (Asciidoctor semantics). + /// Product-name keys defined in are intentionally + /// kept unresolved so the emitter can emit them as docs-builder {{sub}} placeholders. + /// + private void SetAttribute(string name, string? value) + { + if (value is null) + { + _ = _attributes.Remove(name); + return; + } + // Product name subs stay unresolved so the emitter emits {{name}} for docs-builder substitution + if (SharedAttributes.ProductNames.ContainsKey(name)) + return; + // Eagerly expand attribute values at definition time (Asciidoctor semantics) + _attributes[name] = SubstituteAttributes(value); + } + + /// + /// Sets the base path and updates the docdir attribute, which is per-file in Asciidoctor. + /// + private void SetBasePath(string basePath) + { + _basePath = basePath; + _attributes["docdir"] = basePath; + } + + public AsciidocDocument Parse(string content, string basePath) + { + SetBasePath(basePath); + var rawTokens = AsciidocLexer.Tokenize(content); + var processed = ConditionalProcessor.Process(rawTokens, _attributes); + _tokens = processed; + _pos = 0; + + var doc = new AsciidocDocument(); + string? pendingId = null; + string? pendingTitle = null; + TokenMetadata? pendingBlockAttr = null; + + while (_pos < _tokens.Count) + { + var token = Current; + + switch (token.Type) + { + case TokenType.AttributeEntry: + SetAttribute(token.Metadata!.AttributeName!, token.Metadata.AttributeValue); + _pos++; + break; + + case TokenType.AttributeUnset: + _ = _attributes.Remove(token.Metadata!.AttributeName!); + _pos++; + break; + + case TokenType.BlockAnchor: + pendingId = token.Metadata!.Id; + _pos++; + break; + + case TokenType.BlockTitle: + pendingTitle = token.Metadata!.Title; + _pos++; + break; + + case TokenType.BlockAttribute: + pendingBlockAttr = token.Metadata; + _pos++; + break; + + case TokenType.SectionTitle: + if (doc.Title is null && token.Metadata!.Level == 1) + { + doc = doc with + { + Title = SubstituteAttributes(token.Metadata.Title!), + Id = pendingId, + Attributes = new(_attributes, StringComparer.OrdinalIgnoreCase) + }; + pendingId = null; + pendingTitle = null; + _pos++; + } + else + { + var section = ParseSection(pendingId, pendingTitle); + doc.Children.Add(section); + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + } + break; + + case TokenType.Blank: + case TokenType.Comment: + _pos++; + break; + + case TokenType.IncludeDirective: + var included = ProcessInclude(token); + if (included != null) + doc.Children.AddRange(included); + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + _pos++; + break; + + default: + var block = ParseBlock(pendingId, pendingTitle, pendingBlockAttr); + if (block != null) + doc.Children.Add(block); + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + break; + } + } + + return doc with { Attributes = new(_attributes, StringComparer.OrdinalIgnoreCase) }; + } + + private Token Current => _tokens[_pos]; + + private SectionNode ParseSection(string? id, string? title) + { + var token = Current; + var level = token.Metadata!.Level!.Value; + var sectionTitle = SubstituteAttributes(title ?? token.Metadata.Title!); + var sectionId = id ?? ExtractInlineAnchor(sectionTitle); + _pos++; + + var children = new List(); + string? pendingId = null; + string? pendingTitle = null; + TokenMetadata? pendingBlockAttr = null; + var pendingStart = _pos; + + while (_pos < _tokens.Count) + { + var cur = Current; + + if (cur.Type == TokenType.SectionTitle && cur.Metadata!.Level!.Value <= level) + { + _pos = pendingStart; + break; + } + + switch (cur.Type) + { + case TokenType.AttributeEntry: + SetAttribute(cur.Metadata!.AttributeName!, cur.Metadata.AttributeValue); + _pos++; + pendingStart = _pos; + break; + + case TokenType.AttributeUnset: + _ = _attributes.Remove(cur.Metadata!.AttributeName!); + _pos++; + pendingStart = _pos; + break; + + case TokenType.BlockAnchor: + pendingId = cur.Metadata!.Id; + _pos++; + break; + + case TokenType.BlockTitle: + pendingTitle = cur.Metadata!.Title; + _pos++; + break; + + case TokenType.BlockAttribute: + pendingBlockAttr = cur.Metadata; + _pos++; + break; + + case TokenType.SectionTitle: + var childSection = ParseSection(pendingId, null); + children.Add(childSection); + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + pendingStart = _pos; + break; + + case TokenType.Blank: + case TokenType.Comment: + _pos++; + break; + + case TokenType.IncludeDirective: + var included = ProcessInclude(cur); + if (included != null) + children.AddRange(included); + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + _pos++; + pendingStart = _pos; + break; + + default: + var block = ParseBlock(pendingId, pendingTitle, pendingBlockAttr); + if (block != null) + children.Add(block); + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + pendingStart = _pos; + break; + } + } + + return new SectionNode { Level = level, Title = sectionTitle, Id = sectionId, Children = children }; + } + + private IAsciidocNode? ParseBlock(string? id, string? title, TokenMetadata? blockAttr) + { + if (_pos >= _tokens.Count) + return null; + + var token = Current; + + var block = token.Type switch + { + TokenType.BlockDelimiter => ParseDelimitedBlock(blockAttr), + TokenType.ListItemUnordered => ParseUnorderedList(), + TokenType.ListItemOrdered => ParseOrderedList(), + TokenType.DescriptionListItem => ParseDescriptionList(), + TokenType.TableDelimiter => ParseTable(blockAttr), + TokenType.AdmonitionParagraph => ParseAdmonitionParagraph(), + TokenType.ImageBlock => ParseImageBlock(title), + TokenType.PageBreak => ParsePageBreak(), + TokenType.ThematicBreak => ParseThematicBreak(), + TokenType.Text when CalloutItemRegex().IsMatch(token.Raw) => ParseCalloutList(), + TokenType.Text => ParseParagraph(), + TokenType.IncludeDirective => ParseIncludeBlock(), + TokenType.ConditionalStart or TokenType.ConditionalEnd => SkipConditional(), + _ => SkipToken() + }; + + return id is not null && block is not null ? new AnchoredBlock(id, block) : block; + } + + private IAsciidocNode? ParseDelimitedBlock(TokenMetadata? blockAttr) + { + var token = Current; + var delimChar = token.Metadata?.DelimiterChar ?? "-"; + // Trim trailing whitespace from the raw delimiter so `-- ` matches `--` as its close + var openingDelim = token.Raw.TrimEnd(); + _pos++; + + var style = blockAttr?.BlockStyle?.ToLowerInvariant(); + + var contentLines = new List(); + var children = new List(); + + if (IsVerbatimDelimiter(delimChar, openingDelim)) + { + while (_pos < _tokens.Count) + { + var cur = Current; + if (cur.Type == TokenType.BlockDelimiter && IsMatchingClose(cur.Raw, openingDelim)) + { + _pos++; + break; + } + contentLines.Add(cur.Raw); + _pos++; + } + + // Resolve include-tagged:: directives embedded inside verbatim blocks + contentLines = ResolveVerbatimIncludes(contentLines); + } + else + { + var innerTokens = CollectDelimitedTokens(openingDelim); + children = ParseTokensAsBlocks(innerTokens); + } + + // Collect callout annotations that follow a code block (e.g. <1> First step). + // Skip any trailing comment lines (// TEST[...]) and one optional blank line; + // restore position if what follows is not callout markers. + var callouts = new List(); + if (delimChar is "-" && openingDelim.Length >= 4) + { + var savedPos = _pos; + while (_pos < _tokens.Count && Current.Type == TokenType.Comment) + _pos++; + if (_pos < _tokens.Count && Current.Type == TokenType.Blank) + _pos++; + if (_pos >= _tokens.Count || Current.Type != TokenType.Text || !CalloutItemRegex().IsMatch(Current.Raw)) + _pos = savedPos; // not callouts — restore so the normal token handling runs + while (_pos < _tokens.Count && Current.Type == TokenType.Text) + { + var calloutMatch = CalloutItemRegex().Match(Current.Raw); + if (!calloutMatch.Success) + break; + if (int.TryParse(calloutMatch.Groups[1].Value, out var idx) && idx >= 1) + { + while (callouts.Count < idx) + callouts.Add(""); + var text = calloutMatch.Groups[2].Value; + _pos++; + // Collect any continuation lines (Text tokens without prefix) + while (_pos < _tokens.Count && Current.Type == TokenType.Text && !CalloutItemRegex().IsMatch(Current.Raw)) + { + text += " " + Current.Raw.Trim(); + _pos++; + } + callouts[idx - 1] = text; + continue; + } + _pos++; + } + } + + return delimChar switch + { + "-" when openingDelim.Length >= 4 || style == "source" => new CodeBlockNode + { + Language = blockAttr?.Language, + Source = string.Join('\n', contentLines), + Callouts = callouts + }, + "." => new LiteralBlockNode(string.Join('\n', contentLines)), + "=" when IsAdmonitionStyle(style) => new AdmonitionNode + { + Type = ParseAdmonitionType(style!), + Children = children + }, + "=" => new ExampleNode { Children = children }, + "*" => new SidebarNode { Children = children }, + "+" => new PassthroughNode(string.Join('\n', contentLines)), + "-" when openingDelim == "--" => style switch + { + "source" => new CodeBlockNode { Language = blockAttr?.Language, Source = string.Join('\n', contentLines) }, + _ when IsAdmonitionStyle(style) => new AdmonitionNode + { + Type = ParseAdmonitionType(style!), + Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) + }, + "sidebar" => new SidebarNode { Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) }, + _ => new OpenBlockNode { Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) } + }, + "/" => null, + _ => new OpenBlockNode { Children = children.Count > 0 ? children : WrapAsBlocks(contentLines) } + }; + } + + [GeneratedRegex(@"^<(\d+)>\s+(.+)$")] + private static partial Regex CalloutItemRegex(); + + [GeneratedRegex(@"^include::([^\[]+)\[([^\]]*)\]\s*$")] + private static partial Regex TableIncludeRegex(); + + [GeneratedRegex(@"^include-tagged::(.+?)\[([^\]]*)\]\s*$")] + private static partial Regex IncludeTaggedInVerbatimRegex(); + + // Standard AsciiDoc tagged include inside a verbatim block: include::path[tag=name] + [GeneratedRegex(@"^include::(.+?)\[tag=([^\]]+)\]\s*$")] + private static partial Regex IncludeTaggedStandardInVerbatimRegex(); + + // Full-file include inside a verbatim block: include::path[] or include::path[indent=0] etc. + [GeneratedRegex(@"^include::(.+?)\[[^\]]*\]\s*$")] + private static partial Regex IncludeFullFileInVerbatimRegex(); + + // Conditional markers that can appear inside verbatim blocks (e.g. ifeval::[...]/endif::[]) + [GeneratedRegex(@"^(?:ifdef|ifndef|ifeval|endif)::[^\[]*\[.*?\]\s*$")] + private static partial Regex VerbatimConditionalRegex(); + + /// + /// Resolves include directives and strips stray conditional markers inside verbatim blocks. + /// Handles both the Elastic include-tagged::path[tag] extension and the standard + /// AsciiDoc include::path[tag=name] syntax with a tag qualifier. + /// + private List ResolveVerbatimIncludes(List lines) + { + // Fast path: nothing to do + if (!lines.Any(l => + l.Contains("include-tagged::", StringComparison.Ordinal) || + l.Contains("include::", StringComparison.Ordinal) || + l.Contains("ifeval::", StringComparison.Ordinal) || + l.Contains("ifdef::", StringComparison.Ordinal) || + l.Contains("ifndef::", StringComparison.Ordinal) || + l.Contains("endif::", StringComparison.Ordinal))) + return lines; + + var result = new List(lines.Count); + foreach (var line in lines) + { + var trimmed = line.TrimStart(); + + // Strip stray conditional markers (ifeval/ifdef/ifndef/endif) from verbatim content. + // The surrounding content is always kept since we can't evaluate conditions here. + if (VerbatimConditionalRegex().IsMatch(trimmed)) + continue; + + var taggedMatch = IncludeTaggedInVerbatimRegex().Match(trimmed); + if (taggedMatch.Success) + { + result.AddRange(ResolveTaggedInclude(taggedMatch.Groups[1].Value, taggedMatch.Groups[2].Value.Trim(), line)); + continue; + } + + var standardMatch = IncludeTaggedStandardInVerbatimRegex().Match(trimmed); + if (standardMatch.Success) + { + result.AddRange(ResolveTaggedInclude(standardMatch.Groups[1].Value, standardMatch.Groups[2].Value.Trim(), line)); + continue; + } + + var fullFileMatch = IncludeFullFileInVerbatimRegex().Match(trimmed); + if (fullFileMatch.Success) + { + result.AddRange(ResolveFullFileInclude(fullFileMatch.Groups[1].Value, line)); + continue; + } + + result.Add(line); + } + return result; + } + + private IEnumerable ResolveTaggedInclude(string rawPathToken, string tag, string originalLine) + { + var rawPath = SubstituteAttributes(rawPathToken); + var resolvedPath = Path.IsPathRooted(rawPath) + ? Path.GetFullPath(rawPath) + : Path.GetFullPath(Path.Combine(_basePath, rawPath)); + + var fileContent = ReadFile(resolvedPath); + if (fileContent is null) + { + options.OnDiagnostic?.Invoke($"include not resolved: {rawPath} (resolved to {resolvedPath})"); + return [originalLine]; + } + + return ExtractTaggedLines(fileContent, tag); + } + + private IEnumerable ResolveFullFileInclude(string rawPathToken, string originalLine) + { + var rawPath = SubstituteAttributes(rawPathToken); + var resolvedPath = Path.IsPathRooted(rawPath) + ? Path.GetFullPath(rawPath) + : Path.GetFullPath(Path.Combine(_basePath, rawPath)); + + var fileContent = ReadFile(resolvedPath); + if (fileContent is null) + { + options.OnDiagnostic?.Invoke($"include not resolved: {rawPath} (resolved to {resolvedPath})"); + return [originalLine]; + } + + return fileContent.TrimEnd('\n', '\r').Split('\n'); + } + + private static List ExtractTaggedLines(string content, string tag) + { + var lines = content.Split('\n'); + var result = new List(); + var inTag = false; + int? dedentWidth = null; + + var escapedTag = Regex.Escape(tag); + var startPattern = new Regex($@"tag::{escapedTag}\[\]"); + var endPattern = new Regex($@"end::{escapedTag}\[\]"); + + foreach (var line in lines) + { + var trimmed = line.TrimStart(); + if (!inTag && startPattern.IsMatch(trimmed)) + { + inTag = true; + dedentWidth = line.Length - trimmed.Length; + continue; + } + if (inTag && endPattern.IsMatch(trimmed)) + { + inTag = false; + continue; + } + if (!inTag) + continue; + + // Dedent by the leading whitespace of the tag:: line + result.Add(dedentWidth is > 0 && line.Length >= dedentWidth + ? line[dedentWidth.Value..] + : line); + } + return result; + } + + /// + /// Returns true when the opening delimiter's content should be collected verbatim (raw text lines). + /// Open blocks (--) are structural, not verbatim, even though their delimChar is -. + /// + private static bool IsVerbatimDelimiter(string delimChar, string openingDelim) => + delimChar is "-" or "." or "+" or "/" && openingDelim.Length >= 4; + + private static bool IsMatchingClose(string line, string openingDelim) + { + if (openingDelim.Length < 2) + return false; + + var delimChar = openingDelim[0]; + // Trim trailing whitespace so source bugs like `---- ` still close a `----` block + var trimmed = line.TrimEnd(); + // Require exact length so `--------` does not close a `----` block + return trimmed.Length == openingDelim.Length && trimmed.All(c => c == delimChar); + } + + private List CollectDelimitedTokens(string openingDelim) + { + var inner = new List(); + while (_pos < _tokens.Count) + { + var cur = Current; + if (cur.Type == TokenType.BlockDelimiter && IsMatchingClose(cur.Raw, openingDelim)) + { + _pos++; + break; + } + inner.Add(cur); + _pos++; + } + return inner; + } + + private List ParseTokensAsBlocks(List innerTokens) + { + var savedTokens = _tokens; + var savedPos = _pos; + _tokens = innerTokens; + _pos = 0; + + var blocks = new List(); + string? pendingId = null; + string? pendingTitle = null; + TokenMetadata? pendingBlockAttr = null; + + while (_pos < _tokens.Count) + { + var t = Current; + switch (t.Type) + { + case TokenType.BlockAnchor: + pendingId = t.Metadata!.Id; + _pos++; + break; + case TokenType.BlockTitle: + pendingTitle = t.Metadata!.Title; + _pos++; + break; + case TokenType.BlockAttribute: + pendingBlockAttr = t.Metadata; + _pos++; + break; + case TokenType.Blank: + case TokenType.Comment: + _pos++; + break; + case TokenType.AttributeEntry: + SetAttribute(t.Metadata!.AttributeName!, t.Metadata.AttributeValue); + _pos++; + break; + case TokenType.AttributeUnset: + _ = _attributes.Remove(t.Metadata!.AttributeName!); + _pos++; + break; + default: + var block = ParseBlock(pendingId, pendingTitle, pendingBlockAttr); + if (block != null) + blocks.Add(block); + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + break; + } + } + + _tokens = savedTokens; + _pos = savedPos; + return blocks; + } + + private List WrapAsBlocks(List lines) + { + if (lines.Count == 0) + return []; + + var text = string.Join('\n', lines).Trim(); + if (string.IsNullOrEmpty(text)) + return []; + + return [new ParagraphNode { Inlines = ParseInlines(SubstituteAttributes(text)) }]; + } + + private IAsciidocNode ParseUnorderedList() + { + var list = new UnorderedListNode(); + while (_pos < _tokens.Count && Current.Type == TokenType.ListItemUnordered) + { + var item = ParseListItem(TokenType.ListItemUnordered); + list.Items.Add(item); + } + return list; + } + + private IAsciidocNode ParseOrderedList() + { + var list = new OrderedListNode(); + while (_pos < _tokens.Count && Current.Type == TokenType.ListItemOrdered) + { + var item = ParseListItem(TokenType.ListItemOrdered); + list.Items.Add(item); + } + return list; + } + + private ListItemNode ParseListItem(TokenType listType) + { + var token = Current; + var level = token.Metadata!.Level!.Value; + // Collect the first line and any trailing Text tokens together before parsing, + // so that multi-line xrefs (<>) are matched correctly. + var firstLineText = token.Metadata.Content!; + _pos++; + + var children = new List(); + List? inlines = null; + + while (_pos < _tokens.Count) + { + var cur = Current; + + if (inlines is null && cur.Type == TokenType.Text) + { + // Collect the first line + consecutive continuation text lines, then parse once + var textLines = new List { firstLineText }; + while (_pos < _tokens.Count && Current.Type == TokenType.Text) + { + textLines.Add(Current.Raw); + _pos++; + } + inlines = ParseInlines(SubstituteAttributes(string.Join('\n', textLines))); + continue; + } + + inlines ??= ParseInlines(SubstituteAttributes(firstLineText)); + + if (cur.Type == TokenType.ListContinuation) + { + _pos++; + string? contId = null; + string? contTitle = null; + TokenMetadata? contBlockAttr = null; + + while (_pos < _tokens.Count) + { + var peek = Current; + if (peek.Type == TokenType.BlockAnchor) + { + contId = peek.Metadata!.Id; + _pos++; + } + else if (peek.Type == TokenType.BlockTitle) + { + contTitle = peek.Metadata!.Title; + _pos++; + } + else if (peek.Type == TokenType.BlockAttribute) + { + contBlockAttr = peek.Metadata; + _pos++; + } + else if (peek.Type == TokenType.Blank) + { + _pos++; + } + else + { + break; + } + } + + if (_pos < _tokens.Count) + { + var continued = ParseBlock(contId, contTitle, contBlockAttr); + if (continued != null) + children.Add(continued); + } + continue; + } + + if (cur.Type == listType && cur.Metadata!.Level!.Value > level) + { + var nested = listType == TokenType.ListItemUnordered + ? ParseUnorderedList() + : ParseOrderedList(); + children.Add(nested); + continue; + } + + if (cur.Type == TokenType.Text) + { + // Collect consecutive text lines and parse together so multi-line xrefs (<>) match + var textLines = new List(); + while (_pos < _tokens.Count && Current.Type == TokenType.Text) + { + textLines.Add(Current.Raw); + _pos++; + } + inlines.AddRange(ParseInlines(SubstituteAttributes(string.Join('\n', textLines)))); + continue; + } + + break; + } + + inlines ??= ParseInlines(SubstituteAttributes(firstLineText)); + return new ListItemNode { Inlines = inlines, Children = children }; + } + + private IAsciidocNode ParseDescriptionList() + { + var list = new DescriptionListNode(); + while (_pos < _tokens.Count && Current.Type == TokenType.DescriptionListItem) + { + var token = Current; + var term = ParseInlines(SubstituteAttributes(token.Metadata!.Title!)); + var descText = token.Metadata.Content ?? ""; + _pos++; + + var description = new List(); + if (!string.IsNullOrWhiteSpace(descText)) + { + // If the inline description contains an unclosed <<, the xref spans to the next + // line(s) — join with continuation Text tokens until the xref is closed. + var openCount = descText.Split("<<").Length - 1; + var closeCount = descText.Split(">>").Length - 1; + while (openCount > closeCount && _pos < _tokens.Count && Current.Type == TokenType.Text) + { + descText += "\n" + Current.Raw; + closeCount = descText.Split(">>").Length - 1; + _pos++; + } + description.Add(new ParagraphNode { Inlines = ParseInlines(SubstituteAttributes(descText)) }); + } + + while (_pos < _tokens.Count) + { + var cur = Current; + if (cur.Type == TokenType.ListContinuation) + { + _pos++; + if (_pos < _tokens.Count) + { + var continued = ParseBlock(null, null, null); + if (continued != null) + description.Add(continued); + } + continue; + } + + if (cur.Type == TokenType.Text) + { + var lines = new List { cur.Raw }; + _pos++; + while (_pos < _tokens.Count && Current.Type == TokenType.Text) + { + // Stop before a callout marker so ParseCalloutList can handle the run + if (CalloutItemRegex().IsMatch(Current.Raw)) + break; + lines.Add(Current.Raw); + _pos++; + } + description.Add(new ParagraphNode { Inlines = ParseInlines(SubstituteAttributes(string.Join('\n', lines))) }); + continue; + } + + if (cur.Type is TokenType.Blank) + { + _pos++; + break; + } + + break; + } + + list.Items.Add(new DescriptionListItemNode { Term = term, Description = description }); + } + return list; + } + + private IAsciidocNode ParseTable(TokenMetadata? blockAttr) + { + _pos++; + + var format = blockAttr?.NamedAttributes?.GetValueOrDefault("format")?.ToLowerInvariant(); + if (format is "dsv" or "csv" or "tsv") + return ParseSeparatedTable(blockAttr, format); + + var columns = ParseColumnSpecs(blockAttr); + var allRows = new List(); + var currentCells = new List(); + var firstBlankSeen = false; + + while (_pos < _tokens.Count) + { + var cur = Current; + + if (cur.Type == TokenType.TableDelimiter) + { + _pos++; + break; + } + + if (cur.Type == TokenType.Blank) + { + if (currentCells.Count > 0) + { + allRows.Add(BuildTableRow(currentCells)); + currentCells = []; + } + firstBlankSeen = true; + _pos++; + continue; + } + + if (cur.Type == TokenType.TableRow) + { + var cellContent = cur.Metadata?.Content ?? ""; + var cells = SplitTableCells(cellContent); + currentCells.AddRange(cells); + _pos++; + continue; + } + + if (cur.Type is TokenType.Text or TokenType.IncludeDirective) + { + // Inside tables the lexer produces Text tokens for include:: directives + var includeMatch = TableIncludeRegex().Match(cur.Raw); + if (includeMatch.Success) + { + // Flush any accumulated row, then inline the included table rows + if (currentCells.Count > 0) + { + allRows.Add(BuildTableRow(currentCells)); + currentCells = []; + } + var rawPath = includeMatch.Groups[1].Value; + var resolvedPath = rawPath.StartsWith("{docdir}", StringComparison.Ordinal) + ? rawPath.Replace("{docdir}", _basePath) + : Path.GetFullPath(Path.Combine(_basePath, rawPath)); + if (File.Exists(resolvedPath)) + { + foreach (var fileLine in File.ReadAllLines(resolvedPath)) + { + var trimmedLine = fileLine.Trim(); + if (trimmedLine.StartsWith('|')) + { + var cells = SplitTableCells(trimmedLine[1..]); + if (cells.Count > 0) + allRows.Add(BuildTableRow(cells)); + } + } + } + } + else if (currentCells.Count > 0) + { + currentCells[^1] += " " + cur.Raw.Trim(); + } + _pos++; + continue; + } + + _pos++; + } + + if (currentCells.Count > 0) + allRows.Add(BuildTableRow(currentCells)); + + var hasHeader = HasHeaderOption(blockAttr); + if (!hasHeader && allRows.Count > 1 && !firstBlankSeen) + hasHeader = false; + else if (!hasHeader && allRows.Count > 1) + hasHeader = true; + + List headerRows = hasHeader && allRows.Count > 0 ? [allRows[0]] : []; + var bodyRows = hasHeader && allRows.Count > 0 ? allRows[1..] : allRows; + + return new TableNode { Columns = columns, HeaderRows = headerRows, BodyRows = bodyRows }; + } + + private IAsciidocNode ParseSeparatedTable(TokenMetadata? blockAttr, string format) + { + var separator = format switch + { + "csv" => ',', + "tsv" => '\t', + _ => blockAttr?.NamedAttributes?.GetValueOrDefault("separator") is { Length: > 0 } sep ? sep[0] : ':' + }; + + var hasHeader = HasHeaderOption(blockAttr); + var rows = new List(); + + while (_pos < _tokens.Count) + { + var cur = Current; + + if (cur.Type == TokenType.TableDelimiter) + { + _pos++; + break; + } + + if (cur.Type == TokenType.Blank) + { + _pos++; + continue; + } + + var line = cur.Raw; + if (!string.IsNullOrWhiteSpace(line)) + { + var cells = line.Split(separator).Select(c => c.Trim()).ToList(); + rows.Add(BuildTableRow(cells)); + } + + _pos++; + } + + var columns = ParseColumnSpecs(blockAttr); + List headerRows = hasHeader && rows.Count > 0 ? [rows[0]] : []; + var bodyRows = hasHeader && rows.Count > 0 ? rows[1..] : rows; + + return new TableNode { Columns = columns, HeaderRows = headerRows, BodyRows = bodyRows }; + } + + private static bool HasHeaderOption(TokenMetadata? blockAttr) + { + if (blockAttr?.NamedAttributes?.TryGetValue("options", out var opts) == true + && opts.Contains("header", StringComparison.OrdinalIgnoreCase)) + return true; + + var content = blockAttr?.Content ?? blockAttr?.BlockStyle ?? ""; + return content.Contains("%header", StringComparison.OrdinalIgnoreCase); + } + + private static List ParseColumnSpecs(TokenMetadata? blockAttr) + { + var colsValue = blockAttr?.NamedAttributes?.GetValueOrDefault("cols"); + if (string.IsNullOrWhiteSpace(colsValue)) + return []; + + var specs = new List(); + var parts = colsValue.Split(','); + + foreach (var part in parts) + { + var trimmed = part.Trim(); + if (trimmed.EndsWith('*')) + { + var countStr = trimmed[..^1]; + var count = int.TryParse(countStr, out var c) ? c : 1; + for (var i = 0; i < count; i++) + specs.Add(new ColumnSpec()); + } + else + { + var spec = ParseSingleColumnSpec(trimmed); + specs.Add(spec); + } + } + + return specs; + } + + private static ColumnSpec ParseSingleColumnSpec(string spec) + { + var hAlign = ColumnHAlign.Left; + var vAlign = ColumnVAlign.Top; + int? width = null; + string? style = null; + + if (spec.StartsWith('<')) + hAlign = ColumnHAlign.Left; + else if (spec.StartsWith('^')) + hAlign = ColumnHAlign.Center; + else if (spec.StartsWith('>')) + hAlign = ColumnHAlign.Right; + + var digits = new string(spec.Where(char.IsDigit).ToArray()); + if (int.TryParse(digits, out var w)) + width = w; + + if (spec.EndsWith('a')) + style = "asciidoc"; + else if (spec.EndsWith('h')) + style = "header"; + + return new ColumnSpec { HAlign = hAlign, VAlign = vAlign, Width = width, Style = style }; + } + + private static List SplitTableCells(string content) + { + var cells = new List(); + var parts = content.Split('|'); + foreach (var p in parts) + { + var trimmed = p.Trim(); + if (!string.IsNullOrEmpty(trimmed) || cells.Count > 0) + cells.Add(trimmed); + } + return cells; + } + + private TableRowNode BuildTableRow(List cellTexts) + { + var cells = cellTexts + .Select(text => new TableCellNode + { + Content = [new ParagraphNode { Inlines = ParseInlines(SubstituteAttributes(text)) }] + }) + .ToList(); + return new TableRowNode { Cells = cells }; + } + + // Collects a run of callout description lines into an ordered list. + // Called when a Text token at block level matches the callout pattern. + private IAsciidocNode ParseCalloutList() + { + var items = new List(); + while (_pos < _tokens.Count && Current.Type == TokenType.Text) + { + var calloutMatch = CalloutItemRegex().Match(Current.Raw); + if (!calloutMatch.Success) + break; + var text = calloutMatch.Groups[2].Value; + _pos++; + // Collect any continuation lines (Text tokens without prefix) + while (_pos < _tokens.Count && Current.Type == TokenType.Text && !CalloutItemRegex().IsMatch(Current.Raw)) + { + text += " " + Current.Raw.Trim(); + _pos++; + } + items.Add(new ListItemNode { Inlines = ParseInlines(SubstituteAttributes(text)), Children = [] }); + } + return new OrderedListNode { Items = items }; + } + + private IAsciidocNode ParseAdmonitionParagraph() + { + var token = Current; + var type = ParseAdmonitionType(token.Metadata!.BlockStyle!); + var contentParts = new List { token.Metadata.Content! }; + _pos++; + + // Collect continuation lines (non-blank Text tokens that follow the admonition header) + while (_pos < _tokens.Count && Current.Type == TokenType.Text) + { + contentParts.Add(Current.Raw); + _pos++; + } + + var content = string.Join('\n', contentParts); + var paragraph = new ParagraphNode { Inlines = ParseInlines(SubstituteAttributes(content)) }; + return new AdmonitionNode { Type = type, Children = [paragraph] }; + } + + private IAsciidocNode ParseImageBlock(string? title) + { + var token = Current; + var path = SubstituteAttributes(token.Metadata!.Path!); + var alt = token.Metadata.Title is not null ? SubstituteAttributes(token.Metadata.Title) : null; + var resolvedTitle = title is not null ? SubstituteAttributes(title) : null; + _pos++; + + return new ImageNode { Path = path, Alt = alt, Title = resolvedTitle }; + } + + private IAsciidocNode ParsePageBreak() + { + _pos++; + return new PageBreakNode(); + } + + private IAsciidocNode ParseThematicBreak() + { + _pos++; + return new ThematicBreakNode(); + } + + private IAsciidocNode ParseParagraph() + { + var lines = new List(); + while (_pos < _tokens.Count && Current.Type == TokenType.Text) + { + // Stop before a callout marker so ParseCalloutList can handle the run + if (CalloutItemRegex().IsMatch(Current.Raw)) + break; + lines.Add(Current.Raw); + _pos++; + } + + var text = string.Join('\n', lines); + var inlines = ParseInlines(SubstituteAttributes(text)); + return new ParagraphNode { Inlines = inlines }; + } + + private IAsciidocNode? SkipConditional() + { + _pos++; + return null; + } + + private IAsciidocNode? SkipToken() + { + _pos++; + return null; + } + + private IAsciidocNode? ParseIncludeBlock() + { + var token = Current; + _pos++; + var included = ProcessInclude(token); + if (included is null or { Count: 0 }) + return null; + if (included.Count == 1) + return included[0]; + return new OpenBlockNode { Children = included }; + } + + private static bool IsAdmonitionStyle(string? style) => + style is "note" or "tip" or "warning" or "important" or "caution" or + "NOTE" or "TIP" or "WARNING" or "IMPORTANT" or "CAUTION"; + + private static AdmonitionType ParseAdmonitionType(string style) => + style.ToUpperInvariant() switch + { + "NOTE" => AdmonitionType.Note, + "TIP" => AdmonitionType.Tip, + "WARNING" => AdmonitionType.Warning, + "IMPORTANT" => AdmonitionType.Important, + "CAUTION" => AdmonitionType.Caution, + _ => AdmonitionType.Note + }; + + private static string? ExtractInlineAnchor(string title) + { + if (title.StartsWith("[[", StringComparison.Ordinal) && title.Contains("]]", StringComparison.Ordinal)) + { + var end = title.IndexOf("]]", StringComparison.Ordinal); + return title[2..end]; + } + return null; + } + + private List? ProcessInclude(Token token) + { + if (_includeDepth >= options.MaxIncludeDepth) + throw new InvalidOperationException($"Include depth exceeded maximum of {options.MaxIncludeDepth}"); + + var rawPath = SubstituteAttributes(token.Metadata!.Path!); + var resolvedPath = Path.IsPathRooted(rawPath) + ? Path.GetFullPath(rawPath) + : Path.GetFullPath(Path.Combine(_basePath, rawPath)); + var content = ReadFile(resolvedPath); + if (content is null) + { + options.OnDiagnostic?.Invoke($"Include not resolved: {rawPath} (resolved to {resolvedPath})"); + return null; + } + + var attrs = token.Metadata.NamedAttributes ?? []; + + content = ApplyIncludeFilters(content, attrs); + + if (attrs.TryGetValue("leveloffset", out var offsetStr) && int.TryParse(offsetStr, out var offset)) + content = ApplyLevelOffset(content, offset); + + _includeDepth++; + var savedTokens = _tokens; + var savedPos = _pos; + var savedBase = _basePath; + _ = _attributes.TryGetValue("docdir", out var savedDocDir); + + SetBasePath(Path.GetDirectoryName(resolvedPath) ?? _basePath); + var includeTokens = AsciidocLexer.Tokenize(content); + var processed = ConditionalProcessor.Process(includeTokens, _attributes); + _tokens = processed; + _pos = 0; + + var result = new List(); + string? pendingId = null; + string? pendingTitle = null; + TokenMetadata? pendingBlockAttr = null; + + while (_pos < _tokens.Count) + { + var cur = Current; + switch (cur.Type) + { + case TokenType.AttributeEntry: + SetAttribute(cur.Metadata!.AttributeName!, cur.Metadata.AttributeValue); + _pos++; + break; + case TokenType.AttributeUnset: + _ = _attributes.Remove(cur.Metadata!.AttributeName!); + _pos++; + break; + case TokenType.BlockAnchor: + pendingId = cur.Metadata!.Id; + _pos++; + break; + case TokenType.BlockTitle: + pendingTitle = cur.Metadata!.Title; + _pos++; + break; + case TokenType.BlockAttribute: + pendingBlockAttr = cur.Metadata; + _pos++; + break; + case TokenType.Blank: + case TokenType.Comment: + _pos++; + break; + case TokenType.SectionTitle: + var section = ParseSection(pendingId, null); + result.Add(section with { IsIncludeRoot = true }); + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + break; + case TokenType.IncludeDirective: + var included = ProcessInclude(cur); + if (included != null) + result.AddRange(included); + _pos++; + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + break; + default: + var block = ParseBlock(pendingId, pendingTitle, pendingBlockAttr); + if (block != null) + result.Add(block); + pendingId = null; + pendingTitle = null; + pendingBlockAttr = null; + break; + } + } + + _tokens = savedTokens; + _pos = savedPos; + _basePath = savedBase; + if (savedDocDir is not null) + _attributes["docdir"] = savedDocDir; + else + _ = _attributes.Remove("docdir"); + _includeDepth--; + + return result; + } + + private static string ApplyIncludeFilters(string content, Dictionary attrs) + { + if (attrs.TryGetValue("lines", out var linesSpec)) + content = FilterByLines(content, linesSpec); + + if (attrs.TryGetValue("tag", out var tag)) + content = FilterByTags(content, [tag]); + else if (attrs.TryGetValue("tags", out var tags)) + content = FilterByTags(content, tags.Split(';')); + + return content; + } + + private static string FilterByLines(string content, string linesSpec) + { + var allLines = content.Split('\n'); + var result = new List(); + + foreach (var range in linesSpec.Split(';')) + { + var trimmed = range.Trim(); + if (trimmed.Contains("..", StringComparison.Ordinal)) + { + var parts = trimmed.Split(".."); + var start = int.TryParse(parts[0], out var s) ? s : 1; + var endStr = parts.Length > 1 ? parts[1] : ""; + var end = endStr == "-1" || string.IsNullOrEmpty(endStr) ? allLines.Length : int.TryParse(endStr, out var e) ? e : allLines.Length; + + for (var i = Math.Max(1, start); i <= Math.Min(end, allLines.Length); i++) + result.Add(allLines[i - 1]); + } + else if (int.TryParse(trimmed, out var lineNum) && lineNum >= 1 && lineNum <= allLines.Length) + { + result.Add(allLines[lineNum - 1]); + } + } + + return string.Join('\n', result); + } + + private static string FilterByTags(string content, string[] tagNames) + { + var lines = content.Split('\n'); + var result = new List(); + var activeTags = new HashSet(StringComparer.OrdinalIgnoreCase); + var targetTags = new HashSet(tagNames, StringComparer.OrdinalIgnoreCase); + + foreach (var line in lines) + { + var trimmed = line.TrimStart(); + + if (TagStartRegex().Match(trimmed) is { Success: true } startMatch) + { + var tagName = startMatch.Groups[1].Value; + if (targetTags.Contains(tagName)) + _ = activeTags.Add(tagName); + continue; + } + + if (TagEndRegex().Match(trimmed) is { Success: true } endMatch) + { + _ = activeTags.Remove(endMatch.Groups[1].Value); + continue; + } + + if (activeTags.Count > 0) + result.Add(line); + } + + return string.Join('\n', result); + } + + private static string ApplyLevelOffset(string content, int offset) + { + if (offset == 0) + return content; + + var lines = content.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (line.StartsWith('=')) + { + var match = LevelOffsetRegex().Match(line); + if (match.Success) + { + var currentLevel = match.Groups[1].Value.Length; + var newLevel = Math.Max(1, Math.Min(6, currentLevel + offset)); + lines[i] = new string('=', newLevel) + " " + match.Groups[2].Value; + } + } + } + return string.Join('\n', lines); + } + + [GeneratedRegex(@"^(={1,6})\s+(.+)$")] + private static partial Regex LevelOffsetRegex(); + + // Allow hyphens in tag names (e.g. tag::my-tag[]) and any comment prefix (// # --) + [GeneratedRegex(@"tag::([\w-]+)\[\]")] + private static partial Regex TagStartRegex(); + + [GeneratedRegex(@"end::([\w-]+)\[\]")] + private static partial Regex TagEndRegex(); + + private string? ReadFile(string path) + { + if (options.FileReader is not null) + { + // Normalize to Unix-style paths so test FileReader lambdas receive consistent + // forward-slash paths regardless of OS. On Windows, Path.GetFullPath turns + // "/base/foo.adoc" into "C:\base\foo.adoc"; strip the drive letter and flip slashes. + var normalized = path.Replace('\\', '/'); + if (normalized.Length >= 2 && normalized[1] == ':') + normalized = normalized[2..]; + return options.FileReader(normalized); + } + + return File.Exists(path) ? File.ReadAllText(path) : null; + } + + [GeneratedRegex( + @"link:([^\[]+)\[([^\]]*)\]|" + // groups 1,2: link + @"<<([^,>]+)(?:,([\s\S]+?))?>>>|" + // groups 3,4: triple-xref (allow newlines in text) + @"<<([^,>]+)(?:,([\s\S]+?))?>>" + "|" + // groups 5,6: xref (allow newlines in text) + @"image:([^\[]+)\[([^\]]*)\]|" + // groups 7,8: image + @"footnote:\[([^\]]*)\]|" + // group 9: footnote + @"pass:\[([^\]]*)\]|" + // group 10: pass:[] passthrough + @"\[([a-zA-Z][a-zA-Z0-9_-]*)\]#([^#]+)#|" + // groups 11,12: [role]#text# + @"\*\*([^\*<]+)\*\*|" + // group 13: unconstrained bold (no < prevents spanning xref markers) + @"\*([^\*<]+)\*|" + // group 14: constrained bold (no < prevents spanning xref markers) + @"_([^_<]+)_|" + // group 15: italic (no < prevents spanning xref markers) + @"(? "text" + @"\s*\+\s*$" // line-break (no capture) + )] + private static partial Regex InlineCombinedRegex(); + + [GeneratedRegex(@"\{([a-zA-Z0-9_-]+)\}")] + private static partial Regex InlineAttrRefRegex(); + + // Normalize `<<<>, text>>` (double `<<` source bug) → `<>` + [GeneratedRegex(@"<<<<([^,>]+)>>(,[^>]*)?>")] + private static partial Regex DoubleXrefRegex(); + + public List ParseInlines(string text) + { + if (string.IsNullOrEmpty(text)) + return []; + + // Normalize `<<<>, text>>` (double-nested xref source bug) to `<>` + if (text.Contains("<<<<", StringComparison.Ordinal)) + text = DoubleXrefRegex().Replace(text, "<<$1$2>>"); + + + var result = new List(); + var lastIndex = 0; + + foreach (Match match in InlineCombinedRegex().Matches(text)) + { + if (match.Index > lastIndex) + result.Add(new TextInline(text[lastIndex..match.Index])); + + if (match.Groups[1].Success) + result.Add(new InlineLinkNode(match.Groups[1].Value, NullIfEmpty(match.Groups[2].Value))); + else if (match.Groups[5].Success) + result.Add(new InlineCrossRefNode(match.Groups[5].Value, NullIfEmpty(NormalizeWhitespace(match.Groups[6].Value)))); + else if (match.Groups[3].Success) + result.Add(new InlineCrossRefNode(match.Groups[3].Value, NullIfEmpty(NormalizeWhitespace(match.Groups[4].Value)))); + else if (match.Groups[7].Success) + result.Add(new InlineImageNode(match.Groups[7].Value, NullIfEmpty(match.Groups[8].Value))); + else if (match.Groups[9].Success) + result.Add(new FootnoteInline(ParseInlines(match.Groups[9].Value))); + else if (match.Groups[10].Success) + result.Add(new PassthroughInline(match.Groups[10].Value)); + else if (match.Groups[11].Success) + result.Add(new RoleInline(match.Groups[11].Value, ParseInlines(match.Groups[12].Value))); + else if (match.Groups[13].Success) + result.Add(new BoldInline(ParseInlines(match.Groups[13].Value))); + else if (match.Groups[14].Success) + result.Add(new BoldInline(ParseInlines(match.Groups[14].Value))); + else if (match.Groups[15].Success) + result.Add(new ItalicInline(ParseInlines(match.Groups[15].Value))); + else if (match.Groups[16].Success) + result.Add(new MonoInline(match.Groups[16].Value)); + else if (match.Groups[17].Success) + result.Add(new SuperscriptInline(ParseInlines(match.Groups[17].Value))); + else if (match.Groups[18].Success) + result.Add(new SubscriptInline(ParseInlines(match.Groups[18].Value))); + else if (match.Groups[19].Success) + result.Add(new AttributeRefInline(match.Groups[19].Value)); + else if (match.Groups[20].Success) + result.Add(new InlineLinkNode(match.Groups[20].Value, NullIfEmpty(match.Groups[21].Value))); + else if (match.Groups[22].Success) + result.Add(new PassthroughInline(match.Groups[22].Value, Backticks: true)); + else if (match.Groups[23].Success) + result.Add(new TextInline($"\"{match.Groups[23].Value}\"")); + else if (match.Value.TrimEnd().EndsWith('+')) + result.Add(new LineBreakInline()); + + lastIndex = match.Index + match.Length; + } + + if (lastIndex < text.Length) + result.Add(new TextInline(text[lastIndex..])); + + return result; + } + + private string SubstituteAttributes(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + return InlineAttrRefRegex().Replace(text, match => + { + var name = match.Groups[1].Value; + return _attributes.TryGetValue(name, out var value) ? value : match.Value; + }); + } + + private static string? NullIfEmpty(string value) => + string.IsNullOrEmpty(value) ? null : value; + + // Collapse newlines and surrounding whitespace in captured inline text to a single space. + private static string NormalizeWhitespace(string value) => + string.IsNullOrEmpty(value) ? value : WhitespaceCollapseRegex().Replace(value.Trim(), " "); + + [GeneratedRegex(@"\s*\n\s*")] + private static partial Regex WhitespaceCollapseRegex(); +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/AdmonitionNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/AdmonitionNode.cs new file mode 100644 index 0000000000..88c7400e3a --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/AdmonitionNode.cs @@ -0,0 +1,11 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record AdmonitionNode : IBlockNode +{ + public required AdmonitionType Type { get; init; } + public List Children { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/AsciidocDocument.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/AsciidocDocument.cs new file mode 100644 index 0000000000..baa9a36eaf --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/AsciidocDocument.cs @@ -0,0 +1,13 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record AsciidocDocument : IAsciidocNode +{ + public string? Title { get; init; } + public string? Id { get; init; } + public Dictionary Attributes { get; init; } = []; + public List Children { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/CodeBlockNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/CodeBlockNode.cs new file mode 100644 index 0000000000..8603e9540a --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/CodeBlockNode.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.LegacyDocs.Migration.Asciidoc.Ast; + +public record CodeBlockNode : IBlockNode +{ + public string? Language { get; init; } + public required string Source { get; init; } + public List Callouts { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ColumnSpec.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ColumnSpec.cs new file mode 100644 index 0000000000..b451680060 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ColumnSpec.cs @@ -0,0 +1,13 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record ColumnSpec +{ + public ColumnHAlign HAlign { get; init; } = ColumnHAlign.Left; + public ColumnVAlign VAlign { get; init; } = ColumnVAlign.Top; + public int? Width { get; init; } + public string? Style { get; init; } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/DescriptionListItemNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/DescriptionListItemNode.cs new file mode 100644 index 0000000000..4f0d64604b --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/DescriptionListItemNode.cs @@ -0,0 +1,11 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record DescriptionListItemNode : IBlockNode +{ + public List Term { get; init; } = []; + public List Description { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/DescriptionListNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/DescriptionListNode.cs new file mode 100644 index 0000000000..0d22320de1 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/DescriptionListNode.cs @@ -0,0 +1,10 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record DescriptionListNode : IBlockNode +{ + public List Items { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/Enums.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/Enums.cs new file mode 100644 index 0000000000..8b046a85c9 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/Enums.cs @@ -0,0 +1,28 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public enum AdmonitionType +{ + Note, + Tip, + Warning, + Important, + Caution +} + +public enum ColumnHAlign +{ + Left, + Center, + Right +} + +public enum ColumnVAlign +{ + Top, + Middle, + Bottom +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ExampleNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ExampleNode.cs new file mode 100644 index 0000000000..f70964fb21 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ExampleNode.cs @@ -0,0 +1,10 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record ExampleNode : IBlockNode +{ + public List Children { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/IAsciidocNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/IAsciidocNode.cs new file mode 100644 index 0000000000..586e98bca5 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/IAsciidocNode.cs @@ -0,0 +1,13 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public interface IAsciidocNode; + +public interface IInlineNode; + +public interface IBlockNode : IAsciidocNode; + +public record AnchoredBlock(string Id, IAsciidocNode Inner) : IBlockNode; diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ImageNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ImageNode.cs new file mode 100644 index 0000000000..20a68c4fb4 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ImageNode.cs @@ -0,0 +1,14 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record ImageNode : IBlockNode +{ + public required string Path { get; init; } + public string? Alt { get; init; } + public string? Title { get; init; } + public string? Width { get; init; } + public string? Height { get; init; } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/InlineNodes.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/InlineNodes.cs new file mode 100644 index 0000000000..93863aa4e4 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/InlineNodes.cs @@ -0,0 +1,33 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record TextInline(string Text) : IInlineNode; + +public record BoldInline(List Children) : IInlineNode; + +public record ItalicInline(List Children) : IInlineNode; + +public record MonoInline(string Text) : IInlineNode; + +public record AttributeRefInline(string Name) : IInlineNode; + +public record InlineLinkNode(string Url, string? Text = null) : IInlineNode; + +public record InlineCrossRefNode(string Target, string? Text = null) : IInlineNode; + +public record InlineImageNode(string Path, string? Alt = null) : IInlineNode; + +public record FootnoteInline(List Content) : IInlineNode; + +public record SuperscriptInline(List Children) : IInlineNode; + +public record SubscriptInline(List Children) : IInlineNode; + +public record LineBreakInline : IInlineNode; + +public record PassthroughInline(string Content, bool Backticks = false) : IInlineNode; + +public record RoleInline(string Role, List Children) : IInlineNode; diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ListItemNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ListItemNode.cs new file mode 100644 index 0000000000..fbd0a6ca98 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ListItemNode.cs @@ -0,0 +1,11 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record ListItemNode : IBlockNode +{ + public List Inlines { get; init; } = []; + public List Children { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/LiteralBlockNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/LiteralBlockNode.cs new file mode 100644 index 0000000000..62c737ba4e --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/LiteralBlockNode.cs @@ -0,0 +1,7 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record LiteralBlockNode(string Content) : IBlockNode; diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/OpenBlockNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/OpenBlockNode.cs new file mode 100644 index 0000000000..cb580f19cc --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/OpenBlockNode.cs @@ -0,0 +1,10 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record OpenBlockNode : IBlockNode +{ + public List Children { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/OrderedListNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/OrderedListNode.cs new file mode 100644 index 0000000000..dd8b03171a --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/OrderedListNode.cs @@ -0,0 +1,10 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record OrderedListNode : IBlockNode +{ + public List Items { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/PageBreakNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/PageBreakNode.cs new file mode 100644 index 0000000000..f9dda7e147 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/PageBreakNode.cs @@ -0,0 +1,7 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record PageBreakNode : IBlockNode; diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ParagraphNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ParagraphNode.cs new file mode 100644 index 0000000000..22085066d0 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ParagraphNode.cs @@ -0,0 +1,10 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record ParagraphNode : IBlockNode +{ + public List Inlines { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/PassthroughNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/PassthroughNode.cs new file mode 100644 index 0000000000..6c5c820201 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/PassthroughNode.cs @@ -0,0 +1,7 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record PassthroughNode(string Content) : IBlockNode; diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/SectionNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/SectionNode.cs new file mode 100644 index 0000000000..75dd1484e3 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/SectionNode.cs @@ -0,0 +1,15 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record SectionNode : IBlockNode +{ + public required int Level { get; init; } + public required string Title { get; init; } + public string? Id { get; init; } + public List Children { get; init; } = []; + /// True when this section was produced as the top-level result of a ProcessInclude call. + public bool IsIncludeRoot { get; init; } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/SidebarNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/SidebarNode.cs new file mode 100644 index 0000000000..e2c561b23c --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/SidebarNode.cs @@ -0,0 +1,10 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record SidebarNode : IBlockNode +{ + public List Children { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/TableCellNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/TableCellNode.cs new file mode 100644 index 0000000000..a572fa004b --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/TableCellNode.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.LegacyDocs.Migration.Asciidoc.Ast; + +public record TableCellNode : IBlockNode +{ + public List Content { get; init; } = []; + public int ColSpan { get; init; } = 1; + public int RowSpan { get; init; } = 1; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/TableNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/TableNode.cs new file mode 100644 index 0000000000..fc8e75fc9e --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/TableNode.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.LegacyDocs.Migration.Asciidoc.Ast; + +public record TableNode : IBlockNode +{ + public List Columns { get; init; } = []; + public List HeaderRows { get; init; } = []; + public List BodyRows { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/TableRowNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/TableRowNode.cs new file mode 100644 index 0000000000..74323a8910 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/TableRowNode.cs @@ -0,0 +1,10 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record TableRowNode : IBlockNode +{ + public List Cells { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ThematicBreakNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ThematicBreakNode.cs new file mode 100644 index 0000000000..6e1e8af81c --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/ThematicBreakNode.cs @@ -0,0 +1,7 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record ThematicBreakNode : IBlockNode; diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/UnorderedListNode.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/UnorderedListNode.cs new file mode 100644 index 0000000000..45035b3996 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/Ast/UnorderedListNode.cs @@ -0,0 +1,10 @@ +// 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.LegacyDocs.Migration.Asciidoc.Ast; + +public record UnorderedListNode : IBlockNode +{ + public List Items { get; init; } = []; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/ConditionalProcessor.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/ConditionalProcessor.cs new file mode 100644 index 0000000000..874c5dad72 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/ConditionalProcessor.cs @@ -0,0 +1,153 @@ +// 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.LegacyDocs.Migration.Asciidoc; + +public static partial class ConditionalProcessor +{ + public static IReadOnlyList Process(IReadOnlyList tokens, IReadOnlyDictionary attributes) + { + var result = new List(); + var conditionStack = new Stack(); + + foreach (var token in tokens) + { + if (token.Type == TokenType.ConditionalStart) + { + var directive = token.Metadata!.BlockStyle!; + var condition = token.Metadata.Condition!; + var inlineContent = token.Metadata.Content; + + // ifeval::[expr] puts its expression inside the brackets (Content/group 3), + // not before them (Condition/group 2 = empty). It is always block-level. + var isIfeval = directive.Equals("ifeval", StringComparison.OrdinalIgnoreCase); + var conditionExpr = isIfeval ? (inlineContent ?? "") : condition; + var isTrue = EvaluateCondition(directive, conditionExpr, attributes); + + if (!isIfeval && !string.IsNullOrEmpty(inlineContent)) + { + if (IsIncluding(conditionStack) && isTrue) + result.Add(new Token(TokenType.Text, inlineContent, token.LineNumber)); + } + else + { + conditionStack.Push(isTrue); + } + continue; + } + + if (token.Type == TokenType.ConditionalEnd) + { + if (conditionStack.Count > 0) + _ = conditionStack.Pop(); + continue; + } + + if (IsIncluding(conditionStack)) + result.Add(token); + } + + return result; + } + + private static bool IsIncluding(Stack stack) + { + foreach (var condition in stack) + { + if (!condition) + return false; + } + return true; + } + + private static bool EvaluateCondition(string directive, string condition, IReadOnlyDictionary attributes) => + directive.ToLowerInvariant() switch + { + "ifdef" => EvaluateIfdef(condition, attributes), + "ifndef" => EvaluateIfndef(condition, attributes), + "ifeval" => EvaluateIfeval(condition, attributes), + _ => true + }; + + private static bool EvaluateIfdef(string condition, IReadOnlyDictionary attributes) + { + if (condition.Contains('+')) + return condition.Split('+').All(attr => attributes.ContainsKey(attr.Trim())); + + if (condition.Contains(',')) + return condition.Split(',').Any(attr => attributes.ContainsKey(attr.Trim())); + + return attributes.ContainsKey(condition.Trim()); + } + + private static bool EvaluateIfndef(string condition, IReadOnlyDictionary attributes) + { + if (condition.Contains('+')) + return condition.Split('+').All(attr => !attributes.ContainsKey(attr.Trim())); + + if (condition.Contains(',')) + return condition.Split(',').Any(attr => !attributes.ContainsKey(attr.Trim())); + + return !attributes.ContainsKey(condition.Trim()); + } + + private static bool EvaluateIfeval(string condition, IReadOnlyDictionary attributes) + { + var resolved = SubstituteAttributes(condition, attributes); + var match = IfevalRegex().Match(resolved); + if (!match.Success) + return false; + + var left = UnquoteValue(match.Groups[1].Value.Trim()); + var op = match.Groups[2].Value.Trim(); + var right = UnquoteValue(match.Groups[3].Value.Trim()); + + if (double.TryParse(left, out var leftNum) && double.TryParse(right, out var rightNum)) + { + return op switch + { + "==" => Math.Abs(leftNum - rightNum) < 0.0001, + "!=" => Math.Abs(leftNum - rightNum) >= 0.0001, + "<" => leftNum < rightNum, + ">" => leftNum > rightNum, + "<=" => leftNum <= rightNum, + ">=" => leftNum >= rightNum, + _ => false + }; + } + + return op switch + { + "==" => string.Equals(left, right, StringComparison.Ordinal), + "!=" => !string.Equals(left, right, StringComparison.Ordinal), + "<" => string.Compare(left, right, StringComparison.Ordinal) < 0, + ">" => string.Compare(left, right, StringComparison.Ordinal) > 0, + "<=" => string.Compare(left, right, StringComparison.Ordinal) <= 0, + ">=" => string.Compare(left, right, StringComparison.Ordinal) >= 0, + _ => false + }; + } + + private static string SubstituteAttributes(string text, IReadOnlyDictionary attributes) => + AttrRefRegex().Replace(text, match => + { + var name = match.Groups[1].Value; + return attributes.TryGetValue(name, out var value) ? value : match.Value; + }); + + private static string UnquoteValue(string value) + { + if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') + return value[1..^1]; + return value; + } + + [GeneratedRegex(@"\{([a-zA-Z0-9_-]+)\}")] + private static partial Regex AttrRefRegex(); + + [GeneratedRegex(@"(.+?)\s*(==|!=|<=|>=|<|>)\s*(.+)")] + private static partial Regex IfevalRegex(); +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/MarkdownEmitter.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/MarkdownEmitter.cs new file mode 100644 index 0000000000..db2222b717 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/MarkdownEmitter.cs @@ -0,0 +1,624 @@ +// 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; +using System.Text.RegularExpressions; +using Elastic.LegacyDocs.Migration.Asciidoc.Ast; + +namespace Elastic.LegacyDocs.Migration.Asciidoc; + +public record MarkdownEmitterOptions +{ + public string ImagePathPrefix { get; init; } = "images/"; + public string? BookPrefix { get; init; } + public string? Version { get; init; } + public Dictionary AnchorToSlugMap { get; init; } = []; + public Dictionary AnchorToTitleMap { get; init; } = []; + public string? PageSlug { get; init; } +} + +public static partial class GuideUrlRewriter +{ + private const string GuidePrefix = "https://www.elastic.co/guide/"; + + [GeneratedRegex(@"^https://www\.elastic\.co/guide/(.+?)(?:\.html)?(?:#(.+))?$")] + private static partial Regex GuideUrlRegex(); + + public static string? TryRewriteToInternal(string url) + { + if (!url.StartsWith(GuidePrefix, StringComparison.OrdinalIgnoreCase)) + return null; + + var match = GuideUrlRegex().Match(url); + if (!match.Success) + return null; + + var path = match.Groups[1].Value.TrimEnd('/'); + var fragment = match.Groups[2].Success ? $"#{match.Groups[2].Value}" : ""; + + if (path.EndsWith("/index", StringComparison.Ordinal)) + return $"/{path}.md{fragment}"; + + return $"/{path}.md{fragment}"; + } +} + +public partial class MarkdownEmitter(MarkdownEmitterOptions options) +{ + private StringBuilder _sb = new(); + private int _footnoteCounter; + private readonly List<(int Index, string Content)> _footnotes = []; + + public void UpdateAnchorMap(Dictionary slugMap, Dictionary titleMap) => + options = options with { AnchorToSlugMap = slugMap, AnchorToTitleMap = titleMap }; + + public void UpdatePageSlug(string slug) => + options = options with { PageSlug = slug }; + + public string Emit(AsciidocDocument document) + { + Reset(); + + if (document.Title is not null) + { + if (document.Id is not null) + { + WriteLine($"$$${document.Id}$$$"); + WriteLine(); + } + WriteLine($"# {SubstituteTitleXrefs(SubstituteTitleAttrs(document.Title))}"); + WriteLine(); + } + + EmitChildren(document.Children); + AppendFootnotes(); + + return Finalize(); + } + + public string Emit(IAsciidocNode node) + { + Reset(); + EmitNode(node); + AppendFootnotes(); + return Finalize(); + } + + public string EmitInlines(IReadOnlyList inlines) + { + Reset(); + foreach (var inline in inlines) + EmitInline(inline); + return _sb.ToString(); + } + + private void Reset() + { + _sb = new StringBuilder(); + _footnoteCounter = 0; + _footnotes.Clear(); + } + + private string Finalize() => _sb.ToString().TrimEnd() + "\n"; + + private void Write(string value) => _ = _sb.Append(value); + + private void Write(char value) => _ = _sb.Append(value); + + private void WriteLine() => _ = _sb.AppendLine(); + + private void WriteLine(string value) => _ = _sb.AppendLine(value); + + private string CaptureOutput(Action action) + { + var saved = _sb; + _sb = new StringBuilder(); + action(); + var result = _sb.ToString(); + _sb = saved; + return result; + } + + private void EmitChildren(IReadOnlyList children) + { + foreach (var child in children) + EmitNode(child); + } + + private void EmitNode(IAsciidocNode node) + { + switch (node) + { + case SectionNode section: + EmitSection(section); + break; + case ParagraphNode paragraph: + EmitParagraph(paragraph); + break; + case CodeBlockNode codeBlock: + EmitCodeBlock(codeBlock); + break; + case LiteralBlockNode literal: + EmitLiteralBlock(literal); + break; + case AdmonitionNode admonition: + EmitDirective(MapAdmonitionType(admonition.Type), null, admonition.Children); + break; + case UnorderedListNode ul: + EmitUnorderedList(ul, indent: 0); + WriteLine(); + break; + case OrderedListNode ol: + EmitOrderedList(ol, indent: 0); + WriteLine(); + break; + case DescriptionListNode dl: + EmitDescriptionList(dl); + break; + case TableNode table: + EmitTable(table); + break; + case ImageNode image: + EmitBlockImage(image); + break; + case SidebarNode sidebar: + EmitDirective("admonition", "Sidebar", sidebar.Children); + break; + case ExampleNode example: + EmitDirective("admonition", "Example", example.Children); + break; + case OpenBlockNode open: + EmitChildren(open.Children); + break; + case PassthroughNode passthrough: + // is an Elastic AsciiDoc extension for abbreviated nav titles — discard. + if (!TitleAbbrevRegex().IsMatch(passthrough.Content.Trim())) + { + WriteLine(passthrough.Content); + WriteLine(); + } + break; + case AnchoredBlock anchored: + WriteLine($"$$${anchored.Id}$$$"); + WriteLine(); + EmitNode(anchored.Inner); + break; + case ThematicBreakNode: + case PageBreakNode: + WriteLine("---"); + WriteLine(); + break; + } + } + + private void EmitSection(SectionNode section) + { + var hashes = new string('#', section.Level + 1); + if (section.Level == 0 && section.Id is not null) + { + WriteLine($"$$${section.Id}$$$"); + WriteLine(); + WriteLine($"# {SubstituteTitleXrefs(SubstituteTitleAttrs(section.Title))}"); + } + else + { + var anchor = section.Id is not null ? $" [#{section.Id}]" : ""; + WriteLine($"{hashes} {SubstituteTitleXrefs(SubstituteTitleAttrs(section.Title))}{anchor}"); + } + WriteLine(); + + EmitChildren(section.Children); + } + + [GeneratedRegex(@"^.*\s*$", RegexOptions.Singleline)] + private static partial Regex TitleAbbrevRegex(); + + private void EmitParagraph(ParagraphNode paragraph) + { + // is an Elastic AsciiDoc extension for abbreviated nav titles — discard. + if (paragraph.Inlines is [TextInline { Text: var raw }] && TitleAbbrevRegex().IsMatch(raw)) + return; + + foreach (var inline in paragraph.Inlines) + EmitInline(inline); + WriteLine(); + WriteLine(); + } + + private void EmitCodeBlock(CodeBlockNode codeBlock) + { + var lang = codeBlock.Language ?? ""; + WriteLine($"```{lang}"); + Write(codeBlock.Source.TrimEnd()); + WriteLine(); + WriteLine("```"); + WriteLine(); + + if (codeBlock.Callouts.Count <= 0) + return; + + for (var i = 0; i < codeBlock.Callouts.Count; i++) + WriteLine($"{i + 1}. {SubstituteTitleXrefs(SubstituteTitleAttrs(codeBlock.Callouts[i]))}"); + WriteLine(); + } + + private void EmitLiteralBlock(LiteralBlockNode literal) + { + foreach (var line in literal.Content.Split('\n')) + WriteLine($" {line}"); + WriteLine(); + } + + private static string MapAdmonitionType(AdmonitionType type) => + type switch + { + AdmonitionType.Caution => "warning", + _ => type.ToString().ToLowerInvariant() + }; + + private void EmitDirective(string name, string? argument, IReadOnlyList children) + { + var header = argument is not null ? $":::{{{name}}} {argument}" : $":::{{{name}}}"; + WriteLine(header); + EmitChildren(children); + WriteLine(":::"); + WriteLine(); + } + + private void EmitUnorderedList(UnorderedListNode list, int indent) + { + foreach (var item in list.Items) + EmitListItem(item, "- ", indent); + } + + private void EmitOrderedList(OrderedListNode list, int indent) + { + foreach (var item in list.Items) + EmitListItem(item, "1. ", indent); + } + + private void EmitListItem(ListItemNode item, string marker, int indent) + { + var prefix = new string(' ', indent); + Write($"{prefix}{marker}"); + + foreach (var inline in item.Inlines) + EmitInline(inline); + WriteLine(); + + if (item.Children.Count <= 0) + return; + + var continuationIndent = indent + marker.Length; + foreach (var child in item.Children) + { + switch (child) + { + case UnorderedListNode nestedUl: + EmitUnorderedList(nestedUl, continuationIndent); + break; + case OrderedListNode nestedOl: + EmitOrderedList(nestedOl, continuationIndent); + break; + default: + EmitIndentedBlock(child, continuationIndent); + break; + } + } + } + + private void EmitIndentedBlock(IAsciidocNode node, int indent) + { + var content = CaptureOutput(() => EmitNode(node)); + var continuationPrefix = new string(' ', indent); + foreach (var line in content.TrimEnd().Split('\n')) + WriteLine(string.IsNullOrWhiteSpace(line) ? "" : $"{continuationPrefix}{line}"); + WriteLine(); + } + + private void EmitDescriptionList(DescriptionListNode list) + { + foreach (var item in list.Items) + { + foreach (var inline in item.Term) + EmitInline(inline); + WriteLine(); + + var content = CaptureOutput(() => + { + foreach (var descNode in item.Description) + EmitNode(descNode); + }).TrimEnd(); + + var lines = content.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var linePrefix = i == 0 ? ": " : " "; + if (string.IsNullOrWhiteSpace(lines[i])) + WriteLine(); + else + WriteLine($"{linePrefix}{lines[i]}"); + } + WriteLine(); + } + } + + private void EmitTable(TableNode table) + { + if (IsComplexTable(table)) + EmitListTable(table); + else + EmitPipeTable(table); + } + + private static bool IsComplexTable(TableNode table) => + table.HeaderRows + .Concat(table.BodyRows) + .SelectMany(r => r.Cells) + .Any(c => c.ColSpan > 1 || c.RowSpan > 1 || c.Content.Count > 1 + || (c.Content.Count == 1 && c.Content[0] is not ParagraphNode)); + + private void EmitPipeTable(TableNode table) + { + var colCount = ResolveColumnCount(table); + + if (table.HeaderRows.Count > 0) + { + foreach (var row in table.HeaderRows) + EmitPipeRow(row); + } + else + { + Write('|'); + for (var i = 0; i < colCount; i++) + Write(" |"); + WriteLine(); + } + + Write('|'); + for (var i = 0; i < colCount; i++) + Write("---|"); + WriteLine(); + + foreach (var row in table.BodyRows) + EmitPipeRow(row); + + WriteLine(); + } + + private void EmitPipeRow(TableRowNode row) + { + Write('|'); + foreach (var cell in row.Cells) + { + Write(' '); + EmitCellContent(cell); + Write(" |"); + } + WriteLine(); + } + + private void EmitListTable(TableNode table) + { + WriteLine(":::{list-table}"); + if (table.HeaderRows.Count > 0) + WriteLine($":header-rows: {table.HeaderRows.Count}"); + WriteLine(); + + foreach (var row in table.HeaderRows.Concat(table.BodyRows)) + { + for (var i = 0; i < row.Cells.Count; i++) + { + Write(i == 0 ? "* - " : " - "); + EmitCellContent(row.Cells[i]); + WriteLine(); + } + } + + WriteLine(":::"); + WriteLine(); + } + + private void EmitCellContent(TableCellNode cell) + { + if (cell.Content.Count == 0) + return; + + if (cell.Content is [ParagraphNode para]) + { + foreach (var inline in para.Inlines) + EmitInline(inline); + return; + } + + var content = CaptureOutput(() => + { + foreach (var node in cell.Content) + EmitNode(node); + }); + Write(content.TrimEnd().Replace("\n", " ")); + } + + private static int ResolveColumnCount(TableNode table) + { + if (table.Columns.Count > 0) + return table.Columns.Count; + if (table.HeaderRows.Count > 0) + return table.HeaderRows[0].Cells.Count; + if (table.BodyRows.Count > 0) + return table.BodyRows[0].Cells.Count; + return 0; + } + + private void EmitBlockImage(ImageNode image) + { + var alt = image.Alt ?? ""; + var path = image.Path.StartsWith(options.ImagePathPrefix, StringComparison.OrdinalIgnoreCase) + ? image.Path + : $"{options.ImagePathPrefix}{image.Path}"; + WriteLine($"![{alt}]({path})"); + WriteLine(); + } + + private void EmitInline(IInlineNode inline) + { + switch (inline) + { + case TextInline text: + Write(text.Text); + break; + case BoldInline bold: + Write("**"); + EmitInlineChildren(bold.Children); + Write("**"); + break; + case ItalicInline italic: + Write('*'); + EmitInlineChildren(italic.Children); + Write('*'); + break; + case MonoInline mono: + Write($"`{mono.Text}`"); + break; + case AttributeRefInline attrRef: + // Product-name subs are intentional docs-builder {{sub}} placeholders (defined in docset.yml) + if (SharedAttributes.ProductNames.ContainsKey(attrRef.Name)) + Write($"{{{{{attrRef.Name}}}}}"); + else + Write($"{{{attrRef.Name}}}"); + break; + case InlineLinkNode link: + EmitLink(link); + break; + case InlineCrossRefNode xref: + EmitCrossRef(xref); + break; + case InlineImageNode img: + Write($"![{SubstituteTitleAttrs(img.Alt ?? "")}]({img.Path})"); + break; + case FootnoteInline footnote: + EmitFootnote(footnote); + break; + case SuperscriptInline sup: + Write(""); + EmitInlineChildren(sup.Children); + Write(""); + break; + case SubscriptInline sub: + Write(""); + EmitInlineChildren(sub.Children); + Write(""); + break; + case PassthroughInline passthrough: + if (passthrough.Backticks) + Write($"`{passthrough.Content}`"); + else + Write(passthrough.Content); + break; + case RoleInline role: + EmitInlineChildren(role.Children); + break; + case LineBreakInline: + Write('\\'); + WriteLine(); + break; + } + } + + private void EmitInlineChildren(List children) + { + foreach (var child in children) + EmitInline(child); + } + + private void EmitLink(InlineLinkNode link) + { + var rewritten = GuideUrlRewriter.TryRewriteToInternal(link.Url); + var url = rewritten ?? link.Url; + + if (link.Text is not null) + Write($"[{SubstituteTitleAttrs(link.Text)}]({url})"); + else + Write($"<{url}>"); + } + + private void EmitCrossRef(InlineCrossRefNode xref) + { + var displayText = xref.Text is not null + ? SubstituteTitleAttrs(xref.Text) + : options.AnchorToTitleMap.TryGetValue(xref.Target, out var title) ? SubstituteTitleAttrs(title) : xref.Target; + var text = displayText; + + if (!xref.Target.Contains('/') && !xref.Target.Contains("::")) + { + if (options.AnchorToSlugMap.TryGetValue(xref.Target, out var slug)) + { + // Same-page anchor: link to fragment only + if (slug == options.PageSlug) + Write($"[{text}](#{xref.Target})"); + else + Write($"[{text}]({slug}.md#{xref.Target})"); + return; + } + + Write($"[{text}](#{xref.Target})"); + return; + } + + var prefix = options.BookPrefix ?? ""; + var version = options.Version ?? "current"; + var guideUrl = $"https://www.elastic.co/guide/{prefix}/{version}/{xref.Target}.html"; + var rewritten = GuideUrlRewriter.TryRewriteToInternal(guideUrl); + Write($"[{text}]({rewritten ?? guideUrl})"); + } + + private void EmitFootnote(FootnoteInline footnote) + { + _footnoteCounter++; + var index = _footnoteCounter; + Write($"[^{index}]"); + + var content = CaptureOutput(() => + { + foreach (var child in footnote.Content) + EmitInline(child); + }); + _footnotes.Add((index, content)); + } + + private void AppendFootnotes() + { + if (_footnotes.Count == 0) + return; + + WriteLine(); + foreach (var (index, content) in _footnotes) + WriteLine($"[^{index}]: {content}"); + } + + [GeneratedRegex(@"\{([a-z][a-z0-9_-]*)\}")] + private static partial Regex AttrRefRegex(); + + [GeneratedRegex(@"<<([^,>\n]+)(?:,([^>\n]+))?>>")] + private static partial Regex TitleXrefRegex(); + + // Replaces {name} → {{name}} for product-name subs in raw title strings (which + // bypass ParseInlines and never hit the AttributeRefInline emission path). + private static string SubstituteTitleAttrs(string title) => + AttrRefRegex().Replace(title, m => + SharedAttributes.ProductNames.ContainsKey(m.Groups[1].Value) + ? $"{{{{{m.Groups[1].Value}}}}}" + : m.Value); + + // Replaces <> and <> xrefs in raw title strings. + private string SubstituteTitleXrefs(string title) => + TitleXrefRegex().Replace(title, m => + { + var anchor = m.Groups[1].Value.Trim(); + var text = m.Groups[2].Success ? m.Groups[2].Value.Trim() : anchor; + if (options.AnchorToSlugMap.TryGetValue(anchor, out var slug)) + return slug == options.PageSlug ? $"[{text}](#{anchor})" : $"[{text}]({slug}.md#{anchor})"; + return $"[{text}](#{anchor})"; + }); +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/PageChunker.cs b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/PageChunker.cs new file mode 100644 index 0000000000..dd2311765d --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Asciidoc/PageChunker.cs @@ -0,0 +1,192 @@ +// 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; +using Elastic.LegacyDocs.Migration.Asciidoc.Ast; +using Slugify; + +namespace Elastic.LegacyDocs.Migration.Asciidoc; + +public record PageOutput(string Slug, string Title, string MarkdownContent); + +public static partial class PageChunker +{ + private static readonly SlugHelper SlugHelper = new(); + + [GeneratedRegex(@"^(.*)\s*$", RegexOptions.Singleline)] + private static partial Regex TitleAbbrevRegex(); + + public static IReadOnlyList Chunk(AsciidocDocument document, int chunkLevel, MarkdownEmitter emitter) + { + if (chunkLevel <= 0) + { + emitter.UpdatePageSlug("index"); + return [new PageOutput("index", document.Title ?? "Index", emitter.Emit(document))]; + } + + var (slugMap, titleMap) = BuildAnchorMaps(document.Children, chunkLevel); + emitter.UpdateAnchorMap(slugMap, titleMap); + + var (pages, remaining) = ExtractPages(document.Children, chunkLevel, emitter); + + var indexDoc = document with { Children = remaining.ToList() }; + emitter.UpdatePageSlug("index"); + var indexContent = emitter.Emit(indexDoc); + var indexPage = new PageOutput("index", document.Title ?? "Index", indexContent); + + return [indexPage, .. pages]; + } + + private static (Dictionary SlugMap, Dictionary TitleMap) BuildAnchorMaps( + IReadOnlyList children, int chunkLevel) + { + var slugMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + var titleMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + CollectAnchors(children, chunkLevel, slugMap, titleMap); + return (slugMap, titleMap); + } + + private static void CollectAnchors( + IReadOnlyList children, int chunkLevel, + Dictionary slugMap, Dictionary titleMap) + { + foreach (var child in children) + { + // Recurse transparently into open blocks — they may contain sections or anchored blocks + if (child is OpenBlockNode open) + { + CollectAnchors(open.Children, chunkLevel, slugMap, titleMap); + continue; + } + + if (child is not SectionNode section) + continue; + + // Non-include-root Level-0 sections are transparent book-root wrappers + if (section.Level == 0 && !section.IsIncludeRoot) + { + CollectAnchors(section.Children, chunkLevel, slugMap, titleMap); + continue; + } + + // Include roots and sections within chunk depth each become a page + if (section.IsIncludeRoot || section.Level <= chunkLevel) + { + var slug = section.Id ?? GenerateSlug(section.Title); + if (section.Id is not null) + { + slugMap[section.Id] = slug; + titleMap[section.Id] = ExtractDisplayTitle(section); + } + + CollectChildAnchors(section.Children, slug, slugMap, titleMap); + CollectAnchors(section.Children, chunkLevel, slugMap, titleMap); + } + else + { + // Inline section deeper than chunkLevel — recurse for nested IsIncludeRoot sections + CollectAnchors(section.Children, chunkLevel, slugMap, titleMap); + } + } + } + + // Extracts the display title for a section: uses if present, otherwise the section title. + private static string ExtractDisplayTitle(SectionNode section) + { + foreach (var child in section.Children) + { + if (child is not PassthroughNode passthrough) + continue; + var m = TitleAbbrevRegex().Match(passthrough.Content.Trim()); + if (m.Success) + return m.Groups[1].Value; + } + return section.Title; + } + + private static void CollectChildAnchors( + IReadOnlyList children, string parentSlug, + Dictionary slugMap, Dictionary titleMap) + { + foreach (var child in children) + { + if (child is SectionNode sub && sub.Id is not null) + { + slugMap[sub.Id] = parentSlug; + titleMap[sub.Id] = ExtractDisplayTitle(sub); + CollectChildAnchors(sub.Children, parentSlug, slugMap, titleMap); + } + else if (child is AnchoredBlock anchored) + { + slugMap[anchored.Id] = parentSlug; + } + } + } + + private static (List Pages, List Remaining) ExtractPages( + IReadOnlyList children, + int chunkLevel, + MarkdownEmitter emitter + ) + { + var pages = new List(); + var remaining = new List(); + + foreach (var child in children) + { + // Recurse transparently into open blocks so nested sections are chunked correctly + if (child is OpenBlockNode open) + { + var (innerPages, innerRemaining) = ExtractPages(open.Children, chunkLevel, emitter); + pages.AddRange(innerPages); + if (innerRemaining.Count > 0) + remaining.Add(open with { Children = innerRemaining.ToList() }); + continue; + } + + if (child is not SectionNode section) + { + remaining.Add(child); + continue; + } + + // Non-include-root Level-0 sections are transparent book-root wrappers + if (section.Level == 0 && !section.IsIncludeRoot) + { + var (innerPages, innerRemaining) = ExtractPages(section.Children, chunkLevel, emitter); + pages.AddRange(innerPages); + remaining.Add(section with { Children = innerRemaining.ToList() }); + continue; + } + + // Inline sections above the chunk depth stay in the parent page, but their + // children may still contain IsIncludeRoot sections that must be extracted. + if (section.Level > chunkLevel && !section.IsIncludeRoot) + { + var (innerPages, innerRemaining) = ExtractPages(section.Children, chunkLevel, emitter); + pages.AddRange(innerPages); + remaining.Add(section with { Children = innerRemaining.ToList() }); + continue; + } + + var (subPages, sectionRemaining) = ExtractPages(section.Children, chunkLevel, emitter); + + var trimmedSection = section with { Children = sectionRemaining.ToList(), Level = 0 }; + var slug = section.Id ?? GenerateSlug(section.Title); + emitter.UpdatePageSlug(slug); + var content = emitter.Emit(trimmedSection); + + pages.Add(new PageOutput(slug, section.Title, content)); + pages.AddRange(subPages); + } + + return (pages, remaining); + } + + private static string GenerateSlug(string title) + { + var slug = SlugHelper.GenerateSlug(title); + return string.IsNullOrEmpty(slug) ? "section" : slug; + } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/BranchRefConverter.cs b/src/authoring/Elastic.LegacyDocs.Migration/BranchRefConverter.cs new file mode 100644 index 0000000000..3304748d41 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/BranchRefConverter.cs @@ -0,0 +1,112 @@ +// 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 YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Elastic.LegacyDocs.Migration; + +/// Handles mixed-type branch sequences: plain strings and alias dicts like {alias: branch}. +public class BranchRefListConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(List); + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var list = new List(); + + if (!parser.TryConsume(out _)) + return list; + + while (!parser.TryConsume(out _)) + { + if (parser.TryConsume(out var scalar)) + { + list.Add(new BranchRef(scalar.Value)); + } + else if (parser.TryConsume(out _)) + { + // {alias: branch} — first key-value pair wins + var key = parser.Consume(); + var value = parser.Consume(); + list.Add(new BranchRef(Name: value.Value, Alias: key.Value)); + + // Consume remaining pairs (shouldn't exist but be safe) + while (!parser.TryConsume(out _)) + { + _ = parser.Consume(); + _ = parser.Consume(); + } + } + } + + return list; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + { + if (value is not List list) + return; + + emitter.Emit(new SequenceStart(null, null, false, SequenceStyle.Block)); + + foreach (var branch in list) + { + if (branch.Alias is not null) + { + emitter.Emit(new MappingStart(null, null, false, MappingStyle.Flow)); + emitter.Emit(new Scalar(branch.Alias)); + emitter.Emit(new Scalar(branch.Name)); + emitter.Emit(new MappingEnd()); + } + else + { + emitter.Emit(new Scalar(branch.Name)); + } + } + + emitter.Emit(new SequenceEnd()); + } +} + +/// Handles a single scalar or mapping node. +public class BranchRefConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(BranchRef); + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + if (parser.TryConsume(out var scalar)) + return new BranchRef(scalar.Value); + + if (parser.TryConsume(out _)) + { + var key = parser.Consume(); + var value = parser.Consume(); + _ = parser.Consume(); + return new BranchRef(Name: value.Value, Alias: key.Value); + } + + throw new YamlException("Expected scalar or mapping for BranchRef"); + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + { + if (value is not BranchRef branch) + return; + + if (branch.Alias is not null) + { + emitter.Emit(new MappingStart(null, null, false, MappingStyle.Flow)); + emitter.Emit(new Scalar(branch.Alias)); + emitter.Emit(new Scalar(branch.Name)); + emitter.Emit(new MappingEnd()); + } + else + { + emitter.Emit(new Scalar(branch.Name)); + } + } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/Elastic.LegacyDocs.Migration.csproj b/src/authoring/Elastic.LegacyDocs.Migration/Elastic.LegacyDocs.Migration.csproj new file mode 100644 index 0000000000..e9f26ad0b9 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/Elastic.LegacyDocs.Migration.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + + + + + + + + + + diff --git a/src/authoring/Elastic.LegacyDocs.Migration/LatestDocsetGenerator.cs b/src/authoring/Elastic.LegacyDocs.Migration/LatestDocsetGenerator.cs new file mode 100644 index 0000000000..f05c4ab7e7 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/LatestDocsetGenerator.cs @@ -0,0 +1,113 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.LegacyDocs.Migration.Asciidoc; +using Microsoft.Extensions.Logging; +using Slugify; + +namespace Elastic.LegacyDocs.Migration; + +public record LatestGeneratorOptions +{ + public required string OutputDirectory { get; init; } + public string? BookFilter { get; init; } + public required SourceRepoManager RepoManager { get; init; } +} + +public class LatestDocsetGenerator(ILogger logger) +{ + private static readonly SlugHelper SlugHelper = new(); + + public async Task GenerateAsync(LegacyConf conf, LatestGeneratorOptions options, CancellationToken ct = default) + { + var books = conf.Contents + .SelectMany(c => c.Sections) + .Where(b => options.BookFilter is null || b.Prefix == options.BookFilter) + .ToList(); + + logger.LogInformation("Processing {BookCount} books in latest mode", books.Count); + + foreach (var book in books) + { + ct.ThrowIfCancellationRequested(); + + if (string.IsNullOrEmpty(book.Current)) + { + logger.LogWarning("No current version for {BookPrefix} — skipping", book.Prefix); + continue; + } + + await ProcessBook(book, options, ct); + } + + logger.LogInformation("Latest generation complete"); + } + + private async Task ProcessBook(LegacyBook book, LatestGeneratorOptions options, CancellationToken ct) + { + var currentBranch = book.Branches.FirstOrDefault(b => b.VersionLabel == book.Current) + ?? new BranchRef(book.Current); + + var sources = await options.RepoManager.ResolveSourcesAsync(book, currentBranch, ct); + if (sources.Count == 0) + { + logger.LogWarning("No sources resolved for {Prefix}", book.Prefix); + return; + } + + var primarySource = sources[0]; + var indexPath = Path.Combine(primarySource.LocalPath, book.Index); + if (!File.Exists(indexPath)) + { + logger.LogWarning("Index file not found: {IndexPath}", indexPath); + return; + } + + var content = await File.ReadAllTextAsync(indexPath, ct); + var basePath = Path.GetDirectoryName(indexPath) ?? primarySource.LocalPath; + var parserOptions = new AsciidocParserOptions + { + Attributes = new Dictionary + { + ["branch"] = book.Current, + ["doc-tests-src"] = primarySource.LocalPath + } + }; + var parser = new AsciidocParser(parserOptions); + var document = parser.Parse(content, basePath); + + var emitterOptions = new MarkdownEmitterOptions + { + BookPrefix = book.Prefix, + Version = book.Current + }; + var emitter = new MarkdownEmitter(emitterOptions); + var pages = PageChunker.Chunk(document, book.Chunk, emitter); + + if (pages.Count == 0) + { + logger.LogWarning("No pages generated for {Prefix}", book.Prefix); + return; + } + + var repoName = primarySource.RepoName; + var docsDir = Path.Combine(options.OutputDirectory, repoName, "docs"); + _ = Directory.CreateDirectory(docsDir); + + var fileEntries = new List(); + foreach (var page in pages) + { + var filename = $"{page.Slug}.md"; + await File.WriteAllTextAsync(Path.Combine(docsDir, filename), page.MarkdownContent, ct); + fileEntries.Add(new TocEntry { File = filename }); + } + + var projectName = SlugHelper.GenerateSlug(book.Title); + YamlWriter.WriteDocsetYaml(Path.Combine(docsDir, "docset.yml"), projectName, ["."]); + YamlWriter.WriteTocYaml(Path.Combine(docsDir, "toc.yml"), fileEntries); + + logger.LogInformation("Wrote {PageCount} pages for {RepoName}/docs (project: {Project})", + pages.Count, repoName, projectName); + } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/LegacyConf.cs b/src/authoring/Elastic.LegacyDocs.Migration/LegacyConf.cs new file mode 100644 index 0000000000..ebc1eaa478 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/LegacyConf.cs @@ -0,0 +1,53 @@ +// 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.LegacyDocs.Migration; + +public record LegacyConf +{ + public Dictionary Repos { get; init; } = []; + public List Contents { get; init; } = []; +} + +public record LegacyCategory +{ + public string Title { get; init; } = ""; + public List Sections { get; init; } = []; +} + +public record LegacyBook +{ + public string Title { get; init; } = ""; + public string Prefix { get; init; } = ""; + public string BaseDir { get; init; } = ""; + public string Index { get; init; } = ""; + public string Current { get; init; } = ""; + public List Branches { get; init; } = []; + public List Live { get; init; } = []; + public int Chunk { get; init; } = 1; + public string? Tags { get; init; } + public string? Subject { get; init; } + public List Sources { get; init; } = []; + /// Sub-groups within a book entry. Used during conf.yaml parsing only; flattened before use. + public List Sections { get; init; } = []; +} + +public record LegacySource +{ + public string Repo { get; init; } = ""; + public string Path { get; init; } = ""; + public string? Prefix { get; init; } + public bool Private { get; init; } + public List ExcludeBranches { get; init; } = []; + public Dictionary MapBranches { get; init; } = []; +} + +public record BranchRef(string Name, string? Alias = null) +{ + /// Version label used in output paths and URLs. + public string VersionLabel => Alias ?? Name; + + /// Actual git branch to clone. + public string GitBranch => Name; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/LegacyConfParser.cs b/src/authoring/Elastic.LegacyDocs.Migration/LegacyConfParser.cs new file mode 100644 index 0000000000..9687257045 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/LegacyConfParser.cs @@ -0,0 +1,70 @@ +// 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 YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Elastic.LegacyDocs.Migration; + +public static class LegacyConfParser +{ + private static readonly IDeserializer RawDeserializer = new DeserializerBuilder().Build(); + + private static readonly ISerializer RoundTripSerializer = new SerializerBuilder() + .DisableAliases() + .Build(); + + private static readonly IDeserializer TypedDeserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .WithTypeConverter(new BranchRefListConverter()) + .WithTypeConverter(new BranchRefConverter()) + .IgnoreUnmatchedProperties() + .Build(); + + public static LegacyConf Parse(string yaml) + { + var raw = RawDeserializer.Deserialize(yaml); + var resolved = RoundTripSerializer.Serialize(raw); + var conf = TypedDeserializer.Deserialize(resolved) ?? new LegacyConf(); + return Flatten(conf); + } + + private static LegacyConf Flatten(LegacyConf conf) + { + var flatCategories = conf.Contents + .Select(c => c with { Sections = FlattenBooks(c.Sections, "") }) + .ToList(); + return conf with { Contents = flatCategories }; + } + + /// + /// Recursively flattens nested sub-group entries (those with sections but no sources) + /// into leaf records, accumulating the base_dir prefix as we descend. + /// + private static List FlattenBooks(List books, string parentBaseDir) + { + var result = new List(); + foreach (var book in books) + { + var dir = parentBaseDir.Length > 0 && book.BaseDir.Length > 0 + ? $"{parentBaseDir}/{book.BaseDir}" + : parentBaseDir.Length > 0 + ? parentBaseDir + : book.BaseDir; + + if (book.Sections.Count > 0) + { + result.AddRange(FlattenBooks(book.Sections, dir)); + } + else + { + var prefix = dir.Length > 0 && !book.Prefix.StartsWith(dir, StringComparison.Ordinal) + ? $"{dir}/{book.Prefix}" + : book.Prefix; + result.Add(book with { Prefix = prefix }); + } + } + return result; + } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/SharedAttributes.cs b/src/authoring/Elastic.LegacyDocs.Migration/SharedAttributes.cs new file mode 100644 index 0000000000..9b22ca0730 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/SharedAttributes.cs @@ -0,0 +1,170 @@ +// 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.LegacyDocs.Migration; + +/// +/// Well-known AsciiDoc attributes from elastic/docs shared/attributes.asciidoc +/// that map to simple display strings (not URLs). +/// These are emitted as subs in the generated docset.yml. +/// +public static class SharedAttributes +{ + public static readonly Dictionary ProductNames = new(StringComparer.OrdinalIgnoreCase) + { + // Cloud + ["ecloud"] = "Elastic Cloud", + ["ess"] = "Elasticsearch Service", + ["ech"] = "Elastic Cloud Hosted", + ["ece"] = "Elastic Cloud Enterprise", + ["eck"] = "Elastic Cloud on Kubernetes", + ["esf"] = "Elastic Serverless Forwarder", + ["serverless-full"] = "Elastic Cloud Serverless", + ["serverless-short"] = "Serverless", + ["es-serverless"] = "Elasticsearch Serverless", + ["obs-serverless"] = "Elastic Observability Serverless", + ["sec-serverless"] = "Elastic Security Serverless", + + // Core products + ["es"] = "Elasticsearch", + ["kib"] = "Kibana", + ["ls"] = "Logstash", + ["beats"] = "Beats", + ["stack"] = "Elastic Stack", + ["xpack"] = "X-Pack", + ["es-sql"] = "Elasticsearch SQL", + ["esql"] = "ES|QL", + + // Beats + ["auditbeat"] = "Auditbeat", + ["filebeat"] = "Filebeat", + ["heartbeat"] = "Heartbeat", + ["metricbeat"] = "Metricbeat", + ["packetbeat"] = "Packetbeat", + ["winlogbeat"] = "Winlogbeat", + ["functionbeat"] = "Functionbeat", + ["journalbeat"] = "Journalbeat", + + // Agents and ingest + ["agent"] = "Elastic Agent", + ["agents"] = "Elastic Agents", + ["fleet"] = "Fleet", + ["fleet-server"] = "Fleet Server", + ["integrations-server"] = "Integrations Server", + ["integrations"] = "Integrations", + + // Enterprise Search + ["ents"] = "Enterprise Search", + ["crawler"] = "Enterprise Search web crawler", + + // Observability + ["observability"] = "Observability", + + // Security + ["elastic-sec"] = "Elastic Security", + ["elastic-defend"] = "Elastic Defend", + ["elastic-endpoint"] = "Elastic Endpoint", + ["endpoint-sec"] = "Endpoint Security", + + // ML + ["ml"] = "machine learning", + ["ml-cap"] = "Machine learning", + ["ml-init"] = "ML", + ["nlp"] = "natural language processing", + ["nlp-cap"] = "Natural language processing", + + // Features + ["security"] = "X-Pack security", + ["security-features"] = "security features", + ["monitor-features"] = "monitoring features", + ["ml-features"] = "machine learning features", + ["alert-features"] = "alerting features", + ["report-features"] = "reporting features", + ["graph-features"] = "graph analytics features", + ["watcher"] = "Watcher", + ["monitoring"] = "X-Pack monitoring", + ["reporting"] = "X-Pack reporting", + ["graph"] = "X-Pack graph", + + // Abbreviations + ["ccr"] = "cross-cluster replication", + ["ccr-cap"] = "Cross-cluster replication", + ["ccr-init"] = "CCR", + ["ccs"] = "cross-cluster search", + ["ccs-cap"] = "Cross-cluster search", + ["ccs-init"] = "CCS", + ["ilm"] = "index lifecycle management", + ["ilm-cap"] = "Index lifecycle management", + ["ilm-init"] = "ILM", + ["slm"] = "snapshot lifecycle management", + ["slm-cap"] = "Snapshot lifecycle management", + ["slm-init"] = "SLM", + ["rollup"] = "rollup", + ["rollup-cap"] = "Rollup", + ["transform"] = "transform", + ["transform-cap"] = "Transform", + ["transforms"] = "transforms", + ["transforms-cap"] = "Transforms", + ["dfeed"] = "datafeed", + ["dfeeds"] = "datafeeds", + ["anomaly-detect"] = "anomaly detection", + ["anomaly-detect-cap"] = "Anomaly detection", + ["infer"] = "inference", + ["infer-cap"] = "Inference", + ["search-snaps"] = "searchable snapshots", + ["search-snaps-cap"] = "Searchable snapshots", + + // Data views + ["data-source"] = "data view", + ["data-sources"] = "data views", + ["data-source-cap"] = "Data view", + ["data-sources-cap"] = "Data views", + + // Kibana apps + ["apm-app"] = "APM app", + ["uptime-app"] = "Uptime app", + ["synthetics-app"] = "Synthetics app", + ["logs-app"] = "Logs app", + ["metrics-app"] = "Metrics app", + ["infrastructure-app"] = "Infrastructure app", + ["security-app"] = "Elastic Security app", + ["ml-app"] = "Machine Learning", + ["dev-tools-app"] = "Dev Tools", + ["stack-manage-app"] = "Stack Management", + ["stack-monitor-app"] = "Stack Monitoring", + ["maps-app"] = "Maps", + ["data-views-app"] = "Data Views", + + // APM agents + ["apm-agent"] = "APM agent", + ["apm-go-agent"] = "Elastic APM Go agent", + ["apm-java-agent"] = "Elastic APM Java agent", + ["apm-dotnet-agent"] = "Elastic APM .NET agent", + ["apm-node-agent"] = "Elastic APM Node.js agent", + ["apm-php-agent"] = "Elastic APM PHP agent", + ["apm-py-agent"] = "Elastic APM Python agent", + ["apm-ruby-agent"] = "Elastic APM Ruby agent", + ["apm-rum-agent"] = "Elastic APM Real User Monitoring (RUM) JavaScript agent", + + // Misc + ["k8s"] = "Kubernetes", + ["aws"] = "AWS", + ["esh"] = "ES-Hadoop", + ["searchprofiler"] = "Search Profiler", + ["data-viz"] = "Data Visualizer", + ["feat-imp"] = "feature importance", + ["feat-imp-cap"] = "Feature importance", + + // Connectors + ["sn"] = "ServiceNow", + ["sn-itsm"] = "ServiceNow ITSM", + ["sn-itom"] = "ServiceNow ITOM", + ["sn-sir"] = "ServiceNow SecOps", + ["jira"] = "Jira", + ["swimlane"] = "Swimlane", + ["opsgenie"] = "Opsgenie", + ["bedrock"] = "Amazon Bedrock", + ["gemini"] = "Google Gemini", + }; +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/SourceRepoManager.cs b/src/authoring/Elastic.LegacyDocs.Migration/SourceRepoManager.cs new file mode 100644 index 0000000000..eb34619a0b --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/SourceRepoManager.cs @@ -0,0 +1,256 @@ +// 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.Concurrent; +using Microsoft.Extensions.Logging; +using ProcNet; + +namespace Elastic.LegacyDocs.Migration; + +public record SourceRepoOptions +{ + public required string ReposDirectory { get; init; } + public Dictionary RepoUrls { get; init; } = []; + public Dictionary> SparsePaths { get; init; } = []; +} + +public record ResolvedSource +{ + public string RepoName { get; init; } = ""; + public string GitBranch { get; init; } = ""; + public string DocsPath { get; init; } = ""; + public string? Prefix { get; init; } + public required string LocalPath { get; init; } +} + +public class SourceRepoManager(SourceRepoOptions options, ILogger logger) +{ + private readonly ConcurrentDictionary _resolvedPaths = []; + private readonly ConcurrentDictionary _bareClones = []; + + /// Collects all declared source paths per repo from the conf.yaml book definitions. + public static Dictionary> CollectSparsePaths(LegacyConf conf) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var book in conf.Contents.SelectMany(c => c.Sections)) + { + foreach (var source in book.Sources) + { + if (!result.TryGetValue(source.Repo, out var paths)) + { + paths = [with(StringComparer.OrdinalIgnoreCase)]; + result[source.Repo] = paths; + } + + var normalized = NormalizeSparsePath(source.Path); + if (normalized is not null) + _ = paths.Add(normalized); + } + } + + return result; + } + + public async Task> ResolveSourcesAsync( + LegacyBook book, BranchRef version, CancellationToken ct = default) + { + var results = new List(); + var versionLabel = version.VersionLabel; + var defaultGitBranch = version.GitBranch; + + foreach (var source in book.Sources) + { + if (IsExcluded(source, versionLabel)) + continue; + + var gitBranch = ResolveBranch(source, versionLabel, defaultGitBranch); + + try + { + var localPath = await EnsureWorktreeAsync(source.Repo, gitBranch, ct); + results.Add(new ResolvedSource + { + RepoName = source.Repo, + GitBranch = gitBranch, + DocsPath = source.Path, + Prefix = source.Prefix, + LocalPath = localPath + }); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning("Skipping {Repo}@{Branch}: {Message}", source.Repo, gitBranch, ex.Message); + } + } + + return results; + } + + public void CleanAll() + { + if (!Directory.Exists(options.ReposDirectory)) + return; + + logger.LogInformation("Cleaning repos directory: {ReposDir}", options.ReposDirectory); + Directory.Delete(options.ReposDirectory, recursive: true); + _resolvedPaths.Clear(); + _bareClones.Clear(); + } + + private static bool IsExcluded(LegacySource source, string versionLabel) => + source.ExcludeBranches.Any(b => b.VersionLabel == versionLabel); + + private static string ResolveBranch(LegacySource source, string versionLabel, string defaultGitBranch) => + source.MapBranches.TryGetValue(versionLabel, out var mapped) ? mapped : defaultGitBranch; + + private string BareClonePath(string repoName) => + Path.Combine(options.ReposDirectory, $"{repoName}.git"); + + private string WorktreePath(string repoName, string gitBranch) => + Path.Combine(options.ReposDirectory, repoName, gitBranch); + + private async Task EnsureBareCloneAsync(string repoName, CancellationToken ct) + { + if (_bareClones.ContainsKey(repoName)) + return; + + var barePath = BareClonePath(repoName); + if (Directory.Exists(barePath)) + { + _bareClones[repoName] = true; + return; + } + + if (!options.RepoUrls.TryGetValue(repoName, out var url)) + throw new InvalidOperationException($"Unknown repo: {repoName}"); + + logger.LogInformation("Cloning bare {Repo}...", repoName); + + _ = Directory.CreateDirectory(options.ReposDirectory); + await ExecGitAsync(ct, "clone", "--bare", "--filter=blob:none", url, barePath); + + _bareClones[repoName] = true; + logger.LogInformation("Cloned bare {Repo}", repoName); + } + + private async Task EnsureWorktreeAsync(string repoName, string gitBranch, CancellationToken ct) + { + var key = $"{repoName}/{gitBranch}"; + + if (_resolvedPaths.TryGetValue(key, out var existing)) + return existing; + + var worktreePath = WorktreePath(repoName, gitBranch); + + if (Directory.Exists(worktreePath)) + { + _resolvedPaths[key] = worktreePath; + return worktreePath; + } + + await EnsureBareCloneAsync(repoName, ct); + + var barePath = BareClonePath(repoName); + + var resolvedBranch = await ResolveBranchAsync(barePath, gitBranch, ct); + + logger.LogInformation("Creating worktree {Repo}@{Branch}...", repoName, resolvedBranch); + _ = Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!); + await ExecGitInAsync(barePath, allowFailure: false, ct, "worktree", "add", "--detach", worktreePath, resolvedBranch, "--no-checkout"); + + await ApplySparseCheckoutAsync(repoName, worktreePath, ct); + + await ExecGitInAsync(worktreePath, allowFailure: true, ct, "checkout"); + + _resolvedPaths[key] = worktreePath; + logger.LogInformation("Ready {Repo}@{Branch}", repoName, gitBranch); + return worktreePath; + } + + private async Task ApplySparseCheckoutAsync(string repoName, string worktreePath, CancellationToken ct) + { + if (!options.SparsePaths.TryGetValue(repoName, out var paths) || paths.Count == 0) + return; + + logger.LogInformation("Sparse checkout {Repo}: {Paths}", repoName, string.Join(", ", paths)); + + await ExecGitInAsync(worktreePath, allowFailure: false, ct, "sparse-checkout", "init", "--cone"); + await ExecGitInAsync(worktreePath, allowFailure: false, ct, ["sparse-checkout", "set", .. paths]); + } + + private async Task ResolveBranchAsync(string barePath, string gitBranch, CancellationToken ct) + { + string[] candidates = [gitBranch, "master", "main"]; + + foreach (var branch in candidates) + { + var existsLocally = await TryExecGitInAsync(barePath, ct, "rev-parse", "--verify", branch); + if (existsLocally) + return branch; + } + + foreach (var branch in candidates) + { + logger.LogInformation("Fetching {Branch}...", branch); + var fetched = await TryExecGitInAsync(barePath, ct, "fetch", "origin", $"+{branch}:refs/heads/{branch}", "--depth", "1"); + if (fetched) + return branch; + } + + throw new InvalidOperationException($"No branch found for {gitBranch} (also tried master, main) in {barePath}"); + } + + private static async Task TryExecGitInAsync(string workingDirectory, CancellationToken ct, params string[] args) + { + var arguments = new ExecArguments("git", args) + { + WorkingDirectory = workingDirectory, + ValidExitCodeClassifier = _ => true + }; + var exitCode = await Proc.ExecAsync(arguments, ct); + return exitCode == 0; + } + + private static async Task ExecGitAsync(CancellationToken ct, params string[] args) + { + var arguments = new ExecArguments("git", args) + { + ValidExitCodeClassifier = _ => true + }; + var exitCode = await Proc.ExecAsync(arguments, ct); + if (exitCode != 0) + throw new InvalidOperationException($"git {args[0]} failed with exit code {exitCode}"); + } + + private static async Task ExecGitInAsync(string workingDirectory, bool allowFailure, CancellationToken ct, params string[] args) + { + var arguments = new ExecArguments("git", args) + { + WorkingDirectory = workingDirectory, + ValidExitCodeClassifier = _ => true + }; + var exitCode = await Proc.ExecAsync(arguments, ct); + if (exitCode != 0 && !allowFailure) + throw new InvalidOperationException( + $"git {args[0]} failed with exit code {exitCode} in {workingDirectory}"); + } + + /// Extracts the top-level directory from a source path for sparse checkout. + internal static string? NormalizeSparsePath(string sourcePath) + { + var trimmed = sourcePath.Trim().TrimStart('/').TrimEnd('/'); + if (string.IsNullOrEmpty(trimmed)) + return null; + + if (trimmed.Contains('*') || trimmed.StartsWith(":(glob)", StringComparison.Ordinal)) + return null; + + if (!trimmed.Contains('/')) + return trimmed; + + var firstSlash = trimmed.IndexOf('/'); + return trimmed[..firstSlash]; + } +} diff --git a/src/authoring/Elastic.LegacyDocs.Migration/YamlWriter.cs b/src/authoring/Elastic.LegacyDocs.Migration/YamlWriter.cs new file mode 100644 index 0000000000..0c276e8373 --- /dev/null +++ b/src/authoring/Elastic.LegacyDocs.Migration/YamlWriter.cs @@ -0,0 +1,54 @@ +// 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; + +namespace Elastic.LegacyDocs.Migration; + +public record TocEntry +{ + public string? File { get; init; } + public string? Folder { get; init; } + public string? Toc { get; init; } +} + +public static class YamlWriter +{ + public static void WriteDocsetYaml(string path, string project, List tocRefs) + { + var sb = new StringBuilder(); + _ = sb.Append("project: ").Append(project).Append('\n'); + _ = sb.Append("toc:\n"); + foreach (var tocRef in tocRefs) + _ = sb.Append(" - toc: ").Append(tocRef).Append('\n'); + + EnsureDirectoryAndWrite(path, sb.ToString()); + } + + public static void WriteTocYaml(string path, List entries) + { + var sb = new StringBuilder(); + _ = sb.Append("toc:\n"); + foreach (var entry in entries) + { + if (entry.File is not null) + _ = sb.Append(" - file: ").Append(entry.File).Append('\n'); + else if (entry.Folder is not null) + _ = sb.Append(" - folder: ").Append(entry.Folder).Append('\n'); + else if (entry.Toc is not null) + _ = sb.Append(" - toc: ").Append(entry.Toc).Append('\n'); + } + + EnsureDirectoryAndWrite(path, sb.ToString()); + } + + private static void EnsureDirectoryAndWrite(string path, string content) + { + var directory = Path.GetDirectoryName(path); + if (directory is not null) + _ = Directory.CreateDirectory(directory); + + File.WriteAllText(path, content); + } +} diff --git a/src/tooling/adoc-compare/Program.cs b/src/tooling/adoc-compare/Program.cs new file mode 100644 index 0000000000..a462b13e24 --- /dev/null +++ b/src/tooling/adoc-compare/Program.cs @@ -0,0 +1,21 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.LegacyDocs.Migration.Asciidoc; + +var inputFile = args.Length > 0 ? args[0] : "test.adoc"; +var opts = new AsciidocParserOptions +{ + FileReader = path => File.Exists(path) ? File.ReadAllText(path) : null, + Attributes = new Dictionary + { + ["my-product"] = "Elasticsearch", + ["version"] = "8.16", + ["enterprise-only"] = "" + } +}; +var parser = new AsciidocParser(opts); +var doc = parser.Parse(inputFile); +var emitter = new MarkdownEmitter(new MarkdownEmitterOptions()); +Console.Write(emitter.Emit(doc)); diff --git a/src/tooling/adoc-compare/adoc-compare.csproj b/src/tooling/adoc-compare/adoc-compare.csproj new file mode 100644 index 0000000000..453fad727d --- /dev/null +++ b/src/tooling/adoc-compare/adoc-compare.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + + + + + diff --git a/src/tooling/docs-builder/Commands/ServeCommand.cs b/src/tooling/docs-builder/Commands/ServeCommand.cs index 9c4b759957..515c839abd 100644 --- a/src/tooling/docs-builder/Commands/ServeCommand.cs +++ b/src/tooling/docs-builder/Commands/ServeCommand.cs @@ -21,11 +21,12 @@ internal sealed class ServeCommand(ILoggerFactory logFactory, IConfigurationCont /// -p, Documentation source directory. Defaults to the cwd/docs folder. /// Port to serve the documentation. Default: 3000 /// Special flag for dotnet watch optimizations during development + /// Disable the diagnostics HUD and background validation builds [CommandName("serve")] - public async Task Serve(GlobalCliOptions _, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, int port = 3000, bool watch = false, CancellationToken ct = default) + public async Task Serve(GlobalCliOptions _, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, int port = 3000, bool watch = false, bool noHud = false, CancellationToken ct = default) { - var host = new DocumentationWebHost(logFactory, path?.FullName, port, configurationContext, watch); + var host = new DocumentationWebHost(logFactory, path?.FullName, port, configurationContext, watch, noHud); await host.RunAsync(ct); _logger.LogInformation("Find your documentation at http://localhost:{Port}/{Path}", port, host.GeneratorState.Generator.DocumentationSet.FirstInterestingUrl.TrimStart('/') diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 2dee21bcc7..43b9c869f9 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -47,7 +47,8 @@ public DocumentationWebHost(ILoggerFactory logFactory, string? path, int port, IConfigurationContext configurationContext, - bool isWatchBuild + bool isWatchBuild, + bool noHud = false ) { var builder = WebApplication.CreateSlimBuilder(); @@ -77,10 +78,8 @@ bool isWatchBuild CanonicalBaseUrl = new Uri(hostUrl), }; - // Enable diagnostics panel in serve mode - Context.Configuration.Features.DiagnosticsPanelEnabled = true; + Context.Configuration.Features.DiagnosticsPanelEnabled = !noHud; - // Create InMemoryBuildState for background validation builds InMemoryBuildState = new InMemoryBuildState(logFactory, configurationContext); GeneratorState = new ReloadableGeneratorState(logFactory, Context.DocumentationSourceDirectory, Context.OutputDirectory, Context, isWatchBuild); @@ -95,7 +94,7 @@ bool isWatchBuild .Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(3)) .AddSingleton(_ => GeneratorState) .AddSingleton(_ => InMemoryBuildState) - .AddHostedService(sp => new ReloadGeneratorService(GeneratorState, InMemoryBuildState, logFactory.CreateLogger())); + .AddHostedService(sp => new ReloadGeneratorService(GeneratorState, InMemoryBuildState, noHud, logFactory.CreateLogger())); if (IsDotNetWatchBuild()) _ = builder.Services.AddHostedService(); @@ -308,12 +307,16 @@ private static async Task ServeDocumentationFile(ReloadableGeneratorSta slug = slug.Replace('/', Path.DirectorySeparatorChar); slug = slug.TrimEnd('/'); - var s = Path.GetExtension(slug) == string.Empty ? Path.Join(slug, "index.md") : slug; + // Path.GetExtension treats version segments like "8.19" as having extension ".19". + // Only treat the slug as a bare file path when the extension is a known document type. + var slugExt = Path.GetExtension(slug); + var hasKnownExtension = slugExt is ".md" or ".html" or ".json" or ".js" or ".css" or ".svg" or ".png" or ".jpg" or ".jpeg" or ".gif" or ".ico" or ".webp"; + var s = !hasKnownExtension ? Path.Join(slug, "index.md") : slug; var fp = new FilePath(s, generator.DocumentationSet.SourceDirectory); if (!generator.DocumentationSet.Files.TryGetValue(fp, out var documentationFile)) { - s = Path.GetExtension(slug) == string.Empty ? slug + ".md" : s.Replace($"{Path.DirectorySeparatorChar}index.md", ".md"); + s = !hasKnownExtension ? slug + ".md" : s.Replace($"{Path.DirectorySeparatorChar}index.md", ".md"); fp = new FilePath(s, generator.DocumentationSet.SourceDirectory); if (!generator.DocumentationSet.Files.TryGetValue(fp, out documentationFile)) { diff --git a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs index 1097b5723e..4571640296 100644 --- a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs +++ b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs @@ -26,6 +26,7 @@ public static void UpdateApplication(Type[]? _) => Task.Run(async () => public sealed class ReloadGeneratorService( ReloadableGeneratorState reloadableGenerator, InMemoryBuildState inMemoryBuildState, + bool noHud, ILogger logger ) : IHostedService, IDisposable { @@ -52,10 +53,13 @@ public async Task StartAsync(Cancel cancellationToken) var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; await ReloadableGenerator.ReloadAsync(cancellationToken); - // Start the build loop; only shutdownCt (Ctrl+C / app exit) can cancel a running build. - // File-edit triggers enqueue via ScheduleBuild and never interrupt the current build. - _backgroundBuildTask = InMemoryBuildState.RunAsync(_serviceCts.Token); - InMemoryBuildState.ScheduleBuild(sourcePath); + if (!noHud) + { + // Start the build loop; only shutdownCt (Ctrl+C / app exit) can cancel a running build. + // File-edit triggers enqueue via ScheduleBuild and never interrupt the current build. + _backgroundBuildTask = InMemoryBuildState.RunAsync(_serviceCts.Token); + InMemoryBuildState.ScheduleBuild(sourcePath); + } var directory = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; Logger.LogInformation("Start file watch on: {Directory}", directory); @@ -99,10 +103,13 @@ private void Reload(bool reloadConfiguration = false) Logger.LogInformation("Reload complete!"); _ = LiveReloadMiddleware.RefreshWebSocketRequest(); - // Schedule a validation build after every reload — both content edits and structural changes. - // The build loop coalesces rapid triggers: a new request while a build runs queues one more. - var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; - InMemoryBuildState.ScheduleBuild(sourcePath); + if (!noHud) + { + // Schedule a validation build after every reload — both content edits and structural changes. + // The build loop coalesces rapid triggers: a new request while a build runs queues one more. + var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; + InMemoryBuildState.ScheduleBuild(sourcePath); + } }, token); } diff --git a/src/tooling/docs-migrate/CloneCommand.cs b/src/tooling/docs-migrate/CloneCommand.cs new file mode 100644 index 0000000000..c3c0bf1a9f --- /dev/null +++ b/src/tooling/docs-migrate/CloneCommand.cs @@ -0,0 +1,86 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.LegacyDocs.Migration; +using Microsoft.Extensions.Logging; + +namespace Documentation.Migrate; + +internal sealed class CloneCommand(ILoggerFactory logFactory) +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + + /// Clone source repos needed for the selected books and versions. + /// Working directory for migration artifacts + /// Number of top major versions to include (default 1) + /// Max minor versions per major to include (default all) + /// Process all versions + /// Minimum major version to process + /// Filter to books whose prefix starts with this value + /// Delete all cloned repos and start fresh + /// Cancellation token + public async Task Clone( + string? workDir = null, + int majors = 1, + int? minors = null, + bool all = false, + int? minVersion = null, + string? book = null, + bool clean = false, + CancellationToken ct = default + ) + { + var dir = SharedOptions.ResolveWorkDir(workDir); + var conf = await SharedOptions.LoadConfAsync(dir, ct); + + var opts = new FilterOptions(majors, all, minVersion, book, minors); + // Save without Book — book filter is a per-run override, not a persistent setting + SharedOptions.SaveFilterOptions(dir, opts with { Book = null }); + + var books = SharedOptions.FilterBooks(conf, opts.Book); + + var reposDir = Path.Combine(dir, "repos"); + var sparsePaths = SourceRepoManager.CollectSparsePaths(conf); + var repoOptions = new SourceRepoOptions { ReposDirectory = reposDir, RepoUrls = conf.Repos, SparsePaths = sparsePaths }; + var repoManager = new SourceRepoManager(repoOptions, _logger); + + if (clean) + { + repoManager.CleanAll(); + _logger.LogInformation("Cleaned repos directory"); + return 0; + } + + var clonedBranches = new HashSet(StringComparer.OrdinalIgnoreCase); + var clonedRepos = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var b in books) + { + var versions = SharedOptions.FilterVersions(b, opts.Majors, opts.All, opts.MinVersion, opts.Minors); + foreach (var version in versions) + { + ct.ThrowIfCancellationRequested(); + + try + { + var sources = await repoManager.ResolveSourcesAsync(b, version, ct); + foreach (var source in sources) + { + _ = clonedRepos.Add(source.RepoName); + _ = clonedBranches.Add($"{source.RepoName}/{source.GitBranch}"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning("Failed to clone for {Prefix} {Version}: {Message}", + b.Prefix, version.VersionLabel, ex.Message); + } + } + } + + _logger.LogInformation("Cloned {RepoCount} repos, {BranchCount} worktrees", + clonedRepos.Count, clonedBranches.Count); + return 0; + } +} diff --git a/src/tooling/docs-migrate/ConvertCommand.cs b/src/tooling/docs-migrate/ConvertCommand.cs new file mode 100644 index 0000000000..77a8d7328e --- /dev/null +++ b/src/tooling/docs-migrate/ConvertCommand.cs @@ -0,0 +1,274 @@ +// 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; +using Elastic.LegacyDocs.Migration; +using Elastic.LegacyDocs.Migration.Asciidoc; +using Microsoft.Extensions.Logging; + +namespace Documentation.Migrate; + +internal sealed class ConvertCommand(ILoggerFactory logFactory) +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + + /// Convert AsciiDoc books to Markdown docsets. + /// Working directory for migration artifacts + /// Override: number of top major versions to include + /// Override: max minor versions per major to include + /// Override: process all versions + /// Override: minimum major version to process + /// Override: filter to books whose prefix starts with this value + /// Cancellation token + public async Task Convert( + string? workDir = null, + int? majors = null, + int? minors = null, + bool? all = null, + int? minVersion = null, + string? book = null, + CancellationToken ct = default + ) + { + var dir = SharedOptions.ResolveWorkDir(workDir); + var conf = await SharedOptions.LoadConfAsync(dir, ct); + + var opts = SharedOptions.ResolveFilterOptions(dir, majors, all, minVersion, book, minors); + _logger.LogInformation( + "Filter: majors={Majors}, minors={Minors}, all={All}, minVersion={MinVersion}, book={Book}", + opts.Majors, opts.Minors.HasValue ? opts.Minors.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "all", + opts.All, opts.MinVersion ?? (object)"any", opts.Book ?? "all"); + + var books = SharedOptions.FilterBooks(conf, opts.Book); + + var reposDir = Path.Combine(dir, "repos"); + var outputDir = Path.Combine(Directory.GetCurrentDirectory(), ".artifacts", "migrated"); + + var sparsePaths = SourceRepoManager.CollectSparsePaths(conf); + var repoOptions = new SourceRepoOptions { ReposDirectory = reposDir, RepoUrls = conf.Repos, SparsePaths = sparsePaths }; + var repoManager = new SourceRepoManager(repoOptions, logFactory.CreateLogger()); + + var convertedBooks = new Dictionary>(); + var tocRefs = new List(); + + foreach (var b in books) + { + ct.ThrowIfCancellationRequested(); + + var versions = SharedOptions.FilterVersions(b, opts.Majors, opts.All, opts.MinVersion, opts.Minors); + if (versions.Count == 0) + { + _logger.LogWarning("No versions to process for {BookPrefix}", b.Prefix); + continue; + } + + _logger.LogInformation("Book {Prefix}: {Count} versions to process", b.Prefix, versions.Count); + + var prefixDir = Path.Combine(outputDir, b.Prefix); + var versionEntries = new List(); + var convertedVersions = new List(); + + foreach (var version in versions) + { + ct.ThrowIfCancellationRequested(); + + var versionLabel = version.VersionLabel; + try + { + var pages = await ProcessBookVersion(b, version, repoManager, dir, ct); + if (pages.Count == 0) + continue; + + var versionDir = Path.Combine(prefixDir, versionLabel); + _ = Directory.CreateDirectory(versionDir); + + var fileEntries = await WritePages(pages, versionDir, ct); + YamlWriter.WriteTocYaml(Path.Combine(versionDir, "toc.yml"), fileEntries); + versionEntries.Add(new TocEntry { Toc = versionLabel }); + convertedVersions.Add(versionLabel); + + _logger.LogInformation("Wrote {PageCount} pages for {Prefix}/{Version}", + pages.Count, b.Prefix, versionLabel); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Failed to process {Prefix}/{Version}", b.Prefix, versionLabel); + } + } + + if (versionEntries.Count == 0) + continue; + + tocRefs.Add(b.Prefix); + convertedBooks[b.Prefix] = convertedVersions; + + YamlWriter.WriteTocYaml(Path.Combine(prefixDir, "toc.yml"), [ + new TocEntry { File = "index.md" }, + ..versionEntries + ]); + + WriteBookVersionIndex(prefixDir, b, convertedVersions); + } + + if (tocRefs.Count > 0) + { + WriteGuideOverview(outputDir, conf, convertedBooks); + WriteRootDocsetYaml(outputDir, tocRefs); + } + + _logger.LogInformation("Conversion complete: {BookCount} books written to {OutputDir}", tocRefs.Count, outputDir); + return 0; + } + + private async Task> ProcessBookVersion( + LegacyBook book, BranchRef version, SourceRepoManager repoManager, string workDir, CancellationToken ct) + { + var versionLabel = version.VersionLabel; + var sources = await repoManager.ResolveSourcesAsync(book, version, ct); + if (sources.Count == 0) + { + _logger.LogWarning("No sources resolved for {Prefix} version {Version}", book.Prefix, versionLabel); + return []; + } + + var primarySource = sources[0]; + var indexPath = Path.Combine(primarySource.LocalPath, book.Index); + if (!File.Exists(indexPath)) + { + _logger.LogWarning("Index file not found: {IndexPath}", indexPath); + return []; + } + + var content = await File.ReadAllTextAsync(indexPath, ct); + var basePath = Path.GetDirectoryName(indexPath) ?? primarySource.LocalPath; + + // Seed branch-aware attributes first, then path attributes that may reference {branch} + var docsRoot = Path.Combine(workDir, "docs-repo"); + var seedAttributes = new Dictionary + { + ["branch"] = versionLabel, + ["source_branch"] = versionLabel, + ["doc-tests-src"] = primarySource.LocalPath, + // Path-based attributes used in include:: directives across the guide + ["docs-root"] = docsRoot, + ["asciidoc-dir"] = docsRoot, + }; + + foreach (var source in sources) + seedAttributes[$"{source.RepoName}-root"] = source.LocalPath; + + // Pre-load shared/attributes.asciidoc so feature/product name attributes like + // {transform}, {ilm-init}, {anomaly-detect} etc. are resolved during conversion. + var sharedAttrsPath = Path.Combine(docsRoot, "shared", "attributes.asciidoc"); + var attributes = AsciidocParser.LoadAttributeFile(sharedAttrsPath, seedAttributes); + + // Merge seed attributes back (they take precedence over shared attrs) + foreach (var (k, v) in seedAttributes) + attributes[k] = v; + + var parserOptions = new AsciidocParserOptions + { + Attributes = attributes, + OnDiagnostic = msg => _logger.LogDebug("Parser: {Message}", msg) + }; + var parser = new AsciidocParser(parserOptions); + var document = parser.Parse(content, basePath); + + var emitterOptions = new MarkdownEmitterOptions + { + BookPrefix = book.Prefix, + Version = versionLabel + }; + var emitter = new MarkdownEmitter(emitterOptions); + + // conf.yaml chunk: N means "chunk at N levels below the document root (= title)". + // The AST levels match directly: == is Level 1, === is Level 2, etc. + return PageChunker.Chunk(document, book.Chunk, emitter); + } + + private static async Task> WritePages( + IReadOnlyList pages, string directory, CancellationToken ct) + { + var entries = new List(); + foreach (var page in pages) + { + var filename = $"{page.Slug}.md"; + await File.WriteAllTextAsync(Path.Combine(directory, filename), page.MarkdownContent, ct); + entries.Add(new TocEntry { File = filename }); + } + return entries; + } + + private static void WriteBookVersionIndex(string prefixDir, LegacyBook book, List versions) + { + var sb = new StringBuilder(); + _ = sb.Append("# ").Append(book.Title).AppendLine(" — All Versions"); + _ = sb.AppendLine(); + _ = sb.AppendLine("| Version | Status |"); + _ = sb.AppendLine("|---|---|"); + + foreach (var v in versions) + { + var status = v == book.Current ? "current" : ""; + _ = sb.Append("| [").Append(v).Append("](").Append(v).Append("/index.md) | ").Append(status).AppendLine(" |"); + } + + File.WriteAllText(Path.Combine(prefixDir, "index.md"), sb.ToString()); + } + + private static void WriteGuideOverview( + string outputDir, LegacyConf conf, Dictionary> convertedBooks) + { + var sb = new StringBuilder(); + _ = sb.AppendLine("# Elastic Docs"); + _ = sb.AppendLine(); + + foreach (var category in conf.Contents) + { + var categoryBooks = category.Sections + .Where(b => convertedBooks.ContainsKey(b.Prefix)) + .ToList(); + + if (categoryBooks.Count == 0) + continue; + + _ = sb.Append("## ").AppendLine(category.Title); + + foreach (var b in categoryBooks) + { + var versions = convertedBooks[b.Prefix]; + var current = !string.IsNullOrEmpty(b.Current) && versions.Contains(b.Current) + ? b.Current + : versions[0]; + _ = sb.Append("- [").Append(b.Title).Append(" [").Append(current).Append("]](") + .Append(b.Prefix).Append('/').Append(current).Append("/index.md) — [other versions](") + .Append(b.Prefix).AppendLine("/index.md)"); + } + + _ = sb.AppendLine(); + } + + File.WriteAllText(Path.Combine(outputDir, "index.md"), sb.ToString()); + } + + private static void WriteRootDocsetYaml(string outputDir, List tocRefs) + { + var sb = new StringBuilder(); + _ = sb.AppendLine("project: elastic-guide-archive"); + _ = sb.AppendLine("features:"); + _ = sb.AppendLine(" guide-nav: true"); + + _ = sb.AppendLine("subs:"); + foreach (var (key, value) in SharedAttributes.ProductNames.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + _ = sb.Append(" ").Append(key).Append(": \"").Append(value.Replace("\"", "\\\"")).AppendLine("\""); + + _ = sb.AppendLine("toc:"); + _ = sb.AppendLine(" - file: index.md"); + + foreach (var prefix in tocRefs) + _ = sb.Append(" - toc: ").AppendLine(prefix); + + File.WriteAllText(Path.Combine(outputDir, "docset.yml"), sb.ToString()); + } +} diff --git a/src/tooling/docs-migrate/InitCommand.cs b/src/tooling/docs-migrate/InitCommand.cs new file mode 100644 index 0000000000..3a359cfa33 --- /dev/null +++ b/src/tooling/docs-migrate/InitCommand.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 Elastic.LegacyDocs.Migration; +using ProcNet; + +namespace Documentation.Migrate; + +internal sealed class InitCommand +{ + /// Clone the legacy docs repo and extract conf.yaml. + /// Working directory for migration artifacts + /// Re-clone even if conf.yaml already exists + /// Cancellation token + public async Task Init(string? workDir = null, bool force = false, CancellationToken ct = default) + { + var dir = SharedOptions.ResolveWorkDir(workDir); + var confPath = Path.Combine(dir, "conf.yaml"); + + if (File.Exists(confPath) && !force) + { + Console.WriteLine($"conf.yaml already exists at {confPath} (use --force to overwrite)"); + return 0; + } + + _ = Directory.CreateDirectory(dir); + + var docsRepoDir = Path.Combine(dir, "docs-repo"); + if (Directory.Exists(docsRepoDir)) + Directory.Delete(docsRepoDir, recursive: true); + + Console.WriteLine("Cloning elastic/docs (shallow)..."); + var arguments = new ExecArguments("git", ["clone", "--depth", "1", "https://github.com/elastic/docs.git", docsRepoDir]); + var exitCode = await Proc.ExecAsync(arguments, ct); + if (exitCode != 0) + { + Console.Error.WriteLine($"git clone failed with exit code {exitCode}"); + return 1; + } + + var sourceConf = Path.Combine(docsRepoDir, "conf.yaml"); + if (!File.Exists(sourceConf)) + { + Console.Error.WriteLine($"conf.yaml not found in cloned docs repo at {sourceConf}"); + return 1; + } + + File.Copy(sourceConf, confPath, overwrite: true); + Console.WriteLine($"Copied conf.yaml to {confPath}"); + + var yaml = await File.ReadAllTextAsync(confPath, ct); + var conf = LegacyConfParser.Parse(yaml); + var bookCount = conf.Contents.SelectMany(c => c.Sections).Count(); + Console.WriteLine($"Parsed conf.yaml: {bookCount} books across {conf.Contents.Count} categories"); + + return 0; + } +} diff --git a/src/tooling/docs-migrate/ListCommand.cs b/src/tooling/docs-migrate/ListCommand.cs new file mode 100644 index 0000000000..e57940283a --- /dev/null +++ b/src/tooling/docs-migrate/ListCommand.cs @@ -0,0 +1,41 @@ +// 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 Documentation.Migrate; + +internal sealed class ListCommand +{ + /// List all books from conf.yaml grouped by category. + /// Working directory for migration artifacts + /// Cancellation token + public async Task List(string? workDir = null, CancellationToken ct = default) + { + var dir = SharedOptions.ResolveWorkDir(workDir); + var conf = await SharedOptions.LoadConfAsync(dir, ct); + + foreach (var category in conf.Contents) + { + Console.WriteLine(); + Console.WriteLine($"== {category.Title} =="); + Console.WriteLine(); + + foreach (var book in category.Sections) + { + var versions = book.Branches; + var min = versions.Count > 0 ? versions[^1].VersionLabel : "—"; + var max = versions.Count > 0 ? versions[0].VersionLabel : "—"; + var current = !string.IsNullOrEmpty(book.Current) ? book.Current : "—"; + + Console.WriteLine( + $" {book.Prefix,-45} {book.Title,-40} {current,8} (current) [{min} .. {max}] {versions.Count} versions" + ); + } + } + + var totalBooks = conf.Contents.SelectMany(c => c.Sections).Count(); + Console.WriteLine(); + Console.WriteLine($"Total: {totalBooks} books across {conf.Contents.Count} categories"); + return 0; + } +} diff --git a/src/tooling/docs-migrate/Program.cs b/src/tooling/docs-migrate/Program.cs new file mode 100644 index 0000000000..47b7fe8398 --- /dev/null +++ b/src/tooling/docs-migrate/Program.cs @@ -0,0 +1,22 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Documentation.Migrate; +using Microsoft.Extensions.Hosting; +using Nullean.Argh.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddArgh(args, app => +{ + _ = app.UseCliDescription("docs-migrate — convert Elastic legacy AsciiDoc books to docs-builder Markdown."); + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); + _ = app.Map(); +}); + +using var host = builder.Build(); +await host.RunAsync(); diff --git a/src/tooling/docs-migrate/ServeCommand.cs b/src/tooling/docs-migrate/ServeCommand.cs new file mode 100644 index 0000000000..ca511d075c --- /dev/null +++ b/src/tooling/docs-migrate/ServeCommand.cs @@ -0,0 +1,37 @@ +// 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 ProcNet; + +namespace Documentation.Migrate; + +internal sealed class ServeCommand +{ + /// Serve the converted output using docs-builder. + /// Port to serve on (default 3000) + /// Cancellation token + public async Task Serve(int port = 3001, CancellationToken ct = default) + { + var outputDir = Path.Combine(Directory.GetCurrentDirectory(), ".artifacts", "migrated"); + + if (!Directory.Exists(outputDir)) + { + Console.Error.WriteLine($"Output directory not found at {outputDir}. Run 'docs-migrate convert' first."); + return 1; + } + + string[] args = ["run", "--project", "src/tooling/docs-builder", "--", "serve", "--path", outputDir, "--port", $"{port}", "--no-hud"]; + Console.WriteLine($"dotnet {string.Join(' ', args)}"); + + var arguments = new ExecArguments("dotnet", args); + try + { + return await Proc.ExecAsync(arguments, ct); + } + catch (OperationCanceledException) + { + return 0; + } + } +} diff --git a/src/tooling/docs-migrate/SharedOptions.cs b/src/tooling/docs-migrate/SharedOptions.cs new file mode 100644 index 0000000000..c2b046d8a4 --- /dev/null +++ b/src/tooling/docs-migrate/SharedOptions.cs @@ -0,0 +1,151 @@ +// 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.Json; +using Elastic.LegacyDocs.Migration; + +namespace Documentation.Migrate; + +internal sealed record FilterOptions(int Majors = 1, bool All = false, int? MinVersion = null, string? Book = null, int? Minors = null); + +internal static class SharedOptions +{ + private const string CloneOptionsFile = ".clone-options.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static readonly DirectoryInfo DefaultWorkDir = ResolveDefaultWorkDir(); + + public static string ResolveWorkDir(string? workDir) => + workDir ?? DefaultWorkDir.FullName; + + private static DirectoryInfo ResolveDefaultWorkDir() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + // Docker/CI containers often have no XDG_DATA_HOME or HOME set, causing + // LocalApplicationData to return "". Fall back to the system temp directory. + if (string.IsNullOrEmpty(localAppData)) + localAppData = Path.GetTempPath(); + return new DirectoryInfo(Path.Join(localAppData, "elastic", "docs-migrate")); + } + + public static async Task LoadConfAsync(string workDir, CancellationToken ct) + { + var confPath = Path.Combine(workDir, "conf.yaml"); + if (!File.Exists(confPath)) + throw new FileNotFoundException($"conf.yaml not found at {confPath}. Run 'docs-migrate init' first."); + + var yaml = await File.ReadAllTextAsync(confPath, ct); + var conf = LegacyConfParser.Parse(yaml); + + if (!conf.Repos.ContainsKey("docs")) + conf.Repos["docs"] = "https://github.com/elastic/docs.git"; + + return conf; + } + + public static void SaveFilterOptions(string workDir, FilterOptions opts) + { + var path = Path.Combine(workDir, CloneOptionsFile); + File.WriteAllText(path, JsonSerializer.Serialize(opts, JsonOptions)); + } + + public static FilterOptions LoadFilterOptions(string workDir) + { + var path = Path.Combine(workDir, CloneOptionsFile); + if (!File.Exists(path)) + return new FilterOptions(); + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOptions) ?? new FilterOptions(); + } + + public static FilterOptions ResolveFilterOptions( + string workDir, int? majors, bool? all, int? minVersion, string? book, int? minors) + { + var saved = LoadFilterOptions(workDir); + + return new FilterOptions( + Majors: majors ?? saved.Majors, + All: all ?? saved.All, + MinVersion: minVersion ?? saved.MinVersion, + Book: book, + Minors: minors ?? saved.Minors + ); + } + + public static List FilterBooks(LegacyConf conf, string? bookFilter) => + conf.Contents + .SelectMany(c => c.Sections) + .Where(b => bookFilter is null || b.Prefix.StartsWith(bookFilter, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + public static List FilterVersions(LegacyBook book, int majors, bool all, int? minVersion, int? minors = null) + { + var branches = book.Branches.ToList(); + + if (minVersion is not null) + branches = branches + .Where(b => TryParseMajorMinor(b.VersionLabel) is var p && p.HasValue && p.Value.Major >= minVersion) + .ToList(); + + if (all) + return EnsureCurrent(book, SortDescending(branches)); + + var selected = new HashSet(StringComparer.OrdinalIgnoreCase); + + var grouped = branches + .Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) + .Where(x => x.Parsed.HasValue) + .GroupBy(x => x.Parsed!.Value.Major) + .OrderByDescending(g => g.Key) + .Take(majors); + + foreach (var group in grouped) + { + var minorsSorted = group.OrderByDescending(x => x.Parsed!.Value.Minor); + var limited = minors is not null ? minorsSorted.Take(minors.Value) : minorsSorted; + foreach (var (branch, _) in limited) + _ = selected.Add(branch.VersionLabel); + } + + var result = branches.Where(b => selected.Contains(b.VersionLabel)).ToList(); + return EnsureCurrent(book, SortDescending(result)); + } + + private static List EnsureCurrent(LegacyBook book, List versions) + { + if (string.IsNullOrEmpty(book.Current)) + return versions; + + if (versions.Any(v => v.VersionLabel == book.Current)) + return versions; + + var currentBranch = book.Branches.FirstOrDefault(b => b.VersionLabel == book.Current) + ?? new BranchRef(book.Current); + + versions.Insert(0, currentBranch); + return versions; + } + + private static List SortDescending(IEnumerable branches) => + branches + .Select(b => (Branch: b, Parsed: TryParseMajorMinor(b.VersionLabel))) + .OrderByDescending(x => x.Parsed?.Major ?? 0) + .ThenByDescending(x => x.Parsed?.Minor ?? 0) + .Select(x => x.Branch) + .ToList(); + + private static (int Major, int Minor)? TryParseMajorMinor(string version) + { + var parts = version.Split('.'); + if (parts.Length >= 2 && int.TryParse(parts[0], out var major) && int.TryParse(parts[1], out var minor)) + return (major, minor); + return null; + } +} diff --git a/src/tooling/docs-migrate/docs-migrate.csproj b/src/tooling/docs-migrate/docs-migrate.csproj new file mode 100644 index 0000000000..0650fdf4a8 --- /dev/null +++ b/src/tooling/docs-migrate/docs-migrate.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + Exe + docs-migrate + Documentation.Migrate + enable + enable + + + + + + + + + + + + + + + diff --git a/tests/Elastic.LegacyDocs.Migration.Tests/Elastic.LegacyDocs.Migration.Tests.csproj b/tests/Elastic.LegacyDocs.Migration.Tests/Elastic.LegacyDocs.Migration.Tests.csproj new file mode 100644 index 0000000000..296e63cc88 --- /dev/null +++ b/tests/Elastic.LegacyDocs.Migration.Tests/Elastic.LegacyDocs.Migration.Tests.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + diff --git a/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs b/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs new file mode 100644 index 0000000000..66ba5804b4 --- /dev/null +++ b/tests/Elastic.LegacyDocs.Migration.Tests/EmitterTests.cs @@ -0,0 +1,307 @@ +// 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.LegacyDocs.Migration.Asciidoc; + +namespace Elastic.LegacyDocs.Migration.Tests; + +public class EmitterTests +{ + private static string Emit(string asciidoc, Dictionary? attrs = null) + { + var parser = new AsciidocParser(new AsciidocParserOptions { Attributes = attrs ?? [] }); + var doc = parser.Parse(asciidoc, ""); + return new MarkdownEmitter(new MarkdownEmitterOptions()).Emit(doc); + } + + // ── Step 4: attribute emission ──────────────────────────────────────────── + + [Fact] + public void UndefinedAttribute_EmittedAsSingleBraces_NotDouble() + { + var md = Emit("= T\n\n{undefined-attr} text\n"); + // Should be {undefined-attr}, NOT {{undefined-attr}} + md.Should().Contain("{undefined-attr}"); + md.Should().NotContain("{{undefined-attr}}"); + } + + [Fact] + public void ProductNameAttribute_EmittedAsDoubleBraces() + { + // {es} is a ProductNames key — should remain {{es}} for docs-builder substitution + var md = Emit("= T\n\n{es} cluster\n"); + md.Should().Contain("{{es}}"); + } + + [Fact] + public void DefinedAttribute_IsSubstituted_NotEmittedAsRef() + { + var md = Emit("= T\n\n:myattr: hello world\n\n{myattr}\n"); + md.Should().Contain("hello world"); + md.Should().NotContain("{myattr}"); + } + + // ── Step 5: callout list in output ─────────────────────────────────────── + + [Fact] + public void CodeBlock_WithCallouts_EmitsOrderedList() + { + var md = Emit("= T\n\n[source,python]\n----\nfoo() # <1>\nbar() # <2>\n----\n<1> Call foo\n<2> Call bar\n"); + md.Should().Contain("1. Call foo"); + md.Should().Contain("2. Call bar"); + } + + // ── Step 7: xref with > in text ────────────────────────────────────────── + + [Fact] + public void CrossRef_Simple_IsEmittedCorrectly() + { + var md = Emit("= T\n\nSee <>.\n"); + md.Should().NotContain("<<"); + md.Should().Contain("[my-anchor](#my-anchor)"); + } + + [Fact] + public void CrossRef_WithText_IsEmittedCorrectly() + { + var md = Emit("= T\n\nSee <>.\n"); + md.Should().NotContain("<<"); + md.Should().Contain("[Click here](#my-anchor)"); + } + + [Fact] + public void CrossRef_WithBacktickInText_IsEmittedCorrectly() + { + var md = Emit("= T\n\nfilters like <> to normalise.\n"); + md.Should().NotContain("<<"); + md.Should().Contain("[`lowercase`]"); + } + + [Fact] + public void CrossRef_InMultiLineParagraph_WithBacktick_IsEmittedCorrectly() + { + // Multi-line paragraph where xref with backtick in text is on one line + var asciidoc = "= T\n\nIt can be combined\nwith token filters like <> to\nnormalise the analysed terms.\n"; + var md = Emit(asciidoc); + md.Should().NotContain("<<"); + md.Should().Contain("[`lowercase`]"); + } + + [Fact] + public void CrossRef_AfterDLItemWithBlankLineSeparator_IsEmittedCorrectly() + { + // Description list where term is followed by blank line, then description paragraph containing xref + var asciidoc = "= T\n\n<>::\n\nIt can be combined with token filters like <> to\nnormalise the analysed terms.\n"; + var md = Emit(asciidoc); + md.Should().NotContain("<<"); + md.Should().Contain("[`lowercase`]"); + } + + [Fact] + public void CrossRef_InMultiLineParagraph_WithCurlyQuotes_IsEmittedCorrectly() + { + // Paragraph using AsciiDoc curly-quotes ``...'' before the xref line + var asciidoc = "= T\n\nIn order to use scrolling, the initial search request should specify the\n`scroll` parameter in the query string, which tells Elasticsearch how long it\nshould keep the ``search context'' alive (see <>), eg `?scroll=1m`.\n"; + var md = Emit(asciidoc); + md.Should().NotContain("<<"); + md.Should().Contain("[scroll-search-context]"); + } + + [Fact] + public void CrossRef_InOrderedListItem_WithBacktickText_IsEmittedCorrectly() + { + // Ordered list item with xref that has backtick-wrapped text (space after comma) + var md = Emit("= T\n\n. Define a runtime field using the <> queries.\n"); + md.Should().NotContain("<<"); + md.Should().Contain("[`term`]"); + } + + [Fact] + public void CrossRef_InOrderedListItem_WithPrecedingCode_IsEmittedCorrectly() + { + // Ordered list item where a `code` span precedes the xref + var md = Emit("= T\n\n. A type of `lookup` that uses the <> query.\n"); + md.Should().NotContain("<<"); + md.Should().Contain("[`term`]"); + } + + [Fact] + public void CrossRef_WithAsteriskInLinkText_IsEmittedCorrectly() + { + // Bold marker * between text and xref text must not consume the << + var md = Emit("= T\n\nSemantic*text field can be target of <>.\n"); + md.Should().NotContain("<<"); + md.Should().Contain("[copy*to fields]"); + } + + [Fact] + public void CrossRef_WithAngleBracketInText_IsEmittedCorrectly() + { + // < b>> — text contains a literal > + var md = Emit("= T\n\nSee < 7.0>>\n"); + md.Should().Contain("[version > 7.0]"); + } + + // ── Passthrough inline (constrained +..+) ───────────────────────────────── + + [Fact] + public void PassthroughInline_BasicCase_EmitsCodeSpan() + { + var md = Emit("= T\n\nDefaults to +max(1, 10)+.\n"); + md.Should().Contain("`max(1, 10)`"); + } + + [Fact] + public void PassthroughInline_VersionSuffix_NotTreatedAsPassthrough() + { + // "8.0+" is a version indicator, NOT a passthrough delimiter — must not swallow following text + var md = Emit("= T\n\nES 8.0+ does not (see <>). ES 8.0+ returns error.\n"); + md.Should().NotContain("<>"); + md.Should().Contain("[removal-of-types](#removal-of-types)"); + md.Should().Contain("8.0+"); + } + + // ── Verbatim include resolution (Step 6) ────────────────────────────────── + + [Fact] + public void VerbatimBlock_StandardTaggedInclude_IsResolved() + { + // include::path[tag=name] inside a code block should be expanded + var fileSystem = new Dictionary + { + ["/specs/string.csv-spec"] = "# tag::my-example[]\nrow1\nrow2\n# end::my-example[]\n" + }; + var parser = new AsciidocParser(new AsciidocParserOptions + { + Attributes = new Dictionary { ["specs"] = "/specs" }, + FileReader = p => fileSystem.TryGetValue(p, out var c) ? c : null + }); + var doc = parser.Parse("= T\n\n[source,esql]\n----\ninclude::{specs}/string.csv-spec[tag=my-example]\n----\n", ""); + var md = new MarkdownEmitter(new MarkdownEmitterOptions()).Emit(doc); + md.Should().Contain("row1"); + md.Should().Contain("row2"); + md.Should().NotContain("include::"); + } + + [Fact] + public void VerbatimBlock_IfevalConditionMarkers_AreStripped() + { + // ifeval/endif markers inside verbatim blocks should be stripped; content is kept + var adoc = "= T\n\n----\n{\nifeval::[\"{trust}\"==\"api-key\"]\n \"credentials\": \"secret\",\nendif::[]\n}\n----\n"; + var md = Emit(adoc); + md.Should().NotContain("ifeval::"); + md.Should().NotContain("endif::"); + md.Should().Contain("\"credentials\": \"secret\","); + } + + // ── Conditional blocks (ifeval) ──────────────────────────────────────────── + + [Fact] + public void Ifeval_FalseCondition_ExcludesContent() + { + // ifeval::[expr] — condition false, block content must be excluded + var adoc = "= T\n\nBefore.\n\nifeval::[\"{stack}\"==\"something-else\"]\nHidden text.\nendif::[]\n\nAfter.\n"; + var md = Emit(adoc, new Dictionary { ["stack"] = "elastic" }); + md.Should().Contain("Before."); + md.Should().Contain("After."); + md.Should().NotContain("Hidden text."); + } + + [Fact] + public void Ifeval_TrueCondition_IncludesContent() + { + // ifeval::[expr] — condition true, block content must be included + var adoc = "= T\n\nBefore.\n\nifeval::[\"{stack}\"==\"elastic\"]\nVisible text.\nendif::[]\n\nAfter.\n"; + var md = Emit(adoc, new Dictionary { ["stack"] = "elastic" }); + md.Should().Contain("Before."); + md.Should().Contain("After."); + md.Should().Contain("Visible text."); + } + + [Fact] + public void CodeBlock_TrailingSpaceOnClosingDelimiter_StillClosesBlock() + { + // Source files sometimes have `---- ` (with trailing space) as the closing delimiter. + // The lexer must treat this as a valid closing delimiter. + var adoc = "= T\n\n[source,json]\n----\n{\"hello\":\"world\"}\n---- \n\n==== Next section\n"; + var md = Emit(adoc); + md.Should().Contain("```json"); + md.Should().Contain(/*lang=json,strict*/ "{\"hello\":\"world\"}"); + // If the block closes, Next section becomes a real heading; if not, it's inside code block + md.Should().NotContain("==== Next section"); // raw AsciiDoc must not appear + } + + [Fact] + public void Ifeval_ContentAfterBlock_NotLeaked_WhenConditionFalse() + { + // Regression: ConditionalProcessor was not pushing to the stack for ifeval, + // causing all content after the ifeval block to leak through unconditionally. + var adoc = "= T\n\nifeval::[\"{trust}\"==\"api-key\"]\nSecret\nendif::[]\n\nPublic paragraph.\n"; + var md = Emit(adoc, new Dictionary { ["trust"] = "cert" }); + md.Should().NotContain("Secret"); + md.Should().Contain("Public paragraph."); + } + + // ── Long dash delimiter (50 dashes) ────────────────────────────────────── + + [Fact] + public void CodeBlock_LongDashDelimiter_IsTreatedAsCodeFence() + { + // Watcher 2.4 docs use 50-dash lines as code block delimiters (valid AsciiDoc: 4+ dashes). + var adoc = "= T\n\n[source,js]\n--------------------------------------------------\n\"input\": {}\n--------------------------------------------------\n\nNormal paragraph.\n"; + var md = Emit(adoc); + md.Should().Contain("```"); + md.Should().Contain("\"input\": {}"); + md.Should().NotContain("--------------------------------------------------"); + md.Should().Contain("Normal paragraph."); + } + + [Fact] + public void CodeBlock_LongDashDelimiter_InNestedSection_IsTreatedAsCodeFence() + { + // Watcher 2.4 docs: code blocks with 50-dash delimiters inside nested sections (== > === > ====). + var adoc = string.Join("\n", + "= Doc", + "", + "[[top]]", + "== Level1", + "", + "[[sec2]]", + "=== Level2", + "", + "[[sec3]]", + "==== Level3", + "", + "Some text:", + "", + "[source,js]", + "--------------------------------------------------", + "\"key\": \"value\"", + "--------------------------------------------------", + "", + "Normal paragraph.", + ""); + var md = Emit(adoc); + md.Should().Contain("```"); + md.Should().Contain("\"key\": \"value\""); + md.Should().NotContain("--------------------------------------------------"); + md.Should().Contain("Normal paragraph."); + } + + // ── Open block delimiter trailing whitespace ─────────────────────────────── + + [Fact] + public void OpenBlock_TrailingSpaceOnOpeningDelimiter_StillClosesBlock() + { + // Source files can have `-- ` (trailing spaces) as the opening `--` delimiter. + // The parser must not leave the block unclosed because openingDelim = "-- " ≠ "--". + var adoc = "= T\n\n.Requirements\n[sidebar]\n-- \n* req one\n--\n\nNormal paragraph.\n"; + var md = Emit(adoc); + md.Should().Contain("req one"); + md.Should().Contain("Normal paragraph."); + // The trailing `--` must NOT appear raw in the output + md.Should().NotContain("\n--\n"); + } +} diff --git a/tests/Elastic.LegacyDocs.Migration.Tests/LexerTests.cs b/tests/Elastic.LegacyDocs.Migration.Tests/LexerTests.cs new file mode 100644 index 0000000000..31ce8d9795 --- /dev/null +++ b/tests/Elastic.LegacyDocs.Migration.Tests/LexerTests.cs @@ -0,0 +1,47 @@ +// 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.LegacyDocs.Migration.Asciidoc; + +namespace Elastic.LegacyDocs.Migration.Tests; + +public class LexerTests +{ + [Fact] + public void OpenBlock_DashDash_IsNotVerbatim() + { + var tokens = AsciidocLexer.Tokenize("--\nSome content\n--"); + var delimiters = tokens.Where(t => t.Type == TokenType.BlockDelimiter).ToList(); + + delimiters.Should().HaveCount(2); + // Both are regular (non-verbatim) block delimiters + var text = tokens.Where(t => t.Type == TokenType.Text).ToList(); + text.Should().HaveCount(1); + text[0].Raw.Should().Be("Some content"); + } + + [Fact] + public void VerbatimBlock_FourDashes_IsVerbatim() + { + var tokens = AsciidocLexer.Tokenize("----\n<1> callout marker\n----"); + var text = tokens.Where(t => t.Type == TokenType.Text).ToList(); + + // Content inside ---- block is raw text (verbatim) + text.Should().HaveCount(1); + text[0].Raw.Should().Be("<1> callout marker"); + } + + [Fact] + public void ClosingDelimiter_RequiresExactLength() + { + // A longer closing delimiter should NOT close the block + var content = "----\nfoo\n--------\nbar\n----"; + var tokens = AsciidocLexer.Tokenize(content); + var textTokens = tokens.Where(t => t.Type == TokenType.Text).ToList(); + + // "foo", "--------" (not a valid close), and "bar" should all be Text inside the block + textTokens.Should().HaveCount(3); + } +} diff --git a/tests/Elastic.LegacyDocs.Migration.Tests/ParserTests.cs b/tests/Elastic.LegacyDocs.Migration.Tests/ParserTests.cs new file mode 100644 index 0000000000..66831c57db --- /dev/null +++ b/tests/Elastic.LegacyDocs.Migration.Tests/ParserTests.cs @@ -0,0 +1,373 @@ +// 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.LegacyDocs.Migration.Asciidoc; +using Elastic.LegacyDocs.Migration.Asciidoc.Ast; + +namespace Elastic.LegacyDocs.Migration.Tests; + +public class ParserTests +{ + /// + /// Parse without a section title — children go directly into doc.Children. + /// Note: in this codebase, `== Title` (Level 1) sets doc.Title and makes subsequent + /// content direct doc.Children; `= Title` (Level 0) creates a Level-0 SectionNode wrapper. + /// + private static AsciidocDocument Parse(string content, Dictionary? attrs = null) => + new AsciidocParser(new AsciidocParserOptions { Attributes = attrs ?? [] }) + .Parse(content, ""); + + /// Recursively finds the first node of type T in the document tree. + private static T? FindFirst(IEnumerable nodes) where T : class + { + foreach (var node in nodes) + { + if (node is T found) + return found; + if (node is SectionNode s) + { var r = FindFirst(s.Children); if (r is not null) return r; } + if (node is OpenBlockNode o) + { var r = FindFirst(o.Children); if (r is not null) return r; } + if (node is AdmonitionNode a) + { var r = FindFirst(a.Children); if (r is not null) return r; } + } + return null; + } + + private static T? FindFirst(AsciidocDocument doc) where T : class => + FindFirst(doc.Children); + + // ── Step 1: -- open blocks ──────────────────────────────────────────────── + + [Fact] + public void OpenBlock_DashDash_ProducesOpenBlockNode() + { + // No section title — direct doc.Children + var doc = Parse("--\nSome content\n--\n"); + var block = FindFirst(doc); + block.Should().NotBeNull(); + block.Children.Should().HaveCountGreaterThan(0); + } + + [Fact] + public void OpenBlock_NoteStyle_ProducesAdmonitionNode() + { + var doc = Parse("[NOTE]\n--\nNote content here.\n--\n"); + var admonition = FindFirst(doc); + admonition.Should().NotBeNull(); + admonition.Type.Should().Be(AdmonitionType.Note); + } + + [Fact] + public void VerbatimBlock_FourDashes_ProducesCodeBlockNode() + { + var doc = Parse("[source,yaml]\n----\nfoo: bar\n----\n"); + var code = FindFirst(doc); + code.Should().NotBeNull(); + code.Source.Should().Be("foo: bar"); + code.Language.Should().Be("yaml"); + } + + [Fact] + public void OpenBlock_And_VerbatimBlock_AreDifferentNodeTypes() + { + // -- open block should NOT produce a CodeBlockNode + var openDoc = Parse("--\ncontent\n--\n"); + FindFirst(openDoc).Should().BeNull(); + FindFirst(openDoc).Should().NotBeNull(); + + // ---- verbatim block should NOT produce an OpenBlockNode + var codeDoc = Parse("----\ncontent\n----\n"); + FindFirst(codeDoc).Should().BeNull(); + FindFirst(codeDoc).Should().NotBeNull(); + } + + // ── Step 3: Attribute resolution ───────────────────────────────────────── + + [Fact] + public void SetAttribute_EagerlyExpandsValues() + { + // :branch: 8.19 + // :ref: https://example.com/{branch} + // After eager expansion, {ref} should be the fully resolved URL + var content = ":branch: 8.19\n:ref: https://example.com/{branch}\n\nSee {ref}/setup.html\n"; + var doc = Parse(content); + doc.Attributes.TryGetValue("ref", out var refValue).Should().BeTrue(); + refValue.Should().Be("https://example.com/8.19"); + } + + [Fact] + public void SetAttribute_ProductNameKeys_AreNotStored() + { + // ProductNames keys should stay unresolved so the emitter emits {{es}} not "Elasticsearch" + var content = ":es: Elasticsearch Override\n\n{es}\n"; + var doc = Parse(content); + // The parser skips storing ProductNames keys, so {es} remains unresolved → AttributeRefInline + var para = doc.Children.OfType().FirstOrDefault(); + para.Should().NotBeNull(); + var attrRef = para.Inlines.OfType().FirstOrDefault(); + attrRef.Should().NotBeNull(); + attrRef.Name.Should().Be("es"); + } + + [Fact] + public void AttributeResolution_SeedAttributes_AreAvailable() + { + var attrs = new Dictionary + { + ["branch"] = "8.19", + ["docs-root"] = "/work/docs-repo" + }; + var content = "Branch is {branch}\n"; + var parser = new AsciidocParser(new AsciidocParserOptions { Attributes = attrs }); + var doc = parser.Parse(content, ""); + var para = doc.Children.OfType().FirstOrDefault(); + var text = para?.Inlines.OfType().FirstOrDefault(); + text?.Text.Should().Contain("8.19"); + } + + // ── Step 5: Callouts ───────────────────────────────────────────────────── + + [Fact] + public void CodeBlock_Callouts_AreCollected() + { + var content = "[source,java]\n----\nfoo(); // <1>\nbar(); // <2>\n----\n<1> First callout\n<2> Second callout\n"; + var doc = Parse(content); + var code = FindFirst(doc); + code.Should().NotBeNull(); + code.Callouts.Should().HaveCount(2); + code.Callouts[0].Should().Be("First callout"); + code.Callouts[1].Should().Be("Second callout"); + } + + [Fact] + public void CodeBlock_CalloutsOutOfOrder_AreNormalized() + { + // Callout markers appear in document order by number + var content = "----\nfoo <2>\nbar <1>\n----\n<1> Bar annotation\n<2> Foo annotation\n"; + var doc = Parse(content); + var code = FindFirst(doc); + code.Should().NotBeNull(); + code.Callouts.Should().HaveCount(2); + code.Callouts[0].Should().Be("Bar annotation"); + code.Callouts[1].Should().Be("Foo annotation"); + } + + // ── Step 7: Multi-line admonitions ─────────────────────────────────────── + + [Fact] + public void AdmonitionParagraph_MultiLine_CollectsAllContent() + { + var content = "NOTE: First line\nSecond line\nThird line\n\nNext paragraph\n"; + var doc = Parse(content); + var admonition = doc.Children.OfType().FirstOrDefault(); + admonition.Should().NotBeNull(); + // All three lines should be inside the admonition's paragraph + var para = admonition.Children.OfType().FirstOrDefault(); + para.Should().NotBeNull(); + var allText = string.Join("", para.Inlines.Select(i => i switch + { + TextInline t => t.Text, + _ => "" + })); + allText.Should().Contain("First line"); + allText.Should().Contain("Second line"); + allText.Should().Contain("Third line"); + } + + // ── Lexer: trailing whitespace on delimiter ─────────────────────────────── + + [Fact] + public void Lexer_VerbatimBlock_TrailingSpaceOnClosingDelimiter_ClosesBlock() + { + var input = "[source,json]\n----\n{\"k\":\"v\"}\n---- \n\nsome text\n"; + var tokens = AsciidocLexer.Tokenize(input); + // The `---- ` line must be a closing BlockDelimiter, not Text + var textTokens = tokens.Where(t => t.Type == TokenType.Text).Select(t => t.Raw).ToList(); + textTokens.Should().NotContain("---- "); // closing delim must NOT appear as Text + // The JSON content should appear as Text + textTokens.Should().Contain(/*lang=json,strict*/ "{\"k\":\"v\"}"); + } + + // ── Step 2: include:: dispatch in ParseBlock ────────────────────────────── + + [Fact] + public void IncludeDirective_InsideDelimitedBlock_IsResolvedWhenFileExists() + { + var files = new Dictionary + { + ["/base/inner.adoc"] = "included content" + }; + var content = "[NOTE]\n====\ninclude::inner.adoc[]\n====\n"; + var parser = new AsciidocParser(new AsciidocParserOptions + { + FileReader = path => files.TryGetValue(path, out var c) ? c : null + }); + var doc = parser.Parse(content, "/base"); + var admonition = doc.Children.OfType().FirstOrDefault(); + admonition.Should().NotBeNull(); + admonition.Children.Should().HaveCountGreaterThan(0); + } + + [Fact] + public void Parse_file_starting_with_level1_section_promotes_it_to_doc_title() + { + // When a file starts with a == section (Level 1 in AST), Parse() treats it as doc.Title. + // The === subsections become top-level doc.Children (not nested under a SectionNode). + // ProcessInclude uses a different loop that does NOT promote == to doc.Title — it creates + // a SectionNode — so included files behave correctly during chunking. + const string source = """ + == The search API + + Some intro text. + + [discrete] + [[run-an-es-search]] + === Run a search + + Run search content. + + [discrete] + [[common-search-options]] + === Common search options + + Common options content. + """; + + var parser = new AsciidocParser(new AsciidocParserOptions()); + var doc = parser.Parse(source, "."); + + // The == section becomes the document title, not a SectionNode child + doc.Title.Should().Be("The search API"); + + // The === subsections appear at the top level (they're children of the document, not the == section) + var sectionChildren = doc.Children.OfType().ToList(); + sectionChildren.Should().HaveCount(2); + sectionChildren[0].Level.Should().Be(2); + sectionChildren[0].Title.Should().Be("Run a search"); + sectionChildren[1].Level.Should().Be(2); + sectionChildren[1].Title.Should().Be("Common search options"); + } + + [Fact] + public void ChunkLevel2_keeps_level3_within_level2_page() + { + const string source = """ + = Book Title + + [[search-your-data]] + == The search API + + Some intro text. + + [discrete] + [[run-an-es-search]] + === Run a search + + Run search content. + + [discrete] + [[common-search-options]] + === Common search options + + Common options content. + """; + + var parser = new AsciidocParser(new AsciidocParserOptions()); + var doc = parser.Parse(source, "."); + + var emitter = new MarkdownEmitter(new MarkdownEmitterOptions { BookPrefix = "test", Version = "1.0" }); + // chunkLevel=1 matches conf.yaml chunk:1 — extracts == (Level 1) sections, keeps === within them + var pages = PageChunker.Chunk(doc, chunkLevel: 1, emitter); + + // Should produce: index + 1 page for "The search API" + pages.Should().HaveCount(2); + var searchApiPage = pages.First(p => p.Slug == "search-your-data"); + searchApiPage.MarkdownContent.Should().Contain("Run a search"); + searchApiPage.MarkdownContent.Should().Contain("Common search options"); + } + + [Fact] + public void IncludeChain_EachIncludedFile_BecomesASeparatePage() + { + // Mirrors the elastic.co search-your-data structure: + // - index.adoc includes search-your-data.adoc (= level, chunk boundary) + // - search-your-data.adoc has inline discrete === section (stays in its page) + // - search-your-data.adoc includes search-api.adoc (== level, chunk boundary) + // - search-api.adoc includes sort-results.adoc (=== level, still chunk boundary) + var files = new Dictionary + { + ["/base/index.adoc"] = """ + = Elasticsearch Guide + + include::search-your-data.adoc[] + """, + ["/base/search-your-data.adoc"] = """ + [[search-with-elasticsearch]] + = Search your data + + Intro paragraph. + + [discrete] + === Run a search + + Inline section content. + + include::search-api.adoc[] + """, + ["/base/search-api.adoc"] = """ + [[search-your-data-api]] + == The search API + + API intro. + + [discrete] + === API Run a search + + Inline API section. + + include::sort-results.adoc[] + """, + ["/base/sort-results.adoc"] = """ + [[sort-results]] + === Sort search results + + Sort content. + """, + }; + + var parser = new AsciidocParser(new AsciidocParserOptions + { + FileReader = path => files.TryGetValue(path, out var c) ? c : null + }); + var doc = parser.Parse(files["/base/index.adoc"], "/base"); + var emitter = new MarkdownEmitter(new MarkdownEmitterOptions { BookPrefix = "test", Version = "1.0" }); + var pages = PageChunker.Chunk(doc, chunkLevel: 1, emitter); + + var slugs = pages.Select(p => p.Slug).ToList(); + + // index (from = Elasticsearch Guide root) + slugs.Should().Contain("index"); + + // = Search your data → its own page + slugs.Should().Contain("search-with-elasticsearch"); + var searchYourData = pages.First(p => p.Slug == "search-with-elasticsearch"); + searchYourData.MarkdownContent.Should().Contain("Intro paragraph"); + searchYourData.MarkdownContent.Should().Contain("Run a search"); // inline section stays + searchYourData.MarkdownContent.Should().NotContain("The search API"); // NOT merged into this page + + // == The search API → its own page + slugs.Should().Contain("search-your-data-api"); + var theSearchApi = pages.First(p => p.Slug == "search-your-data-api"); + theSearchApi.MarkdownContent.Should().Contain("API intro"); + theSearchApi.MarkdownContent.Should().Contain("API Run a search"); // inline stays + theSearchApi.MarkdownContent.Should().NotContain("Sort search results"); // NOT merged + + // === Sort search results → its own page (included file, even though === level) + slugs.Should().Contain("sort-results"); + var sortResults = pages.First(p => p.Slug == "sort-results"); + sortResults.MarkdownContent.Should().Contain("Sort content"); + } +}