Skip to content
Merged
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
30 changes: 26 additions & 4 deletions docs/syntax/stepper.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,7 +75,7 @@ npm run test

:::::::

## Advanced Example
## Advanced example

:::::::{tab-set}

Expand Down Expand Up @@ -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.
57 changes: 49 additions & 8 deletions src/Elastic.Markdown/IO/MarkdownFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -263,22 +264,50 @@ public static List<PageTocItem> GetAnchors(
.ToArray();

var includedTocs = includes.SelectMany(i => i!.TableOfContentItems).ToArray();
var toc = document

// Collect headings from standard markdown
var headingTocs = document
.Descendants<HeadingBlock>()
.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<DirectiveBlock>()
.OfType<StepBlock>()
Comment on lines +291 to +292
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Wondering if this can be just .Descendants<StepBlock>()

.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)
Expand All @@ -301,6 +330,18 @@ public static List<PageTocItem> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
38 changes: 33 additions & 5 deletions src/Elastic.Markdown/Myst/Directives/Stepper/StepView.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,39 @@
<li class="step">
@if (!string.IsNullOrEmpty(Model.Title))
{
<p id="@Model.Anchor">
<a class="title" href="#@Model.Anchor">
@Model.Title
</a>
</p>
@switch (Model.HeadingLevel)
{
case 1:
<h1 id="@Model.Anchor">
<a href="#@Model.Anchor">@Model.Title</a>
</h1>
break;
case 2:
<h2 id="@Model.Anchor">
<a href="#@Model.Anchor">@Model.Title</a>
</h2>
break;
case 3:
<h3 id="@Model.Anchor">
<a href="#@Model.Anchor">@Model.Title</a>
</h3>
break;
case 4:
<h4 id="@Model.Anchor">
<a href="#@Model.Anchor">@Model.Title</a>
</h4>
break;
case 5:
<h5 id="@Model.Anchor">
<a href="#@Model.Anchor">@Model.Title</a>
</h5>
break;
default:
<h6 id="@Model.Anchor">
<a href="#@Model.Anchor">@Model.Title</a>
</h6>
break;
}
}
@Model.RenderBlock()
</li>
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
39 changes: 39 additions & 0 deletions src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
}
Loading