Skip to content

Commit 6c41484

Browse files
authored
Fix mutation operators not working in links and code blocks (#1606)
* Add tests and fix subs * Remove duplication * Refactor * Update docs * Remove dupe logic * Fix rendering * Peer edits * Fix capitalization issue * Subs in docs * Fix glitch in code blocks
1 parent 447e529 commit 6c41484

File tree

6 files changed

+450
-73
lines changed

6 files changed

+450
-73
lines changed

docs/syntax/version-variables.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,54 @@ can be printed in any kind of ways.
2626
| `{{version.stack | M+1 | M }}` | {{version.stack | M+1 | M }} |
2727
| `{{version.stack.base | M.M+1 }}` | {{version.stack.base | M.M+1 }} |
2828

29-
## Available versioning schemes.
29+
## Mutation Operators in Links and Code Blocks
30+
31+
Mutation operators also work correctly in links and code blocks, making them versatile for various documentation contexts.
32+
33+
### In Links
34+
35+
Mutation operators can be used in both link URLs and link text:
36+
37+
```markdown subs=false
38+
[Download version {{version.stack | M.M}}](https://download.elastic.co/{{version.stack | M.M}}/elasticsearch.tar.gz)
39+
[Latest major version](https://elastic.co/guide/en/elasticsearch/reference/{{version.stack | M}}/index.html)
40+
```
41+
42+
Which renders as:
43+
44+
[Download version {{version.stack | M.M}}](https://download.elastic.co/{{version.stack | M.M}}/elasticsearch.tar.gz)
45+
[Latest major version](https://elastic.co/guide/en/elasticsearch/reference/{{version.stack | M}}/index.html)
46+
47+
### In Code Blocks
48+
49+
Mutation operators work in enhanced code blocks when `subs=true` is specified:
50+
51+
````markdown subs=false
52+
```bash subs=true
53+
curl -X GET "localhost:9200/_cluster/health?v&pretty"
54+
echo "Elasticsearch {{version.stack | M.M}} is running"
55+
```
56+
````
57+
58+
Which renders as:
59+
60+
```bash subs=true
61+
curl -X GET "localhost:9200/_cluster/health?v&pretty"
62+
echo "Elasticsearch {{version.stack | M.M}} is running"
63+
```
64+
65+
### Whitespace Handling
66+
67+
Mutation operators are robust and handle whitespace around the pipe character correctly:
68+
69+
| Syntax | Result | Notes |
70+
|--------|--------| ----- |
71+
| `{{version.stack|M.M}}` | {{version.stack|M.M}} | No spaces |
72+
| `{{version.stack | M.M}}` | {{version.stack | M.M}} | Spaces around pipe |
73+
| `{{version.stack |M.M}}` | {{version.stack |M.M}} | Space before pipe |
74+
| `{{version.stack| M.M}}` | {{version.stack| M.M}} | Space after pipe |
75+
76+
## Available versioning schemes
3077

3178
This is dictated by the [`versions.yml`](https://github.com/elastic/docs-builder/blob/main/config/versions.yml) configuration file
3279

src/Elastic.Markdown/Helpers/Interpolation.cs

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

55
using System.Diagnostics.CodeAnalysis;
6+
using System.Text.Json;
67
using System.Text.RegularExpressions;
8+
using Elastic.Documentation;
79
using Elastic.Documentation.Diagnostics;
810
using Elastic.Markdown.Myst;
11+
using Elastic.Markdown.Myst.InlineParsers.Substitution;
912

1013
namespace Elastic.Markdown.Helpers;
1114

@@ -67,17 +70,27 @@ private static bool ReplaceSubstitutions(
6770
continue;
6871

6972
var spanMatch = span.Slice(match.Index, match.Length);
70-
var key = spanMatch.Trim(['{', '}']);
73+
var fullKey = spanMatch.Trim(['{', '}']);
74+
75+
// Enhanced mutation support: parse key and mutations using shared utility
76+
var (cleanKey, mutations) = SubstitutionMutationHelper.ParseKeyWithMutations(fullKey.ToString());
77+
7178
foreach (var lookup in lookups)
7279
{
73-
if (!lookup.TryGetValue(key, out var value))
74-
continue;
80+
if (lookup.TryGetValue(cleanKey, out var value))
81+
{
82+
// Apply mutations if present using shared utility
83+
if (mutations.Length > 0)
84+
{
85+
value = SubstitutionMutationHelper.ApplyMutations(value, mutations);
86+
}
7587

76-
collector?.CollectUsedSubstitutionKey(key);
88+
collector?.CollectUsedSubstitutionKey(cleanKey);
7789

78-
replacement ??= span.ToString();
79-
replacement = replacement.Replace(spanMatch.ToString(), value);
80-
replaced = true;
90+
replacement ??= span.ToString();
91+
replacement = replacement.Replace(spanMatch.ToString(), value);
92+
replaced = true;
93+
}
8194
}
8295
}
8396

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System;
6+
using System.Linq;
7+
using System.Text.Json;
8+
using Elastic.Documentation;
9+
10+
namespace Elastic.Markdown.Myst.InlineParsers.Substitution;
11+
12+
/// <summary>
13+
/// Shared utility for parsing and applying substitution mutations
14+
/// </summary>
15+
public static class SubstitutionMutationHelper
16+
{
17+
/// <summary>
18+
/// Parses a substitution key with mutations and returns the key and mutation components
19+
/// </summary>
20+
/// <param name="rawKey">The raw substitution key (e.g., "version.stack | M.M")</param>
21+
/// <returns>A tuple containing the cleaned key and array of mutation strings</returns>
22+
public static (string Key, string[] Mutations) ParseKeyWithMutations(string rawKey)
23+
{
24+
// Improved handling of pipe-separated components with better whitespace handling
25+
var components = rawKey.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
26+
var key = components[0].Trim();
27+
var mutations = components.Length > 1 ? components[1..] : [];
28+
29+
return (key, mutations);
30+
}
31+
32+
/// <summary>
33+
/// Applies mutations to a value using the existing SubstitutionMutation system
34+
/// </summary>
35+
/// <param name="value">The original value to transform</param>
36+
/// <param name="mutations">Collection of SubstitutionMutation enums to apply</param>
37+
/// <returns>The transformed value</returns>
38+
public static string ApplyMutations(string value, IReadOnlyCollection<SubstitutionMutation> mutations)
39+
{
40+
var result = value;
41+
foreach (var mutation in mutations)
42+
{
43+
result = ApplySingleMutation(result, mutation);
44+
}
45+
return result;
46+
}
47+
48+
/// <summary>
49+
/// Applies mutations to a value using the existing SubstitutionMutation system
50+
/// </summary>
51+
/// <param name="value">The original value to transform</param>
52+
/// <param name="mutations">Array of mutation strings to apply</param>
53+
/// <returns>The transformed value</returns>
54+
public static string ApplyMutations(string value, string[] mutations)
55+
{
56+
var result = value;
57+
foreach (var mutationStr in mutations)
58+
{
59+
var trimmedMutation = mutationStr.Trim();
60+
if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true))
61+
{
62+
result = ApplySingleMutation(result, mutation);
63+
}
64+
}
65+
return result;
66+
}
67+
68+
/// <summary>
69+
/// Applies a single mutation to a value
70+
/// </summary>
71+
/// <param name="value">The value to transform</param>
72+
/// <param name="mutation">The mutation to apply</param>
73+
/// <returns>The transformed value</returns>
74+
private static string ApplySingleMutation(string value, SubstitutionMutation mutation)
75+
{
76+
var (success, result) = mutation switch
77+
{
78+
SubstitutionMutation.MajorComponent => TryGetVersion(value, v => $"{v.Major}"),
79+
SubstitutionMutation.MajorX => TryGetVersion(value, v => $"{v.Major}.x"),
80+
SubstitutionMutation.MajorMinor => TryGetVersion(value, v => $"{v.Major}.{v.Minor}"),
81+
SubstitutionMutation.IncreaseMajor => TryGetVersion(value, v => $"{v.Major + 1}.0.0"),
82+
SubstitutionMutation.IncreaseMinor => TryGetVersion(value, v => $"{v.Major}.{v.Minor + 1}.0"),
83+
SubstitutionMutation.LowerCase => (true, value.ToLowerInvariant()),
84+
SubstitutionMutation.UpperCase => (true, value.ToUpperInvariant()),
85+
SubstitutionMutation.Capitalize => (true, Capitalize(value)),
86+
SubstitutionMutation.KebabCase => (true, ToKebabCase(value)),
87+
SubstitutionMutation.CamelCase => (true, ToCamelCase(value)),
88+
SubstitutionMutation.PascalCase => (true, ToPascalCase(value)),
89+
SubstitutionMutation.SnakeCase => (true, ToSnakeCase(value)),
90+
SubstitutionMutation.TitleCase => (true, TitleCase(value)),
91+
SubstitutionMutation.Trim => (true, Trim(value)),
92+
_ => (false, value)
93+
};
94+
return success ? result : value;
95+
}
96+
97+
private static (bool Success, string Result) TryGetVersion(string version, Func<SemVersion, string> transform)
98+
{
99+
if (!SemVersion.TryParse(version, out var v) && !SemVersion.TryParse(version + ".0", out v))
100+
return (false, version);
101+
return (true, transform(v));
102+
}
103+
104+
// These methods match the exact implementation in SubstitutionRenderer
105+
private static string Capitalize(string input) =>
106+
input switch
107+
{
108+
null => string.Empty,
109+
"" => string.Empty,
110+
_ => string.Concat(input[0].ToString().ToUpper(), input.AsSpan(1))
111+
};
112+
113+
private static string ToKebabCase(string str) => JsonNamingPolicy.KebabCaseLower.ConvertName(str).Replace(" ", string.Empty);
114+
115+
private static string ToCamelCase(string str) => JsonNamingPolicy.CamelCase.ConvertName(str).Replace(" ", string.Empty);
116+
117+
private static string ToPascalCase(string str) => TitleCase(str).Replace(" ", string.Empty);
118+
119+
private static string ToSnakeCase(string str) => JsonNamingPolicy.SnakeCaseLower.ConvertName(str).Replace(" ", string.Empty);
120+
121+
private static string TitleCase(string str) => str.Split(' ').Select(word => Capitalize(word)).Aggregate((a, b) => $"{a} {b}");
122+
123+
private static string Trim(string str) => str.TrimEnd('!', '.', ',', ';', ':', '?', ' ', '\t', '\n', '\r');
124+
}

src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs

Lines changed: 13 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -66,61 +66,10 @@ protected override void Write(HtmlRenderer renderer, SubstitutionLeaf leaf)
6666
return;
6767
}
6868

69-
foreach (var mutation in leaf.Mutations)
70-
{
71-
var (success, update) = mutation switch
72-
{
73-
SubstitutionMutation.MajorComponent => TryGetVersion(replacement, v => $"{v.Major}"),
74-
SubstitutionMutation.MajorX => TryGetVersion(replacement, v => $"{v.Major}.x"),
75-
SubstitutionMutation.MajorMinor => TryGetVersion(replacement, v => $"{v.Major}.{v.Minor}"),
76-
SubstitutionMutation.IncreaseMajor => TryGetVersion(replacement, v => $"{v.Major + 1}.0.0"),
77-
SubstitutionMutation.IncreaseMinor => TryGetVersion(replacement, v => $"{v.Major}.{v.Minor + 1}.0"),
78-
SubstitutionMutation.LowerCase => (true, replacement.ToLowerInvariant()),
79-
SubstitutionMutation.UpperCase => (true, replacement.ToUpperInvariant()),
80-
SubstitutionMutation.Capitalize => (true, Capitalize(replacement)),
81-
SubstitutionMutation.KebabCase => (true, ToKebabCase(replacement)),
82-
SubstitutionMutation.CamelCase => (true, ToCamelCase(replacement)),
83-
SubstitutionMutation.PascalCase => (true, ToPascalCase(replacement)),
84-
SubstitutionMutation.SnakeCase => (true, ToSnakeCase(replacement)),
85-
SubstitutionMutation.TitleCase => (true, TitleCase(replacement)),
86-
SubstitutionMutation.Trim => (true, Trim(replacement)),
87-
_ => throw new Exception($"encountered an unknown mutation '{mutation.ToStringFast(true)}'")
88-
};
89-
if (!success)
90-
{
91-
_ = renderer.Write(leaf.Content);
92-
return;
93-
}
94-
replacement = update;
95-
}
69+
// Apply mutations using shared utility
70+
replacement = SubstitutionMutationHelper.ApplyMutations(replacement, leaf.Mutations);
9671
_ = renderer.Write(replacement);
9772
}
98-
99-
private static string ToCamelCase(string str) => JsonNamingPolicy.CamelCase.ConvertName(str.Replace(" ", string.Empty));
100-
private static string ToSnakeCase(string str) => JsonNamingPolicy.SnakeCaseLower.ConvertName(str).Replace(" ", string.Empty);
101-
private static string ToKebabCase(string str) => JsonNamingPolicy.KebabCaseLower.ConvertName(str).Replace(" ", string.Empty);
102-
private static string ToPascalCase(string str) => TitleCase(str).Replace(" ", string.Empty);
103-
104-
private static string TitleCase(string str) => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(str);
105-
106-
private static string Trim(string str) =>
107-
str.AsSpan().Trim(['!', ' ', '\t', '\r', '\n', '.', ',', ')', '(', ':', ';', '<', '>', '[', ']']).ToString();
108-
109-
private static string Capitalize(string input) =>
110-
input switch
111-
{
112-
null => string.Empty,
113-
"" => string.Empty,
114-
_ => string.Concat(input[0].ToString().ToUpper(), input.AsSpan(1))
115-
};
116-
117-
private (bool, string) TryGetVersion(string version, Func<SemVersion, string> mutate)
118-
{
119-
if (!SemVersion.TryParse(version, out var v) && !SemVersion.TryParse(version + ".0", out v))
120-
return (false, string.Empty);
121-
122-
return (true, mutate(v));
123-
}
12473
}
12574

12675
public class SubstitutionParser : InlineParser
@@ -177,12 +126,12 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice)
177126
startPosition -= openSticks;
178127
startPosition = Math.Max(startPosition, 0);
179128

180-
var key = content.ToString().Trim(['{', '}']).Trim().ToLowerInvariant();
129+
var rawKey = content.ToString().Trim(['{', '}']).Trim().ToLowerInvariant();
181130
var found = false;
182131
var replacement = string.Empty;
183-
var components = key.Split('|');
184-
if (components.Length > 1)
185-
key = components[0].Trim(['{', '}']).Trim().ToLowerInvariant();
132+
133+
// Use shared mutation parsing logic
134+
var (key, mutationStrings) = SubstitutionMutationHelper.ParseKeyWithMutations(rawKey);
186135

187136
if (context.Substitutions.TryGetValue(key, out var value))
188137
{
@@ -215,19 +164,21 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice)
215164
else
216165
{
217166
List<SubstitutionMutation>? mutations = null;
218-
if (components.Length >= 10)
167+
if (mutationStrings.Length >= 10)
219168
processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Substitution key {{{key}}} defines too many mutations, none will be applied");
220-
else if (components.Length > 1)
169+
else if (mutationStrings.Length > 0)
221170
{
222-
foreach (var c in components[1..])
171+
foreach (var mutationStr in mutationStrings)
223172
{
224-
if (SubstitutionMutationExtensions.TryParse(c.Trim(), out var mutation, true, true))
173+
// Ensure mutation string is properly trimmed and normalized
174+
var trimmedMutation = mutationStr.Trim();
175+
if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true))
225176
{
226177
mutations ??= [];
227178
mutations.Add(mutation);
228179
}
229180
else
230-
processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Mutation '{c}' on {{{key}}} is undefined");
181+
processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Mutation '{trimmedMutation}' on {{{key}}} is undefined");
231182
}
232183
}
233184

0 commit comments

Comments
 (0)