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,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "helpers"
5
+
6
+ namespace :bunko do
7
+ include Bunko::RakeHelpers
8
+
9
+ desc "Add a PostType or Collection (automatically detects which)"
10
+ task :add, [:name] => :environment do |t, args|
11
+ unless args[:name]
12
+ puts "⚠️ Please provide a name"
13
+ puts " Usage: rails bunko:add[blog]"
14
+ exit 1
15
+ end
16
+
17
+ name = args[:name]
18
+ format = ENV.fetch("FORMAT", "html").downcase
19
+
20
+ # Validate format
21
+ valid_formats = %w[plain html]
22
+ unless valid_formats.include?(format)
23
+ puts "⚠️ Invalid format: #{format}"
24
+ puts " Valid formats: #{valid_formats.join(", ")}"
25
+ exit 1
26
+ end
27
+
28
+ # Check if it's a PostType
29
+ pt_config = Bunko.configuration.post_types.find { |pt| pt[:name] == name }
30
+
31
+ # Check if it's a Collection
32
+ collection_config = Bunko.configuration.collections.find { |c| c[:name] == name }
33
+
34
+ unless pt_config || collection_config
35
+ # Not found in either
36
+ puts "⚠️ '#{name}' not found in configuration"
37
+ puts ""
38
+
39
+ available_post_types = Bunko.configuration.post_types.map { |pt| pt[:name] }
40
+ available_collections = Bunko.configuration.collections.map { |c| c[:name] }
41
+
42
+ if available_post_types.any?
43
+ puts " Available PostTypes: #{available_post_types.join(", ")}"
44
+ end
45
+
46
+ if available_collections.any?
47
+ puts " Available Collections: #{available_collections.join(", ")}"
48
+ end
49
+
50
+ puts ""
51
+ puts " Add it to config/initializers/bunko.rb first:"
52
+ puts " config.post_type \"#{name}\""
53
+ puts " # or"
54
+ puts " config.collection \"#{name}\" do |c|"
55
+ puts " c.post_types = [...]"
56
+ puts " end"
57
+ exit 1
58
+ end
59
+
60
+ # Step 1: If it's a PostType, create DB entry
61
+ if pt_config
62
+ create_post_type_in_database(name, pt_config[:title])
63
+ end
64
+
65
+ # Step 2: Always generate artifacts (for both PostTypes and Collections)
66
+ is_collection = collection_config.present?
67
+ generate_artifacts(name, format: format, is_collection: is_collection)
68
+
69
+ # Step 3: Add to nav
70
+ if pt_config
71
+ add_to_nav(name, title: pt_config[:title])
72
+ else
73
+ add_to_nav(name, title: collection_config[:title])
74
+ end
75
+
76
+ # Success message
77
+ puts ""
78
+ if pt_config
79
+ puts "PostType '#{name}' added successfully!"
80
+ else
81
+ puts "Collection '#{name}' added successfully!"
82
+ end
83
+ puts "Visit: http://localhost:3000/#{name.tr("_", "-")}"
84
+ end
85
+
86
+ # Helper methods
87
+
88
+ def create_post_type_in_database(name, title)
89
+ post_type = PostType.find_by(name: name)
90
+
91
+ if post_type
92
+ puts " ✓ PostType already exists: #{title} (#{name})"
93
+ else
94
+ PostType.create!(name: name, title: title)
95
+ puts " ✓ Created PostType: #{title} (#{name})"
96
+ end
97
+ puts ""
98
+ end
99
+
100
+ def generate_artifacts(name, format:, is_collection:)
101
+ # Step 1: Generate controller
102
+ puts "Generating controller..."
103
+ generate_controller(name)
104
+ puts ""
105
+
106
+ # Step 2: Generate views
107
+ puts "Generating views..."
108
+ generate_views(name, format: format, is_collection: is_collection)
109
+ puts ""
110
+
111
+ # Step 3: Add route
112
+ puts "Adding route..."
113
+ add_route(name)
114
+ end
115
+
116
+ def generate_controller(collection_name)
117
+ controller_name = "#{collection_name.camelize}Controller"
118
+ controller_file = Rails.root.join("app/controllers/#{collection_name}_controller.rb")
119
+
120
+ if File.exist?(controller_file)
121
+ puts " - #{collection_name}_controller.rb already exists (skipped)"
122
+ return false
123
+ end
124
+
125
+ controller_content = render_template("controllers/controller.rb.tt", {
126
+ controller_name: controller_name,
127
+ collection_name: collection_name
128
+ })
129
+
130
+ File.write(controller_file, controller_content)
131
+ puts " ✓ Created #{collection_name}_controller.rb"
132
+ true
133
+ end
134
+
135
+ def generate_views(collection_name, format:, is_collection:)
136
+ views_dir = Rails.root.join("app/views/#{collection_name}")
137
+
138
+ if Dir.exist?(views_dir) && Dir.glob("#{views_dir}/*").any?
139
+ puts " - #{collection_name} views already exist (skipped)"
140
+ return false
141
+ end
142
+
143
+ FileUtils.mkdir_p(views_dir)
144
+
145
+ # Generate index.html.erb
146
+ index_content = generate_index_view(collection_name, is_collection: is_collection)
147
+ File.write(File.join(views_dir, "index.html.erb"), index_content)
148
+
149
+ # Collections only get index view, PostTypes get both index and show
150
+ if is_collection
151
+ puts " ✓ Created views for #{collection_name} (index only - collection)"
152
+ else
153
+ # Generate show.html.erb for PostTypes only
154
+ show_content = generate_show_view(collection_name, format: format)
155
+ File.write(File.join(views_dir, "show.html.erb"), show_content)
156
+ puts " ✓ Created views for #{collection_name} (index, show)"
157
+ end
158
+
159
+ true
160
+ end
161
+
162
+ def generate_index_view(collection_name, is_collection:)
163
+ is_plural = collection_name.pluralize == collection_name
164
+
165
+ render_template("views/collections/index.html.erb.tt", {
166
+ collection_name: collection_name,
167
+ collection_title: collection_name.titleize,
168
+ path_helper: "#{collection_name.singularize}_path",
169
+ index_path_helper: is_plural ? "#{collection_name}_path" : "#{collection_name}_index_path",
170
+ is_collection: is_collection
171
+ })
172
+ end
173
+
174
+ def generate_show_view(collection_name, format:)
175
+ is_plural = collection_name.pluralize == collection_name
176
+
177
+ render_template("views/collections/show.html.erb.tt", {
178
+ collection_name: collection_name,
179
+ collection_title: collection_name.titleize,
180
+ index_path_helper: is_plural ? "#{collection_name}_path" : "#{collection_name}_index_path",
181
+ format: format
182
+ })
183
+ end
184
+
185
+ def add_route(collection_name)
186
+ routes_file = Rails.root.join("config/routes.rb")
187
+ routes_content = File.read(routes_file)
188
+
189
+ route_line = " bunko_collection :#{collection_name}"
190
+
191
+ if routes_content.include?(route_line.strip)
192
+ puts " - Route for :#{collection_name} already exists (skipped)"
193
+ return false
194
+ end
195
+
196
+ # Find the last 'end' in the file and insert before it
197
+ lines = routes_content.lines
198
+ last_end_index = lines.rindex { |line| line.match?(/^end\s*$/) }
199
+
200
+ if last_end_index
201
+ lines.insert(last_end_index, "#{route_line}\n")
202
+ updated_content = lines.join
203
+ else
204
+ # Fallback: append before the last line if no 'end' found
205
+ updated_content = routes_content.sub(/\z/, "#{route_line}\n")
206
+ end
207
+
208
+ File.write(routes_file, updated_content)
209
+ puts " ✓ Added route for :#{collection_name}"
210
+ true
211
+ end
212
+
213
+ def add_to_nav(name, title:)
214
+ shared_dir = Rails.root.join("app/views/shared")
215
+ nav_file = shared_dir.join("_bunko_nav.html.erb")
216
+
217
+ # If nav doesn't exist, generate from scratch (edge case)
218
+ unless File.exist?(nav_file)
219
+ FileUtils.mkdir_p(shared_dir)
220
+ nav_content = render_template("views/layouts/bunko_nav.html.erb.tt", {
221
+ post_types: Bunko.configuration.post_types,
222
+ collections: Bunko.configuration.collections
223
+ })
224
+ File.write(nav_file, nav_content)
225
+ puts " ✓ Created shared/_bunko_nav.html.erb"
226
+ return true
227
+ end
228
+
229
+ # Nav exists - append new link to existing file
230
+ nav_content = File.read(nav_file)
231
+
232
+ # Generate the new link
233
+ is_plural = name.pluralize == name
234
+ path_helper = is_plural ? "#{name}_path" : "#{name}_index_path"
235
+ new_link = " <%= link_to \"#{title}\", #{path_helper} %>\n"
236
+
237
+ # Check if link already exists (check for the title and path, not exact match)
238
+ if nav_content.match?(/link_to\s+"#{Regexp.escape(title)}",\s+#{path_helper}/)
239
+ puts " - #{title} already in nav (skipped)"
240
+ return false
241
+ end
242
+
243
+ # Try to insert before the marker comment (preferred)
244
+ marker = "<%# bunko_collection_links - additional collections will be added here unless you delete this line %>"
245
+ nav_content = if nav_content.include?(marker)
246
+ nav_content.sub(marker, "#{new_link} #{marker}")
247
+ else
248
+ # Fallback: Find the closing </div> before </nav> and insert the new link before it
249
+ nav_content.sub(/(\s*)<\/div>\s*<\/nav>/) do
250
+ indent = $1
251
+ "#{new_link}#{indent}</div>\n</nav>"
252
+ end
253
+ end
254
+
255
+ File.write(nav_file, nav_content)
256
+ puts " ✓ Added #{title} to shared/_bunko_nav.html.erb"
257
+ true
258
+ end
259
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Bunko
6
+ module RakeHelpers
7
+ def render_template(template_name, locals = {})
8
+ template_path = File.expand_path("../templates/#{template_name}", __dir__)
9
+
10
+ unless File.exist?(template_path)
11
+ raise "Template file not found: #{template_path}"
12
+ end
13
+
14
+ template_content = File.read(template_path)
15
+
16
+ # Create a context object with all local variables as methods
17
+ context = Object.new
18
+ locals.each do |key, value|
19
+ context.define_singleton_method(key) { value }
20
+ end
21
+
22
+ ERB.new(template_content, trim_mode: "-").result(context.instance_eval { binding })
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "helpers"
5
+
6
+ namespace :bunko do
7
+ include Bunko::RakeHelpers
8
+
9
+ desc "Install Bunko by creating migrations, models, and initializer"
10
+ task install: :environment do
11
+ puts "Installing Bunko..."
12
+ puts ""
13
+
14
+ # Parse options from environment variables
15
+ skip_seo = ENV["SKIP_SEO"] == "true"
16
+ json_content = ENV["JSON_CONTENT"] == "true"
17
+
18
+ # Step 1: Create migrations
19
+ puts "Creating migrations..."
20
+ create_post_types_migration(skip_seo: skip_seo, json_content: json_content)
21
+ sleep 1 # Ensure different timestamps
22
+ create_posts_migration(skip_seo: skip_seo, json_content: json_content)
23
+ puts ""
24
+
25
+ # Step 2: Create models
26
+ puts "Creating models..."
27
+ create_models
28
+ puts ""
29
+
30
+ # Step 3: Create initializer
31
+ puts "Creating initializer..."
32
+ create_initializer
33
+ puts ""
34
+
35
+ # Step 4: Show instructions
36
+ show_install_instructions
37
+ end
38
+
39
+ # Helper methods
40
+
41
+ def create_post_types_migration(skip_seo:, json_content:)
42
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
43
+ migration_file = Rails.root.join("db/migrate/#{timestamp}_create_post_types.rb")
44
+
45
+ if Dir.glob(Rails.root.join("db/migrate/*_create_post_types.rb")).any?
46
+ puts " - create_post_types migration already exists (skipped)"
47
+ return false
48
+ end
49
+
50
+ migration_content = render_template("db/migrate/create_post_types.rb.tt", {
51
+ include_seo_fields?: !skip_seo,
52
+ use_json_content?: json_content
53
+ })
54
+
55
+ File.write(migration_file, migration_content)
56
+ puts " ✓ Created db/migrate/#{timestamp}_create_post_types.rb"
57
+ true
58
+ end
59
+
60
+ def create_posts_migration(skip_seo:, json_content:)
61
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
62
+ migration_file = Rails.root.join("db/migrate/#{timestamp}_create_posts.rb")
63
+
64
+ if Dir.glob(Rails.root.join("db/migrate/*_create_posts.rb")).any?
65
+ puts " - create_posts migration already exists (skipped)"
66
+ return false
67
+ end
68
+
69
+ migration_content = render_template("db/migrate/create_posts.rb.tt", {
70
+ include_seo_fields?: !skip_seo,
71
+ use_json_content?: json_content
72
+ })
73
+
74
+ File.write(migration_file, migration_content)
75
+ puts " ✓ Created db/migrate/#{timestamp}_create_posts.rb"
76
+ true
77
+ end
78
+
79
+ def create_models
80
+ # Create Post model
81
+ post_file = Rails.root.join("app/models/post.rb")
82
+ if File.exist?(post_file)
83
+ puts " - app/models/post.rb already exists (skipped)"
84
+ else
85
+ post_content = render_template("models/post.rb.tt", {})
86
+ File.write(post_file, post_content)
87
+ puts " ✓ Created app/models/post.rb"
88
+ end
89
+
90
+ # Create PostType model
91
+ post_type_file = Rails.root.join("app/models/post_type.rb")
92
+ if File.exist?(post_type_file)
93
+ puts " - app/models/post_type.rb already exists (skipped)"
94
+ else
95
+ post_type_content = render_template("models/post_type.rb.tt", {})
96
+ File.write(post_type_file, post_type_content)
97
+ puts " ✓ Created app/models/post_type.rb"
98
+ end
99
+ end
100
+
101
+ def create_initializer
102
+ initializer_dir = Rails.root.join("config/initializers")
103
+ initializer_file = initializer_dir.join("bunko.rb")
104
+
105
+ if File.exist?(initializer_file)
106
+ puts " - config/initializers/bunko.rb already exists (skipped)"
107
+ return false
108
+ end
109
+
110
+ FileUtils.mkdir_p(initializer_dir)
111
+ initializer_content = render_template("config/initializers/bunko.rb.tt", {})
112
+ File.write(initializer_file, initializer_content)
113
+ puts " ✓ Created config/initializers/bunko.rb"
114
+ true
115
+ end
116
+
117
+ def show_install_instructions
118
+ instructions_path = File.expand_path("../templates/INSTALL.md", __dir__)
119
+ instructions = File.read(instructions_path)
120
+
121
+ puts "=" * 79
122
+ puts instructions
123
+ puts "=" * 79
124
+ end
125
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/sample_data_generator"
4
+
5
+ namespace :bunko do
6
+ desc "Generate sample posts for all configured post types"
7
+ task sample_data: :environment do
8
+ # Warn if running in production
9
+ if Rails.env.production?
10
+ puts ""
11
+ puts "⚠️ WARNING: You're about to generate sample data in PRODUCTION"
12
+ puts " Press Ctrl+C to cancel, or Enter to continue..."
13
+ $stdin.gets
14
+ puts ""
15
+ end
16
+
17
+ # Parse configuration from ENV
18
+ posts_per_type = ENV.fetch("COUNT", ENV.fetch("POSTS_PER_TYPE", "100")).to_i
19
+ min_words = ENV.fetch("MIN_WORDS", "500").to_i
20
+ max_words = ENV.fetch("MAX_WORDS", "2000").to_i
21
+ clear_existing = ENV.fetch("CLEAR", "false").downcase == "true"
22
+ format = ENV.fetch("FORMAT", "html").downcase.to_sym
23
+
24
+ # Validate format
25
+ unless Bunko::SampleDataGenerator::FORMATS.include?(format)
26
+ puts "⚠️ Invalid format: #{format}"
27
+ puts " Valid formats: #{Bunko::SampleDataGenerator::FORMATS.join(", ")}"
28
+ puts ""
29
+ exit 1
30
+ end
31
+
32
+ puts "Bunko Sample Data Generator"
33
+ puts "=" * 79
34
+ puts "Configuration:"
35
+ puts " Posts per type: #{posts_per_type}"
36
+ puts " Word range: #{min_words}-#{max_words} words"
37
+ puts " Content format: #{format}"
38
+ puts " Clear existing: #{clear_existing ? "Yes" : "No"}"
39
+ puts ""
40
+
41
+ # Clear existing posts if requested
42
+ if clear_existing
43
+ puts "Clearing existing posts..."
44
+ Post.destroy_all
45
+ puts "✓ Cleared #{Post.count} posts"
46
+ puts ""
47
+ end
48
+
49
+ # Get all post types from database
50
+ post_types = PostType.all
51
+
52
+ if post_types.empty?
53
+ puts "⚠️ No post types found. Please run 'rails bunko:setup' first."
54
+ exit 1
55
+ end
56
+
57
+ puts "Generating posts for #{post_types.size} post types..."
58
+ puts ""
59
+
60
+ # Generate posts for each type
61
+ post_types.each do |post_type|
62
+ puts "#{post_type.title} (#{post_type.name}):"
63
+ print " "
64
+
65
+ posts_per_type.times do |i|
66
+ # Create varied dates: 90% past, 10% future
67
+ published_at = if rand < 0.9
68
+ Bunko::SampleDataGenerator.past_date(years_ago: 2)
69
+ else
70
+ Bunko::SampleDataGenerator.future_date(months_ahead: 3)
71
+ end
72
+
73
+ # Generate title and content based on post type
74
+ title = Bunko::SampleDataGenerator.title_for(post_type.name)
75
+ target_words = rand(min_words..max_words)
76
+ content = Bunko::SampleDataGenerator.content_for(post_type.name, target_words: target_words, format: format)
77
+
78
+ # Create unique slug
79
+ base_slug = title.parameterize
80
+ slug = base_slug
81
+ counter = 1
82
+
83
+ while Post.exists?(post_type: post_type, slug: slug)
84
+ slug = "#{base_slug}-#{counter}"
85
+ counter += 1
86
+ end
87
+
88
+ # Create meta description
89
+ meta_description = Bunko::SampleDataGenerator.sentence(word_count: rand(15..25)).chomp(".")
90
+
91
+ # Create post
92
+ Post.create!(
93
+ post_type: post_type,
94
+ title: title,
95
+ slug: slug,
96
+ content: content,
97
+ meta_description: meta_description,
98
+ title_tag: "#{title} | Sample Site",
99
+ status: "published",
100
+ published_at: published_at
101
+ )
102
+
103
+ print "." if (i + 1) % 5 == 0
104
+ end
105
+
106
+ puts " ✓"
107
+ end
108
+
109
+ puts ""
110
+ puts "=" * 79
111
+ puts "Summary:"
112
+ post_types.each do |post_type|
113
+ count = Post.where(post_type: post_type).count
114
+ future_count = Post.where(post_type: post_type).where("published_at > ?", Time.current).count
115
+ avg_words = Post.where(post_type: post_type).average(:word_count).to_i
116
+ puts " #{post_type.title}: #{count} posts (#{future_count} scheduled, avg #{avg_words} words)"
117
+ end
118
+ puts "=" * 79
119
+ puts ""
120
+ puts "Usage examples:"
121
+ puts " rake bunko:sample_data # 100 posts per type (HTML with images)"
122
+ puts " rake bunko:sample_data COUNT=50 # 50 posts per type"
123
+ puts " rake bunko:sample_data FORMAT=markdown # Markdown formatted content"
124
+ puts " rake bunko:sample_data MIN_WORDS=500 MAX_WORDS=1500"
125
+ puts " rake bunko:sample_data CLEAR=true # Clear existing first"
126
+ puts ""
127
+ end
128
+ end