concerns_on_rails 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8810e1d51d69ba3de04b06ec9f372aca86611a0ac555b0d0652284680b35d37a
4
- data.tar.gz: d6d8d480ffac223059af1942c0161ab7eff86b6af5320852838a2e5c0c9e3611
3
+ metadata.gz: 0d4b68b3625ea62546fa003654e7bd0618a56b09b615b406dcaed12f16560338
4
+ data.tar.gz: 4d048584cdf09f1eb9859a4cca0ccd64182b331bfa8a6ffb897ff61ab1c4636a
5
5
  SHA512:
6
- metadata.gz: fdc35dc31371b5bef4f0897494250778dcb794598136c21db6d33db5b165a0fc95151c2939ee610312249e3a6012738e0041673ce592846515eaa38b0a8dd453
7
- data.tar.gz: b9704d4368ba22504f7b2bf6081832fd2e4a6014f74ea9a88abc73ab55dc0e4af07cc89d09cff6442d59357fa0907503eb19900e4165ef241e5183527e1b512f
6
+ metadata.gz: 8797f42f52e32309614fa95bf2a52b0a17e6adff54f07bbdcc9f9f27863a92fdfe0dfec1a14d7ad9b34285e25abc6a12cf37194a4da4532874a29d0fc7a13b01
7
+ data.tar.gz: e60133f68c2b4778cdea6b34fdcddd279eee5076ef060c009734c9e42eda3d379d65c9adfa847dd30dcb47e06e4a39c88313644fa248e407a5d60e242e8e3756
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.3.0 (2026-05-16)
4
+
5
+ ### Added
6
+ - Hashable: Auto-generate a random value on create (`:hex`, `:uuid`, `:integer`, or `:custom` alphabet). Adds `hashable_by` macro and a dynamic `regenerate_<field>!` instance method.
7
+
3
8
  ## 1.1.0 (2025-04-17)
4
9
 
5
10
  ### Added
data/README.md CHANGED
@@ -10,6 +10,7 @@ A simple collection of reusable Rails concerns to keep your models clean and DRY
10
10
  - 🔢 `Sortable`: Sort records based on a field using `acts_as_list`, with flexible sorting field and direction
11
11
  - 📤 `Publishable`: Easily manage published/unpublished records using a simple `published_at` field
12
12
  - ❌ `SoftDeletable`: Soft delete records using a configurable timestamp field (e.g., `deleted_at`) with automatic scoping
13
+ - 🔐 `Hashable`: Auto-generate a random hex/UUID/integer/custom-alphabet value on create, with a `regenerate_<field>!` helper
13
14
 
14
15
  ---
15
16
 
@@ -127,29 +128,113 @@ Soft delete records using a timestamp field (default: `deleted_at`).
127
128
  class User < ApplicationRecord
128
129
  include ConcernsOnRails::SoftDeletable
129
130
 
130
- soft_deletable_by :deleted_at
131
+ # Optional: customize field and touch behavior
132
+ soft_deletable_by :deleted_at, touch: true
131
133
  end
134
+ ```
132
135
 
133
- user = User.create!(name: "Alice")
134
- user.soft_delete!
135
- user.deleted? # => true
136
-
136
+ #### Scopes
137
+ ```ruby
137
138
  User.without_deleted # => returns only active users
138
139
  User.soft_deleted # => returns soft-deleted users
140
+ User.active # => same as without_deleted
139
141
  User.all # => returns only non-deleted by default (default_scope applied)
142
+ ```
143
+
144
+ #### Soft delete and restore
145
+ ```ruby
146
+ user.soft_delete! # Soft delete the user (sets deleted_at)
147
+ user.deleted? # => true
148
+ user.soft_deleted? # => true (alias)
149
+ user.is_soft_deleted? # => true (alias)
140
150
 
141
- user.restore!
142
- user.deleted? # => false
151
+ user.restore! # Restore the user (sets deleted_at to nil)
152
+ user.deleted? # => false
143
153
  ```
144
154
 
145
- Additional features:
146
- - Default field is `deleted_at`, can be configured
147
- - Automatically applies `default_scope` to hide soft-deleted records
148
- - Scopes: `without_deleted`, `soft_deleted`, `active`
149
- - Methods: `soft_delete!`, `restore!`, `deleted?`, `really_delete!`
150
- - Callbacks: `before_soft_delete`, `after_soft_delete`, `before_restore`, `after_restore`
151
- - Touch support when soft deleting or restoring (can be turned off)
155
+ #### Permanently delete
156
+ ```ruby
157
+ user.really_delete! # Hard delete the record from DB
158
+ ```
159
+
160
+ #### Soft delete/hard delete all records
161
+ ```ruby
162
+ User.destroy_all # Soft delete all users (sets deleted_at)
163
+ User.really_destroy_all # Hard delete ALL users (removes from DB)
164
+ ```
165
+
166
+ #### Callbacks (Hooks)
167
+ You can use the following hooks to run logic before/after soft delete or restore:
168
+ ```ruby
169
+ class User < ApplicationRecord
170
+ include ConcernsOnRails::SoftDeletable
171
+
172
+ def before_soft_delete
173
+ # Code to run before soft delete
174
+ end
175
+
176
+ def after_soft_delete
177
+ # Code to run after soft delete
178
+ end
179
+
180
+ def before_restore
181
+ # Code to run before restore
182
+ end
183
+
184
+ def after_restore
185
+ # Code to run after restore
186
+ end
187
+ end
188
+ ```
189
+
190
+ #### Notes
191
+ - Default field is `deleted_at`, can be changed with `soft_deletable_by :your_field`
192
+ - `touch: false` to skip updating updated_at when soft deleting/restoring
152
193
  - Aliases for `deleted?`: `soft_deleted?`, `is_soft_deleted?`
194
+ - All scopes and methods work seamlessly with ActiveRecord
195
+
196
+ ---
197
+
198
+ ### 5. Hashable
199
+
200
+ Auto-generate a random value (hex, UUID, fixed-digit integer, or custom-alphabet string) on create.
201
+
202
+ ```ruby
203
+ class Order < ApplicationRecord
204
+ include ConcernsOnRails::Hashable
205
+
206
+ # Defaults: type: :hex, length: 16 (32-char hex string)
207
+ hashable_by :token
208
+ end
209
+
210
+ order = Order.create!
211
+ order.token # => "a3f7c9b1e2d40859e2f1c9b73d40a857"
212
+ order.regenerate_token! # rolls a new random value and persists it
213
+ ```
214
+
215
+ #### Types
216
+
217
+ ```ruby
218
+ hashable_by :token, type: :hex, length: 16
219
+ hashable_by :external_id, type: :uuid
220
+ hashable_by :code, type: :integer, length: 6
221
+ hashable_by :code, type: :custom, length: 8,
222
+ alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
223
+ ```
224
+
225
+ | Type | `length` means | Example output |
226
+ |------------|-------------------------|--------------------------|
227
+ | `:hex` | byte count (output is `length * 2` hex chars) | `"a3f7c9b1e2d40859"` |
228
+ | `:uuid` | ignored | `"550e8400-e29b-41d4-a716-446655440000"` |
229
+ | `:integer` | digit count | `483921` |
230
+ | `:custom` | output length, samples from `alphabet:` | `"K7M3PQ9A"` |
231
+
232
+ #### Notes
233
+ - Auto-assigns on `before_create` only when the field is blank, so callers can still pass an explicit value.
234
+ - A `regenerate_<field>!` instance method is defined dynamically to match the configured column.
235
+ - No uniqueness retry is built in. For collision-prone configurations (e.g. short integer codes), add a unique index and rescue at the application level.
236
+ - For fixed-width numeric codes (e.g. `000042`), use a string column — integer columns drop leading zeros.
237
+ - If your model has `validates :<field>, presence: true`, switch to a `before_validation` callback in your model since the concern uses `before_create`.
153
238
 
154
239
  ---
155
240
 
@@ -0,0 +1,77 @@
1
+ require "active_support/concern"
2
+ require "securerandom"
3
+
4
+ module ConcernsOnRails
5
+ module Hashable
6
+ extend ActiveSupport::Concern
7
+
8
+ VALID_TYPES = %i[hex uuid integer custom].freeze
9
+
10
+ included do
11
+ class_attribute :hashable_field, instance_accessor: false
12
+ class_attribute :hashable_type, instance_accessor: false, default: :hex
13
+ class_attribute :hashable_length, instance_accessor: false, default: 16
14
+ class_attribute :hashable_alphabet, instance_accessor: false, default: nil
15
+ end
16
+
17
+ class_methods do
18
+ # Define hashable field and generation options.
19
+ # Example:
20
+ # hashable_by :token
21
+ # hashable_by :token, type: :hex, length: 16
22
+ # hashable_by :external_id, type: :uuid
23
+ # hashable_by :code, type: :integer, length: 6
24
+ # hashable_by :code, type: :custom, length: 8, alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
25
+ def hashable_by(field, type: :hex, length: 16, alphabet: nil)
26
+ self.hashable_field = field.to_sym
27
+ self.hashable_type = type.to_sym
28
+ self.hashable_length = length.to_i
29
+ self.hashable_alphabet = alphabet
30
+
31
+ validate_hashable_options!
32
+ before_create :assign_hashable_value
33
+
34
+ define_method("regenerate_#{hashable_field}!") do
35
+ update!(self.class.hashable_field => self.class.generate_hashable_value)
36
+ end
37
+ end
38
+ end
39
+
40
+ class_methods do
41
+ # Generate a new random value using the configured type/length/alphabet.
42
+ def generate_hashable_value
43
+ case hashable_type
44
+ when :hex then SecureRandom.hex(hashable_length)
45
+ when :uuid then SecureRandom.uuid
46
+ when :integer then SecureRandom.random_number(10**hashable_length).to_s.rjust(hashable_length, "0").to_i
47
+ when :custom then Array.new(hashable_length) { hashable_alphabet[SecureRandom.random_number(hashable_alphabet.size)] }.join
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def validate_hashable_options!
54
+ unless column_names.include?(hashable_field.to_s)
55
+ raise ArgumentError, "ConcernsOnRails::Hashable: hashable_field '#{hashable_field}' does not exist in the database"
56
+ end
57
+
58
+ unless VALID_TYPES.include?(hashable_type)
59
+ raise ArgumentError, "ConcernsOnRails::Hashable: unknown type '#{hashable_type}'. Valid types: #{VALID_TYPES.join(', ')}"
60
+ end
61
+
62
+ return unless hashable_type == :custom && (!hashable_alphabet.is_a?(String) || hashable_alphabet.empty?)
63
+
64
+ raise ArgumentError, "ConcernsOnRails::Hashable: type :custom requires a non-empty alphabet: String"
65
+ end
66
+ end
67
+
68
+ # Assigns the generated value only when the field is blank,
69
+ # so callers can still pass an explicit value at create time.
70
+ def assign_hashable_value
71
+ field = self.class.hashable_field
72
+ return if self[field].present?
73
+
74
+ self[field] = self.class.generate_hashable_value
75
+ end
76
+ end
77
+ end
@@ -4,28 +4,22 @@ module ConcernsOnRails
4
4
  module Publishable
5
5
  extend ActiveSupport::Concern
6
6
 
7
- # instance methods
8
7
  included do
9
- # declare class attributes and set default values
10
- class_attribute :publishable_field, instance_accessor: false
11
- self.publishable_field ||= :published_at
8
+ class_attribute :publishable_field, instance_accessor: false, default: :published_at
9
+
10
+ scope :published, -> { where(arel_table[publishable_field].lteq(Time.zone.now)) }
11
+ scope :unpublished, lambda {
12
+ where(arel_table[publishable_field].eq(nil).or(arel_table[publishable_field].gt(Time.zone.now)))
13
+ }
12
14
  end
13
15
 
14
- # class methods
15
16
  class_methods do
16
- # Define publishable field
17
- # Example:
18
- # publishable_by :published_at
19
17
  def publishable_by(field = nil)
20
18
  self.publishable_field = field || :published_at
21
19
 
22
- # validate publishable_field exists in database
23
- unless column_names.include?(publishable_field.to_s)
24
- raise ArgumentError, "ConcernsOnRails::Publishable: publishable_field '#{publishable_field}' does not exist in the database"
25
- end
20
+ return if column_names.include?(publishable_field.to_s)
26
21
 
27
- scope :published, -> { where(arel_table[publishable_field].not_eq(nil)) }
28
- scope :unpublished, -> { where(arel_table[publishable_field].eq(nil)) }
22
+ raise ArgumentError, "ConcernsOnRails::Publishable: publishable_field '#{publishable_field}' does not exist in the database"
29
23
  end
30
24
  end
31
25
 
@@ -48,7 +42,10 @@ module ConcernsOnRails
48
42
  # Example:
49
43
  # record.published?
50
44
  def published?
51
- self[self.class.publishable_field].present?
45
+ value = self[self.class.publishable_field]
46
+ return false unless value.present?
47
+
48
+ value.respond_to?(:<=) ? value <= Time.zone.now : true
52
49
  end
53
50
 
54
51
  # Check if the record is unpublished
@@ -12,6 +12,7 @@ module ConcernsOnRails
12
12
  self.sluggable_field ||= :name
13
13
 
14
14
  extend FriendlyId
15
+
15
16
  # we need use a lambda to access the instance variable
16
17
  # instead of friendly_id :slug_source, use: :slugged
17
18
  friendly_id :slug_source, use: :slugged
@@ -37,11 +38,12 @@ module ConcernsOnRails
37
38
  end
38
39
 
39
40
  private
41
+
40
42
  # Validate sluggable_field exists in database
41
43
  def validate_sluggable_field!
42
- unless column_names.include?(sluggable_field.to_s)
43
- raise ArgumentError, "ConcernsOnRails::Sluggable: sluggable_field '#{sluggable_field}' does not exist in the database"
44
- end
44
+ return if column_names.include?(sluggable_field.to_s)
45
+
46
+ raise ArgumentError, "ConcernsOnRails::Sluggable: sluggable_field '#{sluggable_field}' does not exist in the database"
45
47
  end
46
48
  end
47
49
 
@@ -51,13 +53,8 @@ module ConcernsOnRails
51
53
  # Example:
52
54
  # record.slug_source
53
55
  def slug_source
54
- if self.class.sluggable_field.present? && respond_to?(self.class.sluggable_field)
55
- send(self.class.sluggable_field)
56
- elsif respond_to?(:title)
57
- title
58
- else
59
- to_s
60
- end
56
+ field = self.class.sluggable_field
57
+ respond_to?(field) ? send(field) : to_s
61
58
  end
62
59
  end
63
- end
60
+ end
@@ -8,17 +8,13 @@ module ConcernsOnRails
8
8
  # declare class attributes and set default values
9
9
  class_attribute :soft_delete_field, instance_accessor: false, default: :deleted_at
10
10
  class_attribute :soft_delete_touch, instance_accessor: false, default: true
11
-
11
+
12
12
  # scopes
13
13
  scope :active, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) }
14
14
  scope :without_deleted, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) }
15
15
  scope :soft_deleted, -> { unscope(where: soft_delete_field).where.not(soft_delete_field => nil) }
16
16
  # Optionally, uncomment to hide deleted by default:
17
17
  default_scope { without_deleted }
18
-
19
- # define callbacks
20
- define_model_callbacks :soft_delete
21
- define_model_callbacks :restore
22
18
  end
23
19
 
24
20
  class_methods do
@@ -29,9 +25,19 @@ module ConcernsOnRails
29
25
  self.soft_delete_field = field || :deleted_at
30
26
  self.soft_delete_touch = touch
31
27
 
32
- unless column_names.include?(soft_delete_field.to_s)
33
- raise ArgumentError, "ConcernsOnRails::SoftDeletable: soft_delete_field '#{soft_delete_field}' does not exist in the database"
34
- end
28
+ return if column_names.include?(soft_delete_field.to_s)
29
+
30
+ raise ArgumentError, "ConcernsOnRails::SoftDeletable: soft_delete_field '#{soft_delete_field}' does not exist in the database"
31
+ end
32
+
33
+ # Override destroy_all to perform soft delete on all records
34
+ def destroy_all
35
+ all.each(&:soft_delete!)
36
+ end
37
+
38
+ # Provide really_destroy_all to hard delete all records
39
+ def really_destroy_all
40
+ unscoped.delete_all
35
41
  end
36
42
  end
37
43
 
@@ -41,57 +47,49 @@ module ConcernsOnRails
41
47
  def before_restore; end
42
48
  def after_restore; end
43
49
 
44
- # add soft delete methods
45
50
  def soft_delete!
46
51
  return true if deleted?
47
- run_callbacks(:soft_delete) do
48
- before_soft_delete
49
- if self.class.soft_delete_touch
50
- update(self.class.soft_delete_field => Time.zone.now).tap do |result|
51
- touch if respond_to?(:touch)
52
- after_soft_delete if result
53
- end
54
- else
55
- update_column(self.class.soft_delete_field, Time.zone.now).tap do |result|
56
- after_soft_delete if result
57
- end
58
- end
59
- end
60
- end
61
52
 
62
- # really delete the record
63
- def really_delete!
64
- destroy
53
+ before_soft_delete
54
+ result = if self.class.soft_delete_touch
55
+ update(self.class.soft_delete_field => Time.zone.now)
56
+ else
57
+ update_column(self.class.soft_delete_field, Time.zone.now)
58
+ end
59
+ after_soft_delete if result
60
+ result
65
61
  end
66
-
62
+
67
63
  def restore!
68
- run_callbacks(:restore) do
69
- before_restore
70
- if self.class.soft_delete_touch
71
- update(self.class.soft_delete_field => nil).tap do |result|
72
- touch if respond_to?(:touch)
73
- after_restore if result
74
- end
75
- else
76
- update_column(self.class.soft_delete_field, nil).tap do |result|
77
- after_restore if result
78
- end
79
- end
80
- end
64
+ return true unless deleted?
65
+
66
+ before_restore
67
+ result = if self.class.soft_delete_touch
68
+ update(self.class.soft_delete_field => nil)
69
+ else
70
+ update_column(self.class.soft_delete_field, nil)
71
+ end
72
+ after_restore if result
73
+ result
81
74
  end
82
-
75
+
76
+ # bypasses AR callbacks and validations — use when you want a true hard delete
77
+ def really_delete!
78
+ self.class.unscoped.where(self.class.primary_key => id).delete_all
79
+ freeze
80
+ end
81
+
83
82
  def deleted?
84
83
  self[self.class.soft_delete_field].present?
85
84
  end
86
85
 
87
86
  # alias methods
88
87
  # define here to avoid issue: undefined method `deleted?' for module `ConcernsOnRails::SoftDeletable'
89
- alias_method :is_soft_deleted?, :deleted?
90
- alias_method :soft_deleted?, :deleted?
88
+ alias is_soft_deleted? deleted?
89
+ alias soft_deleted? deleted?
91
90
 
92
- # Is really deleted?
93
91
  def is_really_deleted?
94
- !self.class.exists?(id)
92
+ !self.class.unscoped.exists?(id)
95
93
  end
96
94
  end
97
95
  end
@@ -22,7 +22,13 @@ module ConcernsOnRails
22
22
  self.sortable_direction ||= :asc
23
23
 
24
24
  # we cannot use acts_as_list here
25
- default_scope { order(sortable_field => sortable_direction) }
25
+ default_scope do
26
+ unless column_names.include?(sortable_field.to_s)
27
+ raise ArgumentError, "#{name}: '#{sortable_field}' column not found. Call `sortable_by :your_column` to configure the sort field."
28
+ end
29
+
30
+ order(sortable_field => sortable_direction)
31
+ end
26
32
  end
27
33
 
28
34
  # class methods
@@ -35,7 +41,9 @@ module ConcernsOnRails
35
41
  # sortable_by position: :desc
36
42
  #
37
43
  # sortable_by :position, use_acts_as_list: false
38
- def sortable_by(field_config, use_acts_as_list: true)
44
+ def sortable_by(field_config = nil, use_acts_as_list: true, **field_options)
45
+ field_config = field_options if field_config.nil? && field_options.any?
46
+
39
47
  # parse field_config
40
48
  field, direction = parse_sortable_config(field_config)
41
49
 
@@ -48,15 +56,11 @@ module ConcernsOnRails
48
56
 
49
57
  validate_sortable_field!
50
58
 
51
- # add acts_as_list and default scope
52
- # Setup sorting behaviors
53
59
  acts_as_list column: sortable_field if use_acts_as_list
54
-
55
- # add default scope: position => asc
56
- default_scope { order(sortable_field => sortable_direction) }
57
60
  end
58
61
 
59
62
  private
63
+
60
64
  def parse_sortable_config(config)
61
65
  if config.is_a?(Hash)
62
66
  # extract key and value
@@ -71,9 +75,9 @@ module ConcernsOnRails
71
75
 
72
76
  # Validate sortable_field exists in database
73
77
  def validate_sortable_field!
74
- unless column_names.include?(sortable_field.to_s)
75
- raise ArgumentError, "ConcernsOnRails::Sortable: sortable_field '#{sortable_field}' does not exist in the database"
76
- end
78
+ return if column_names.include?(sortable_field.to_s)
79
+
80
+ raise ArgumentError, "ConcernsOnRails::Sortable: sortable_field '#{sortable_field}' does not exist in the database"
77
81
  end
78
82
  end
79
83
  end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.1.0"
2
+ VERSION = "1.3.0".freeze
3
3
  end
@@ -4,6 +4,7 @@ require "concerns_on_rails/sortable"
4
4
  require "concerns_on_rails/publishable"
5
5
  require "concerns_on_rails/sluggable"
6
6
  require "concerns_on_rails/soft_deletable"
7
+ require "concerns_on_rails/hashable"
7
8
 
8
9
  module ConcernsOnRails
9
10
  end
metadata CHANGED
@@ -1,29 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-16 00:00:00.000000000 Z
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '5.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - "~>"
27
+ - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: '5.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: acts_as_list
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -64,6 +70,7 @@ files:
64
70
  - CODE_OF_CONDUCT.md
65
71
  - README.md
66
72
  - lib/concerns_on_rails.rb
73
+ - lib/concerns_on_rails/hashable.rb
67
74
  - lib/concerns_on_rails/publishable.rb
68
75
  - lib/concerns_on_rails/sluggable.rb
69
76
  - lib/concerns_on_rails/soft_deletable.rb
@@ -91,7 +98,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
98
  - !ruby/object:Gem::Version
92
99
  version: '0'
93
100
  requirements: []
94
- rubygems_version: 3.1.6
101
+ rubygems_version: 3.4.19
95
102
  signing_key:
96
103
  specification_version: 4
97
104
  summary: Reusable Rails concerns like Sortable, Publishable, and Sluggable