diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index d47364b566..9ad3b74072 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -201,12 +201,45 @@ public static List GetAnchors( IReadOnlyDictionary subs, out string[] anchors) { - var includeBlocks = document.Descendants().ToArray(); - var includes = includeBlocks - .Where(i => i.Found) - .Select(i => + // Single traversal — collects typed lists in DFS order. + // We also track the last heading seen so that IncludeBlocks can be annotated + // with their preceding heading level at discovery time, eliminating any need for + // a position index or secondary traversal. + // IncludeBlock / StepBlock / ChangelogBlock / SettingsBlock all extend DirectiveBlock, + // so one bucket covers them all. + List headings = []; + List directives = []; + List inlineAnchors = []; + // Pairs each IncludeBlock with the heading level that immediately precedes it in + // DFS order — recorded inline so we never need to re-traverse. + List<(IncludeBlock Block, int? PrecedingHeadingLevel)> includeContexts = []; + int? lastHeadingLevel = null; + foreach (var node in document.Descendants()) + { + switch (node) + { + case HeadingBlock h: + headings.Add(h); + lastHeadingLevel = h.Level; + break; + case IncludeBlock inc: + directives.Add(inc); + includeContexts.Add((inc, lastHeadingLevel)); + break; + case DirectiveBlock d: + directives.Add(d); + break; + case InlineAnchor a: + inlineAnchors.Add(a); + break; + } + } + + var includes = includeContexts + .Where(t => t.Block.Found) + .Select(t => { - var relativePath = i.IncludePathRelativeToSource; + var relativePath = t.Block.IncludePathRelativeToSource; if (relativePath is null) return null; var doc = documentationFileLookup(relativePath); @@ -214,7 +247,7 @@ public static List GetAnchors( return null; var anchors = snippet.GetAnchors(collector, documentationFileLookup, parser, frontMatter); - return new { Block = i, Anchors = anchors }; + return new { t.Block, Anchors = anchors, t.PrecedingHeadingLevel }; }) .Where(i => i is not null) .ToArray(); @@ -222,8 +255,7 @@ public static List GetAnchors( var includedTocs = includes .SelectMany(i => { - // Calculate the heading level context at the include block position - var precedingLevel = GetPrecedingHeadingLevel(i!.Block); + var precedingLevel = i!.PrecedingHeadingLevel; return i.Anchors!.TableOfContentItems .Select(item => @@ -243,9 +275,8 @@ public static List GetAnchors( }) .ToArray(); - // Collect headings from standard markdown - var headingTocs = document - .Descendants() + // Collect headings from standard markdown (already have the list — no second traversal) + var headingTocs = headings .Where(block => block is { Level: >= 2 }) .Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level, h.Line)) .Where(h => h.Item1 is not null) @@ -264,9 +295,8 @@ public static List GetAnchors( }; }); - // Collect headings from Stepper steps - var stepperTocs = document - .Descendants() + // Collect headings from Stepper steps (filter from already-collected directives) + var stepperTocs = directives .OfType() .Where(step => !string.IsNullOrEmpty(step.Title)) .Where(step => !IsNestedInOtherDirective(step)) @@ -291,15 +321,13 @@ public static List GetAnchors( }); // Collect headings from Changelog directives - var changelogTocs = document - .Descendants() + var changelogTocs = directives .OfType() .SelectMany(changelog => changelog.GeneratedTableOfContent .Select(tocItem => new { TocItem = tocItem, changelog.Line })); // Collect settings group headings (h2) from {settings} directives - var settingsTocs = document - .Descendants() + var settingsTocs = directives .OfType() .Where(settings => !IsNestedInOtherDirective(settings)) .SelectMany(settings => settings.GeneratedTableOfContent @@ -320,7 +348,6 @@ public static List GetAnchors( .ToList(); var includedAnchors = includes.SelectMany(i => i!.Anchors!.Anchors).ToArray(); - var directives = document.Descendants().ToArray(); anchors = [ ..directives @@ -328,7 +355,7 @@ public static List GetAnchors( .Where(l => !string.IsNullOrWhiteSpace(l)) .Select(s => s.Slugify()) .Concat(directives.SelectMany(b => b.GeneratedAnchors)) - .Concat(document.Descendants().Select(a => a.Anchor)) + .Concat(inlineAnchors.Select(a => a.Anchor)) .Concat(toc.Select(t => t.Slug)) .Where(anchor => !string.IsNullOrEmpty(anchor)) .Concat(includedAnchors) @@ -348,38 +375,6 @@ private static bool IsNestedInOtherDirective(DirectiveBlock block) return false; } - /// - /// Finds the heading level that precedes the given block in the document. - /// Used to provide context for included snippets so stepper heading levels - /// can be adjusted relative to the parent document's structure. - /// - private static int? GetPrecedingHeadingLevel(MarkdownObject block) - { - // Find the document root - var current = block; - while (current is ContainerBlock container && container.Parent != null) - current = container.Parent; - - if (current is not ContainerBlock root) - return null; - - // Find all blocks and locate this one - var allBlocks = root.Descendants().ToList(); - var thisIndex = allBlocks.IndexOf(block); - - if (thisIndex == -1) - return null; - - // Look backwards for the most recent heading - for (var i = thisIndex - 1; i >= 0; i--) - { - if (allBlocks[i] is HeadingBlock heading) - return heading.Level; - } - - return null; - } - private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document) { if (document.FirstOrDefault() is not YamlFrontMatterBlock yaml) diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs index 5f78c6c6f7..f0e4128603 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs @@ -997,3 +997,91 @@ public void StepperStepsAtDocumentStartDefaultToH2() toc[2].Level.Should().Be(2); } } + +/// +/// Exercises GetPrecedingHeadingLevel with multiple includes at different positions in the document. +/// This directly guards the single-pass position index: if the index is wrong, the stepper levels +/// in the second include will reflect the first include's heading context instead of the correct one. +/// +public class MultipleIncludesInterleavedWithHeadingsTests(ITestOutputHelper output) : DirectiveTest(output, +""" +## Section A + +:::{include} _snippets/stepper-a.md +::: + +## Section B + +:::{include} _snippets/stepper-b.md +::: +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + // Stepper snippet — step level depends on preceding heading (## = level 2, step should become level 3) + fileSystem.AddFile(@"docs/_snippets/stepper-a.md", + """ + :::::{stepper} + + ::::{step} Step A1 + content + :::: + + ::::{step} Step A2 + content + :::: + + ::::: + """); + + fileSystem.AddFile(@"docs/_snippets/stepper-b.md", + """ + :::::{stepper} + + ::::{step} Step B1 + content + :::: + + ::::{step} Step B2 + content + :::: + + ::::: + """); + } + + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void GetPrecedingHeadingLevel_UsesCorrectContextForEachInclude() + { + var toc = File.PageTableOfContent.Values.ToList(); + + // Expected order: Section A, Step A1, Step A2, Section B, Step B1, Step B2 + toc.Should().HaveCount(6); + + toc[0].Heading.Should().Be("Section A"); + toc[0].Level.Should().Be(2); + + toc[1].Heading.Should().Be("Step A1"); + toc[1].IsStepperStep.Should().BeTrue(); + toc[1].Level.Should().Be(3, "stepper step after h2 should be h3"); + + toc[2].Heading.Should().Be("Step A2"); + toc[2].IsStepperStep.Should().BeTrue(); + toc[2].Level.Should().Be(3); + + toc[3].Heading.Should().Be("Section B"); + toc[3].Level.Should().Be(2); + + toc[4].Heading.Should().Be("Step B1"); + toc[4].IsStepperStep.Should().BeTrue(); + toc[4].Level.Should().Be(3, "stepper step after second h2 should also be h3"); + + toc[5].Heading.Should().Be("Step B2"); + toc[5].IsStepperStep.Should().BeTrue(); + toc[5].Level.Should().Be(3); + } +}