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($"");
+ 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($"");
+ 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