Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion docs/syntax/version-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 20 additions & 7 deletions src/Elastic.Markdown/Helpers/Interpolation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Shared utility for parsing and applying substitution mutations
/// </summary>
public static class SubstitutionMutationHelper
{
/// <summary>
/// Parses a substitution key with mutations and returns the key and mutation components
/// </summary>
/// <param name="rawKey">The raw substitution key (e.g., "version.stack | M.M")</param>
/// <returns>A tuple containing the cleaned key and array of mutation strings</returns>
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);
}

/// <summary>
/// Applies mutations to a value using the existing SubstitutionMutation system
/// </summary>
/// <param name="value">The original value to transform</param>
/// <param name="mutations">Collection of SubstitutionMutation enums to apply</param>
/// <returns>The transformed value</returns>
public static string ApplyMutations(string value, IReadOnlyCollection<SubstitutionMutation> mutations)
{
var result = value;
foreach (var mutation in mutations)
{
result = ApplySingleMutation(result, mutation);
}
return result;
}

/// <summary>
/// Applies mutations to a value using the existing SubstitutionMutation system
/// </summary>
/// <param name="value">The original value to transform</param>
/// <param name="mutations">Array of mutation strings to apply</param>
/// <returns>The transformed value</returns>
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;
}

/// <summary>
/// Applies a single mutation to a value
/// </summary>
/// <param name="value">The value to transform</param>
/// <param name="mutation">The mutation to apply</param>
/// <returns>The transformed value</returns>
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<SemVersion, string> 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');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SemVersion, string> 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
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -215,19 +164,21 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice)
else
{
List<SubstitutionMutation>? 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");
}
}

Expand Down
Loading
Loading