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,186 @@
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 "Set up Bunko by creating all configured PostTypes and Collections"
10
+ task setup: :environment do
11
+ puts "Setting up Bunko..."
12
+ puts ""
13
+
14
+ post_types = Bunko.configuration.post_types
15
+ collections = Bunko.configuration.collections
16
+ allow_static_pages = Bunko.configuration.allow_static_pages
17
+
18
+ if post_types.empty? && !allow_static_pages
19
+ puts "⚠️ No post types configured and static pages are disabled."
20
+ puts " Either enable static pages or add post types to config/initializers/bunko.rb"
21
+ puts ""
22
+ puts " Example:"
23
+ puts " config.allow_static_pages = true"
24
+ puts " # OR"
25
+ puts " config.post_type \"blog\""
26
+ puts " config.post_type \"docs\" do |type|"
27
+ puts " type.title = \"Documentation\""
28
+ puts " end"
29
+ exit
30
+ end
31
+
32
+ # Generate shared partials once
33
+ puts "Generating shared partials..."
34
+ generate_shared_nav
35
+ generate_shared_styles
36
+ generate_shared_footer
37
+ puts ""
38
+
39
+ # Set up static pages if enabled
40
+ if allow_static_pages
41
+ puts "Setting up static pages..."
42
+ setup_static_pages
43
+ puts ""
44
+ end
45
+
46
+ # Add all post types
47
+ post_types.each do |pt_config|
48
+ Rake::Task["bunko:add"].reenable
49
+ Rake::Task["bunko:add"].invoke(pt_config[:name])
50
+ end
51
+
52
+ # Add all collections
53
+ collections.each do |collection_config|
54
+ Rake::Task["bunko:add"].reenable
55
+ Rake::Task["bunko:add"].invoke(collection_config[:name])
56
+ end
57
+
58
+ puts "=" * 79
59
+ puts "Setup complete!"
60
+ puts ""
61
+ puts "Next steps:"
62
+ puts " 1. Create your first post in the Rails console or admin panel"
63
+ puts " 2. Visit your collections:"
64
+
65
+ # Show PostType routes
66
+ post_types.each do |pt|
67
+ url_path = pt[:name].tr("_", "-")
68
+ puts " http://localhost:3000/#{url_path}"
69
+ end
70
+
71
+ # Show Collection routes
72
+ collections.each do |c|
73
+ url_path = c[:name].tr("_", "-")
74
+ puts " http://localhost:3000/#{url_path} (collection: #{c[:post_types].join(", ")})"
75
+ end
76
+
77
+ puts "=" * 79
78
+ puts ""
79
+ puts "To add more later, update your initializer and run:"
80
+ puts " rails bunko:add[name]"
81
+ puts "=" * 79
82
+ end
83
+
84
+ # Helper methods
85
+
86
+ def generate_shared_nav
87
+ shared_dir = Rails.root.join("app/views/shared")
88
+ nav_file = shared_dir.join("_bunko_nav.html.erb")
89
+
90
+ if File.exist?(nav_file)
91
+ puts " - _bunko_nav.html.erb already exists (skipped)"
92
+ return false
93
+ end
94
+
95
+ FileUtils.mkdir_p(shared_dir)
96
+
97
+ nav_content = render_template("views/layouts/bunko_nav.html.erb.tt", {})
98
+ File.write(nav_file, nav_content)
99
+
100
+ puts " ✓ Created shared/_bunko_nav.html.erb"
101
+ true
102
+ end
103
+
104
+ def generate_shared_styles
105
+ shared_dir = Rails.root.join("app/views/shared")
106
+ styles_file = shared_dir.join("_bunko_styles.html.erb")
107
+
108
+ if File.exist?(styles_file)
109
+ puts " - _bunko_styles.html.erb already exists (skipped)"
110
+ return false
111
+ end
112
+
113
+ FileUtils.mkdir_p(shared_dir)
114
+
115
+ styles_content = render_template("views/layouts/bunko_styles.html.erb.tt", {})
116
+ File.write(styles_file, styles_content)
117
+
118
+ puts " ✓ Created shared/_bunko_styles.html.erb"
119
+ true
120
+ end
121
+
122
+ def generate_shared_footer
123
+ shared_dir = Rails.root.join("app/views/shared")
124
+ footer_file = shared_dir.join("_bunko_footer.html.erb")
125
+
126
+ if File.exist?(footer_file)
127
+ puts " - _bunko_footer.html.erb already exists (skipped)"
128
+ return false
129
+ end
130
+
131
+ FileUtils.mkdir_p(shared_dir)
132
+
133
+ footer_content = render_template("views/layouts/bunko_footer.html.erb.tt", {})
134
+ File.write(footer_file, footer_content)
135
+
136
+ puts " ✓ Created shared/_bunko_footer.html.erb"
137
+ true
138
+ end
139
+
140
+ def setup_static_pages
141
+ # Create "pages" PostType in database
142
+ PostType.find_or_create_by!(name: "pages") do |pt|
143
+ pt.title = "Pages"
144
+ end
145
+ puts " ✓ Created 'pages' PostType in database"
146
+
147
+ # Generate PagesController
148
+ generate_pages_controller
149
+
150
+ # Generate pages/show.html.erb view
151
+ generate_pages_show_view
152
+ end
153
+
154
+ def generate_pages_controller
155
+ controller_path = Rails.root.join("app/controllers/pages_controller.rb")
156
+
157
+ if File.exist?(controller_path)
158
+ puts " - pages_controller.rb already exists (skipped)"
159
+ return false
160
+ end
161
+
162
+ controller_content = render_template("controllers/pages_controller.rb.tt", {})
163
+ File.write(controller_path, controller_content)
164
+
165
+ puts " ✓ Created app/controllers/pages_controller.rb"
166
+ true
167
+ end
168
+
169
+ def generate_pages_show_view
170
+ views_dir = Rails.root.join("app/views/pages")
171
+ show_file = views_dir.join("show.html.erb")
172
+
173
+ if File.exist?(show_file)
174
+ puts " - pages/show.html.erb already exists (skipped)"
175
+ return false
176
+ end
177
+
178
+ FileUtils.mkdir_p(views_dir)
179
+
180
+ show_content = render_template("views/pages/show.html.erb.tt", {})
181
+ File.write(show_file, show_content)
182
+
183
+ puts " ✓ Created app/views/pages/show.html.erb"
184
+ true
185
+ end
186
+ end
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bunko
4
+ # Simple sample data generator for creating realistic-looking posts
5
+ # No external dependencies - uses built-in Ruby randomization
6
+ module SampleDataGenerator
7
+ # Word pools for generating varied content
8
+ NOUNS = %w[system interface component module feature service platform application framework
9
+ solution architecture database network security authentication authorization deployment
10
+ integration workflow pipeline process functionality capability performance scalability
11
+ infrastructure configuration management monitoring analytics documentation implementation
12
+ optimization validation testing deployment].freeze
13
+
14
+ VERBS = %w[build create develop implement integrate configure optimize enhance improve streamline
15
+ automate manage deploy monitor analyze validate test debug refactor scale maintain
16
+ upgrade migrate extend customize adapt transform modernize accelerate simplify].freeze
17
+
18
+ ADJECTIVES = %w[efficient powerful flexible robust scalable secure reliable fast modern advanced
19
+ comprehensive intuitive seamless integrated automated intelligent dynamic responsive
20
+ innovative cutting-edge enterprise production-ready cloud-native distributed].freeze
21
+
22
+ TECH_TERMS = %w[API REST GraphQL microservice container orchestration Kubernetes Docker CI/CD
23
+ authentication JWT OAuth serverless lambda function middleware cache Redis
24
+ PostgreSQL MongoDB WebSocket HTTP HTTPS SSL TLS encryption algorithm].freeze
25
+
26
+ COMPANIES = %w[TechCorp DataSystems CloudWorks InnovateLabs ScaleUp DevOps Solutions
27
+ Enterprise Digital Ventures Analytics Group Platform Technologies
28
+ NetworkPro SecureBase CodeCraft BuildTools DeployFirst].freeze
29
+
30
+ LOREM_WORDS = %w[lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor
31
+ incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud
32
+ exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute
33
+ irure in reprehenderit voluptate velit esse cillum fugiat nulla pariatur
34
+ excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt
35
+ mollit anim id est laborum].freeze
36
+
37
+ # Safe external links for sample content
38
+ SAFE_LINKS = [
39
+ {url: "https://github.com/kanejamison/bunko", text: "Bunko on GitHub"},
40
+ {url: "https://rubyonrails.org", text: "Ruby on Rails"},
41
+ {url: "https://www.ruby-lang.org", text: "Ruby Language"},
42
+ {url: "https://rubygems.org", text: "RubyGems"}
43
+ ].freeze
44
+
45
+ # Random image dimensions from picsum.photos
46
+ IMAGE_SIZES = [
47
+ {width: 800, height: 400, type: :hero},
48
+ {width: 1200, height: 600, type: :hero},
49
+ {width: 700, height: 500, type: :content},
50
+ {width: 600, height: 400, type: :content},
51
+ {width: 500, height: 350, type: :content},
52
+ {width: 400, height: 300, type: :inline}
53
+ ].freeze
54
+
55
+ # Supported content formats
56
+ FORMATS = [:markdown, :html].freeze
57
+
58
+ class << self
59
+ # Generate a random word from various pools
60
+ def word(type = :general)
61
+ case type
62
+ when :noun then NOUNS.sample
63
+ when :verb then VERBS.sample
64
+ when :adjective then ADJECTIVES.sample
65
+ when :tech then TECH_TERMS.sample
66
+ when :company then COMPANIES.sample
67
+ else LOREM_WORDS.sample
68
+ end
69
+ end
70
+
71
+ # Generate a sentence with specified word count and optional formatting
72
+ def sentence(word_count: nil, capitalize: true, format: :plain)
73
+ # Vary sentence length: 20% short (3-5 words), 80% medium (8-15 words)
74
+ word_count ||= (rand < 0.2) ? rand(3..5) : rand(8..15)
75
+
76
+ words = Array.new(word_count) { LOREM_WORDS.sample }
77
+ sentence = words.join(" ")
78
+ sentence = sentence.capitalize if capitalize
79
+ sentence = "#{sentence}."
80
+
81
+ # Randomly apply inline formatting (30% chance)
82
+ if format != :plain && rand < 0.3
83
+ sentence = apply_inline_formatting(sentence, format)
84
+ end
85
+
86
+ sentence
87
+ end
88
+
89
+ # Generate a paragraph with specified sentence count and optional formatting
90
+ def paragraph(sentence_count: nil, format: :plain)
91
+ # Vary paragraph length: 25% short (1-2 sentences), 75% medium (4-8 sentences)
92
+ sentence_count ||= (rand < 0.25) ? rand(1..2) : rand(4..8)
93
+
94
+ text = Array.new(sentence_count) { sentence(format: format) }.join(" ")
95
+
96
+ # Wrap in paragraph tags for HTML (20% chance for class)
97
+ if format == :html
98
+ css_class = (rand < 0.2) ? " class=\"content-paragraph\"" : ""
99
+ text = "<p#{css_class}>#{text}</p>"
100
+ end
101
+
102
+ text
103
+ end
104
+
105
+ # Generate multiple paragraphs with optional formatting
106
+ def paragraphs(count: 3, target_words: nil, format: :plain)
107
+ paras = if target_words
108
+ # Calculate sentences needed (avg 12 words per sentence)
109
+ sentences_needed = (target_words / 12.0).ceil
110
+ # Group into paragraphs (4-8 sentences each)
111
+ paragraph_count = [(sentences_needed / 6.0).ceil, 1].max
112
+
113
+ Array.new(paragraph_count) do
114
+ sentences_in_paragraph = [sentences_needed / paragraph_count, 1].max
115
+ paragraph(sentence_count: sentences_in_paragraph, format: format)
116
+ end
117
+ else
118
+ Array.new(count) { paragraph(format: format) }
119
+ end
120
+
121
+ # Randomly add special elements (lists, blockquotes, links)
122
+ paras = inject_special_elements(paras, format) if format != :plain
123
+
124
+ paras.join("\n\n")
125
+ end
126
+
127
+ # Generate a random date in the past
128
+ def past_date(years_ago: 2)
129
+ seconds_ago = rand(0..(years_ago * 365 * 24 * 60 * 60))
130
+ Time.now - seconds_ago
131
+ end
132
+
133
+ # Generate a random date in the future
134
+ def future_date(months_ahead: 3)
135
+ seconds_ahead = rand(0..(months_ahead * 30 * 24 * 60 * 60))
136
+ Time.now + seconds_ahead
137
+ end
138
+
139
+ # Generate a title
140
+ def title_for(post_type_name)
141
+ [
142
+ "#{VERBS.sample.capitalize} Your #{NOUNS.sample.capitalize} with #{ADJECTIVES.sample.capitalize} #{NOUNS.sample.capitalize}",
143
+ "How to #{VERBS.sample.capitalize} #{ADJECTIVES.sample.capitalize} #{NOUNS.sample.capitalize}",
144
+ "The Complete Guide to #{NOUNS.sample.capitalize} #{NOUNS.sample.capitalize}",
145
+ "Understanding #{ADJECTIVES.sample.capitalize} #{NOUNS.sample.capitalize}",
146
+ "#{rand(5..10)} Ways to #{VERBS.sample.capitalize} Your #{NOUNS.sample.capitalize}",
147
+ "#{ADJECTIVES.sample.capitalize} #{NOUNS.sample.capitalize} for #{NOUNS.sample.capitalize}",
148
+ "#{VERBS.sample.capitalize} #{NOUNS.sample.capitalize} Like a Pro",
149
+ "A Deep Dive into #{ADJECTIVES.sample.capitalize} #{NOUNS.sample.capitalize}"
150
+ ].sample
151
+ end
152
+
153
+ # Generate content structure
154
+ def content_for(post_type_name, target_words:, format: :plain)
155
+ default_content(target_words, format)
156
+ end
157
+
158
+ private
159
+
160
+ # Content generator
161
+ def default_content(target_words, format = :plain)
162
+ section_words = target_words / 5
163
+ [
164
+ paragraphs(target_words: section_words * 0.5, format: format),
165
+ format_hero_image(format),
166
+ format_heading("#{ADJECTIVES.sample.capitalize} #{NOUNS.sample.capitalize}", format),
167
+ paragraphs(target_words: section_words * 0.5, format: format),
168
+ format_subheading("Key Points", format),
169
+ paragraphs(target_words: section_words * 0.4, format: format),
170
+ format_heading("#{VERBS.sample.capitalize} #{NOUNS.sample.capitalize}", format),
171
+ paragraphs(target_words: section_words * 0.6, format: format),
172
+ format_heading("#{ADJECTIVES.sample.capitalize} Approach", format),
173
+ paragraphs(target_words: section_words * 0.5, format: format),
174
+ format_subheading("Implementation Details", format),
175
+ paragraphs(target_words: section_words * 0.5, format: format),
176
+ format_heading("Summary", format),
177
+ paragraphs(target_words: section_words * 0.4, format: format)
178
+ ].join("\n\n")
179
+ end
180
+
181
+ def code_example(format = :plain)
182
+ method = VERBS.sample
183
+ obj = NOUNS.sample
184
+ param = NOUNS.sample
185
+ comment = sentence(word_count: rand(5..8)).chomp(".")
186
+
187
+ code_content = "# #{comment}\n#{obj} = #{obj.capitalize}.new(#{param}: '#{word(:adjective)}')\n#{obj}.#{method}!"
188
+
189
+ case format
190
+ when :markdown
191
+ "```ruby\n#{code_content}\n```"
192
+ when :html
193
+ css_class = (rand < 0.3) ? " class=\"code-block\"" : ""
194
+ "<pre#{css_class}><code>#{code_content}</code></pre>"
195
+ else
196
+ code_content
197
+ end
198
+ end
199
+
200
+ # Formatting helpers
201
+ def format_heading(text, format)
202
+ if format == :html
203
+ css_class = (rand < 0.3) ? " class=\"section-heading\"" : ""
204
+ "<h2#{css_class}>#{text}</h2>"
205
+ else
206
+ "## #{text}"
207
+ end
208
+ end
209
+
210
+ def format_subheading(text, format)
211
+ if format == :html
212
+ css_class = (rand < 0.3) ? " class=\"subsection-heading\"" : ""
213
+ "<h3#{css_class}>#{text}</h3>"
214
+ else
215
+ "### #{text}"
216
+ end
217
+ end
218
+
219
+ def apply_inline_formatting(text, format)
220
+ # Pick a random formatting style
221
+ style = [:bold, :italic, :underline].sample
222
+
223
+ # Find words to format (avoid the last word with period)
224
+ words = text.chomp(".").split
225
+ return text if words.length < 3
226
+
227
+ # Pick 1-5 consecutive words to format
228
+ num_words_to_format = [rand(1..5), words.length - 2].min
229
+ start_index = rand(1...(words.length - num_words_to_format))
230
+ words_to_format = words[start_index, num_words_to_format].join(" ")
231
+
232
+ formatted_text = case format
233
+ when :markdown
234
+ case style
235
+ when :bold then "**#{words_to_format}**"
236
+ when :italic then "_#{words_to_format}_"
237
+ when :underline then words_to_format # Markdown doesn't have underline
238
+ end
239
+ when :html
240
+ case style
241
+ when :bold then "<strong>#{words_to_format}</strong>"
242
+ when :italic then "<em>#{words_to_format}</em>"
243
+ when :underline then "<u>#{words_to_format}</u>"
244
+ end
245
+ else
246
+ words_to_format
247
+ end
248
+
249
+ # Replace the words with formatted version
250
+ words[start_index, num_words_to_format] = [formatted_text]
251
+ "#{words.join(" ")}."
252
+ end
253
+
254
+ def inject_special_elements(paragraphs, format)
255
+ return paragraphs if paragraphs.length < 2
256
+
257
+ # Randomly inject a blockquote (20% chance)
258
+ if rand < 0.2
259
+ quote_index = rand(1...paragraphs.length)
260
+ quote_text = sentence(word_count: rand(10..15), format: format)
261
+ paragraphs.insert(quote_index, format_blockquote(quote_text, format))
262
+ end
263
+
264
+ # Randomly inject a list (30% chance)
265
+ if rand < 0.3
266
+ list_index = rand(1...paragraphs.length)
267
+ paragraphs.insert(list_index, format_list(format))
268
+ end
269
+
270
+ # Randomly inject a code block (25% chance)
271
+ if rand < 0.25
272
+ code_index = rand(1...paragraphs.length)
273
+ paragraphs.insert(code_index, code_example(format))
274
+ end
275
+
276
+ # Randomly inject images (50% chance for 1-2 images)
277
+ # Skip first 3 paragraphs to give space after hero image
278
+ if rand < 0.5 && paragraphs.length > 3
279
+ num_images = rand(1..2)
280
+ num_images.times do
281
+ # Start from index 3 to avoid hero image area
282
+ image_index = rand(3...paragraphs.length)
283
+ paragraphs.insert(image_index, format_image(format))
284
+ end
285
+ end
286
+
287
+ # Randomly inject a link into one paragraph (40% chance)
288
+ if rand < 0.4 && paragraphs.any?
289
+ link_para_index = rand(0...paragraphs.length)
290
+ paragraphs[link_para_index] = inject_link(paragraphs[link_para_index], format)
291
+ end
292
+
293
+ paragraphs
294
+ end
295
+
296
+ def format_blockquote(text, format)
297
+ case format
298
+ when :markdown
299
+ "> #{text}"
300
+ when :html
301
+ css_class = (rand < 0.3) ? " class=\"content-quote\"" : ""
302
+ "<blockquote#{css_class}>#{text}</blockquote>"
303
+ else
304
+ text
305
+ end
306
+ end
307
+
308
+ def format_list(format)
309
+ items = rand(3..5).times.map { "#{VERBS.sample.capitalize} #{NOUNS.sample}" }
310
+ # 50% chance for ordered list, 50% for unordered
311
+ ordered = rand < 0.5
312
+
313
+ case format
314
+ when :markdown
315
+ if ordered
316
+ items.map.with_index(1) { |item, i| "#{i}. #{item}" }.join("\n")
317
+ else
318
+ items.map { |item| "- #{item}" }.join("\n")
319
+ end
320
+ when :html
321
+ css_class = (rand < 0.3) ? " class=\"content-list\"" : ""
322
+ list_items = items.map { |item| "<li>#{item}</li>" }.join("\n")
323
+ if ordered
324
+ "<ol#{css_class}>\n#{list_items}\n</ol>"
325
+ else
326
+ "<ul#{css_class}>\n#{list_items}\n</ul>"
327
+ end
328
+ else
329
+ items.join(", ")
330
+ end
331
+ end
332
+
333
+ def inject_link(text, format)
334
+ link_data = SAFE_LINKS.sample
335
+
336
+ case format
337
+ when :markdown
338
+ # Append link after paragraph
339
+ "#{text} Learn more about [#{link_data[:text]}](#{link_data[:url]})."
340
+ when :html
341
+ # Inject link inside the paragraph tag (before closing </p>)
342
+ if text.include?("</p>")
343
+ text.sub("</p>", " Learn more about <a href=\"#{link_data[:url]}\">#{link_data[:text]}</a>.</p>")
344
+ else
345
+ # Fallback for non-paragraph HTML
346
+ "#{text} Learn more about <a href=\"#{link_data[:url]}\">#{link_data[:text]}</a>."
347
+ end
348
+ else
349
+ text
350
+ end
351
+ end
352
+
353
+ def format_hero_image(format)
354
+ # Hero images are always the largest sizes
355
+ hero_sizes = IMAGE_SIZES.select { |img| img[:type] == :hero }
356
+ image = hero_sizes.sample
357
+ width = image[:width]
358
+ height = image[:height]
359
+
360
+ # Generate random seed for consistent random image
361
+ seed = rand(1..1000)
362
+ image_url = "https://picsum.photos/#{width}/#{height}?random=#{seed}"
363
+ alt_text = "#{ADJECTIVES.sample.capitalize} #{NOUNS.sample}"
364
+
365
+ case format
366
+ when :markdown
367
+ "![#{alt_text}](#{image_url})"
368
+ when :html
369
+ "<img src=\"#{image_url}\" alt=\"#{alt_text}\" width=\"#{width}\" height=\"#{height}\" class=\"hero-image\">"
370
+ else
371
+ ""
372
+ end
373
+ end
374
+
375
+ def format_image(format)
376
+ # Select a random image size (excluding hero sizes for inline images)
377
+ non_hero_sizes = IMAGE_SIZES.reject { |img| img[:type] == :hero }
378
+ image = non_hero_sizes.sample
379
+ width = image[:width]
380
+ height = image[:height]
381
+
382
+ # Generate random seed for consistent random image
383
+ seed = rand(1..1000)
384
+ image_url = "https://picsum.photos/#{width}/#{height}?random=#{seed}"
385
+ alt_text = "#{ADJECTIVES.sample.capitalize} #{NOUNS.sample}"
386
+
387
+ case format
388
+ when :markdown
389
+ "![#{alt_text}](#{image_url})"
390
+ when :html
391
+ css_class = (rand < 0.3) ? " class=\"content-image\"" : ""
392
+ "<img src=\"#{image_url}\" alt=\"#{alt_text}\" width=\"#{width}\" height=\"#{height}\"#{css_class}>"
393
+ else
394
+ ""
395
+ end
396
+ end
397
+ end
398
+ end
399
+ end
@@ -0,0 +1,62 @@
1
+ ===============================================================================
2
+
3
+ Bunko has been installed!
4
+
5
+ Migrations, models, and initializer have been created.
6
+
7
+ Next steps:
8
+
9
+ 1. (Optional) Customize your post types in config/initializers/bunko.rb
10
+
11
+ We've configured "blog" for you, but you can change that or add more:
12
+
13
+ config.post_type "blog" # Title auto-generated as "Blog"
14
+
15
+ config.post_type "docs" do |type|
16
+ type.title = "Documentation"
17
+ end
18
+
19
+ config.post_type "changelog"
20
+
21
+ 2. Run the migrations:
22
+
23
+ $ rails db:migrate
24
+
25
+ 3. Run the setup task:
26
+
27
+ $ rails bunko:setup
28
+
29
+ This will:
30
+ - Create PostTypes from your config
31
+ - Generate controllers for each post type
32
+ - Generate views (index, show) for each post type
33
+ - Add routes for each post type
34
+
35
+ Safe to re-run if you add more types later!
36
+
37
+ 4. Create your first post (Rails console or admin panel):
38
+
39
+ blog_type = PostType.find_by(name: "blog")
40
+ Post.create!(
41
+ title: "Welcome to Bunko",
42
+ content: "Your first blog post!",
43
+ post_type: blog_type,
44
+ status: "published",
45
+ published_at: Time.current
46
+ )
47
+
48
+ 5. Start your server and visit:
49
+
50
+ http://localhost:3000/blog
51
+
52
+ Want to add more collections later?
53
+ 1. Add the post type to config/initializers/bunko.rb
54
+ 2. Run either:
55
+ - rails bunko:setup[product] (set up just the new "product" collection)
56
+ - rails bunko:setup (set up all collections, safe to re-run unless you delete any setup files on other models)
57
+ 3. Done! Controllers, views, and routes are generated automatically.
58
+
59
+ Need help? Check out the documentation at:
60
+ https://github.com/kanejamison/bunko
61
+
62
+ ===============================================================================
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ Bunko.configure do |config|
4
+ # Define your post types (use lowercase with underscores)
5
+ # These will be created when you run: rails bunko:setup
6
+ config.post_type "blog" # Title will be auto-generated as "Blog"
7
+
8
+ # Want more? Add additional post types:
9
+ # config.post_type "docs" do |type|
10
+ # type.title = "Documentation" # Custom title (optional)
11
+ # end
12
+ #
13
+ # config.post_type "changelog" # Title: "Changelog"
14
+ #
15
+ # config.post_type "case_studies" do |type|
16
+ # type.title = "Case Studies" # Custom title
17
+ # end
18
+ #
19
+ # Note: Names use underscores, URLs automatically use hyphens (/case-studies/)
20
+
21
+ # Smart collections - aggregate or filter posts from multiple post types
22
+ # config.collection "resources", post_types: ["articles", "videos", "tutorials"]
23
+ # config.collection "long_reads" do |c|
24
+ # c.post_types = ["articles", "tutorials"]
25
+ # c.scope = -> { where("word_count > ?", 1500) }
26
+ # end
27
+
28
+ # Enable standalone pages feature (About, Contact, Privacy, etc.)
29
+ # When enabled, rails bunko:setup creates a PagesController and pages PostType
30
+ # Use bunko_page :about in routes to create single-page routes
31
+ # Default: true
32
+ # config.allow_static_pages = true
33
+
34
+ # Reading speed for calculating estimated reading time (in words per minute)
35
+ # Default: 250
36
+ # config.reading_speed = 250
37
+
38
+ # Excerpt length for post.excerpt method (in characters)
39
+ # Default: 160
40
+ # config.excerpt_length = 160
41
+
42
+ # Automatically update word_count when content changes
43
+ # Default: true
44
+ # config.auto_update_word_count = true
45
+ end