diff --git a/docs/syntax/version-variables.md b/docs/syntax/version-variables.md
index 42a06a0da..280c57b18 100644
--- a/docs/syntax/version-variables.md
+++ b/docs/syntax/version-variables.md
@@ -26,7 +26,54 @@ can be printed in any kind of ways.
| `{{version.stack | M+1 | M }}` | {{version.stack | M+1 | M }} |
| `{{version.stack.base | M.M+1 }}` | {{version.stack.base | M.M+1 }} |
-## Available versioning schemes.
+## Mutation Operators in Links and Code Blocks
+
+Mutation operators also work correctly in links and code blocks, making them versatile for various documentation contexts.
+
+### In Links
+
+Mutation operators can be used in both link URLs and link text:
+
+```markdown subs=false
+[Download version {{version.stack | M.M}}](https://download.elastic.co/{{version.stack | M.M}}/elasticsearch.tar.gz)
+[Latest major version](https://elastic.co/guide/en/elasticsearch/reference/{{version.stack | M}}/index.html)
+```
+
+Which renders as:
+
+[Download version {{version.stack | M.M}}](https://download.elastic.co/{{version.stack | M.M}}/elasticsearch.tar.gz)
+[Latest major version](https://elastic.co/guide/en/elasticsearch/reference/{{version.stack | M}}/index.html)
+
+### In Code Blocks
+
+Mutation operators work in enhanced code blocks when `subs=true` is specified:
+
+````markdown subs=false
+```bash subs=true
+curl -X GET "localhost:9200/_cluster/health?v&pretty"
+echo "Elasticsearch {{version.stack | M.M}} is running"
+```
+````
+
+Which renders as:
+
+```bash subs=true
+curl -X GET "localhost:9200/_cluster/health?v&pretty"
+echo "Elasticsearch {{version.stack | M.M}} is running"
+```
+
+### Whitespace Handling
+
+Mutation operators are robust and handle whitespace around the pipe character correctly:
+
+| Syntax | Result | Notes |
+|--------|--------| ----- |
+| `{{version.stack|M.M}}` | {{version.stack|M.M}} | No spaces |
+| `{{version.stack | M.M}}` | {{version.stack | M.M}} | Spaces around pipe |
+| `{{version.stack |M.M}}` | {{version.stack |M.M}} | Space before pipe |
+| `{{version.stack| M.M}}` | {{version.stack| M.M}} | Space after pipe |
+
+## Available versioning schemes
This is dictated by the [`versions.yml`](https://github.com/elastic/docs-builder/blob/main/config/versions.yml) configuration file
diff --git a/src/Elastic.Markdown/Helpers/Interpolation.cs b/src/Elastic.Markdown/Helpers/Interpolation.cs
index b74b0d7a8..2c40116e9 100644
--- a/src/Elastic.Markdown/Helpers/Interpolation.cs
+++ b/src/Elastic.Markdown/Helpers/Interpolation.cs
@@ -3,9 +3,12 @@
// See the LICENSE file in the project root for more information
using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
using System.Text.RegularExpressions;
+using Elastic.Documentation;
using Elastic.Documentation.Diagnostics;
using Elastic.Markdown.Myst;
+using Elastic.Markdown.Myst.InlineParsers.Substitution;
namespace Elastic.Markdown.Helpers;
@@ -67,17 +70,27 @@ private static bool ReplaceSubstitutions(
continue;
var spanMatch = span.Slice(match.Index, match.Length);
- var key = spanMatch.Trim(['{', '}']);
+ var fullKey = spanMatch.Trim(['{', '}']);
+
+ // Enhanced mutation support: parse key and mutations using shared utility
+ var (cleanKey, mutations) = SubstitutionMutationHelper.ParseKeyWithMutations(fullKey.ToString());
+
foreach (var lookup in lookups)
{
- if (!lookup.TryGetValue(key, out var value))
- continue;
+ if (lookup.TryGetValue(cleanKey, out var value))
+ {
+ // Apply mutations if present using shared utility
+ if (mutations.Length > 0)
+ {
+ value = SubstitutionMutationHelper.ApplyMutations(value, mutations);
+ }
- collector?.CollectUsedSubstitutionKey(key);
+ collector?.CollectUsedSubstitutionKey(cleanKey);
- replacement ??= span.ToString();
- replacement = replacement.Replace(spanMatch.ToString(), value);
- replaced = true;
+ replacement ??= span.ToString();
+ replacement = replacement.Replace(spanMatch.ToString(), value);
+ replaced = true;
+ }
}
}
diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs
new file mode 100644
index 000000000..10b347d92
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs
@@ -0,0 +1,124 @@
+// 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;
+using System.Linq;
+using System.Text.Json;
+using Elastic.Documentation;
+
+namespace Elastic.Markdown.Myst.InlineParsers.Substitution;
+
+///
+/// Shared utility for parsing and applying substitution mutations
+///
+public static class SubstitutionMutationHelper
+{
+ ///
+ /// Parses a substitution key with mutations and returns the key and mutation components
+ ///
+ /// The raw substitution key (e.g., "version.stack | M.M")
+ /// A tuple containing the cleaned key and array of mutation strings
+ public static (string Key, string[] Mutations) ParseKeyWithMutations(string rawKey)
+ {
+ // Improved handling of pipe-separated components with better whitespace handling
+ var components = rawKey.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ var key = components[0].Trim();
+ var mutations = components.Length > 1 ? components[1..] : [];
+
+ return (key, mutations);
+ }
+
+ ///
+ /// Applies mutations to a value using the existing SubstitutionMutation system
+ ///
+ /// The original value to transform
+ /// Collection of SubstitutionMutation enums to apply
+ /// The transformed value
+ public static string ApplyMutations(string value, IReadOnlyCollection mutations)
+ {
+ var result = value;
+ foreach (var mutation in mutations)
+ {
+ result = ApplySingleMutation(result, mutation);
+ }
+ return result;
+ }
+
+ ///
+ /// Applies mutations to a value using the existing SubstitutionMutation system
+ ///
+ /// The original value to transform
+ /// Array of mutation strings to apply
+ /// The transformed value
+ public static string ApplyMutations(string value, string[] mutations)
+ {
+ var result = value;
+ foreach (var mutationStr in mutations)
+ {
+ var trimmedMutation = mutationStr.Trim();
+ if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true))
+ {
+ result = ApplySingleMutation(result, mutation);
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// Applies a single mutation to a value
+ ///
+ /// The value to transform
+ /// The mutation to apply
+ /// The transformed value
+ private static string ApplySingleMutation(string value, SubstitutionMutation mutation)
+ {
+ var (success, result) = mutation switch
+ {
+ SubstitutionMutation.MajorComponent => TryGetVersion(value, v => $"{v.Major}"),
+ SubstitutionMutation.MajorX => TryGetVersion(value, v => $"{v.Major}.x"),
+ SubstitutionMutation.MajorMinor => TryGetVersion(value, v => $"{v.Major}.{v.Minor}"),
+ SubstitutionMutation.IncreaseMajor => TryGetVersion(value, v => $"{v.Major + 1}.0.0"),
+ SubstitutionMutation.IncreaseMinor => TryGetVersion(value, v => $"{v.Major}.{v.Minor + 1}.0"),
+ SubstitutionMutation.LowerCase => (true, value.ToLowerInvariant()),
+ SubstitutionMutation.UpperCase => (true, value.ToUpperInvariant()),
+ SubstitutionMutation.Capitalize => (true, Capitalize(value)),
+ SubstitutionMutation.KebabCase => (true, ToKebabCase(value)),
+ SubstitutionMutation.CamelCase => (true, ToCamelCase(value)),
+ SubstitutionMutation.PascalCase => (true, ToPascalCase(value)),
+ SubstitutionMutation.SnakeCase => (true, ToSnakeCase(value)),
+ SubstitutionMutation.TitleCase => (true, TitleCase(value)),
+ SubstitutionMutation.Trim => (true, Trim(value)),
+ _ => (false, value)
+ };
+ return success ? result : value;
+ }
+
+ private static (bool Success, string Result) TryGetVersion(string version, Func transform)
+ {
+ if (!SemVersion.TryParse(version, out var v) && !SemVersion.TryParse(version + ".0", out v))
+ return (false, version);
+ return (true, transform(v));
+ }
+
+ // These methods match the exact implementation in SubstitutionRenderer
+ private static string Capitalize(string input) =>
+ input switch
+ {
+ null => string.Empty,
+ "" => string.Empty,
+ _ => string.Concat(input[0].ToString().ToUpper(), input.AsSpan(1))
+ };
+
+ private static string ToKebabCase(string str) => JsonNamingPolicy.KebabCaseLower.ConvertName(str).Replace(" ", string.Empty);
+
+ private static string ToCamelCase(string str) => JsonNamingPolicy.CamelCase.ConvertName(str).Replace(" ", string.Empty);
+
+ private static string ToPascalCase(string str) => TitleCase(str).Replace(" ", string.Empty);
+
+ private static string ToSnakeCase(string str) => JsonNamingPolicy.SnakeCaseLower.ConvertName(str).Replace(" ", string.Empty);
+
+ private static string TitleCase(string str) => str.Split(' ').Select(word => Capitalize(word)).Aggregate((a, b) => $"{a} {b}");
+
+ private static string Trim(string str) => str.TrimEnd('!', '.', ',', ';', ':', '?', ' ', '\t', '\n', '\r');
+}
diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs
index 7b0f33e67..08422bd72 100644
--- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs
+++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs
@@ -66,61 +66,10 @@ protected override void Write(HtmlRenderer renderer, SubstitutionLeaf leaf)
return;
}
- foreach (var mutation in leaf.Mutations)
- {
- var (success, update) = mutation switch
- {
- SubstitutionMutation.MajorComponent => TryGetVersion(replacement, v => $"{v.Major}"),
- SubstitutionMutation.MajorX => TryGetVersion(replacement, v => $"{v.Major}.x"),
- SubstitutionMutation.MajorMinor => TryGetVersion(replacement, v => $"{v.Major}.{v.Minor}"),
- SubstitutionMutation.IncreaseMajor => TryGetVersion(replacement, v => $"{v.Major + 1}.0.0"),
- SubstitutionMutation.IncreaseMinor => TryGetVersion(replacement, v => $"{v.Major}.{v.Minor + 1}.0"),
- SubstitutionMutation.LowerCase => (true, replacement.ToLowerInvariant()),
- SubstitutionMutation.UpperCase => (true, replacement.ToUpperInvariant()),
- SubstitutionMutation.Capitalize => (true, Capitalize(replacement)),
- SubstitutionMutation.KebabCase => (true, ToKebabCase(replacement)),
- SubstitutionMutation.CamelCase => (true, ToCamelCase(replacement)),
- SubstitutionMutation.PascalCase => (true, ToPascalCase(replacement)),
- SubstitutionMutation.SnakeCase => (true, ToSnakeCase(replacement)),
- SubstitutionMutation.TitleCase => (true, TitleCase(replacement)),
- SubstitutionMutation.Trim => (true, Trim(replacement)),
- _ => throw new Exception($"encountered an unknown mutation '{mutation.ToStringFast(true)}'")
- };
- if (!success)
- {
- _ = renderer.Write(leaf.Content);
- return;
- }
- replacement = update;
- }
+ // Apply mutations using shared utility
+ replacement = SubstitutionMutationHelper.ApplyMutations(replacement, leaf.Mutations);
_ = renderer.Write(replacement);
}
-
- private static string ToCamelCase(string str) => JsonNamingPolicy.CamelCase.ConvertName(str.Replace(" ", string.Empty));
- private static string ToSnakeCase(string str) => JsonNamingPolicy.SnakeCaseLower.ConvertName(str).Replace(" ", string.Empty);
- private static string ToKebabCase(string str) => JsonNamingPolicy.KebabCaseLower.ConvertName(str).Replace(" ", string.Empty);
- private static string ToPascalCase(string str) => TitleCase(str).Replace(" ", string.Empty);
-
- private static string TitleCase(string str) => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(str);
-
- private static string Trim(string str) =>
- str.AsSpan().Trim(['!', ' ', '\t', '\r', '\n', '.', ',', ')', '(', ':', ';', '<', '>', '[', ']']).ToString();
-
- private static string Capitalize(string input) =>
- input switch
- {
- null => string.Empty,
- "" => string.Empty,
- _ => string.Concat(input[0].ToString().ToUpper(), input.AsSpan(1))
- };
-
- private (bool, string) TryGetVersion(string version, Func mutate)
- {
- if (!SemVersion.TryParse(version, out var v) && !SemVersion.TryParse(version + ".0", out v))
- return (false, string.Empty);
-
- return (true, mutate(v));
- }
}
public class SubstitutionParser : InlineParser
@@ -177,12 +126,12 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice)
startPosition -= openSticks;
startPosition = Math.Max(startPosition, 0);
- var key = content.ToString().Trim(['{', '}']).Trim().ToLowerInvariant();
+ var rawKey = content.ToString().Trim(['{', '}']).Trim().ToLowerInvariant();
var found = false;
var replacement = string.Empty;
- var components = key.Split('|');
- if (components.Length > 1)
- key = components[0].Trim(['{', '}']).Trim().ToLowerInvariant();
+
+ // Use shared mutation parsing logic
+ var (key, mutationStrings) = SubstitutionMutationHelper.ParseKeyWithMutations(rawKey);
if (context.Substitutions.TryGetValue(key, out var value))
{
@@ -215,19 +164,21 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice)
else
{
List? mutations = null;
- if (components.Length >= 10)
+ if (mutationStrings.Length >= 10)
processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Substitution key {{{key}}} defines too many mutations, none will be applied");
- else if (components.Length > 1)
+ else if (mutationStrings.Length > 0)
{
- foreach (var c in components[1..])
+ foreach (var mutationStr in mutationStrings)
{
- if (SubstitutionMutationExtensions.TryParse(c.Trim(), out var mutation, true, true))
+ // Ensure mutation string is properly trimmed and normalized
+ var trimmedMutation = mutationStr.Trim();
+ if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true))
{
mutations ??= [];
mutations.Add(mutation);
}
else
- processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Mutation '{c}' on {{{key}}} is undefined");
+ processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Mutation '{trimmedMutation}' on {{{key}}} is undefined");
}
}
diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs
index 2acb938e4..f5e01bd21 100644
--- a/src/Elastic.Markdown/Myst/MarkdownParser.cs
+++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs
@@ -5,6 +5,7 @@
using System.IO.Abstractions;
using Cysharp.IO;
using Elastic.Documentation.Configuration;
+using Elastic.Markdown.Helpers;
using Elastic.Markdown.Myst.CodeBlocks;
using Elastic.Markdown.Myst.Comments;
using Elastic.Markdown.Myst.Directives;
@@ -23,7 +24,7 @@
namespace Elastic.Markdown.Myst;
-public class MarkdownParser(BuildContext build, IParserResolvers resolvers)
+public partial class MarkdownParser(BuildContext build, IParserResolvers resolvers)
{
private BuildContext Build { get; } = build;
public IParserResolvers Resolvers { get; } = resolvers;
@@ -69,7 +70,11 @@ public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IPar
CrossLinkResolver = resolvers.CrossLinkResolver
};
var context = new ParserContext(state);
- var markdownDocument = Markdig.Markdown.Parse(markdown, pipeline, context);
+
+ // Preprocess substitutions in link patterns before Markdig parsing
+ var preprocessedMarkdown = PreprocessLinkSubstitutions(markdown, context);
+
+ var markdownDocument = Markdig.Markdown.Parse(preprocessedMarkdown, pipeline, context);
return markdownDocument;
}
@@ -105,7 +110,10 @@ private static async Task ParseAsync(
else
inputMarkdown = await path.FileSystem.File.ReadAllTextAsync(path.FullName, ctx);
- var markdownDocument = Markdig.Markdown.Parse(inputMarkdown, pipeline, context);
+ // Preprocess substitutions in link patterns before Markdig parsing
+ var preprocessedMarkdown = PreprocessLinkSubstitutions(inputMarkdown, (ParserContext)context);
+
+ var markdownDocument = Markdig.Markdown.Parse(preprocessedMarkdown, pipeline, context);
return markdownDocument;
}
@@ -166,4 +174,91 @@ public static MarkdownPipeline Pipeline
return PipelineCached;
}
}
+
+ [System.Text.RegularExpressions.GeneratedRegex(@"\[([^\]]+)\]\(([^\)]+)\)", System.Text.RegularExpressions.RegexOptions.Multiline)]
+ private static partial System.Text.RegularExpressions.Regex LinkPattern();
+
+ ///
+ /// Preprocesses substitutions specifically in link patterns [text](url) before Markdig parsing
+ /// Only processes links that are not inside code blocks with subs=false
+ ///
+ private static string PreprocessLinkSubstitutions(string markdown, ParserContext context)
+ {
+ // Find all code block boundaries to avoid processing links inside subs=false blocks
+ var codeBlockRanges = GetCodeBlockRanges(markdown);
+
+ return LinkPattern().Replace(markdown, match =>
+ {
+ // Check if this link is inside a code block with subs=false
+ if (IsInsideSubsDisabledCodeBlock(match.Index, codeBlockRanges))
+ {
+ return match.Value; // Don't process links in subs=false code blocks
+ }
+
+ var linkText = match.Groups[1].Value;
+ var linkUrl = match.Groups[2].Value;
+
+ // Only preprocess external links to preserve internal link validation behavior
+ // Check if URL contains substitutions and looks like it might resolve to an external URL
+ if (linkUrl.Contains("{{") && (linkUrl.Contains("http") || linkText.Contains("{{")))
+ {
+ // Apply substitutions to both link text and URL
+ var processedText = linkText.ReplaceSubstitutions(context);
+ var processedUrl = linkUrl.ReplaceSubstitutions(context);
+ return $"[{processedText}]({processedUrl})";
+ }
+
+ // Return original match for internal links
+ return match.Value;
+ });
+ }
+
+ private static List<(int start, int end, bool subsDisabled)> GetCodeBlockRanges(string markdown)
+ {
+ var ranges = new List<(int start, int end, bool subsDisabled)>();
+ var lines = markdown.Split('\n');
+ var currentPos = 0;
+
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i];
+
+ // Check for code block start (``` or ````)
+ if (line.TrimStart().StartsWith("```"))
+ {
+ // Check if this line contains subs=false
+ var subsDisabled = line.Contains("subs=false");
+ var blockStart = currentPos;
+
+ // Find the end of the code block
+ var blockEnd = currentPos + line.Length;
+ for (var j = i + 1; j < lines.Length; j++)
+ {
+ blockEnd += lines[j].Length + 1; // +1 for newline
+ if (lines[j].TrimStart().StartsWith("```"))
+ {
+ break;
+ }
+ }
+
+ ranges.Add((blockStart, blockEnd, subsDisabled));
+ }
+
+ currentPos += line.Length + 1; // +1 for newline
+ }
+
+ return ranges;
+ }
+
+ private static bool IsInsideSubsDisabledCodeBlock(int index, List<(int start, int end, bool subsDisabled)> codeBlockRanges)
+ {
+ foreach (var (start, end, subsDisabled) in codeBlockRanges)
+ {
+ if (index >= start && index <= end && subsDisabled)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
}
diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs
index 26af37945..040509985 100644
--- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs
+++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs
@@ -205,3 +205,150 @@ public void OnlySeesGlobalVariable() =>
Html.Should().NotContain("title=\"{{hello-world}}\"")
.And.Contain("title=\"Hello World\"");
}
+
+public class MutationOperatorTest(ITestOutputHelper output) : InlineTest(output,
+"""
+---
+sub:
+ version: "9.0.4"
+---
+
+# Testing Mutation Operators
+
+Version: {{version|M.M}}
+Version with space: {{version | M.M}}
+Major only: {{version|M}}
+Major only with space: {{version | M}}
+Major.x: {{version|M.x}}
+Major.x with space: {{version | M.x}}
+Increase major: {{version|M+1}}
+Increase major with space: {{version | M+1}}
+Increase minor: {{version|M.M+1}}
+Increase minor with space: {{version | M.M+1}}
+"""
+)
+{
+ [Fact]
+ public void MutationOperatorsWorkWithAndWithoutSpaces()
+ {
+ // Both versions with and without spaces should render the same way
+ Html.Should().Contain("Version: 9.0")
+ .And.Contain("Version with space: 9.0")
+ .And.Contain("Major only: 9")
+ .And.Contain("Major only with space: 9")
+ .And.Contain("Major.x: 9.x")
+ .And.Contain("Major.x with space: 9.x")
+ .And.Contain("Increase major: 10.0.0")
+ .And.Contain("Increase major with space: 10.0.0")
+ .And.Contain("Increase minor: 9.1.0")
+ .And.Contain("Increase minor with space: 9.1.0");
+ }
+
+ [Fact]
+ public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
+}
+
+public class MultipleMutationOperatorsTest(ITestOutputHelper output) : InlineTest(output,
+"""
+---
+sub:
+ version: "9.0.4"
+ product: "Elasticsearch"
+---
+
+# Testing Multiple Mutation Operators
+
+Version: {{version|M.M|lc}}
+Version with spaces: {{version | M.M | lc}}
+Product: {{product|uc}}
+Product with spaces: {{product | uc}}
+"""
+)
+{
+ [Fact]
+ public void MultipleMutationOperatorsWorkWithAndWithoutSpaces()
+ {
+ // Both versions with and without spaces should render the same way
+ Html.Should().Contain("Version: 9.0")
+ .And.Contain("Version with spaces: 9.0")
+ .And.Contain("Product: ELASTICSEARCH")
+ .And.Contain("Product with spaces: ELASTICSEARCH");
+ }
+
+ [Fact]
+ public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
+}
+
+public class MutationOperatorsInLinksTest(ITestOutputHelper output) : InlineTest(output,
+"""
+---
+sub:
+ version: "9.0.4"
+ product: "Elasticsearch"
+---
+
+# Testing Mutation Operators in Links
+
+[Link with mutation operator](https://www.elastic.co/guide/en/elasticsearch/reference/{{version|M.M}}/index.html)
+[Link with mutation operator and space](https://www.elastic.co/guide/en/elasticsearch/reference/{{version | M.M}}/index.html)
+[Link text with mutation]({{product|uc}} {{version|M.M}})
+[Link text with mutation and space]({{product | uc}} {{version | M.M}})
+
+"""
+)
+{
+ [Fact]
+ public void MutationOperatorsWorkInLinks()
+ {
+ // Check URL mutations
+ Html.Should().Contain("href=\"https://www.elastic.co/guide/en/elasticsearch/reference/9.0/index.html\"")
+ .And.NotContain("{{version|M.M}}")
+ .And.NotContain("{{version | M.M}}");
+
+ // Check link text mutations
+ Html.Should().Contain("ELASTICSEARCH 9.0")
+ .And.NotContain("{{product|uc}}")
+ .And.NotContain("{{version|M.M}}");
+
+ // Check link text mutations with spaces
+ Html.Should().Contain("ELASTICSEARCH 9.0")
+ .And.NotContain("{{product | uc}}")
+ .And.NotContain("{{version | M.M}}");
+ }
+
+ [Fact]
+ public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
+}
+
+public class MutationOperatorsInCodeBlocksTest(ITestOutputHelper output) : BlockTest(output,
+"""
+---
+sub:
+ version: "9.0.4"
+ product: "Elasticsearch"
+---
+
+# Testing Mutation Operators in Code Blocks
+
+```{code} sh subs=true
+# Install Elasticsearch {{version|M.M}}
+wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-{{version|M.M}}-linux-x86_64.tar.gz
+
+# With space in mutation
+wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-{{version | M.M}}-linux-x86_64.tar.gz
+```
+"""
+)
+{
+ [Fact]
+ public void MutationOperatorsWorkInCodeBlocks()
+ {
+ Html.Should().Contain("# Install Elasticsearch 9.0")
+ .And.Contain("elasticsearch-9.0-linux-x86_64.tar.gz")
+ .And.NotContain("{{version|M.M}}")
+ .And.NotContain("{{version | M.M}}");
+ }
+
+ [Fact]
+ public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
+}