From b338d3e1ea42526c4f02271f4c0580ca7eca2ae4 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 13:16:18 +0200 Subject: [PATCH 01/10] Add tests and fix subs --- src/Elastic.Markdown/Helpers/Interpolation.cs | 72 ++++++++- .../Substitution/SubstitutionParser.cs | 12 +- .../Inline/SubstitutionTest.cs | 147 ++++++++++++++++++ 3 files changed, 226 insertions(+), 5 deletions(-) diff --git a/src/Elastic.Markdown/Helpers/Interpolation.cs b/src/Elastic.Markdown/Helpers/Interpolation.cs index b74b0d7a8..990b5d461 100644 --- a/src/Elastic.Markdown/Helpers/Interpolation.cs +++ b/src/Elastic.Markdown/Helpers/Interpolation.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.RegularExpressions; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.Myst; @@ -67,7 +68,11 @@ private static bool ReplaceSubstitutions( continue; var spanMatch = span.Slice(match.Index, match.Length); - var key = spanMatch.Trim(['{', '}']); + var fullKey = spanMatch.Trim(['{', '}']); + + // Handle mutation operators (same logic as SubstitutionParser) + var components = fullKey.ToString().Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var key = components.Length > 1 ? components[0].Trim() : fullKey.ToString(); foreach (var lookup in lookups) { if (!lookup.TryGetValue(key, out var value)) @@ -75,6 +80,12 @@ private static bool ReplaceSubstitutions( collector?.CollectUsedSubstitutionKey(key); + // Apply mutations if present + if (components.Length > 1) + { + value = ApplyMutations(value, components[1..]); + } + replacement ??= span.ToString(); replacement = replacement.Replace(spanMatch.ToString(), value); replaced = true; @@ -83,4 +94,63 @@ private static bool ReplaceSubstitutions( return replaced; } + + private static string ApplyMutations(string value, string[] mutations) + { + var result = value; + foreach (var mutation in mutations) + { + var mutationStr = mutation.Trim(); + result = mutationStr switch + { + "M" => TryGetVersionMajor(result), + "M.M" => TryGetVersionMajorMinor(result), + "M.x" => TryGetVersionMajorX(result), + "M+1" => TryGetVersionIncreaseMajor(result), + "M.M+1" => TryGetVersionIncreaseMinor(result), + "lc" => result.ToLowerInvariant(), + "uc" => result.ToUpperInvariant(), + "tc" => CultureInfo.CurrentCulture.TextInfo.ToTitleCase(result.ToLowerInvariant()), + "c" => char.ToUpperInvariant(result[0]) + result[1..].ToLowerInvariant(), + "trim" => result.Trim(), + _ => result // Unknown mutation, return unchanged + }; + } + return result; + } + + private static string TryGetVersionMajor(string version) + { + if (Version.TryParse(version, out var v)) + return v.Major.ToString(); + return version; + } + + private static string TryGetVersionMajorMinor(string version) + { + if (Version.TryParse(version, out var v)) + return $"{v.Major}.{v.Minor}"; + return version; + } + + private static string TryGetVersionMajorX(string version) + { + if (Version.TryParse(version, out var v)) + return $"{v.Major}.x"; + return version; + } + + private static string TryGetVersionIncreaseMajor(string version) + { + if (Version.TryParse(version, out var v)) + return $"{v.Major + 1}.0.0"; + return version; + } + + private static string TryGetVersionIncreaseMinor(string version) + { + if (Version.TryParse(version, out var v)) + return $"{v.Major}.{v.Minor + 1}.0"; + return version; + } } diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs index 7b0f33e67..395e7026a 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs @@ -180,9 +180,11 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) var key = content.ToString().Trim(['{', '}']).Trim().ToLowerInvariant(); var found = false; var replacement = string.Empty; - var components = key.Split('|'); + + // Improved handling of pipe-separated components with better whitespace handling + var components = key.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (components.Length > 1) - key = components[0].Trim(['{', '}']).Trim().ToLowerInvariant(); + key = components[0].Trim(); if (context.Substitutions.TryGetValue(key, out var value)) { @@ -221,13 +223,15 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) { foreach (var c in components[1..]) { - if (SubstitutionMutationExtensions.TryParse(c.Trim(), out var mutation, true, true)) + // Ensure mutation string is properly trimmed and normalized + var mutationStr = c.Trim(); + if (SubstitutionMutationExtensions.TryParse(mutationStr, 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 '{mutationStr}' on {{{key}}} is undefined"); } } 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); +} From cce710c6805bce2ac49aff1fa6e36628dad9b1db Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 13:25:03 +0200 Subject: [PATCH 02/10] Remove duplication --- src/Elastic.Markdown/Helpers/Interpolation.cs | 100 ++++++++++-------- 1 file changed, 54 insertions(+), 46 deletions(-) diff --git a/src/Elastic.Markdown/Helpers/Interpolation.cs b/src/Elastic.Markdown/Helpers/Interpolation.cs index 990b5d461..7ab9b1ecf 100644 --- a/src/Elastic.Markdown/Helpers/Interpolation.cs +++ b/src/Elastic.Markdown/Helpers/Interpolation.cs @@ -3,10 +3,12 @@ // See the LICENSE file in the project root for more information using System.Diagnostics.CodeAnalysis; -using System.Globalization; +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; @@ -83,7 +85,7 @@ private static bool ReplaceSubstitutions( // Apply mutations if present if (components.Length > 1) { - value = ApplyMutations(value, components[1..]); + value = ApplyMutationsUsingExistingSystem(value, components[1..]); } replacement ??= span.ToString(); @@ -95,62 +97,68 @@ private static bool ReplaceSubstitutions( return replaced; } - private static string ApplyMutations(string value, string[] mutations) + private static string ApplyMutationsUsingExistingSystem(string value, string[] mutations) { var result = value; - foreach (var mutation in mutations) + foreach (var mutationStr in mutations) { - var mutationStr = mutation.Trim(); - result = mutationStr switch + var trimmedMutation = mutationStr.Trim(); + if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true)) { - "M" => TryGetVersionMajor(result), - "M.M" => TryGetVersionMajorMinor(result), - "M.x" => TryGetVersionMajorX(result), - "M+1" => TryGetVersionIncreaseMajor(result), - "M.M+1" => TryGetVersionIncreaseMinor(result), - "lc" => result.ToLowerInvariant(), - "uc" => result.ToUpperInvariant(), - "tc" => CultureInfo.CurrentCulture.TextInfo.ToTitleCase(result.ToLowerInvariant()), - "c" => char.ToUpperInvariant(result[0]) + result[1..].ToLowerInvariant(), - "trim" => result.Trim(), - _ => result // Unknown mutation, return unchanged - }; + // Use the same logic as SubstitutionRenderer.Write + var (success, update) = mutation switch + { + SubstitutionMutation.MajorComponent => TryGetVersion(result, v => $"{v.Major}"), + SubstitutionMutation.MajorX => TryGetVersion(result, v => $"{v.Major}.x"), + SubstitutionMutation.MajorMinor => TryGetVersion(result, v => $"{v.Major}.{v.Minor}"), + SubstitutionMutation.IncreaseMajor => TryGetVersion(result, v => $"{v.Major + 1}.0.0"), + SubstitutionMutation.IncreaseMinor => TryGetVersion(result, v => $"{v.Major}.{v.Minor + 1}.0"), + SubstitutionMutation.LowerCase => (true, result.ToLowerInvariant()), + SubstitutionMutation.UpperCase => (true, result.ToUpperInvariant()), + SubstitutionMutation.Capitalize => (true, Capitalize(result)), + SubstitutionMutation.KebabCase => (true, ToKebabCase(result)), + SubstitutionMutation.CamelCase => (true, ToCamelCase(result)), + SubstitutionMutation.PascalCase => (true, ToPascalCase(result)), + SubstitutionMutation.SnakeCase => (true, ToSnakeCase(result)), + SubstitutionMutation.TitleCase => (true, TitleCase(result)), + SubstitutionMutation.Trim => (true, Trim(result)), + _ => (false, result) + }; + if (success) + { + result = update; + } + } } return result; } - private static string TryGetVersionMajor(string version) + private static (bool Success, string Result) TryGetVersion(string version, Func transform) { - if (Version.TryParse(version, out var v)) - return v.Major.ToString(); - return version; + if (!SemVersion.TryParse(version, out var v) && !SemVersion.TryParse(version + ".0", out v)) + return (false, version); + return (true, transform(v)); } - private static string TryGetVersionMajorMinor(string version) - { - if (Version.TryParse(version, out var v)) - return $"{v.Major}.{v.Minor}"; - return version; - } + // 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 TryGetVersionMajorX(string version) - { - if (Version.TryParse(version, out var v)) - return $"{v.Major}.x"; - return version; - } + private static string ToKebabCase(string str) => JsonNamingPolicy.KebabCaseLower.ConvertName(str).Replace(" ", string.Empty); - private static string TryGetVersionIncreaseMajor(string version) - { - if (Version.TryParse(version, out var v)) - return $"{v.Major + 1}.0.0"; - return version; - } + private static string ToCamelCase(string str) => JsonNamingPolicy.CamelCase.ConvertName(str).Replace(" ", string.Empty); - private static string TryGetVersionIncreaseMinor(string version) - { - if (Version.TryParse(version, out var v)) - return $"{v.Major}.{v.Minor + 1}.0"; - return version; - } + 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) => System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(str); + + private static string Trim(string str) => + str.AsSpan().Trim(['!', ' ', '\t', '\r', '\n', '.', ',', ')', '(', ':', ';', '<', '>', '[', ']']).ToString(); } From ee27f2a2523dea0cdc92403a14da10418713ab18 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 13:29:49 +0200 Subject: [PATCH 03/10] Refactor --- src/Elastic.Markdown/Helpers/Interpolation.cs | 93 +++------------- .../SubstitutionMutationHelper.cs | 101 ++++++++++++++++++ .../Substitution/SubstitutionParser.cs | 20 ++-- 3 files changed, 124 insertions(+), 90 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs diff --git a/src/Elastic.Markdown/Helpers/Interpolation.cs b/src/Elastic.Markdown/Helpers/Interpolation.cs index 7ab9b1ecf..2c40116e9 100644 --- a/src/Elastic.Markdown/Helpers/Interpolation.cs +++ b/src/Elastic.Markdown/Helpers/Interpolation.cs @@ -72,93 +72,28 @@ private static bool ReplaceSubstitutions( var spanMatch = span.Slice(match.Index, match.Length); var fullKey = spanMatch.Trim(['{', '}']); - // Handle mutation operators (same logic as SubstitutionParser) - var components = fullKey.ToString().Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - var key = components.Length > 1 ? components[0].Trim() : fullKey.ToString(); + // 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; - - collector?.CollectUsedSubstitutionKey(key); - - // Apply mutations if present - if (components.Length > 1) + if (lookup.TryGetValue(cleanKey, out var value)) { - value = ApplyMutationsUsingExistingSystem(value, components[1..]); - } + // Apply mutations if present using shared utility + if (mutations.Length > 0) + { + value = SubstitutionMutationHelper.ApplyMutations(value, mutations); + } - replacement ??= span.ToString(); - replacement = replacement.Replace(spanMatch.ToString(), value); - replaced = true; - } - } + collector?.CollectUsedSubstitutionKey(cleanKey); - return replaced; - } - - private static string ApplyMutationsUsingExistingSystem(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)) - { - // Use the same logic as SubstitutionRenderer.Write - var (success, update) = mutation switch - { - SubstitutionMutation.MajorComponent => TryGetVersion(result, v => $"{v.Major}"), - SubstitutionMutation.MajorX => TryGetVersion(result, v => $"{v.Major}.x"), - SubstitutionMutation.MajorMinor => TryGetVersion(result, v => $"{v.Major}.{v.Minor}"), - SubstitutionMutation.IncreaseMajor => TryGetVersion(result, v => $"{v.Major + 1}.0.0"), - SubstitutionMutation.IncreaseMinor => TryGetVersion(result, v => $"{v.Major}.{v.Minor + 1}.0"), - SubstitutionMutation.LowerCase => (true, result.ToLowerInvariant()), - SubstitutionMutation.UpperCase => (true, result.ToUpperInvariant()), - SubstitutionMutation.Capitalize => (true, Capitalize(result)), - SubstitutionMutation.KebabCase => (true, ToKebabCase(result)), - SubstitutionMutation.CamelCase => (true, ToCamelCase(result)), - SubstitutionMutation.PascalCase => (true, ToPascalCase(result)), - SubstitutionMutation.SnakeCase => (true, ToSnakeCase(result)), - SubstitutionMutation.TitleCase => (true, TitleCase(result)), - SubstitutionMutation.Trim => (true, Trim(result)), - _ => (false, result) - }; - if (success) - { - result = update; + replacement ??= span.ToString(); + replacement = replacement.Replace(spanMatch.ToString(), value); + replaced = true; } } } - return result; - } - 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)); + return replaced; } - - // 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) => System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(str); - - private static string Trim(string str) => - str.AsSpan().Trim(['!', ' ', '\t', '\r', '\n', '.', ',', ')', '(', ':', ';', '<', '>', '[', ']']).ToString(); } 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..e4a914310 --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs @@ -0,0 +1,101 @@ +// 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 + /// 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)) + { + // Use the same logic as SubstitutionRenderer.Write + var (success, update) = mutation switch + { + SubstitutionMutation.MajorComponent => TryGetVersion(result, v => $"{v.Major}"), + SubstitutionMutation.MajorX => TryGetVersion(result, v => $"{v.Major}.x"), + SubstitutionMutation.MajorMinor => TryGetVersion(result, v => $"{v.Major}.{v.Minor}"), + SubstitutionMutation.IncreaseMajor => TryGetVersion(result, v => $"{v.Major + 1}.0.0"), + SubstitutionMutation.IncreaseMinor => TryGetVersion(result, v => $"{v.Major}.{v.Minor + 1}.0"), + SubstitutionMutation.LowerCase => (true, result.ToLowerInvariant()), + SubstitutionMutation.UpperCase => (true, result.ToUpperInvariant()), + SubstitutionMutation.Capitalize => (true, Capitalize(result)), + SubstitutionMutation.KebabCase => (true, ToKebabCase(result)), + SubstitutionMutation.CamelCase => (true, ToCamelCase(result)), + SubstitutionMutation.PascalCase => (true, ToPascalCase(result)), + SubstitutionMutation.SnakeCase => (true, ToSnakeCase(result)), + SubstitutionMutation.TitleCase => (true, TitleCase(result)), + SubstitutionMutation.Trim => (true, Trim(result)), + _ => (false, result) + }; + if (success) + { + result = update; + } + } + } + return result; + } + + 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) => JsonNamingPolicy.CamelCase.ConvertName(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.Trim(); +} diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs index 395e7026a..28a1b83a4 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs @@ -177,14 +177,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; - // Improved handling of pipe-separated components with better whitespace handling - var components = key.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - if (components.Length > 1) - key = components[0].Trim(); + // Use shared mutation parsing logic + var (key, mutationStrings) = SubstitutionMutationHelper.ParseKeyWithMutations(rawKey); if (context.Substitutions.TryGetValue(key, out var value)) { @@ -217,21 +215,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) { // Ensure mutation string is properly trimmed and normalized - var mutationStr = c.Trim(); - if (SubstitutionMutationExtensions.TryParse(mutationStr, out var mutation, true, true)) + 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 '{mutationStr}' on {{{key}}} is undefined"); + processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Mutation '{trimmedMutation}' on {{{key}}} is undefined"); } } From d9c344e6e5c268cbb79f15d4bb7e0ffe8ab1918e Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 13:31:55 +0200 Subject: [PATCH 04/10] Update docs --- docs/syntax/version-variables.md | 49 +++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/syntax/version-variables.md b/docs/syntax/version-variables.md index 42a06a0da..f164855d4 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 +[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 +```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 From 7897b6b4c837bdc27f7e7ecc725c0664b8ef06b3 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 13:47:32 +0200 Subject: [PATCH 05/10] Remove dupe logic --- .../Substitution/SubstitutionParser.cs | 56 +------------------ 1 file changed, 3 insertions(+), 53 deletions(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs index 28a1b83a4..8f260ac44 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs @@ -66,61 +66,11 @@ 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 + var mutationStrings = leaf.Mutations.Select(m => m.ToStringFast(true)).ToArray(); + replacement = SubstitutionMutationHelper.ApplyMutations(replacement, mutationStrings); _ = 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 From b647f624312737f5d26f1194444bfcefa064135b Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 14:49:34 +0200 Subject: [PATCH 06/10] Fix rendering --- src/Elastic.Markdown/Myst/MarkdownParser.cs | 40 +++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 2acb938e4..cc4ab15af 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,30 @@ 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 + /// + private static string PreprocessLinkSubstitutions(string markdown, ParserContext context) => + LinkPattern().Replace(markdown, match => + { + 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; + }); } From c8d27f8069c92ffe255aaa627bb3b1e8e666dda5 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 14:53:57 +0200 Subject: [PATCH 07/10] Peer edits --- .../SubstitutionMutationHelper.cs | 69 ++++++++++++------- .../Substitution/SubstitutionParser.cs | 3 +- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs index e4a914310..6a51207a7 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs @@ -29,6 +29,22 @@ public static (string Key, string[] Mutations) ParseKeyWithMutations(string rawK 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 /// @@ -43,34 +59,41 @@ public static string ApplyMutations(string value, string[] mutations) var trimmedMutation = mutationStr.Trim(); if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true)) { - // Use the same logic as SubstitutionRenderer.Write - var (success, update) = mutation switch - { - SubstitutionMutation.MajorComponent => TryGetVersion(result, v => $"{v.Major}"), - SubstitutionMutation.MajorX => TryGetVersion(result, v => $"{v.Major}.x"), - SubstitutionMutation.MajorMinor => TryGetVersion(result, v => $"{v.Major}.{v.Minor}"), - SubstitutionMutation.IncreaseMajor => TryGetVersion(result, v => $"{v.Major + 1}.0.0"), - SubstitutionMutation.IncreaseMinor => TryGetVersion(result, v => $"{v.Major}.{v.Minor + 1}.0"), - SubstitutionMutation.LowerCase => (true, result.ToLowerInvariant()), - SubstitutionMutation.UpperCase => (true, result.ToUpperInvariant()), - SubstitutionMutation.Capitalize => (true, Capitalize(result)), - SubstitutionMutation.KebabCase => (true, ToKebabCase(result)), - SubstitutionMutation.CamelCase => (true, ToCamelCase(result)), - SubstitutionMutation.PascalCase => (true, ToPascalCase(result)), - SubstitutionMutation.SnakeCase => (true, ToSnakeCase(result)), - SubstitutionMutation.TitleCase => (true, TitleCase(result)), - SubstitutionMutation.Trim => (true, Trim(result)), - _ => (false, result) - }; - if (success) - { - result = update; - } + 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)) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs index 8f260ac44..08422bd72 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs @@ -67,8 +67,7 @@ protected override void Write(HtmlRenderer renderer, SubstitutionLeaf leaf) } // Apply mutations using shared utility - var mutationStrings = leaf.Mutations.Select(m => m.ToStringFast(true)).ToArray(); - replacement = SubstitutionMutationHelper.ApplyMutations(replacement, mutationStrings); + replacement = SubstitutionMutationHelper.ApplyMutations(replacement, leaf.Mutations); _ = renderer.Write(replacement); } } From 3de5f7cba45ed0fc1d55509e4fc563275eb55821 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 15:02:33 +0200 Subject: [PATCH 08/10] Fix capitalization issue --- .../InlineParsers/Substitution/SubstitutionMutationHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs index 6a51207a7..10b347d92 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs @@ -114,11 +114,11 @@ private static string Capitalize(string input) => private static string ToCamelCase(string str) => JsonNamingPolicy.CamelCase.ConvertName(str).Replace(" ", string.Empty); - private static string ToPascalCase(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.Trim(); + private static string Trim(string str) => str.TrimEnd('!', '.', ',', ';', ':', '?', ' ', '\t', '\n', '\r'); } From 75e94068c4cc62cdc27a6b6f845e2775bf644322 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 15:07:08 +0200 Subject: [PATCH 09/10] Subs in docs --- docs/syntax/version-variables.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/syntax/version-variables.md b/docs/syntax/version-variables.md index f164855d4..280c57b18 100644 --- a/docs/syntax/version-variables.md +++ b/docs/syntax/version-variables.md @@ -34,7 +34,7 @@ Mutation operators also work correctly in links and code blocks, making them ver Mutation operators can be used in both link URLs and link text: -```markdown +```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) ``` @@ -48,7 +48,7 @@ Which renders as: Mutation operators work in enhanced code blocks when `subs=true` is specified: -````markdown +````markdown subs=false ```bash subs=true curl -X GET "localhost:9200/_cluster/health?v&pretty" echo "Elasticsearch {{version.stack | M.M}} is running" From 68b4edc714c22c9c2bcd46955d6c73288f9f9e59 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 23 Jul 2025 15:20:49 +0200 Subject: [PATCH 10/10] Fix glitch in code blocks --- src/Elastic.Markdown/Myst/MarkdownParser.cs | 65 ++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index cc4ab15af..f5e01bd21 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -180,10 +180,21 @@ public static MarkdownPipeline Pipeline /// /// 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) => - LinkPattern().Replace(markdown, match => + 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; @@ -200,4 +211,54 @@ private static string PreprocessLinkSubstitutions(string markdown, ParserContext // 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; + } }