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"); + } +}