Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 47 additions & 52 deletions src/Elastic.Markdown/IO/MarkdownFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,29 +201,61 @@ public static List<PageTocItem> GetAnchors(
IReadOnlyDictionary<string, string> subs,
out string[] anchors)
{
var includeBlocks = document.Descendants<IncludeBlock>().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<HeadingBlock> headings = [];
List<DirectiveBlock> directives = [];
List<InlineAnchor> 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);
if (doc is not SnippetFile snippet)
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();

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 =>
Expand All @@ -243,9 +275,8 @@ public static List<PageTocItem> GetAnchors(
})
.ToArray();

// Collect headings from standard markdown
var headingTocs = document
.Descendants<HeadingBlock>()
// 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)
Expand All @@ -264,9 +295,8 @@ public static List<PageTocItem> GetAnchors(
};
});

// Collect headings from Stepper steps
var stepperTocs = document
.Descendants<DirectiveBlock>()
// Collect headings from Stepper steps (filter from already-collected directives)
var stepperTocs = directives
.OfType<StepBlock>()
.Where(step => !string.IsNullOrEmpty(step.Title))
.Where(step => !IsNestedInOtherDirective(step))
Expand All @@ -291,15 +321,13 @@ public static List<PageTocItem> GetAnchors(
});

// Collect headings from Changelog directives
var changelogTocs = document
.Descendants<DirectiveBlock>()
var changelogTocs = directives
.OfType<ChangelogBlock>()
.SelectMany(changelog => changelog.GeneratedTableOfContent
.Select(tocItem => new { TocItem = tocItem, changelog.Line }));

// Collect settings group headings (h2) from {settings} directives
var settingsTocs = document
.Descendants<DirectiveBlock>()
var settingsTocs = directives
.OfType<SettingsBlock>()
.Where(settings => !IsNestedInOtherDirective(settings))
.SelectMany(settings => settings.GeneratedTableOfContent
Expand All @@ -320,15 +348,14 @@ public static List<PageTocItem> GetAnchors(
.ToList();

var includedAnchors = includes.SelectMany(i => i!.Anchors!.Anchors).ToArray();
var directives = document.Descendants<DirectiveBlock>().ToArray();
anchors =
[
..directives
.Select(b => b.CrossReferenceName)
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(s => s.Slugify())
.Concat(directives.SelectMany(b => b.GeneratedAnchors))
.Concat(document.Descendants<InlineAnchor>().Select(a => a.Anchor))
.Concat(inlineAnchors.Select(a => a.Anchor))
.Concat(toc.Select(t => t.Slug))
.Where(anchor => !string.IsNullOrEmpty(anchor))
.Concat(includedAnchors)
Expand All @@ -348,38 +375,6 @@ private static bool IsNestedInOtherDirective(DirectiveBlock block)
return false;
}

/// <summary>
/// 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.
/// </summary>
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)
Expand Down
88 changes: 88 additions & 0 deletions tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -997,3 +997,91 @@ public void StepperStepsAtDocumentStartDefaultToH2()
toc[2].Level.Should().Be(2);
}
}

/// <summary>
/// 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.
/// </summary>
public class MultipleIncludesInterleavedWithHeadingsTests(ITestOutputHelper output) : DirectiveTest<IncludeBlock>(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);
}
}
Loading