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
9 changes: 9 additions & 0 deletions Benchmarks/Benchmark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,14 @@ public string ToTitleCase()
{
return input.ToTitleCase();
}

/// <summary>
/// Benchmark for the Slugify method.
/// </summary>
[Benchmark]
public string Slugify()
{
return input.Slugify();
}
}
}
72 changes: 72 additions & 0 deletions StringExtension/Casing/Casing.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Buffers;
using System.Globalization;
using System.Text;
using StringExtension.Internal;

Expand Down Expand Up @@ -388,4 +389,75 @@ private static bool IsEnglishMinorWord(ReadOnlySpan<char> word)

return false;
}

/// <summary>
/// Converts the given string into a URL-friendly slug: lowercase, with accents
/// removed and every run of non-alphanumeric characters collapsed into a single
/// <paramref name="separator"/>.
/// </summary>
/// <param name="input">The input string.</param>
/// <param name="separator">The character used to join words. Defaults to <c>-</c>.</param>
/// <returns>The slugified string.</returns>
/// <remarks>
/// 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 <see cref="string.Empty"/> if
/// <paramref name="input"/> is <see langword="null"/> or empty.
/// </remarks>
public static string Slugify(this string input, char separator = '-')
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}

ReadOnlySpan<char> normalized = input.Normalize(NormalizationForm.FormD);

char[]? pooledBuffer = null;
Span<char> buffer = (uint)normalized.Length <= BufferLimits.StackAllocThreshold
? stackalloc char[normalized.Length]
: (pooledBuffer = ArrayPool<char>.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<char>.Shared.Return(pooledBuffer);
}
}
}
}
49 changes: 49 additions & 0 deletions UnitTests/StringExtensionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -329,4 +329,53 @@ public void TestToTitleCase_LastWordAlwaysCapitalized()
string result = input.ToTitleCase(useEnglishMinorWordRules: true);
Assert.That(result, Is.EqualTo(expected));
}


/// <summary>
/// Tests the Slugify method with accented characters and punctuation.
/// </summary>
[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));
}

/// <summary>
/// Tests that Slugify supports a custom separator.
/// </summary>
[Test]
public void TestSlugify_CustomSeparator()
{
string input = "Hello World";
string expected = "hello_world";
string result = input.Slugify('_');
Assert.That(result, Is.EqualTo(expected));
}

/// <summary>
/// Tests that Slugify does not produce leading or trailing separators
/// when the input starts or ends with punctuation.
/// </summary>
[Test]
public void TestSlugify_NoLeadingOrTrailingSeparator()
{
string input = "!!!Hello World!!!";
string expected = "hello-world";
string result = input.Slugify();
Assert.That(result, Is.EqualTo(expected));
}

/// <summary>
/// Tests that Slugify returns an empty string for a null input.
/// </summary>
[Test]
public void TestSlugify_NullInput()
{
string input = null!;
string result = input.Slugify();
Assert.That(result, Is.EqualTo(string.Empty));
}
}
Loading