fix(blog): auto-generate slug when admin leaves it empty
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled

Allow optional slug on create/update, generate from title with unique fallback,
so BackOffice can publish posts without manual slug entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masoodafar-web
2026-06-08 02:06:30 +03:30
parent d22eb1617f
commit 121291eeed
5 changed files with 109 additions and 9 deletions
@@ -0,0 +1,85 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.Common;
public static class BlogSlugHelper
{
private const int MaxSlugLength = 200;
public static string? NormalizeOptional(string? slug)
{
if (string.IsNullOrWhiteSpace(slug))
return null;
return slug.Trim().ToLowerInvariant();
}
/// <summary>
/// Builds a URL slug from title (ASCII letters/digits). Persian-only titles yield empty string.
/// </summary>
public static string GenerateFromTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
return string.Empty;
var sb = new System.Text.StringBuilder();
var lastWasHyphen = false;
foreach (var c in title.Trim().ToLowerInvariant())
{
if (c is >= 'a' and <= 'z' or >= '0' and <= '9')
{
sb.Append(c);
lastWasHyphen = false;
}
else if (c is ' ' or '_' or '-')
{
if (sb.Length > 0 && !lastWasHyphen)
{
sb.Append('-');
lastWasHyphen = true;
}
}
}
var result = sb.ToString().Trim('-');
return result.Length > MaxSlugLength ? result[..MaxSlugLength].TrimEnd('-') : result;
}
public static string CreateFallbackSlug() =>
$"post-{Guid.NewGuid():N}"[..Math.Min(20, MaxSlugLength)];
public static string ResolveForCreate(string? requestedSlug, string title)
{
var normalized = NormalizeOptional(requestedSlug);
if (!string.IsNullOrEmpty(normalized))
return normalized;
var fromTitle = GenerateFromTitle(title);
return !string.IsNullOrEmpty(fromTitle) ? fromTitle : CreateFallbackSlug();
}
public static async Task<string> EnsureUniquePostSlugAsync(
IApplicationDbContext context,
string slug,
long? excludePostId = null,
CancellationToken cancellationToken = default)
{
var candidate = slug;
var suffix = 2;
while (await context.BlogPosts.AnyAsync(
p => p.Slug == candidate && !p.IsDeleted && (excludePostId == null || p.Id != excludePostId.Value),
cancellationToken))
{
var suffixStr = $"-{suffix}";
var maxBase = MaxSlugLength - suffixStr.Length;
var basePart = slug.Length > maxBase ? slug[..maxBase].TrimEnd('-') : slug;
candidate = basePart + suffixStr;
suffix++;
}
return candidate;
}
}