diff --git a/spec/marten/core/sluggable_spec.cr b/spec/marten/core/sluggable_spec.cr new file mode 100644 index 000000000..eec97b570 --- /dev/null +++ b/spec/marten/core/sluggable_spec.cr @@ -0,0 +1,54 @@ +require "./spec_helper" + +describe Marten::Core::Sluggable do + describe ".generate_slug" do + value = "Test Title 123" + max_size = 20 + slug = Marten::Core::Sluggable.generate_slug(value, max_size) + + it "removes non-alphanumeric characters" do + value_with_special_chars = "Test@Title#123!" + slug = Marten::Core::Sluggable.generate_slug(value_with_special_chars, max_size) + slug.starts_with?("testtitle12-").should be_true + end + + it "converts the string to lowercase" do + slug.starts_with?("testtitle12-").should be_true + end + + it "replaces whitespace and hyphens with a single hyphen" do + value_with_spaces_and_hyphens = "Test - Title 123" + slug = Marten::Core::Sluggable.generate_slug(value_with_spaces_and_hyphens, max_size) + slug.starts_with?("test-title-").should be_true + end + + it "strips leading and trailing hyphens and underscores" do + value_with_hyphens = "-Test Title 123-" + slug = Marten::Core::Sluggable.generate_slug(value_with_hyphens, max_size) + slug.starts_with?("test-title-").should be_true + end + + it "removes non-ASCII characters" do + value_with_non_ascii = "Test Títle 123" + slug = Marten::Core::Sluggable.generate_slug(value_with_non_ascii, max_size) + slug.starts_with?("test-ttle-1-").should be_true + end + + it "limits the slug length to max_size" do + slug.size.should eq(max_size) + end + + it "does not exceed max_size even with long input" do + long_value = "This is a very long title that should be truncated" + slug = Marten::Core::Sluggable.generate_slug(long_value, max_size) + slug.size.should eq(max_size) + end + + it "does not truncate the slug when max_size is large enough" do + long_value = "This is a very long title that should not be truncated" + slug = Marten::Core::Sluggable.generate_slug(long_value, 100) + + slug.starts_with?("this-is-a-very-long-title-that-should-not-be-truncated").should be_true + end + end +end diff --git a/spec/marten/db/field/slug_spec.cr b/spec/marten/db/field/slug_spec.cr index 159130d4b..7755c4f81 100644 --- a/spec/marten/db/field/slug_spec.cr +++ b/spec/marten/db/field/slug_spec.cr @@ -111,14 +111,6 @@ describe Marten::DB::Field::Slug do article.slug.not_nil!.should eq("custom-slug") end - - it "uses a custom slug generator function when provided" do - article = Marten::DB::Field::SlugSpec::ArticleWithCustomSlugGenerator.new(title: "My First Article") - - article.save - - article.slug.not_nil!.should eq("MY_FIRST_ARTICLE") - end end describe "#validate" do diff --git a/spec/marten/db/field/slug_spec/models/article_with_custom_slug_generator.cr b/spec/marten/db/field/slug_spec/models/article_with_custom_slug_generator.cr deleted file mode 100644 index fc1ea92a9..000000000 --- a/spec/marten/db/field/slug_spec/models/article_with_custom_slug_generator.cr +++ /dev/null @@ -1,12 +0,0 @@ -module Marten::DB::Field::SlugSpec - class ArticleWithCustomSlugGenerator < Marten::Model - field :id, :big_int, primary_key: true, auto: true - field :title, :string, max_size: 255 - field :slug, :slug, slugify: :title, slugify_cb: ->(value : ::String) { custom_slug_generator(value) } - end -end - -def custom_slug_generator(value : String) : String - # Custom slug generation logic here - value.upcase.gsub(" ", "_") -end diff --git a/src/marten/core/sluggable.cr b/src/marten/core/sluggable.cr new file mode 100644 index 000000000..bdca16af0 --- /dev/null +++ b/src/marten/core/sluggable.cr @@ -0,0 +1,31 @@ +module Marten + module Core + # The Sluggable module provides functionality for generating URL-friendly slugs from strings. + module Sluggable + extend self + + NON_ALPHANUMERIC_RE = /[^\w\s-]/ + WHITESPACE_HYPHEN_RE = /[-\s]+/ + NON_ASCII_RE = /[^\x00-\x7F]/ + + # Generates a slug from the given value, ensuring the resulting slug does not exceed the specified max_size. + # + # The slug is created by: + # 1. Removing non-alphanumeric characters (except whitespace and hyphens). + # 2. Converting the string to lowercase. + # 3. Replacing sequences of whitespace and hyphens with a single hyphen. + # 4. Removing non-ASCII characters. + # 5. Truncating the slug to fit within the max_size, minus the size of a randomly generated suffix. + # 6. Stripping trailing hyphens and underscores of the slug without suffix, and appending the suffix. + def generate_slug(value, max_size) + suffix = "-#{Random::Secure.hex(4)}" + + slug = value.gsub(NON_ALPHANUMERIC_RE, "").downcase + slug = slug.gsub(WHITESPACE_HYPHEN_RE, "-") + slug = slug.gsub(NON_ASCII_RE, "") + + slug[...(max_size - suffix.size)].strip("-_") + suffix + end + end + end +end diff --git a/src/marten/db/field/base.cr b/src/marten/db/field/base.cr index cc9268dd2..d4942ecf6 100644 --- a/src/marten/db/field/base.cr +++ b/src/marten/db/field/base.cr @@ -86,11 +86,8 @@ module Marten def perform_validation(record : Model) value = record.get_field_value(id) - if value.nil? && !@null - record.errors.add(id, null_error_message(record), type: :null) - elsif empty_value?(value) && !@blank - record.errors.add(id, blank_error_message(record), type: :blank) - end + validate_null(record, value) + validate_blank(record, value) validate(record, value) end @@ -147,6 +144,18 @@ module Marten def validate(record, value) end + protected def validate_null(record : Model, value) + if value.nil? && !@null + record.errors.add(id, null_error_message(record), type: :null) + end + end + + protected def validate_blank(record : Model, value) + if empty_value?(value) && !@blank + record.errors.add(id, blank_error_message(record), type: :blank) + end + end + # :nodoc: macro check_definition(field_id, kwargs) end diff --git a/src/marten/db/field/slug.cr b/src/marten/db/field/slug.cr index 61cbde4c8..915436ef6 100644 --- a/src/marten/db/field/slug.cr +++ b/src/marten/db/field/slug.cr @@ -5,11 +5,7 @@ module Marten module Field # Represents a slug field. class Slug < String - NON_ALPHANUMERIC_RE = /[^\w\s-]/ - WHITESPACE_HYPHEN_RE = /[-\s]+/ - private getter slugify - private getter slugify_cb def initialize( @id : ::String, @@ -21,13 +17,8 @@ module Marten @unique = false, @index = true, @db_column = nil, - @slugify : Symbol? = nil, - @slugify_cb : (::String -> ::String) = ->(value : ::String) { generate_slug(value) } + @slugify : Symbol? = nil ) - if @slugify - @null = true - @blank = true - end end macro check_definition(field_id, kwargs) @@ -36,9 +27,8 @@ module Marten def validate(record, value) if slugify?(value) - slug = slugify_cb.call(record.get_field_value(slugify.not_nil!).to_s) + slug = Core::Sluggable.generate_slug(record.get_field_value(slugify.not_nil!).to_s, max_size) record.set_field_value(id, slug) - return end return if !value.is_a?(::String) @@ -51,14 +41,12 @@ module Marten end end - private def generate_slug(value) - suffix = "-#{Random::Secure.hex(4)}" - - slug = value.gsub(NON_ALPHANUMERIC_RE, "").downcase - slug = slug.gsub(WHITESPACE_HYPHEN_RE, "-").strip("-_") - slug = slug.gsub(/[^\x00-\x7F]/, "") + protected def validate_null(record : Model, value) + super if slugify.nil? + end - slug[...(max_size - suffix.size)] + suffix + protected def validate_blank(record : Model, value) + super if slugify.nil? end private def slugify?(value)