bunko 0.2.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.standard.yml +3 -0
  3. data/CHANGELOG.md +41 -0
  4. data/CLAUDE.md +351 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +641 -0
  7. data/ROADMAP.md +519 -0
  8. data/Rakefile +10 -0
  9. data/lib/bunko/configuration.rb +180 -0
  10. data/lib/bunko/controllers/acts_as.rb +22 -0
  11. data/lib/bunko/controllers/collection.rb +160 -0
  12. data/lib/bunko/controllers.rb +5 -0
  13. data/lib/bunko/models/acts_as.rb +24 -0
  14. data/lib/bunko/models/post_methods/publishable.rb +51 -0
  15. data/lib/bunko/models/post_methods/sluggable.rb +47 -0
  16. data/lib/bunko/models/post_methods/word_countable.rb +76 -0
  17. data/lib/bunko/models/post_methods.rb +75 -0
  18. data/lib/bunko/models/post_type_methods.rb +18 -0
  19. data/lib/bunko/models.rb +6 -0
  20. data/lib/bunko/railtie.rb +22 -0
  21. data/lib/bunko/routing/mapper_methods.rb +103 -0
  22. data/lib/bunko/routing.rb +4 -0
  23. data/lib/bunko/version.rb +5 -0
  24. data/lib/bunko.rb +11 -0
  25. data/lib/tasks/bunko/add.rake +259 -0
  26. data/lib/tasks/bunko/helpers.rb +25 -0
  27. data/lib/tasks/bunko/install.rake +125 -0
  28. data/lib/tasks/bunko/sample_data.rake +128 -0
  29. data/lib/tasks/bunko/setup.rake +186 -0
  30. data/lib/tasks/support/sample_data_generator.rb +399 -0
  31. data/lib/tasks/templates/INSTALL.md +62 -0
  32. data/lib/tasks/templates/config/initializers/bunko.rb.tt +45 -0
  33. data/lib/tasks/templates/controllers/controller.rb.tt +25 -0
  34. data/lib/tasks/templates/controllers/pages_controller.rb.tt +29 -0
  35. data/lib/tasks/templates/db/migrate/create_post_types.rb.tt +14 -0
  36. data/lib/tasks/templates/db/migrate/create_posts.rb.tt +31 -0
  37. data/lib/tasks/templates/models/post.rb.tt +8 -0
  38. data/lib/tasks/templates/models/post_type.rb.tt +8 -0
  39. data/lib/tasks/templates/views/collections/index.html.erb.tt +67 -0
  40. data/lib/tasks/templates/views/collections/show.html.erb.tt +39 -0
  41. data/lib/tasks/templates/views/layouts/bunko_footer.html.erb.tt +3 -0
  42. data/lib/tasks/templates/views/layouts/bunko_nav.html.erb.tt +9 -0
  43. data/lib/tasks/templates/views/layouts/bunko_styles.html.erb.tt +3 -0
  44. data/lib/tasks/templates/views/pages/show.html.erb.tt +16 -0
  45. data/sig/bunko.rbs +4 -0
  46. metadata +116 -0
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ module Controllers
5
+ module ActsAs
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def bunko_collection(collection_name, **options)
10
+ include Bunko::Controllers::Collection
11
+
12
+ bunko_collection(collection_name, **options)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ # Extend ActionController::Base with bunko_collection method
20
+ if defined?(ActionController::Base)
21
+ ActionController::Base.include Bunko::Controllers::ActsAs
22
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ module Controllers
5
+ module Collection
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :bunko_collection_name
10
+ class_attribute :bunko_collection_options
11
+ end
12
+
13
+ class_methods do
14
+ def bunko_collection(collection_name, **options)
15
+ self.bunko_collection_name = collection_name.to_s
16
+ self.bunko_collection_options = {
17
+ per_page: 10,
18
+ order: :published_at_desc
19
+ }.merge(options)
20
+
21
+ # Set layout if specified
22
+ layout(options[:layout]) if options[:layout]
23
+
24
+ # Define index and show actions
25
+ define_method :index do
26
+ load_collection
27
+ end
28
+
29
+ define_method :show do
30
+ load_post
31
+ end
32
+
33
+ # Make helpers available
34
+ helper_method :collection_name if respond_to?(:helper_method)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def load_collection
41
+ @collection_name = bunko_collection_name
42
+
43
+ # Smart lookup: Check PostType first, then Collection
44
+ post_type_config = Bunko.configuration.find_post_type(@collection_name)
45
+ collection_config = Bunko.configuration.find_collection(@collection_name)
46
+
47
+ if post_type_config
48
+ # Single PostType collection
49
+ @post_type = PostType.find_by(name: @collection_name)
50
+ unless @post_type
51
+ render plain: "PostType '#{@collection_name}' not found in database. Run: rails bunko:setup[#{@collection_name}]", status: :not_found
52
+ return
53
+ end
54
+
55
+ base_query = post_model.published.by_post_type(@collection_name)
56
+ elsif collection_config
57
+ # Multi-type collection
58
+ base_query = post_model.published.where(post_type: PostType.where(name: collection_config[:post_types]))
59
+
60
+ # Apply collection scope if defined
61
+ if collection_config[:scope]
62
+ base_query = base_query.instance_exec(&collection_config[:scope])
63
+ end
64
+ else
65
+ render plain: "Collection '#{@collection_name}' not found. Add it to config/initializers/bunko.rb", status: :not_found
66
+ return
67
+ end
68
+
69
+ # Apply ordering
70
+ ordered_query = apply_ordering(base_query)
71
+
72
+ # Apply pagination
73
+ @posts = paginate(ordered_query)
74
+ @pagination = pagination_metadata
75
+ end
76
+
77
+ def load_post
78
+ @collection_name = bunko_collection_name
79
+
80
+ # Smart lookup: Check PostType first, then Collection
81
+ post_type_config = Bunko.configuration.find_post_type(@collection_name)
82
+ collection_config = Bunko.configuration.find_collection(@collection_name)
83
+
84
+ # Collections should not have show routes (posts are accessed via their canonical PostType URL)
85
+ if collection_config
86
+ render plain: "Posts in this collection can only be accessed through their PostType URL. This collection only supports index.", status: :not_found
87
+ return
88
+ end
89
+
90
+ if post_type_config
91
+ # Single PostType collection
92
+ @post_type = PostType.find_by(name: @collection_name)
93
+ unless @post_type
94
+ render plain: "PostType '#{@collection_name}' not found in database. Run: rails bunko:setup[#{@collection_name}]", status: :not_found
95
+ return
96
+ end
97
+
98
+ base_query = post_model.published.by_post_type(@collection_name)
99
+ else
100
+ render plain: "Collection '#{@collection_name}' not found. Add it to config/initializers/bunko.rb", status: :not_found
101
+ return
102
+ end
103
+
104
+ # Find post by slug within this collection
105
+ @post = base_query.find_by(slug: params[:slug])
106
+
107
+ unless @post
108
+ render plain: "Post not found", status: :not_found
109
+ end
110
+ end
111
+
112
+ def post_model
113
+ @post_model ||= Post
114
+ end
115
+
116
+ def apply_ordering(query)
117
+ case bunko_collection_options[:order]
118
+ when :published_at_desc
119
+ query.reorder(published_at: :desc)
120
+ when :published_at_asc
121
+ query.reorder(published_at: :asc)
122
+ when :created_at_desc
123
+ query.reorder(created_at: :desc)
124
+ when :created_at_asc
125
+ query.reorder(created_at: :asc)
126
+ else
127
+ query
128
+ end
129
+ end
130
+
131
+ def paginate(query)
132
+ page_number = [params[:page].to_i, 1].max
133
+ per_page = bunko_collection_options[:per_page]
134
+
135
+ offset = (page_number - 1) * per_page
136
+
137
+ @_total_count = query.count
138
+ @_current_page = page_number
139
+ @_per_page = per_page
140
+
141
+ query.limit(per_page).offset(offset)
142
+ end
143
+
144
+ def pagination_metadata
145
+ {
146
+ current_page: @_current_page,
147
+ per_page: @_per_page,
148
+ total_count: @_total_count,
149
+ total_pages: (@_total_count.to_f / @_per_page).ceil,
150
+ prev_page: (@_current_page > 1) ? @_current_page - 1 : nil,
151
+ next_page: (@_current_page < (@_total_count.to_f / @_per_page).ceil) ? @_current_page + 1 : nil
152
+ }
153
+ end
154
+
155
+ def collection_name
156
+ @collection_name
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load all controller concerns
4
+ require_relative "controllers/collection"
5
+ require_relative "controllers/acts_as"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ module Models
5
+ module ActsAs
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def acts_as_bunko_post
10
+ include Bunko::Models::PostMethods
11
+ end
12
+
13
+ def acts_as_bunko_post_type
14
+ include Bunko::Models::PostTypeMethods
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ # Extend ActiveRecord::Base with acts_as methods
22
+ if defined?(ActiveRecord::Base)
23
+ ActiveRecord::Base.include Bunko::Models::ActsAs
24
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ module Models
5
+ module PostMethods
6
+ module Publishable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ # Validations
11
+ validates :status, presence: true, inclusion: {
12
+ in: ->(_) { Bunko.configuration.valid_statuses },
13
+ message: "%{value} is not a valid status"
14
+ }
15
+
16
+ # Callbacks
17
+ before_validation :set_published_at, if: :should_set_published_at?
18
+ validate :validate_status_value
19
+
20
+ # Scopes
21
+ scope :published, -> { where(status: "published").where("published_at <= ?", Time.current).order(published_at: :desc) }
22
+ scope :draft, -> { where(status: "draft").order(created_at: :desc) }
23
+ scope :scheduled, -> { where(status: "published").where("published_at > ?", Time.current).order(published_at: :asc) }
24
+ end
25
+
26
+ # Instance method to check if post is scheduled for future publication
27
+ def scheduled?
28
+ status == "published" && published_at.present? && published_at > Time.current
29
+ end
30
+
31
+ private
32
+
33
+ def should_set_published_at?
34
+ status == "published" && published_at.blank?
35
+ end
36
+
37
+ def set_published_at
38
+ self.published_at = Time.current
39
+ end
40
+
41
+ def validate_status_value
42
+ return if status.blank?
43
+
44
+ unless Bunko.configuration.valid_statuses.include?(status)
45
+ raise ArgumentError, "#{status} is not a valid status"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ module Models
5
+ module PostMethods
6
+ module Sluggable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_validation :generate_slug, if: :should_generate_slug?
11
+ end
12
+
13
+ private
14
+
15
+ def should_generate_slug?
16
+ slug.blank? && title.present?
17
+ end
18
+
19
+ def generate_slug
20
+ return if title.blank?
21
+
22
+ # Generate slug using parameterize, then normalize:
23
+ # 1. Convert underscores to hyphens (parameterize keeps them in Rails 8+)
24
+ # 2. Remove consecutive hyphens
25
+ # 3. Remove leading/trailing hyphens or underscores
26
+ base_slug = title.parameterize
27
+ .tr("_", "-").squeeze("-")
28
+ .gsub(/^[-_]+|[-_]+$/, "")
29
+
30
+ # Skip if slug would be empty (e.g., title with only non-Latin characters)
31
+ return if base_slug.blank?
32
+
33
+ self.slug = base_slug
34
+
35
+ # Ensure uniqueness within post_type
36
+ return unless self.class.unscoped.where(
37
+ post_type_id: post_type_id,
38
+ slug: slug
39
+ ).where.not(id: id).exists?
40
+
41
+ # Add random suffix if slug exists
42
+ self.slug = "#{base_slug}-#{SecureRandom.hex(4)}"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ module Models
5
+ module PostMethods
6
+ module WordCountable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_save :update_word_count, if: :should_update_word_count?
11
+ end
12
+
13
+ # Instance methods
14
+ def reading_time
15
+ return nil unless word_count.present? && word_count > 0
16
+
17
+ (word_count.to_f / Bunko.configuration.reading_speed).ceil
18
+ end
19
+
20
+ def reading_time_text
21
+ return nil unless reading_time.present?
22
+
23
+ "#{reading_time} min read"
24
+ end
25
+
26
+ private
27
+
28
+ def should_update_word_count?
29
+ # Only update word_count if:
30
+ # 1. Auto-update is enabled in config
31
+ # 2. Content changed
32
+ # 3. Model has word_count attribute
33
+ Bunko.configuration.auto_update_word_count &&
34
+ content_changed? &&
35
+ respond_to?(:word_count=)
36
+ end
37
+
38
+ def update_word_count
39
+ if content.blank?
40
+ self.word_count = 0
41
+ return
42
+ end
43
+
44
+ # Check if content is a text field or JSON field
45
+ column = self.class.columns_hash["content"]
46
+
47
+ if column && [:json, :jsonb].include?(column.type)
48
+ # For JSON content, try to extract text recursively
49
+ self.word_count = count_words_in_json(content)
50
+ else
51
+ # For text content, strip HTML tags and count words
52
+ text = content.to_s.gsub(/<[^>]*>/, "")
53
+ self.word_count = text.split(/\s+/).count(&:present?)
54
+ end
55
+ end
56
+
57
+ def count_words_in_json(data)
58
+ case data
59
+ when String
60
+ # Strip HTML and count words in string
61
+ text = data.gsub(/<[^>]*>/, "")
62
+ text.split(/\s+/).count(&:present?)
63
+ when Hash
64
+ # Recursively count words in hash values
65
+ data.values.sum { |value| count_words_in_json(value) }
66
+ when Array
67
+ # Recursively count words in array elements
68
+ data.sum { |element| count_words_in_json(element) }
69
+ else
70
+ 0
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "post_methods/sluggable"
4
+ require_relative "post_methods/word_countable"
5
+ require_relative "post_methods/publishable"
6
+
7
+ module Bunko
8
+ module Models
9
+ module PostMethods
10
+ extend ActiveSupport::Concern
11
+
12
+ include Sluggable
13
+ include WordCountable
14
+ include Publishable
15
+
16
+ included do
17
+ # Associations
18
+ belongs_to :post_type
19
+
20
+ # Validations
21
+ validates :title, presence: true
22
+ validates :slug,
23
+ presence: true,
24
+ uniqueness: {scope: :post_type_id},
25
+ format: {
26
+ with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/,
27
+ message: "must contain only lowercase letters, numbers, and hyphens"
28
+ },
29
+ length: {maximum: 255}
30
+
31
+ # Default scope for ordering
32
+ default_scope { order(created_at: :desc) }
33
+ end
34
+
35
+ class_methods do
36
+ def by_post_type(type_name)
37
+ joins(:post_type).where(post_types: {name: type_name})
38
+ end
39
+ end
40
+
41
+ # Instance methods
42
+ def to_param
43
+ slug
44
+ end
45
+
46
+ def excerpt(length: nil, omission: "...")
47
+ return nil unless content.present?
48
+
49
+ # Use configured default if length not specified
50
+ length ||= Bunko.configuration.excerpt_length
51
+
52
+ # Strip HTML tags using Rails sanitizer (more robust than regex)
53
+ text = ActionView::Base.full_sanitizer.sanitize(content.to_s)
54
+
55
+ # Clean up extra whitespace
56
+ text = text.gsub(/\s+/, " ").strip
57
+
58
+ # Return full text if shorter than length
59
+ return text if text.length <= length
60
+
61
+ # Truncate to word boundary
62
+ truncated = text[0...length]
63
+ last_space = truncated.rindex(" ") || length
64
+
65
+ "#{truncated[0...last_space]}#{omission}"
66
+ end
67
+
68
+ def published_date(format = :long)
69
+ return nil unless published_at.present?
70
+
71
+ I18n.l(published_at, format: format)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ module Models
5
+ module PostTypeMethods
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # Associations
10
+ has_many :posts, dependent: :restrict_with_error
11
+
12
+ # Validations
13
+ validates :name, presence: true, uniqueness: true
14
+ validates :title, presence: true
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load all model concerns
4
+ require_relative "models/post_methods"
5
+ require_relative "models/post_type_methods"
6
+ require_relative "models/acts_as"
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Bunko
6
+ class Railtie < Rails::Railtie
7
+ # Extend Rails routing DSL with bunko_collection
8
+ initializer "bunko.routing" do
9
+ ActiveSupport.on_load(:action_controller) do
10
+ require "bunko/routing"
11
+ ActionDispatch::Routing::Mapper.include Bunko::Routing::MapperMethods
12
+ end
13
+ end
14
+
15
+ rake_tasks do
16
+ load "tasks/bunko/install.rake"
17
+ load "tasks/bunko/setup.rake"
18
+ load "tasks/bunko/add.rake"
19
+ load "tasks/bunko/sample_data.rake"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ module Routing
5
+ module MapperMethods
6
+ # Defines routes for a Bunko collection
7
+ #
8
+ # @param collection_name [Symbol] The name identifier for the collection (e.g., :blog, :case_study)
9
+ # @param options [Hash] Routing options
10
+ # @option options [String] :path Custom URL path (default: name with hyphens)
11
+ # @option options [String] :controller Custom controller name (default: name)
12
+ # @option options [Array<Symbol>] :only Actions to route (default: [:index, :show])
13
+ #
14
+ # @example Basic usage
15
+ # bunko_collection :blog
16
+ # # Generates: /blog -> blog#index, /blog/:slug -> blog#show
17
+ #
18
+ # @example Custom path
19
+ # bunko_collection :case_study, path: "case-studies"
20
+ # # Generates: /case-studies -> case_study#index, /case-studies/:slug -> case_study#show
21
+ #
22
+ # @example Custom controller
23
+ # bunko_collection :blog, controller: "articles"
24
+ # # Generates: /blog -> articles#index, /blog/:slug -> articles#show
25
+ #
26
+ def bunko_collection(collection_name, **options)
27
+ # Extract options with defaults
28
+ custom_path = options.delete(:path)
29
+ controller = options.delete(:controller) || collection_name.to_s
30
+
31
+ # Smart detection: Collections (multi-type aggregations) only get index routes
32
+ # PostTypes get both index and show routes
33
+ collection_config = Bunko.configuration.find_collection(collection_name.to_s)
34
+
35
+ # Default actions: Collections get [:index], PostTypes get [:index, :show]
36
+ # Users can override with :only option
37
+ default_actions = collection_config ? [:index] : [:index, :show]
38
+ actions = options.delete(:only) || default_actions
39
+
40
+ # Resource name must use underscores (for path helpers)
41
+ # Path can use hyphens (for URLs)
42
+ if custom_path
43
+ # User provided custom path - use it for URLs, underscored version for resource name
44
+ resource_name = custom_path.to_s.tr("-", "_").to_sym
45
+ path_value = custom_path
46
+ else
47
+ # No custom path - use collection_name for resource name, hyphenate for path
48
+ resource_name = collection_name
49
+ path_value = collection_name.to_s.dasherize
50
+ end
51
+
52
+ # Define the routes
53
+ resources resource_name,
54
+ controller: controller,
55
+ path: path_value,
56
+ only: actions,
57
+ param: :slug,
58
+ **options
59
+ end
60
+
61
+ # Defines a route for a standalone Bunko page
62
+ #
63
+ # @param page_name [Symbol] The name identifier for the page (e.g., :about, :contact)
64
+ # @param options [Hash] Routing options
65
+ # @option options [String] :path Custom URL path (default: name with hyphens)
66
+ # @option options [String] :controller Custom controller name (default: "pages")
67
+ #
68
+ # @example Basic usage
69
+ # bunko_page :about
70
+ # # Generates: GET /about -> pages#show with params[:page] = "about"
71
+ #
72
+ # @example Custom path
73
+ # bunko_page :about, path: "about-us"
74
+ # # Generates: GET /about-us -> pages#show with params[:page] = "about"
75
+ #
76
+ # @example Custom controller
77
+ # bunko_page :contact, controller: "static_pages"
78
+ # # Generates: GET /contact -> static_pages#show with params[:page] = "contact"
79
+ #
80
+ def bunko_page(page_name, **options)
81
+ # Extract options with defaults
82
+ custom_path = options.delete(:path)
83
+ controller = options.delete(:controller) || "pages"
84
+
85
+ # Convert to underscores for Ruby conventions (route name, helpers)
86
+ slug = page_name.to_s.underscore
87
+
88
+ # URL path uses hyphens (Rails convention)
89
+ path_value = custom_path || slug.dasherize
90
+
91
+ # Route name uses underscores for path helpers (e.g., about_path)
92
+ route_name = slug.to_sym
93
+
94
+ # Define single GET route
95
+ # Pass hyphenated slug to match Post.slug format in database
96
+ get path_value,
97
+ to: "#{controller}#show",
98
+ defaults: {page: slug.dasherize},
99
+ as: route_name
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load routing extensions
4
+ require_relative "routing/mapper_methods"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ VERSION = "0.2.0"
5
+ end
data/lib/bunko.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bunko/version"
4
+ require_relative "bunko/configuration"
5
+ require_relative "bunko/models"
6
+ require_relative "bunko/controllers"
7
+ require_relative "bunko/railtie" if defined?(Rails::Railtie)
8
+
9
+ module Bunko
10
+ class Error < StandardError; end
11
+ end