Skip to content

Commit af67fed

Browse files
authored
Add semantic headings to stepper component (#1472)
* Add semantic steps to stepper * Update docs * Incremental steps * Adapt heading style * Docs update * Doc edit
1 parent 6945015 commit af67fed

File tree

6 files changed

+150
-18
lines changed

6 files changed

+150
-18
lines changed

docs/syntax/stepper.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,12 @@ to break down processes into manageable stages.
66
By default every step title is a link with a generated anchor.
77
But you can override the default anchor by adding the `:anchor:` option to the step.
88

9-
## Basic Stepper
9+
## Basic stepper
1010

1111
:::::::{tab-set}
1212
::::::{tab-item} Output
1313
:::::{stepper}
1414

15-
:::::{stepper}
16-
1715
::::{step} Install
1816
First install the dependencies.
1917
```shell
@@ -77,7 +75,7 @@ npm run test
7775

7876
:::::::
7977

80-
## Advanced Example
78+
## Advanced example
8179

8280
:::::::{tab-set}
8381

@@ -203,3 +201,27 @@ To see how dynamic mapping works, add a new document to the `books` index with a
203201
::::::
204202

205203
:::::::
204+
205+
## Table of contents integration
206+
207+
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.
208+
209+
### Nested steppers
210+
211+
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.
212+
213+
**Example of excluded stepper:**
214+
```markdown
215+
::::{tab-set}
216+
:::{tab-item} Tab 1
217+
::{stepper}
218+
:{step} This step won't appear in ToC
219+
Content here...
220+
:
221+
::
222+
:::
223+
::::
224+
```
225+
## Dynamic heading levels
226+
227+
Stepper step titles automatically adjust their heading level based on the preceding heading in the document, ensuring proper document hierarchy and semantic structure.

src/Elastic.Markdown/IO/MarkdownFile.cs

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Elastic.Markdown.Myst;
1515
using Elastic.Markdown.Myst.Directives;
1616
using Elastic.Markdown.Myst.Directives.Include;
17+
using Elastic.Markdown.Myst.Directives.Stepper;
1718
using Elastic.Markdown.Myst.FrontMatter;
1819
using Elastic.Markdown.Myst.InlineParsers;
1920
using Markdig;
@@ -263,22 +264,50 @@ public static List<PageTocItem> GetAnchors(
263264
.ToArray();
264265

265266
var includedTocs = includes.SelectMany(i => i!.TableOfContentItems).ToArray();
266-
var toc = document
267+
268+
// Collect headings from standard markdown
269+
var headingTocs = document
267270
.Descendants<HeadingBlock>()
268271
.Where(block => block is { Level: >= 2 })
269-
.Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level))
272+
.Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level, h.Line))
270273
.Where(h => h.Item1 is not null)
271274
.Select(h =>
272275
{
273276
var header = h.Item1!.StripMarkdown();
274-
return new PageTocItem
277+
return new
275278
{
276-
Heading = header,
277-
Slug = (h.Item2 ?? h.Item1).Slugify(),
278-
Level = h.Level
279+
TocItem = new PageTocItem
280+
{
281+
Heading = header,
282+
Slug = (h.Item2 ?? h.Item1).Slugify(),
283+
Level = h.Level
284+
},
285+
h.Line
279286
};
280-
})
281-
.Concat(includedTocs)
287+
});
288+
289+
// Collect headings from Stepper steps
290+
var stepperTocs = document
291+
.Descendants<DirectiveBlock>()
292+
.OfType<StepBlock>()
293+
.Where(step => !string.IsNullOrEmpty(step.Title))
294+
.Where(step => !IsNestedInOtherDirective(step))
295+
.Select(step => new
296+
{
297+
TocItem = new PageTocItem
298+
{
299+
Heading = step.Title,
300+
Slug = step.Anchor,
301+
Level = step.HeadingLevel // Use dynamic heading level
302+
},
303+
step.Line
304+
});
305+
306+
var toc = headingTocs
307+
.Concat(stepperTocs)
308+
.Concat(includedTocs.Select(item => new { TocItem = item, Line = 0 }))
309+
.OrderBy(item => item.Line)
310+
.Select(item => item.TocItem)
282311
.Select(toc => subs.Count == 0
283312
? toc
284313
: toc.Heading.AsSpan().ReplaceSubstitutions(subs, set.Context.Collector, out var r)
@@ -301,6 +330,18 @@ public static List<PageTocItem> GetAnchors(
301330
return toc;
302331
}
303332

333+
private static bool IsNestedInOtherDirective(DirectiveBlock block)
334+
{
335+
var parent = block.Parent;
336+
while (parent is not null)
337+
{
338+
if (parent is DirectiveBlock { } otherDirective && otherDirective != block && otherDirective is not StepperBlock)
339+
return true;
340+
parent = parent.Parent;
341+
}
342+
return false;
343+
}
344+
304345
private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document)
305346
{
306347
if (document.FirstOrDefault() is not YamlFrontMatterBlock yaml)

src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ private static void WriteStepBlock(HtmlRenderer renderer, StepBlock block)
124124
{
125125
DirectiveBlock = block,
126126
Title = block.Title,
127-
Anchor = block.Anchor
127+
Anchor = block.Anchor,
128+
HeadingLevel = block.HeadingLevel
128129
});
129130
RenderRazorSlice(slice, renderer);
130131
}

src/Elastic.Markdown/Myst/Directives/Stepper/StepView.cshtml

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,39 @@
22
<li class="step">
33
@if (!string.IsNullOrEmpty(Model.Title))
44
{
5-
<p id="@Model.Anchor">
6-
<a class="title" href="#@Model.Anchor">
7-
@Model.Title
8-
</a>
9-
</p>
5+
@switch (Model.HeadingLevel)
6+
{
7+
case 1:
8+
<h1 id="@Model.Anchor">
9+
<a href="#@Model.Anchor">@Model.Title</a>
10+
</h1>
11+
break;
12+
case 2:
13+
<h2 id="@Model.Anchor">
14+
<a href="#@Model.Anchor">@Model.Title</a>
15+
</h2>
16+
break;
17+
case 3:
18+
<h3 id="@Model.Anchor">
19+
<a href="#@Model.Anchor">@Model.Title</a>
20+
</h3>
21+
break;
22+
case 4:
23+
<h4 id="@Model.Anchor">
24+
<a href="#@Model.Anchor">@Model.Title</a>
25+
</h4>
26+
break;
27+
case 5:
28+
<h5 id="@Model.Anchor">
29+
<a href="#@Model.Anchor">@Model.Title</a>
30+
</h5>
31+
break;
32+
default:
33+
<h6 id="@Model.Anchor">
34+
<a href="#@Model.Anchor">@Model.Title</a>
35+
</h6>
36+
break;
37+
}
1038
}
1139
@Model.RenderBlock()
1240
</li>

src/Elastic.Markdown/Myst/Directives/Stepper/StepViewModel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ public class StepViewModel : DirectiveViewModel
88
{
99
public required string Title { get; init; }
1010
public required string Anchor { get; init; }
11+
public required int HeadingLevel { get; init; }
1112
}

src/Elastic.Markdown/Myst/Directives/Stepper/StepperBlock.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for more information
44

55
using Elastic.Markdown.Helpers;
6+
using Markdig.Syntax;
67

78
namespace Elastic.Markdown.Myst.Directives.Stepper;
89

@@ -20,10 +21,48 @@ public class StepBlock(DirectiveBlockParser parser, ParserContext context) : Dir
2021
public override string Directive => "step";
2122
public string Title { get; private set; } = string.Empty;
2223
public string Anchor { get; private set; } = string.Empty;
24+
public int HeadingLevel { get; private set; } = 3; // Default to h3
2325

2426
public override void FinalizeAndValidate(ParserContext context)
2527
{
2628
Title = Arguments ?? string.Empty;
2729
Anchor = Prop("anchor") ?? Title.Slugify();
30+
31+
// Calculate heading level based on preceding heading
32+
HeadingLevel = CalculateHeadingLevel();
33+
34+
// Set CrossReferenceName so this step can be found by ToC generation
35+
if (!string.IsNullOrEmpty(Title))
36+
{
37+
CrossReferenceName = Anchor;
38+
}
39+
}
40+
41+
private int CalculateHeadingLevel()
42+
{
43+
// Find the document root
44+
var current = (ContainerBlock)this;
45+
while (current.Parent != null)
46+
current = current.Parent;
47+
48+
// Find all headings that come before this step in document order
49+
var allBlocks = current.Descendants().ToList();
50+
var thisIndex = allBlocks.IndexOf(this);
51+
52+
if (thisIndex == -1)
53+
return 3; // Default fallback
54+
55+
// Look backwards for the most recent heading
56+
for (var i = thisIndex - 1; i >= 0; i--)
57+
{
58+
if (allBlocks[i] is HeadingBlock heading)
59+
{
60+
// Step should be one level deeper than the preceding heading
61+
return Math.Min(heading.Level + 1, 6); // Cap at h6
62+
}
63+
}
64+
65+
// No preceding heading found, default to h2 (level 2)
66+
return 2;
2867
}
2968
}

0 commit comments

Comments
 (0)