diff --git a/handlers/article.go b/handlers/article.go index debca07..a34123e 100644 --- a/handlers/article.go +++ b/handlers/article.go @@ -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-" 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-"). 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- 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- 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)