fix: generate non-empty slugs for non-ASCII titles
The slug regex stripped everything that is not a-z0-9, so Chinese or punctuation-only titles produced an empty slug (stored as ""), which broke the unique index and article URLs. generateSlug now keeps ASCII word characters and returns "" when the title yields no URL-safe ASCII (e.g. CJK or punctuation only). Both the create and update handlers fall back to a "post-<id>" slug in that case; on create a temporary token-based placeholder is refined to post-<id> once the row exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+48
-8
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -16,8 +17,18 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// slugRe matches characters that are not URL-safe.
|
||||
var slugRe = regexp.MustCompile(`[^a-z0-9\-]+`)
|
||||
// slugSepRe matches runs of non-word characters (anything that is not a
|
||||
// letter or digit); these become single dashes.
|
||||
var slugSepRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
||||
|
||||
// slugDashRe matches runs of dashes to collapse.
|
||||
var slugDashRe = regexp.MustCompile(`-{2,}`)
|
||||
|
||||
// slugAsciiRe matches a slug made only of URL-safe ASCII letters, digits and
|
||||
// dashes. Slugs containing other characters (e.g. CJK, or Unicode lowercase
|
||||
// quirks like the Turkish dotless i) are not URL-stable and are discarded in
|
||||
// favor of a "post-<id>" fallback.
|
||||
var slugAsciiRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
// randomToken returns a 32-byte hex token used to own pending attachments on
|
||||
// the article-create page until the article is saved.
|
||||
@@ -30,17 +41,31 @@ func randomToken() string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateSlug converts a title into a URL-friendly slug.
|
||||
// generateSlug converts a title into a URL-friendly ASCII slug. Returns "" when
|
||||
// the title yields no URL-safe ASCII characters (the caller must fall back,
|
||||
// e.g. to "post-<id>"). Non-ASCII letters are intentionally dropped rather
|
||||
// than kept, because raw CJK in a URL slug is not stable.
|
||||
func generateSlug(title string) string {
|
||||
s := strings.ToLower(title)
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
s = slugRe.ReplaceAllString(s, "")
|
||||
s := strings.ToLower(strings.TrimSpace(title))
|
||||
s = slugSepRe.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
// Collapse multiple dashes.
|
||||
s = regexp.MustCompile(`-{2,}`).ReplaceAllString(s, "-")
|
||||
s = slugDashRe.ReplaceAllString(s, "-")
|
||||
if !slugAsciiRe.MatchString(s) {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// fallbackSlug returns a non-empty slug when title-derived slugs are empty
|
||||
// (e.g. a title of only punctuation/whitespace, or all-stripped). Uses the
|
||||
// article ID when available, else a short token.
|
||||
func fallbackSlug(id uint) string {
|
||||
if id > 0 {
|
||||
return fmt.Sprintf("post-%d", id)
|
||||
}
|
||||
return "post-" + randomToken()[:8]
|
||||
}
|
||||
|
||||
// articleForm holds the parsed article form fields, shared by the create and
|
||||
// edit handlers and their validation-error repopulation paths.
|
||||
type articleForm struct {
|
||||
@@ -164,6 +189,11 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
// Auto-generate slug if empty.
|
||||
if f.Slug == "" {
|
||||
f.Slug = generateSlug(f.Title)
|
||||
// Title had no usable characters (e.g. only punctuation). Use a
|
||||
// temporary token-based slug now; refine to post-<id> after insert.
|
||||
if f.Slug == "" {
|
||||
f.Slug = fallbackSlug(0)
|
||||
}
|
||||
}
|
||||
|
||||
status := statusFromForm(f.StatusStr)
|
||||
@@ -197,6 +227,13 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Refine a token-based placeholder slug to the readable post-<id> form.
|
||||
if strings.HasPrefix(f.Slug, "post-") && len(f.Slug) > 9 {
|
||||
if newSlug := fallbackSlug(article.ID); newSlug != "" {
|
||||
db.Model(&article).Update("slug", newSlug)
|
||||
}
|
||||
}
|
||||
|
||||
// Bind any attachments uploaded during creation (plan A: pending rows
|
||||
// owned by session_token, article_id=0).
|
||||
if f.SessionToken != "" {
|
||||
@@ -275,6 +312,9 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
if f.Slug == "" {
|
||||
f.Slug = generateSlug(f.Title)
|
||||
if f.Slug == "" {
|
||||
f.Slug = fallbackSlug(article.ID)
|
||||
}
|
||||
}
|
||||
|
||||
newStatus := statusFromForm(f.StatusStr)
|
||||
|
||||
Reference in New Issue
Block a user