Skip to content
Open
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
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,24 @@ var result = helper.GenerateSlug("Simple,short&quick Example");
Console.WriteLine(result); // Simple-short-quick-Example
```

To enable hash-based shortening for unique truncated slugs:

```csharp
var config = new SlugHelperConfiguration
{
MaximumLength = 12,
EnableHashedShortening = true,
HashLength = 4 // Use 4-character hash for better collision resistance
};

var helper = new SlugHelper(config);

// These will produce different results despite similar input
Console.WriteLine(helper.GenerateSlug("The very long name liga")); // "the-v-2a4b"
Console.WriteLine(helper.GenerateSlug("The very long name liga (W)")); // "the-v-8f3c"
Console.WriteLine(helper.GenerateSlug("The very long name liga (M)")); // "the-v-d1e7"
```

The following options can be configured with the `SlugHelperConfiguration`:

### `ForceLowerCase`
Expand Down Expand Up @@ -214,4 +232,39 @@ Specifying the `DeniedCharactersRegex` option will disable the character removal

This will limit the length of the generated slug to be a maximum of the number of chars given by the parameter. If the truncation happens in a way that a trailing `-` is left, it will be removed.

- Default value: `null`
- Default value: `null`

### `EnableHashedShortening`

When enabled, slugs that exceed `MaximumLength` will be shortened with a hash postfix to ensure uniqueness. The hash postfix is derived from the full slug before truncation using a deterministic FNV-1a hash algorithm. This prevents different inputs from producing identical shortened slugs.

For example, when `MaximumLength` is 12:
- `"The very long name liga"` becomes `"the-very-54"` (instead of `"the-very-lon"`)
- `"The very long name liga (W)"` becomes `"the-very-a2"` (instead of `"the-very-lon"`)

The hash postfix format is `"-XX"` where `XX` is a lowercase hexadecimal hash. The hash length can be configured using `HashLength`. If `MaximumLength` is too small to accommodate the hash postfix, it will fall back to simple truncation.

- Default value: `false`

### `HashLength`

Controls the length of the hash postfix when `EnableHashedShortening` is enabled. Valid values are 2-6 characters. Higher values provide better collision resistance but use more characters from the maximum length.

- 2 characters: 256 possible values (good for small sets)
- 4 characters: 65,536 possible values (recommended for most use cases)
- 6 characters: 16,777,216 possible values (best collision resistance)

For example:
```csharp
var config = new SlugHelperConfiguration
{
MaximumLength = 15,
EnableHashedShortening = true,
HashLength = 4 // Use 4-character hash for better collision resistance
};

var helper = new SlugHelper(config);
Console.WriteLine(helper.GenerateSlug("The very long name liga")); // "the-very-2a4b"
```

- Default value: `2`
76 changes: 72 additions & 4 deletions src/Slugify.Core/SlugHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,83 @@ public virtual string GenerateSlug(string inputString)

if (Config.MaximumLength.HasValue && sb.Length > Config.MaximumLength.Value)
{
sb.Remove(Config.MaximumLength.Value, sb.Length - Config.MaximumLength.Value);
// Remove trailing dash if it exists
if (sb.Length > 0 && sb[sb.Length - 1] == '-')
if (Config.EnableHashedShortening)
{
sb.Remove(sb.Length - 1, 1);
// Generate hash from the full slug before truncation
var fullSlug = sb.ToString();
var hash = GenerateSlugHash(fullSlug, Config.HashLength);

// Calculate target length leaving room for hash and separator
var hashWithSeparator = Config.HashLength + 1; // +1 for the dash
var targetLength = Config.MaximumLength.Value - hashWithSeparator;
if (targetLength < 1)
{
// If maximum length is too small for hash postfix, just truncate normally
sb.Remove(Config.MaximumLength.Value, sb.Length - Config.MaximumLength.Value);
}
else
{
// Truncate to make room for hash
sb.Remove(targetLength, sb.Length - targetLength);

// Remove trailing dash if it exists
while (sb.Length > 0 && sb[sb.Length - 1] == '-')
{
sb.Remove(sb.Length - 1, 1);
}

// Append hash postfix
sb.Append('-');
sb.Append(hash);
}
}
else
{
// Original behavior: simple truncation
sb.Remove(Config.MaximumLength.Value, sb.Length - Config.MaximumLength.Value);
// Remove trailing dash if it exists
if (sb.Length > 0 && sb[sb.Length - 1] == '-')
{
sb.Remove(sb.Length - 1, 1);
}
}
}

return sb.ToString();
}

/// <summary>
/// Generates a deterministic hash from the input string for use as a postfix.
/// Uses FNV-1a algorithm for consistent cross-platform results and better collision resistance.
/// </summary>
/// <param name="input">The input string to hash</param>
/// <param name="length">The desired length of the hash (2-6 characters)</param>
/// <returns>A lowercase hexadecimal hash of the specified length</returns>
private static string GenerateSlugHash(string input, int length)
{
// Clamp length to valid range
length = Math.Max(2, Math.Min(6, length));

// FNV-1a hash algorithm constants
const uint FNV_OFFSET_BASIS = 2166136261;
const uint FNV_PRIME = 16777619;

// Calculate FNV-1a hash
uint hash = FNV_OFFSET_BASIS;
var bytes = Encoding.UTF8.GetBytes(input);

foreach (byte b in bytes)
{
hash ^= b;
hash *= FNV_PRIME;
}

// Convert to hex string of desired length
var hashBytes = BitConverter.GetBytes(hash);
var hexString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();

// Take the first 'length' characters for the hash
return hexString.Substring(0, length);
}

}
14 changes: 14 additions & 0 deletions src/Slugify.Core/SlugHelperConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ public class SlugHelperConfiguration
/// </summary>
public int? MaximumLength { get; set; }

/// <summary>
/// When enabled, slugs that exceed MaximumLength will be shortened with a hash postfix to ensure uniqueness.
/// The hash postfix is a suffix derived from the full slug before truncation.
/// Defaults to false for backward compatibility.
/// </summary>
public bool EnableHashedShortening { get; set; }

/// <summary>
/// Length of the hash postfix when EnableHashedShortening is true.
/// Valid values are 2-6 characters. Defaults to 2 for backward compatibility.
/// Higher values provide better collision resistance but use more characters.
/// </summary>
public int HashLength { get; set; } = 2;

/// <summary>
/// Enable non-ASCII languages support. Defaults to false
/// </summary>
Expand Down
Loading