diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index f5bc182ff..232de7a35 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -211,8 +211,27 @@ public static List GetAnchors( .ToArray(); var includedTocs = includes - .SelectMany(i => i!.Anchors!.TableOfContentItems - .Select(item => new { TocItem = item, i.Block.Line })) + .SelectMany(i => + { + // Calculate the heading level context at the include block position + var precedingLevel = GetPrecedingHeadingLevel(i!.Block); + + return i.Anchors!.TableOfContentItems + .Select(item => + { + // Only adjust stepper steps, not regular headings + // Stepper steps default to level 2 when parsed in isolation (no preceding heading in snippet), + // but should be relative to the preceding heading in the parent document + var adjustedItem = item; + if (item.IsStepperStep && precedingLevel.HasValue && item.Level == 2) + { + // The step was parsed without context (defaulted to h2) + // Adjust it to be one level deeper than the preceding heading + adjustedItem = item with { Level = Math.Min(precedingLevel.Value + 1, 6) }; + } + return new { TocItem = adjustedItem, i.Block.Line }; + }); + }) .ToArray(); // Collect headings from standard markdown @@ -255,7 +274,8 @@ public static List GetAnchors( { Heading = processedTitle, Slug = step.Anchor, - Level = step.HeadingLevel // Use dynamic heading level + Level = step.HeadingLevel, // Use dynamic heading level + IsStepperStep = true }, step.Line }; @@ -300,6 +320,38 @@ 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/src/Elastic.Markdown/MarkdownLayoutViewModel.cs b/src/Elastic.Markdown/MarkdownLayoutViewModel.cs index 9f740d631..5793cb124 100644 --- a/src/Elastic.Markdown/MarkdownLayoutViewModel.cs +++ b/src/Elastic.Markdown/MarkdownLayoutViewModel.cs @@ -37,4 +37,11 @@ public record PageTocItem public required string Heading { get; init; } public required string Slug { get; init; } public required int Level { get; init; } + + /// + /// Indicates if this ToC item came from a stepper step. + /// Used to distinguish stepper steps (which may need level adjustment in includes) + /// from regular headings (which should preserve their levels). + /// + public bool IsStepperStep { get; init; } } diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs index 100dd299b..8d58c067d 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/HeadingOrderTests.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions.TestingHelpers; using Elastic.Markdown.IO; using Elastic.Markdown.Myst.Directives.Include; +using Elastic.Markdown.Myst.Directives.Stepper; using Elastic.Markdown.Tests.Directives; using FluentAssertions; @@ -218,6 +219,22 @@ public void TableOfContentsRespectsOrderWithIncludeInMiddle() var actualOrder = toc.Select(t => t.Heading).ToArray(); actualOrder.Should().Equal(expectedOrder); } + + [Fact] + public void HeadingLevelsArePreservedFromSnippet() + { + // Verify that real h2/h3 headings from snippets keep their original levels + // and are NOT adjusted based on parent document context + var toc = File.PageTableOfContent.Values.ToList(); + + // Five is ## in the snippet, should stay level 2 + toc[4].Heading.Should().Be("Five"); + toc[4].Level.Should().Be(2, "h2 from snippet should remain level 2, not be adjusted"); + + // Six is ### in the snippet, should stay level 3 + toc[5].Heading.Should().Be("Six"); + toc[5].Level.Should().Be(3, "h3 from snippet should remain level 3"); + } } public class IncludeWithStepperOrderTests(ITestOutputHelper output) : DirectiveTest(output, """ @@ -402,3 +419,477 @@ public void TableOfContentsRespectsOrderWithStepperBeforeInclude() actualOrder.Should().Equal(expectedOrder); } } + +/// +/// Tests that stepper steps in included snippets inherit the correct heading level +/// from the parent document's context. This is the key test for the DocumentTraversal fix. +/// +public class StepperInIncludeHeadingLevelTests(ITestOutputHelper output) : DirectiveTest(output, +""" +## Main Heading + +Some intro content. + +:::{include} _snippets/stepper-snippet.md +::: + +## Another Section +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + // The stepper in this snippet should get heading level 3 (one deeper than the ## heading before the include) + var inclusion = """ +:::::{stepper} + +::::{step} Step One +First step content. +:::: + +::::{step} Step Two +Second step content. +:::: + +::::: +"""; + fileSystem.AddFile(@"docs/_snippets/stepper-snippet.md", inclusion); + } + + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void StepperStepsInSnippetInheritCorrectHeadingLevel() + { + // Get the table of contents + var toc = File.PageTableOfContent.Values.ToList(); + + // Should have: Main Heading (2), Step One (3), Step Two (3), Another Section (2) + toc.Should().HaveCount(4); + + // Verify headings + toc[0].Heading.Should().Be("Main Heading"); + toc[0].Level.Should().Be(2); + + // Key assertion: stepper steps from included snippet should be level 3 + // (one level deeper than the ## Main Heading that precedes the include) + toc[1].Heading.Should().Be("Step One"); + toc[1].Level.Should().Be(3, "stepper step in snippet should inherit heading level from parent document"); + + toc[2].Heading.Should().Be("Step Two"); + toc[2].Level.Should().Be(3, "stepper step in snippet should inherit heading level from parent document"); + + toc[3].Heading.Should().Be("Another Section"); + toc[3].Level.Should().Be(2); + } +} + +/// +/// Tests stepper heading levels with a deeper heading context (### before include). +/// +public class StepperInIncludeWithH3ContextTests(ITestOutputHelper output) : DirectiveTest(output, +""" +## Main Heading + +### Sub Heading + +Some content under sub heading. + +:::{include} _snippets/stepper-snippet.md +::: + +## Another Section +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + // The stepper in this snippet should get heading level 4 (one deeper than the ### heading before the include) + var inclusion = """ +:::::{stepper} + +::::{step} Deep Step +Step content. +:::: + +::::: +"""; + fileSystem.AddFile(@"docs/_snippets/stepper-snippet.md", inclusion); + } + + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void StepperStepsInSnippetInheritDeeperHeadingLevel() + { + var toc = File.PageTableOfContent.Values.ToList(); + + // Should have: Main Heading (2), Sub Heading (3), Deep Step (4), Another Section (2) + toc.Should().HaveCount(4); + + toc[0].Heading.Should().Be("Main Heading"); + toc[0].Level.Should().Be(2); + + toc[1].Heading.Should().Be("Sub Heading"); + toc[1].Level.Should().Be(3); + + // Key assertion: step should be level 4 (one deeper than ### Sub Heading) + toc[2].Heading.Should().Be("Deep Step"); + toc[2].Level.Should().Be(4, "stepper step should be one level deeper than the ### heading before the include"); + + toc[3].Heading.Should().Be("Another Section"); + toc[3].Level.Should().Be(2); + } +} + +/// +/// Tests stepper in snippet when there's no preceding heading (should default to h2). +/// +public class StepperInIncludeWithNoHeadingContextTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{include} _snippets/stepper-snippet.md +::: + +## After Include +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + var inclusion = """ +:::::{stepper} + +::::{step} First Step +No heading before this include. +:::: + +::::: +"""; + fileSystem.AddFile(@"docs/_snippets/stepper-snippet.md", inclusion); + } + + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void StepperStepsDefaultToH2WhenNoHeadingContext() + { + var toc = File.PageTableOfContent.Values.ToList(); + + // Should have: First Step (2), After Include (2) + toc.Should().HaveCount(2); + + // With no preceding heading, stepper should default to level 2 + toc[0].Heading.Should().Be("First Step"); + toc[0].Level.Should().Be(2, "stepper step should default to h2 when no preceding heading"); + + toc[1].Heading.Should().Be("After Include"); + toc[1].Level.Should().Be(2); + } +} + +/// +/// Tests that stepper steps in snippets respect their own snippet's heading structure +/// and are NOT adjusted when the snippet has its own preceding heading. +/// +public class StepperInSnippetWithOwnHeadingTests(ITestOutputHelper output) : DirectiveTest(output, +""" +## Parent Heading + +:::{include} _snippets/stepper-snippet.md +::: + +## Another Parent Heading +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + // The snippet has its own h2 heading before the stepper + // The stepper step should be level 3 (based on snippet's own h2), NOT adjusted by parent + var inclusion = """ +## Snippet Heading + +:::::{stepper} + +::::{step} Step In Snippet +Step content. +:::: + +::::: +"""; + fileSystem.AddFile(@"docs/_snippets/stepper-snippet.md", inclusion); + } + + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void StepperStepRespectsSnippetOwnHeadingStructure() + { + var toc = File.PageTableOfContent.Values.ToList(); + + // Should have: Parent Heading (2), Snippet Heading (2), Step In Snippet (3), Another Parent Heading (2) + toc.Should().HaveCount(4); + + toc[0].Heading.Should().Be("Parent Heading"); + toc[0].Level.Should().Be(2); + + // The snippet's own heading should be preserved + toc[1].Heading.Should().Be("Snippet Heading"); + toc[1].Level.Should().Be(2, "snippet's own heading should remain level 2"); + + // The stepper step should be level 3 based on snippet's own h2, NOT adjusted by parent + // This verifies that when a snippet has its own heading structure, we don't override it + toc[2].Heading.Should().Be("Step In Snippet"); + toc[2].Level.Should().Be(3, "stepper step should be level 3 based on snippet's own h2, not adjusted by parent"); + toc[2].IsStepperStep.Should().BeTrue(); + + toc[3].Heading.Should().Be("Another Parent Heading"); + toc[3].Level.Should().Be(2); + } +} + +/// +/// Tests that stepper steps are capped at h6 even when preceding heading would push them deeper. +/// +public class StepperInSnippetWithH6CappingTests(ITestOutputHelper output) : DirectiveTest(output, +""" +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +:::{include} _snippets/stepper-snippet.md +::: +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + var inclusion = """ +:::::{stepper} + +::::{step} Step After H6 +Step content. +:::: + +::::: +"""; + fileSystem.AddFile(@"docs/_snippets/stepper-snippet.md", inclusion); + } + + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void StepperStepIsCappedAtH6() + { + var toc = File.PageTableOfContent.Values.ToList(); + + // Should have: H2 (2), H3 (3), H4 (4), H5 (5), H6 (6), Step After H6 (6) + toc.Should().HaveCount(6); + + // Verify the step is capped at level 6, not level 7 + toc[5].Heading.Should().Be("Step After H6"); + toc[5].Level.Should().Be(6, "stepper step should be capped at h6 even when preceding heading is h6"); + toc[5].IsStepperStep.Should().BeTrue(); + } +} + +/// +/// Tests multiple includes with different heading contexts to ensure each is adjusted independently. +/// +public class MultipleIncludesWithDifferentContextsTests(ITestOutputHelper output) : DirectiveTest(output, +""" +## First Section + +:::{include} _snippets/first.md +::: + +### Subsection + +:::{include} _snippets/second.md +::: + +## Final Section +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + var firstInclusion = """ +:::::{stepper} + +::::{step} First Step +First step content. +:::: + +::::: +"""; + fileSystem.AddFile(@"docs/_snippets/first.md", firstInclusion); + + var secondInclusion = """ +:::::{stepper} + +::::{step} Second Step +Second step content. +:::: + +::::: +"""; + fileSystem.AddFile(@"docs/_snippets/second.md", secondInclusion); + } + + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void EachIncludeAdjustsBasedOnItsOwnContext() + { + var toc = File.PageTableOfContent.Values.ToList(); + + // Should have: First Section (2), First Step (3), Subsection (3), Second Step (4), Final Section (2) + toc.Should().HaveCount(5); + + toc[0].Heading.Should().Be("First Section"); + toc[0].Level.Should().Be(2); + + // First step should be level 3 (after h2) + toc[1].Heading.Should().Be("First Step"); + toc[1].Level.Should().Be(3, "first step should be level 3 after h2"); + toc[1].IsStepperStep.Should().BeTrue(); + + toc[2].Heading.Should().Be("Subsection"); + toc[2].Level.Should().Be(3); + + // Second step should be level 4 (after h3) + toc[3].Heading.Should().Be("Second Step"); + toc[3].Level.Should().Be(4, "second step should be level 4 after h3"); + toc[3].IsStepperStep.Should().BeTrue(); + + toc[4].Heading.Should().Be("Final Section"); + toc[4].Level.Should().Be(2); + } +} + +/// +/// Tests that stepper steps in the main document (not in snippets) correctly calculate +/// their heading levels based on preceding headings. This ensures our changes didn't break +/// the existing behavior for steppers in the main document. +/// +public class StepperInMainDocumentTests(ITestOutputHelper output) : DirectiveTest(output, +""" +## Main Heading + +### Sub Heading + +:::::{stepper} + +::::{step} Step After H3 +First step after h3 heading. +:::: + +::::{step} Another Step +Second step, should also be h4. +:::: + +::::: + +## Another Main Heading + +:::::{stepper} + +::::{step} Step After H2 +Step after h2 heading. +:::: + +::::: +""" +) +{ + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void StepperStepsInMainDocumentCalculateCorrectHeadingLevels() + { + var toc = File.PageTableOfContent.Values.ToList(); + + // Should have: Main Heading (2), Sub Heading (3), Step After H3 (4), Another Step (4), + // Another Main Heading (2), Step After H2 (3) + toc.Should().HaveCount(6); + + toc[0].Heading.Should().Be("Main Heading"); + toc[0].Level.Should().Be(2); + + toc[1].Heading.Should().Be("Sub Heading"); + toc[1].Level.Should().Be(3); + + // Stepper steps after h3 should be h4 + toc[2].Heading.Should().Be("Step After H3"); + toc[2].Level.Should().Be(4, "stepper step after h3 should be h4"); + toc[2].IsStepperStep.Should().BeTrue(); + + toc[3].Heading.Should().Be("Another Step"); + toc[3].Level.Should().Be(4, "stepper step after h3 should be h4"); + toc[3].IsStepperStep.Should().BeTrue(); + + toc[4].Heading.Should().Be("Another Main Heading"); + toc[4].Level.Should().Be(2); + + // Stepper step after h2 should be h3 + toc[5].Heading.Should().Be("Step After H2"); + toc[5].Level.Should().Be(3, "stepper step after h2 should be h3"); + toc[5].IsStepperStep.Should().BeTrue(); + } +} + +/// +/// Tests stepper steps at the beginning of a document (no preceding heading). +/// +public class StepperAtDocumentStartTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{stepper} + +::::{step} First Step +Step at the very beginning of the document. +:::: + +::::{step} Second Step +Another step at the beginning. +:::: + +::::: + +## First Heading +""" +) +{ + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void StepperStepsAtDocumentStartDefaultToH2() + { + var toc = File.PageTableOfContent.Values.ToList(); + + // Should have: First Step (2), Second Step (2), First Heading (2) + toc.Should().HaveCount(3); + + // With no preceding heading, stepper steps should default to h2 + toc[0].Heading.Should().Be("First Step"); + toc[0].Level.Should().Be(2, "stepper step at document start should default to h2"); + toc[0].IsStepperStep.Should().BeTrue(); + + toc[1].Heading.Should().Be("Second Step"); + toc[1].Level.Should().Be(2, "stepper step at document start should default to h2"); + toc[1].IsStepperStep.Should().BeTrue(); + + toc[2].Heading.Should().Be("First Heading"); + toc[2].Level.Should().Be(2); + } +}