diff --git a/src/Elastic.Markdown/Helpers/SemVersion.cs b/src/Elastic.Markdown/Helpers/SemVersion.cs
new file mode 100644
index 0000000..d59f7ca
--- /dev/null
+++ b/src/Elastic.Markdown/Helpers/SemVersion.cs
@@ -0,0 +1,314 @@
+// 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.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Elastic.Markdown.Helpers;
+
+///
+/// A semver2 compatible version.
+///
+public sealed class SemVersion :
+ IEquatable,
+ IComparable,
+ IComparable
+{
+ // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
+ private static readonly Regex Regex = new(@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$");
+
+ ///
+ /// The major version part.
+ ///
+ public int Major { get; }
+
+ ///
+ /// The minor version part.
+ ///
+ public int Minor { get; }
+
+ ///
+ /// The patch version part.
+ ///
+ public int Patch { get; }
+
+ ///
+ /// The prerelease version part.
+ ///
+ public string Prerelease { get; }
+
+ ///
+ /// The metadata version part.
+ ///
+ public string Metadata { get; }
+
+ ///
+ /// Initializes a new instance.
+ ///
+ /// The major version part.
+ /// The minor version part.
+ /// The patch version part.
+ public SemVersion(int major, int minor, int patch)
+ {
+ Major = major;
+ Minor = minor;
+ Patch = patch;
+ Prerelease = string.Empty;
+ Metadata = string.Empty;
+ }
+
+ ///
+ /// Initializes a new instance.
+ ///
+ /// The major version part.
+ /// The minor version part.
+ /// The patch version part.
+ /// The prerelease version part.
+ public SemVersion(int major, int minor, int patch, string? prerelease)
+ {
+ Major = major;
+ Minor = minor;
+ Patch = patch;
+ Prerelease = prerelease ?? string.Empty;
+ Metadata = string.Empty;
+ }
+
+ ///
+ /// Initializes a new instance.
+ ///
+ /// The major version part.
+ /// The minor version part.
+ /// The patch version part.
+ /// The prerelease version part.
+ /// The metadata version part.
+ public SemVersion(int major, int minor, int patch, string? prerelease, string? metadata)
+ {
+ Major = major;
+ Minor = minor;
+ Patch = patch;
+ Prerelease = prerelease ?? string.Empty;
+ Metadata = metadata ?? string.Empty;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool operator ==(SemVersion left, SemVersion right) => Equals(left, right);
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool operator !=(SemVersion left, SemVersion right) => !Equals(left, right);
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool operator >(SemVersion left, SemVersion right) => (left.CompareTo(right) > 0);
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool operator >=(SemVersion left, SemVersion right) => (left == right) || (left > right);
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool operator <(SemVersion left, SemVersion right) => (left.CompareTo(right) < 0);
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool operator <=(SemVersion left, SemVersion right) => (left == right) || (left < right);
+
+ ///
+ /// Tries to initialize a new instance from the given string.
+ ///
+ /// The semver2 compatible version string.
+ /// The parsed instance.
+ /// True if the passed string is a valid semver2 version string or false, if not.
+ public static bool TryParse(string input, [NotNullWhen(true)] out SemVersion? version)
+ {
+ version = null;
+
+ var match = Regex.Match(input);
+ if (!match.Success)
+ return false;
+
+ if (!int.TryParse(match.Groups[1].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var major))
+ return false;
+ if (!int.TryParse(match.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var minor))
+ return false;
+ if (!int.TryParse(match.Groups[3].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var patch))
+ return false;
+
+ version = new SemVersion(major, minor, patch, match.Groups[4].Value, match.Groups[5].Value);
+
+ return true;
+ }
+
+ ///
+ /// Returns a new instance with updated components. Unchanged parts should be set to null.
+ ///
+ /// The major version part, or null to keep the current value.
+ /// The minor version part, or null to keep the current value.
+ /// The patch version part, or null to keep the current value.
+ /// The prerelease version part, or null to keep the current value.
+ /// The metadata version part, or null to keep the current value.
+ ///
+ public SemVersion Update(int? major = null, int? minor = null, int? patch = null, string? prerelease = null, string? metadata = null) =>
+ new(major ?? Major,
+ minor ?? Minor,
+ patch ?? Patch,
+ prerelease ?? Prerelease,
+ metadata ?? Metadata);
+
+ ///
+ /// Compares the current version to another version in a natural way (by component/part precedence).
+ ///
+ /// The to compare to.
+ /// 0 if both versions are equal, a positive number, if the other version is lower or a negative number if the other version is higher.
+ public int CompareByPrecedence(SemVersion? other)
+ {
+ if (ReferenceEquals(other, null))
+ return 1;
+
+ var result = Major.CompareTo(other.Major);
+ if (result != 0)
+ return result;
+
+ result = Minor.CompareTo(other.Minor);
+ if (result != 0)
+ return result;
+
+ result = Patch.CompareTo(other.Patch);
+ if (result != 0)
+ return result;
+
+ result = CompareComponent(Prerelease, other.Prerelease, true);
+ if (result != 0)
+ return result;
+
+ return CompareComponent(Prerelease, other.Metadata, true);
+ }
+
+ ///
+ public int CompareTo(SemVersion? other)
+ {
+ if (ReferenceEquals(other, null))
+ return 1;
+
+ return CompareByPrecedence(other);
+ }
+
+ ///
+ public int CompareTo(object? obj) => CompareTo(obj as SemVersion);
+
+ ///
+ public bool Equals(SemVersion? other)
+ {
+ if (ReferenceEquals(null, other))
+ return false;
+
+ if (ReferenceEquals(this, other))
+ return true;
+
+ return (Major == other.Major) && (Minor == other.Minor) && (Patch == other.Patch) &&
+ (Prerelease == other.Prerelease) && (Metadata == other.Metadata);
+ }
+
+ ///
+ public override bool Equals(object? obj) => ReferenceEquals(this, obj) || obj is SemVersion other && Equals(other);
+
+ ///
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var hashCode = Major;
+ hashCode = (hashCode * 397) ^ Minor;
+ hashCode = (hashCode * 397) ^ Patch;
+ hashCode = (hashCode * 397) ^ Prerelease.GetHashCode();
+ hashCode = (hashCode * 397) ^ Metadata.GetHashCode();
+ return hashCode;
+ }
+ }
+
+ ///
+ public override string ToString()
+ {
+ var version = $"{Major}.{Minor}.{Patch}";
+
+ if (!string.IsNullOrEmpty(Prerelease))
+ version += "-" + Prerelease;
+ if (!string.IsNullOrEmpty(Metadata))
+ version += "+" + Metadata;
+
+ return version;
+ }
+
+ private static int CompareComponent(string a, string b, bool lower = false)
+ {
+ var aEmpty = string.IsNullOrEmpty(a);
+ var bEmpty = string.IsNullOrEmpty(b);
+ if (aEmpty && bEmpty)
+ return 0;
+
+ if (aEmpty)
+ return lower ? 1 : -1;
+ if (bEmpty)
+ return lower ? -1 : 1;
+
+ var aComps = a.Split('.');
+ var bComps = b.Split('.');
+
+ var minLen = Math.Min(aComps.Length, bComps.Length);
+ for (var i = 0; i < minLen; i++)
+ {
+ var ac = aComps[i];
+ var bc = bComps[i];
+ var isanum = int.TryParse(ac, out var anum);
+ var isbnum = int.TryParse(bc, out var bnum);
+ int r;
+ if (isanum && isbnum)
+ {
+ r = anum.CompareTo(bnum);
+ if (r != 0)
+ return anum.CompareTo(bnum);
+ }
+ else
+ {
+ if (isanum)
+ return -1;
+ if (isbnum)
+ return 1;
+
+ r = string.CompareOrdinal(ac, bc);
+ if (r != 0)
+ return r;
+ }
+ }
+
+ return aComps.Length.CompareTo(bComps.Length);
+ }
+}
+
diff --git a/src/Elastic.Markdown/Myst/Directives/VersionBlock.cs b/src/Elastic.Markdown/Myst/Directives/VersionBlock.cs
index b145f50..ad225e7 100644
--- a/src/Elastic.Markdown/Myst/Directives/VersionBlock.cs
+++ b/src/Elastic.Markdown/Myst/Directives/VersionBlock.cs
@@ -1,6 +1,9 @@
// 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 Elastic.Markdown.Helpers;
+
namespace Elastic.Markdown.Myst.Directives;
public class VersionBlock(DirectiveBlockParser parser, string directive, Dictionary properties)
@@ -8,20 +11,30 @@ public class VersionBlock(DirectiveBlockParser parser, string directive, Diction
{
public override string Directive => directive;
public string Class => directive.Replace("version", "");
+ public SemVersion? Version { get; private set; }
+
+ public string Title { get; private set; } = string.Empty;
- public string Title
+ public override void FinalizeAndValidate(ParserContext context)
{
- get
+ var tokens = Arguments?.Split(" ", 2, StringSplitOptions.RemoveEmptyEntries) ?? [];
+ if (tokens.Length < 1)
{
- var title = Thread.CurrentThread.CurrentCulture.TextInfo.ToTitleCase(directive.Replace("version", "version "));
- if (!string.IsNullOrEmpty(Arguments))
- title += $" {Arguments}";
+ EmitError(context, $"{directive} needs exactly 2 arguments: ");
+ return;
+ }
- return title;
+ if (!SemVersion.TryParse(tokens[0], out var version))
+ {
+ EmitError(context, $"{tokens[0]} is not a valid version");
+ return;
}
- }
- public override void FinalizeAndValidate(ParserContext context)
- {
+ Version = version;
+ var title = Thread.CurrentThread.CurrentCulture.TextInfo.ToTitleCase(directive.Replace("version", "version "));
+ title += $" ({Version})";
+ if (tokens.Length > 1 && !string.IsNullOrWhiteSpace(tokens[1]))
+ title += $": {tokens[1]}";
+ Title = title;
}
}
diff --git a/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs b/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs
index 5657365..8e0573e 100644
--- a/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs
+++ b/tests/Elastic.Markdown.Tests/Directives/VersionTests.cs
@@ -1,6 +1,8 @@
// 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 Elastic.Markdown.Helpers;
using Elastic.Markdown.Myst.Directives;
using FluentAssertions;
using Markdig.Syntax;
@@ -10,7 +12,7 @@ namespace Elastic.Markdown.Tests.Directives;
public abstract class VersionTests(ITestOutputHelper output, string directive) : DirectiveTest(output,
$$"""
-```{{{directive}}}
+```{{{directive}}} 1.0.1-beta1 more information
Version brief summary
```
A regular paragraph.
@@ -22,26 +24,29 @@ A regular paragraph.
[Fact]
public void SetsCorrectDirectiveType() => Block!.Directive.Should().Be(directive);
+
+ [Fact]
+ public void SetsVersion() => Block!.Version.Should().Be(new SemVersion(1, 0, 1, "beta1"));
}
public class VersionAddedTests(ITestOutputHelper output) : VersionTests(output, "versionadded")
{
[Fact]
- public void SetsTitle() => Block!.Title.Should().Be("Version Added");
+ public void SetsTitle() => Block!.Title.Should().Be("Version Added (1.0.1-beta1): more information");
}
public class VersionChangedTests(ITestOutputHelper output) : VersionTests(output, "versionchanged")
{
[Fact]
- public void SetsTitle() => Block!.Title.Should().Be("Version Changed");
+ public void SetsTitle() => Block!.Title.Should().Be("Version Changed (1.0.1-beta1): more information");
}
public class VersionRemovedTests(ITestOutputHelper output) : VersionTests(output, "versionremoved")
{
[Fact]
- public void SetsTitle() => Block!.Title.Should().Be("Version Removed");
+ public void SetsTitle() => Block!.Title.Should().Be("Version Removed (1.0.1-beta1): more information");
}
public class VersionDeprectatedTests(ITestOutputHelper output) : VersionTests(output, "deprecated")
{
[Fact]
- public void SetsTitle() => Block!.Title.Should().Be("Deprecated");
+ public void SetsTitle() => Block!.Title.Should().Be("Deprecated (1.0.1-beta1): more information");
}