From f007d808cbd553e2e331c01ff079b772c4f5675a Mon Sep 17 00:00:00 2001 From: Antyss77 Date: Sun, 9 Aug 2026 13:36:35 +0200 Subject: [PATCH] feat: add Slugify method to StringExtension.Casing --- Benchmarks/Benchmark.cs | 9 ++++ StringExtension/Casing/Casing.cs | 72 +++++++++++++++++++++++++++++++ UnitTests/StringExtensionTests.cs | 49 +++++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/Benchmarks/Benchmark.cs b/Benchmarks/Benchmark.cs index 786add2..c0ac8b1 100644 --- a/Benchmarks/Benchmark.cs +++ b/Benchmarks/Benchmark.cs @@ -140,5 +140,14 @@ public string ToTitleCase() { return input.ToTitleCase(); } + + /// + /// Benchmark for the Slugify method. + /// + [Benchmark] + public string Slugify() + { + return input.Slugify(); + } } } \ No newline at end of file diff --git a/StringExtension/Casing/Casing.cs b/StringExtension/Casing/Casing.cs index 1dbd981..c0b12ee 100644 --- a/StringExtension/Casing/Casing.cs +++ b/StringExtension/Casing/Casing.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Globalization; using System.Text; using StringExtension.Internal; @@ -388,4 +389,75 @@ private static bool IsEnglishMinorWord(ReadOnlySpan word) return false; } + + /// + /// Converts the given string into a URL-friendly slug: lowercase, with accents + /// removed and every run of non-alphanumeric characters collapsed into a single + /// . + /// + /// The input string. + /// The character used to join words. Defaults to -. + /// The slugified string. + /// + /// Accented Latin letters are converted to their unaccented equivalent (e.g. + /// "é" becomes "e") via Unicode decomposition. Letters from non-Latin scripts + /// (e.g. Cyrillic, CJK) are lowercased and kept as-is rather than being + /// stripped or transliterated. Returns if + /// is or empty. + /// + public static string Slugify(this string input, char separator = '-') + { + if (string.IsNullOrEmpty(input)) + { + return string.Empty; + } + + ReadOnlySpan normalized = input.Normalize(NormalizationForm.FormD); + + char[]? pooledBuffer = null; + Span buffer = (uint)normalized.Length <= BufferLimits.StackAllocThreshold + ? stackalloc char[normalized.Length] + : (pooledBuffer = ArrayPool.Shared.Rent(normalized.Length)); + + try + { + var count = 0; + var pendingSeparator = false; + + foreach (var rune in normalized.EnumerateRunes()) + { + if (Rune.GetUnicodeCategory(rune) == UnicodeCategory.NonSpacingMark) + { + // A combining accent mark produced by decomposition (e.g. the + // acute accent split off "é"). The base letter it modifies was + // already appended; drop the mark itself. + continue; + } + + if (Rune.IsLetterOrDigit(rune)) + { + if (pendingSeparator && count > 0) + { + buffer[count++] = separator; + } + + count += Rune.ToLowerInvariant(rune).EncodeToUtf16(buffer[count..]); + pendingSeparator = false; + } + else + { + pendingSeparator = true; + } + } + + return new string(buffer[..count]); + } + finally + { + if (pooledBuffer is not null) + { + ArrayPool.Shared.Return(pooledBuffer); + } + } + } } \ No newline at end of file diff --git a/UnitTests/StringExtensionTests.cs b/UnitTests/StringExtensionTests.cs index 19f81b3..dc53768 100644 --- a/UnitTests/StringExtensionTests.cs +++ b/UnitTests/StringExtensionTests.cs @@ -329,4 +329,53 @@ public void TestToTitleCase_LastWordAlwaysCapitalized() string result = input.ToTitleCase(useEnglishMinorWordRules: true); Assert.That(result, Is.EqualTo(expected)); } + + + /// + /// Tests the Slugify method with accented characters and punctuation. + /// + [Test] + public void TestSlugify() + { + string input = "Café de la Gare! 2024"; + string expected = "cafe-de-la-gare-2024"; + string result = input.Slugify(); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests that Slugify supports a custom separator. + /// + [Test] + public void TestSlugify_CustomSeparator() + { + string input = "Hello World"; + string expected = "hello_world"; + string result = input.Slugify('_'); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests that Slugify does not produce leading or trailing separators + /// when the input starts or ends with punctuation. + /// + [Test] + public void TestSlugify_NoLeadingOrTrailingSeparator() + { + string input = "!!!Hello World!!!"; + string expected = "hello-world"; + string result = input.Slugify(); + Assert.That(result, Is.EqualTo(expected)); + } + + /// + /// Tests that Slugify returns an empty string for a null input. + /// + [Test] + public void TestSlugify_NullInput() + { + string input = null!; + string result = input.Slugify(); + Assert.That(result, Is.EqualTo(string.Empty)); + } } \ No newline at end of file