diff --git a/docs/_snippets/applies-switch.md b/docs/_snippets/applies-switch.md
new file mode 100644
index 000000000..3d3f4fe98
--- /dev/null
+++ b/docs/_snippets/applies-switch.md
@@ -0,0 +1,11 @@
+::::{applies-switch}
+
+:::{applies-item} stack:
+Content for Stack
+:::
+
+:::{applies-item} serverless:
+Content for Serverless
+:::
+
+::::
diff --git a/docs/testing/index.md b/docs/testing/index.md
index 4d2cebf71..0ca898093 100644
--- a/docs/testing/index.md
+++ b/docs/testing/index.md
@@ -99,3 +99,10 @@ const foo = "bar"; <1>
```
1. This is a JavaScript code block.
+
+
+:::{include} /_snippets/applies-switch.md
+:::
+
+:::{include} /_snippets/applies-switch.md
+:::
diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs
index ab34406a2..82599b415 100644
--- a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs
+++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs
@@ -22,15 +22,12 @@ public class AppliesSwitchBlock(DirectiveBlockParser parser, ParserContext conte
private int _index = -1;
- // For simplicity, we use the line number as the index.
- // It's not ideal, but it's unique.
- // This is more efficient than finding the root block and then finding the index.
public int FindIndex()
{
if (_index > -1)
return _index;
- _index = Line;
+ _index = GetUniqueLineIndex();
return _index;
}
}
diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs
index 31765c359..bbed3077e 100644
--- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs
@@ -58,6 +58,12 @@ public abstract class DirectiveBlock(
public bool SkipValidation { get; } = context.SkipValidation;
+ ///
+ /// The line number of the include directive that brought this block into the document.
+ /// Null if the block is not inside an included snippet.
+ ///
+ protected int? IncludeLine { get; } = context.IncludeLine;
+
public int OpeningLength => Directive.Length;
public abstract string Directive { get; }
@@ -148,4 +154,12 @@ protected bool PropBool(params string[] keys)
return default;
}
+ ///
+ /// Gets a unique index based on the block's line number that accounts for include context.
+ /// When the block is inside an included snippet, combines the include directive's line
+ /// with the snippet line to ensure uniqueness across multiple includes and multiple blocks.
+ ///
+ /// A unique integer index suitable for generating HTML IDs.
+ protected int GetUniqueLineIndex() =>
+ IncludeLine.HasValue ? (IncludeLine.Value * 1000) + Line : Line;
}
diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
index a3a37d757..eab1fc9ab 100644
--- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
@@ -361,7 +361,7 @@ private static void WriteIncludeBlock(HtmlRenderer renderer, IncludeBlock block)
var snippet = block.Build.ReadFileSystem.FileInfo.New(block.IncludePath);
var parentPath = block.Context.MarkdownParentPath ?? block.Context.MarkdownSourcePath;
- var document = MarkdownParser.ParseSnippetAsync(block.Build, block.Context, snippet, parentPath, block.Context.YamlFrontMatter, default)
+ var document = MarkdownParser.ParseSnippetAsync(block.Build, block.Context, snippet, parentPath, block.Context.YamlFrontMatter, default, block.Line)
.GetAwaiter().GetResult();
var html = document.ToHtml(MarkdownParser.Pipeline);
diff --git a/src/Elastic.Markdown/Myst/Directives/Tabs/TabSetBlock.cs b/src/Elastic.Markdown/Myst/Directives/Tabs/TabSetBlock.cs
index e91744028..f8ffd6623 100644
--- a/src/Elastic.Markdown/Myst/Directives/Tabs/TabSetBlock.cs
+++ b/src/Elastic.Markdown/Myst/Directives/Tabs/TabSetBlock.cs
@@ -19,15 +19,12 @@ public class TabSetBlock(DirectiveBlockParser parser, ParserContext context)
private int _index = -1;
- // For simplicity, we use the line number as the index.
- // It's not ideal, but it's unique.
- // This is more efficient than finding the root block and then finding the index.
public int FindIndex()
{
if (_index > -1)
return _index;
- _index = Line;
+ _index = GetUniqueLineIndex();
return _index;
}
}
diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs
index cf121086a..9eeff5659 100644
--- a/src/Elastic.Markdown/Myst/MarkdownParser.cs
+++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs
@@ -83,7 +83,7 @@ public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IPar
}
public static Task ParseSnippetAsync(BuildContext build, IParserResolvers resolvers, IFileInfo path, IFileInfo parentPath,
- YamlFrontMatter? matter, Cancel ctx)
+ YamlFrontMatter? matter, Cancel ctx, int? includeLine = null)
{
var state = new ParserState(build)
{
@@ -93,7 +93,8 @@ public static Task ParseSnippetAsync(BuildContext build, IPars
TryFindDocumentByRelativePath = resolvers.TryFindDocumentByRelativePath,
CrossLinkResolver = resolvers.CrossLinkResolver,
NavigationTraversable = resolvers.NavigationTraversable,
- ParentMarkdownPath = parentPath
+ ParentMarkdownPath = parentPath,
+ IncludeLine = includeLine
};
var context = new ParserContext(state);
return ParseAsync(path, context, Pipeline, ctx);
diff --git a/src/Elastic.Markdown/Myst/ParserContext.cs b/src/Elastic.Markdown/Myst/ParserContext.cs
index 01280082e..bb464cc78 100644
--- a/src/Elastic.Markdown/Myst/ParserContext.cs
+++ b/src/Elastic.Markdown/Myst/ParserContext.cs
@@ -54,6 +54,12 @@ public record ParserState(BuildContext Build) : ParserResolvers
public IFileInfo? ParentMarkdownPath { get; init; }
public bool SkipValidation { get; init; }
+
+ ///
+ /// The line number of the include directive in the parent file.
+ /// Used to generate unique IDs for blocks within included snippets.
+ ///
+ public int? IncludeLine { get; init; }
}
public class ParserContext : MarkdownParserContext, IParserResolvers
@@ -72,6 +78,12 @@ public class ParserContext : MarkdownParserContext, IParserResolvers
public IReadOnlyDictionary ContextSubstitutions { get; }
public INavigationTraversable NavigationTraversable { get; }
+ ///
+ /// The line number of the include directive in the parent file.
+ /// Used to generate unique IDs for blocks within included snippets.
+ ///
+ public int? IncludeLine { get; }
+
public ParserContext(ParserState state)
{
Build = state.Build;
@@ -79,6 +91,7 @@ public ParserContext(ParserState state)
YamlFrontMatter = state.YamlFrontMatter;
SkipValidation = state.SkipValidation;
MarkdownParentPath = state.ParentMarkdownPath;
+ IncludeLine = state.IncludeLine;
CrossLinkResolver = state.CrossLinkResolver;
MarkdownSourcePath = state.MarkdownSourcePath;
diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs
index b70be94e0..d5eeec1c9 100644
--- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs
+++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs
@@ -520,7 +520,7 @@ private void WriteIncludeBlock(LlmMarkdownRenderer renderer, IncludeBlock block)
try
{
var parentPath = block.Context.MarkdownParentPath ?? block.Context.MarkdownSourcePath;
- var document = MarkdownParser.ParseSnippetAsync(block.Build, block.Context, snippet, parentPath, block.Context.YamlFrontMatter, Cancel.None)
+ var document = MarkdownParser.ParseSnippetAsync(block.Build, block.Context, snippet, parentPath, block.Context.YamlFrontMatter, Cancel.None, block.Line)
.GetAwaiter().GetResult();
_ = renderer.Render(document);
}
diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs
new file mode 100644
index 000000000..296493031
--- /dev/null
+++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedAppliesSwitchTests.cs
@@ -0,0 +1,106 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions.TestingHelpers;
+using Elastic.Markdown.Myst.Directives.Include;
+using Elastic.Markdown.Tests.Directives;
+using FluentAssertions;
+
+namespace Elastic.Markdown.Tests.FileInclusion;
+
+///
+/// Tests that when the same snippet containing applies-switch is included multiple times,
+/// each include generates unique IDs to avoid HTML ID collisions.
+///
+public class IncludedAppliesSwitchTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{include} _snippets/applies-switch.md
+:::
+
+Some content between includes.
+
+:::{include} _snippets/applies-switch.md
+:::
+"""
+)
+{
+ protected override void AddToFileSystem(MockFileSystem fileSystem)
+ {
+ // language=markdown
+ var snippet =
+"""
+::::{applies-switch}
+:::{applies-item} stack:
+Content for Stack
+:::
+:::{applies-item} serverless:
+Content for Serverless
+:::
+::::
+""";
+ fileSystem.AddFile(@"docs/_snippets/applies-switch.md", snippet);
+ }
+
+ [Fact]
+ public void ParsesBlock() => Block.Should().NotBeNull();
+
+ [Fact]
+ public void EachIncludeHasUniqueIds()
+ {
+ // First include at line 2: (2 * 1000) + 0 = 2000
+ // Second include at line 7: (7 * 1000) + 0 = 7000
+ Html.Should().Contain("applies-switch-item-2000-0");
+ Html.Should().Contain("applies-switch-item-2000-1");
+ Html.Should().Contain("applies-switch-set-2000");
+
+ Html.Should().Contain("applies-switch-item-7000-0");
+ Html.Should().Contain("applies-switch-item-7000-1");
+ Html.Should().Contain("applies-switch-set-7000");
+ }
+}
+
+///
+/// Tests that a snippet with multiple applies-switches generates unique IDs for each one.
+///
+public class IncludedMultipleAppliesSwitchTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{include} _snippets/multi-applies-switch.md
+:::
+"""
+)
+{
+ protected override void AddToFileSystem(MockFileSystem fileSystem)
+ {
+ // language=markdown
+ var snippet =
+"""
+::::{applies-switch}
+:::{applies-item} stack:
+First switch - Stack
+:::
+::::
+
+Some content between.
+
+::::{applies-switch}
+:::{applies-item} serverless:
+Second switch - Serverless
+:::
+::::
+""";
+ fileSystem.AddFile(@"docs/_snippets/multi-applies-switch.md", snippet);
+ }
+
+ [Fact]
+ public void ParsesBlock() => Block.Should().NotBeNull();
+
+ [Fact]
+ public void EachAppliesSwitchHasUniqueIds()
+ {
+ // Include at line 2, first applies-switch at line 0: (2 * 1000) + 0 = 2000
+ // Include at line 2, second applies-switch at line 8: (2 * 1000) + 8 = 2008
+ Html.Should().Contain("applies-switch-set-2000");
+ Html.Should().Contain("applies-switch-set-2008");
+ }
+}
diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs
new file mode 100644
index 000000000..67d78d443
--- /dev/null
+++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludedTabSetTests.cs
@@ -0,0 +1,106 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions.TestingHelpers;
+using Elastic.Markdown.Myst.Directives.Include;
+using Elastic.Markdown.Tests.Directives;
+using FluentAssertions;
+
+namespace Elastic.Markdown.Tests.FileInclusion;
+
+///
+/// Tests that when the same snippet containing tab-set is included multiple times,
+/// each include generates unique IDs to avoid HTML ID collisions.
+///
+public class IncludedTabSetTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{include} _snippets/tab-set.md
+:::
+
+Some content between includes.
+
+:::{include} _snippets/tab-set.md
+:::
+"""
+)
+{
+ protected override void AddToFileSystem(MockFileSystem fileSystem)
+ {
+ // language=markdown
+ var snippet =
+"""
+::::{tab-set}
+:::{tab-item} First
+Content for first tab
+:::
+:::{tab-item} Second
+Content for second tab
+:::
+::::
+""";
+ fileSystem.AddFile(@"docs/_snippets/tab-set.md", snippet);
+ }
+
+ [Fact]
+ public void ParsesBlock() => Block.Should().NotBeNull();
+
+ [Fact]
+ public void EachIncludeHasUniqueIds()
+ {
+ // First include at line 2: (2 * 1000) + 0 = 2000
+ // Second include at line 7: (7 * 1000) + 0 = 7000
+ Html.Should().Contain("tabs-item-2000-0");
+ Html.Should().Contain("tabs-item-2000-1");
+ Html.Should().Contain("tabs-set-2000");
+
+ Html.Should().Contain("tabs-item-7000-0");
+ Html.Should().Contain("tabs-item-7000-1");
+ Html.Should().Contain("tabs-set-7000");
+ }
+}
+
+///
+/// Tests that a snippet with multiple tab-sets generates unique IDs for each one.
+///
+public class IncludedMultipleTabSetTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{include} _snippets/multi-tab-set.md
+:::
+"""
+)
+{
+ protected override void AddToFileSystem(MockFileSystem fileSystem)
+ {
+ // language=markdown
+ var snippet =
+"""
+::::{tab-set}
+:::{tab-item} First
+First tab set
+:::
+::::
+
+Some content between.
+
+::::{tab-set}
+:::{tab-item} Second
+Second tab set
+:::
+::::
+""";
+ fileSystem.AddFile(@"docs/_snippets/multi-tab-set.md", snippet);
+ }
+
+ [Fact]
+ public void ParsesBlock() => Block.Should().NotBeNull();
+
+ [Fact]
+ public void EachTabSetHasUniqueIds()
+ {
+ // Include at line 2, first tab-set at line 0: (2 * 1000) + 0 = 2000
+ // Include at line 2, second tab-set at line 8: (2 * 1000) + 8 = 2008
+ Html.Should().Contain("tabs-set-2000");
+ Html.Should().Contain("tabs-set-2008");
+ }
+}