diff --git a/docs/syntax/stepper.md b/docs/syntax/stepper.md index 4415dd658..8b30c92e0 100644 --- a/docs/syntax/stepper.md +++ b/docs/syntax/stepper.md @@ -6,14 +6,12 @@ to break down processes into manageable stages. By default every step title is a link with a generated anchor. But you can override the default anchor by adding the `:anchor:` option to the step. -## Basic Stepper +## Basic stepper :::::::{tab-set} ::::::{tab-item} Output :::::{stepper} -:::::{stepper} - ::::{step} Install First install the dependencies. ```shell @@ -77,7 +75,7 @@ npm run test ::::::: -## Advanced Example +## Advanced example :::::::{tab-set} @@ -203,3 +201,27 @@ To see how dynamic mapping works, add a new document to the `books` index with a :::::: ::::::: + +## Table of contents integration + +Stepper step titles automatically appear in the page's "On this page" table of contents (ToC) sidebar, making it easier for users to navigate directly to specific steps. + +### Nested steppers + +When steppers are nested inside other directive components (like `{tab-set}`, `{dropdown}`, or other containers), their step titles are **not** included in the ToC to avoid duplicate or competing headings across multiple tabs or links to content that might be collapsed or hidden. + +**Example of excluded stepper:** +```markdown +::::{tab-set} +:::{tab-item} Tab 1 +::{stepper} +:{step} This step won't appear in ToC +Content here... +: +:: +::: +:::: +``` +## Dynamic heading levels + +Stepper step titles automatically adjust their heading level based on the preceding heading in the document, ensuring proper document hierarchy and semantic structure. \ No newline at end of file diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index dae259a96..592d69832 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -14,6 +14,7 @@ using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.Directives.Include; +using Elastic.Markdown.Myst.Directives.Stepper; using Elastic.Markdown.Myst.FrontMatter; using Elastic.Markdown.Myst.InlineParsers; using Markdig; @@ -263,22 +264,50 @@ public static List GetAnchors( .ToArray(); var includedTocs = includes.SelectMany(i => i!.TableOfContentItems).ToArray(); - var toc = document + + // Collect headings from standard markdown + var headingTocs = document .Descendants() .Where(block => block is { Level: >= 2 }) - .Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level)) + .Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level, h.Line)) .Where(h => h.Item1 is not null) .Select(h => { var header = h.Item1!.StripMarkdown(); - return new PageTocItem + return new { - Heading = header, - Slug = (h.Item2 ?? h.Item1).Slugify(), - Level = h.Level + TocItem = new PageTocItem + { + Heading = header, + Slug = (h.Item2 ?? h.Item1).Slugify(), + Level = h.Level + }, + h.Line }; - }) - .Concat(includedTocs) + }); + + // Collect headings from Stepper steps + var stepperTocs = document + .Descendants() + .OfType() + .Where(step => !string.IsNullOrEmpty(step.Title)) + .Where(step => !IsNestedInOtherDirective(step)) + .Select(step => new + { + TocItem = new PageTocItem + { + Heading = step.Title, + Slug = step.Anchor, + Level = step.HeadingLevel // Use dynamic heading level + }, + step.Line + }); + + var toc = headingTocs + .Concat(stepperTocs) + .Concat(includedTocs.Select(item => new { TocItem = item, Line = 0 })) + .OrderBy(item => item.Line) + .Select(item => item.TocItem) .Select(toc => subs.Count == 0 ? toc : toc.Heading.AsSpan().ReplaceSubstitutions(subs, set.Context.Collector, out var r) @@ -301,6 +330,18 @@ public static List GetAnchors( return toc; } + private static bool IsNestedInOtherDirective(DirectiveBlock block) + { + var parent = block.Parent; + while (parent is not null) + { + if (parent is DirectiveBlock { } otherDirective && otherDirective != block && otherDirective is not StepperBlock) + return true; + parent = parent.Parent; + } + return false; + } + private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document) { if (document.FirstOrDefault() is not YamlFrontMatterBlock yaml) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 2c2a6cd28..a15ac0ac9 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -124,7 +124,8 @@ private static void WriteStepBlock(HtmlRenderer renderer, StepBlock block) { DirectiveBlock = block, Title = block.Title, - Anchor = block.Anchor + Anchor = block.Anchor, + HeadingLevel = block.HeadingLevel }); RenderRazorSlice(slice, renderer); } diff --git a/src/Elastic.Markdown/Myst/Directives/Stepper/StepView.cshtml b/src/Elastic.Markdown/Myst/Directives/Stepper/StepView.cshtml index 870b3dd72..e6712b38c 100644 --- a/src/Elastic.Markdown/Myst/Directives/Stepper/StepView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Stepper/StepView.cshtml @@ -2,11 +2,39 @@
  • @if (!string.IsNullOrEmpty(Model.Title)) { -

    - - @Model.Title - -

    + @switch (Model.HeadingLevel) + { + case 1: +

    + @Model.Title +

    + break; + case 2: +

    + @Model.Title +

    + break; + case 3: +

    + @Model.Title +

    + break; + case 4: +

    + @Model.Title +

    + break; + case 5: +
    + @Model.Title +
    + break; + default: +
    + @Model.Title +
    + break; + } } @Model.RenderBlock()
  • diff --git a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs index 412c176cc..2efcb442e 100644 --- a/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs @@ -8,4 +8,5 @@ public class StepViewModel : DirectiveViewModel { public required string Title { get; init; } public required string Anchor { get; init; } + public required int HeadingLevel { get; init; } } diff --git a/src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs b/src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs index 2add20803..a0ad9e509 100644 --- a/src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using Elastic.Markdown.Helpers; +using Markdig.Syntax; namespace Elastic.Markdown.Myst.Directives.Stepper; @@ -20,10 +21,48 @@ public class StepBlock(DirectiveBlockParser parser, ParserContext context) : Dir public override string Directive => "step"; public string Title { get; private set; } = string.Empty; public string Anchor { get; private set; } = string.Empty; + public int HeadingLevel { get; private set; } = 3; // Default to h3 public override void FinalizeAndValidate(ParserContext context) { Title = Arguments ?? string.Empty; Anchor = Prop("anchor") ?? Title.Slugify(); + + // Calculate heading level based on preceding heading + HeadingLevel = CalculateHeadingLevel(); + + // Set CrossReferenceName so this step can be found by ToC generation + if (!string.IsNullOrEmpty(Title)) + { + CrossReferenceName = Anchor; + } + } + + private int CalculateHeadingLevel() + { + // Find the document root + var current = (ContainerBlock)this; + while (current.Parent != null) + current = current.Parent; + + // Find all headings that come before this step in document order + var allBlocks = current.Descendants().ToList(); + var thisIndex = allBlocks.IndexOf(this); + + if (thisIndex == -1) + return 3; // Default fallback + + // Look backwards for the most recent heading + for (var i = thisIndex - 1; i >= 0; i--) + { + if (allBlocks[i] is HeadingBlock heading) + { + // Step should be one level deeper than the preceding heading + return Math.Min(heading.Level + 1, 6); // Cap at h6 + } + } + + // No preceding heading found, default to h2 (level 2) + return 2; } }