lean_cms 0.2.12

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 (130) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +235 -0
  3. data/LICENSE +21 -0
  4. data/README.md +107 -0
  5. data/app/assets/images/lean_cms/sloth-404.png +0 -0
  6. data/app/assets/images/lean_cms/sloth-500.png +0 -0
  7. data/app/assets/images/lean_cms/sloth-favicon-16.png +0 -0
  8. data/app/assets/images/lean_cms/sloth-favicon-32.png +0 -0
  9. data/app/assets/images/lean_cms/sloth-favicon-64.png +0 -0
  10. data/app/assets/images/lean_cms/sloth-logo.png +0 -0
  11. data/app/assets/lean_cms/actiontext.css +440 -0
  12. data/app/assets/lean_cms/cms_edit_controls.css +548 -0
  13. data/app/assets/tailwind/lean_cms/engine.css +14 -0
  14. data/app/components/lean_cms/base_component.rb +61 -0
  15. data/app/components/lean_cms/bullets_section_component.html.erb +23 -0
  16. data/app/components/lean_cms/bullets_section_component.rb +54 -0
  17. data/app/components/lean_cms/cards_section_component.html.erb +237 -0
  18. data/app/components/lean_cms/cards_section_component.rb +71 -0
  19. data/app/components/lean_cms/editable_content_component.html.erb +15 -0
  20. data/app/components/lean_cms/editable_content_component.rb +53 -0
  21. data/app/components/lean_cms/section_component.html.erb +18 -0
  22. data/app/components/lean_cms/section_component.rb +35 -0
  23. data/app/controllers/concerns/lean_cms/authentication.rb +60 -0
  24. data/app/controllers/concerns/lean_cms/authorization.rb +60 -0
  25. data/app/controllers/lean_cms/activity_controller.rb +16 -0
  26. data/app/controllers/lean_cms/application_controller.rb +48 -0
  27. data/app/controllers/lean_cms/dashboard_controller.rb +13 -0
  28. data/app/controllers/lean_cms/form_submissions_controller.rb +37 -0
  29. data/app/controllers/lean_cms/notification_settings_controller.rb +145 -0
  30. data/app/controllers/lean_cms/notifications_controller.rb +26 -0
  31. data/app/controllers/lean_cms/page_contents_controller.rb +403 -0
  32. data/app/controllers/lean_cms/password_setup_controller.rb +65 -0
  33. data/app/controllers/lean_cms/passwords_controller.rb +42 -0
  34. data/app/controllers/lean_cms/posts_controller.rb +78 -0
  35. data/app/controllers/lean_cms/sessions_controller.rb +50 -0
  36. data/app/controllers/lean_cms/settings_controller.rb +124 -0
  37. data/app/controllers/lean_cms/users_controller.rb +113 -0
  38. data/app/helpers/lean_cms/activity_helper.rb +190 -0
  39. data/app/helpers/lean_cms/application_helper.rb +43 -0
  40. data/app/helpers/lean_cms/content_helper.rb +34 -0
  41. data/app/helpers/lean_cms/page_content_helper.rb +359 -0
  42. data/app/javascript/controllers/cards_editor_controller.js +317 -0
  43. data/app/javascript/controllers/cms_sticky_overlay_controller.js +59 -0
  44. data/app/javascript/controllers/field_editor_form_controller.js +68 -0
  45. data/app/javascript/controllers/field_editor_modal_controller.js +79 -0
  46. data/app/javascript/controllers/inline_edit_controller.js +414 -0
  47. data/app/javascript/controllers/inline_edit_toggle_controller.js +81 -0
  48. data/app/javascript/controllers/notifications_controller.js +19 -0
  49. data/app/javascript/controllers/settings_inline_edit_sync_controller.js +38 -0
  50. data/app/javascript/controllers/settings_override_controller.js +45 -0
  51. data/app/mailers/lean_cms/application_mailer.rb +6 -0
  52. data/app/mailers/lean_cms/passwords_mailer.rb +8 -0
  53. data/app/mailers/lean_cms/users_mailer.rb +39 -0
  54. data/app/models/lean_cms/current.rb +6 -0
  55. data/app/models/lean_cms/form_submission.rb +45 -0
  56. data/app/models/lean_cms/magic_link.rb +76 -0
  57. data/app/models/lean_cms/meta_tag.rb +30 -0
  58. data/app/models/lean_cms/notification_setting.rb +69 -0
  59. data/app/models/lean_cms/page.rb +23 -0
  60. data/app/models/lean_cms/page_content.rb +245 -0
  61. data/app/models/lean_cms/post.rb +65 -0
  62. data/app/models/lean_cms/session.rb +7 -0
  63. data/app/models/lean_cms/setting.rb +156 -0
  64. data/app/policies/lean_cms/application_policy.rb +35 -0
  65. data/app/policies/lean_cms/page_content_policy.rb +31 -0
  66. data/app/policies/lean_cms/post_policy.rb +37 -0
  67. data/app/policies/lean_cms/setting_policy.rb +17 -0
  68. data/app/views/layouts/lean_cms/application.html.erb +114 -0
  69. data/app/views/layouts/lean_cms/auth.html.erb +200 -0
  70. data/app/views/lean_cms/activity/index.html.erb +79 -0
  71. data/app/views/lean_cms/dashboard/index.html.erb +180 -0
  72. data/app/views/lean_cms/form_submissions/index.html.erb +104 -0
  73. data/app/views/lean_cms/form_submissions/show.html.erb +157 -0
  74. data/app/views/lean_cms/notification_settings/edit.html.erb +192 -0
  75. data/app/views/lean_cms/notifications/index.html.erb +72 -0
  76. data/app/views/lean_cms/notifications/show.html.erb +39 -0
  77. data/app/views/lean_cms/page_contents/_field_editor.html.erb +174 -0
  78. data/app/views/lean_cms/page_contents/edit.html.erb +428 -0
  79. data/app/views/lean_cms/page_contents/index.html.erb +113 -0
  80. data/app/views/lean_cms/password_setup/show.html.erb +35 -0
  81. data/app/views/lean_cms/passwords/edit.html.erb +26 -0
  82. data/app/views/lean_cms/passwords/new.html.erb +21 -0
  83. data/app/views/lean_cms/passwords_mailer/reset.html.erb +6 -0
  84. data/app/views/lean_cms/passwords_mailer/reset.text.erb +4 -0
  85. data/app/views/lean_cms/posts/_form.html.erb +118 -0
  86. data/app/views/lean_cms/posts/edit.html.erb +31 -0
  87. data/app/views/lean_cms/posts/index.html.erb +100 -0
  88. data/app/views/lean_cms/posts/new.html.erb +16 -0
  89. data/app/views/lean_cms/sessions/new.html.erb +28 -0
  90. data/app/views/lean_cms/settings/edit.html.erb +384 -0
  91. data/app/views/lean_cms/shared/_admin_bar.html.erb +85 -0
  92. data/app/views/lean_cms/shared/_header.html.erb +86 -0
  93. data/app/views/lean_cms/shared/_notifications_bell.html.erb +84 -0
  94. data/app/views/lean_cms/shared/_sidebar.html.erb +102 -0
  95. data/app/views/lean_cms/users/_form.html.erb +105 -0
  96. data/app/views/lean_cms/users/edit.html.erb +8 -0
  97. data/app/views/lean_cms/users/index.html.erb +99 -0
  98. data/app/views/lean_cms/users/new.html.erb +8 -0
  99. data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.html.erb +13 -0
  100. data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.text.erb +11 -0
  101. data/app/views/lean_cms/users_mailer/invitation.html.erb +13 -0
  102. data/app/views/lean_cms/users_mailer/invitation.text.erb +11 -0
  103. data/app/views/lean_cms/users_mailer/reactivation.html.erb +13 -0
  104. data/app/views/lean_cms/users_mailer/reactivation.text.erb +11 -0
  105. data/config/importmap.rb +8 -0
  106. data/config/routes.rb +78 -0
  107. data/db/migrate/20251112034030_create_lean_cms_tables.rb +131 -0
  108. data/db/migrate/20260513000001_create_lean_cms_auth_tables.rb +31 -0
  109. data/db/migrate/20260514000001_create_paper_trail_versions.rb +16 -0
  110. data/db/migrate/20260514000002_create_action_text_tables.rb +18 -0
  111. data/db/migrate/20260514000003_create_active_storage_tables.rb +45 -0
  112. data/db/migrate/20260514000004_create_noticed_tables.rb +27 -0
  113. data/lib/generators/lean_cms/demo/demo_generator.rb +54 -0
  114. data/lib/generators/lean_cms/demo/templates/lean_cms_structure.yml +129 -0
  115. data/lib/generators/lean_cms/demo/templates/pages_controller.rb +30 -0
  116. data/lib/generators/lean_cms/demo/templates/views/pages/about.html.erb +40 -0
  117. data/lib/generators/lean_cms/demo/templates/views/pages/contact.html.erb +55 -0
  118. data/lib/generators/lean_cms/demo/templates/views/pages/home.html.erb +31 -0
  119. data/lib/generators/lean_cms/install/install_generator.rb +317 -0
  120. data/lib/generators/lean_cms/install/templates/add_lean_cms_columns_to_users.rb.tt +7 -0
  121. data/lib/generators/lean_cms/install/templates/lean_cms.rb +11 -0
  122. data/lib/generators/lean_cms/install/templates/lean_cms_structure.yml +29 -0
  123. data/lib/lean_cms/configuration.rb +32 -0
  124. data/lib/lean_cms/engine.rb +93 -0
  125. data/lib/lean_cms/loader.rb +217 -0
  126. data/lib/lean_cms/sync_helper.rb +182 -0
  127. data/lib/lean_cms/version.rb +3 -0
  128. data/lib/lean_cms.rb +26 -0
  129. data/lib/tasks/lean_cms.rake +390 -0
  130. metadata +313 -0
@@ -0,0 +1,390 @@
1
+ namespace :lean_cms do
2
+ # Plain-text stdout logger for the Rake tasks — no timestamps or severity
3
+ # tags, since these are interactive CLI commands. Background-job callers
4
+ # use Rails.logger (or whatever they pass in) instead.
5
+ rake_logger = lambda do
6
+ logger = ActiveSupport::Logger.new($stdout)
7
+ logger.formatter = ->(_severity, _time, _progname, msg) { "#{msg}\n" }
8
+ logger
9
+ end
10
+
11
+ desc "Load page content structure from YAML file"
12
+ task load_structure: :environment do
13
+ LeanCms::Loader.new(logger: rake_logger.call).load!
14
+ rescue LeanCms::Loader::StructureFileMissing => e
15
+ warn "Error: #{e.message}"
16
+ exit 1
17
+ rescue LeanCms::Loader::NoUsersFound => e
18
+ warn "Error: #{e.message} Run `bin/rails generate authentication` (or your auth gem's install) first."
19
+ exit 1
20
+ end
21
+
22
+ desc "Clear all page content (WARNING: destructive)"
23
+ task clear_content: :environment do
24
+ print "Are you sure you want to delete ALL page content? (yes/no): "
25
+ confirmation = STDIN.gets.chomp
26
+
27
+ if confirmation.downcase == 'yes'
28
+ count = LeanCms::PageContent.count
29
+ LeanCms::PageContent.destroy_all
30
+ puts "Deleted #{count} page content records"
31
+ else
32
+ puts "Operation cancelled"
33
+ end
34
+ end
35
+
36
+ desc "Reload structure (clear and load)"
37
+ task reload_structure: :environment do
38
+ Rake::Task['lean_cms:clear_content'].invoke
39
+ Rake::Task['lean_cms:load_structure'].invoke
40
+ end
41
+
42
+ desc "Export current LeanCms::PageContent records to YAML (config/lean_cms_structure_export.yml)"
43
+ task export_structure: :environment do
44
+ require "yaml"
45
+
46
+ output_path = ENV["OUTPUT"] || Rails.root.join("config", "lean_cms_structure_export.yml").to_s
47
+
48
+ pages_yaml = {}
49
+
50
+ page_keys = LeanCms::PageContent.distinct.order(:page_order, :page).pluck(:page)
51
+
52
+ page_keys.each do |page_key|
53
+ page_scope = LeanCms::PageContent.where(page: page_key)
54
+ first = page_scope.order(:page_order).first
55
+
56
+ page_yaml = {
57
+ "display_title" => first.page_display_title.presence || page_key.titleize,
58
+ "page_order" => first.page_order || 0,
59
+ "sections" => {}
60
+ }
61
+
62
+ section_keys = page_scope.distinct.order(:section_order, :section).pluck(:section)
63
+ section_keys.each do |section_key|
64
+ section_scope = page_scope.where(section: section_key)
65
+ section_first = section_scope.order(:section_order).first
66
+
67
+ section_yaml = {
68
+ "display_title" => section_first.display_title.presence || section_key.titleize,
69
+ "section_order" => section_first.section_order || 0,
70
+ "fields" => {}
71
+ }
72
+
73
+ section_scope.order(:position, :key).each do |record|
74
+ case record.content_type
75
+ when "cards"
76
+ items = JSON.parse(record.content.to_s) rescue []
77
+ section_yaml["cards"] = {
78
+ "type" => "cards",
79
+ "max_cards" => record.options.is_a?(Hash) ? record.options["max_cards"] : nil,
80
+ "items" => items
81
+ }.compact
82
+ when "bullets"
83
+ items = JSON.parse(record.content.to_s) rescue []
84
+ section_yaml["bullets"] = {
85
+ "type" => "bullets",
86
+ "max_items" => record.options.is_a?(Hash) ? record.options["max_items"] : nil,
87
+ "items" => items
88
+ }.compact
89
+ else
90
+ field_yaml = { "type" => record.content_type }
91
+ field_yaml["label"] = record.label if record.label.present?
92
+ field_yaml["position"] = record.position if record.position.to_i != 0
93
+
94
+ # Current production value becomes the seeded default in fresh environments.
95
+ field_yaml["default"] =
96
+ if record.content_type == "rich_text"
97
+ record.rich_content&.to_s
98
+ elsif record.content_type == "boolean"
99
+ record.value == "true"
100
+ else
101
+ record.value
102
+ end
103
+
104
+ field_yaml.compact!
105
+
106
+ if record.options.is_a?(Hash)
107
+ field_yaml["max_length"] = record.options["max_length"] if record.options["max_length"]
108
+ field_yaml["options"] = record.options["options"] if record.options["options"]
109
+ end
110
+
111
+ section_yaml["fields"][record.key] = field_yaml
112
+ end
113
+ end
114
+
115
+ page_yaml["sections"][section_key] = section_yaml
116
+ end
117
+
118
+ pages_yaml[page_key] = page_yaml
119
+ end
120
+
121
+ File.write(output_path, { "pages" => pages_yaml }.to_yaml)
122
+ puts "Exported #{LeanCms::PageContent.count} content records across #{page_keys.size} pages to:"
123
+ puts " #{output_path}"
124
+ puts ""
125
+ puts "Note: image attachments are not included in the YAML export. Re-attach them"
126
+ puts " via the CMS UI or copy ActiveStorage blobs separately."
127
+ end
128
+
129
+ desc "Show page content stats"
130
+ task stats: :environment do
131
+ puts "LeanCMS Page Content Statistics"
132
+ puts "=" * 60
133
+
134
+ pages = LeanCms::PageContent.distinct.pluck(:page)
135
+
136
+ pages.each do |page|
137
+ sections = LeanCms::PageContent.where(page: page).distinct.pluck(:section)
138
+ total_fields = LeanCms::PageContent.where(page: page).count
139
+
140
+ puts "\nPage: #{page.upcase}"
141
+ puts " Sections: #{sections.count}"
142
+ puts " Total fields: #{total_fields}"
143
+
144
+ sections.each do |section|
145
+ field_count = LeanCms::PageContent.where(page: page, section: section).count
146
+ puts " - #{section}: #{field_count} fields"
147
+ end
148
+ end
149
+
150
+ puts "\n" + "=" * 60
151
+ puts "Total: #{LeanCms::PageContent.count} content fields across #{pages.count} pages"
152
+ puts "=" * 60
153
+ end
154
+
155
+ # ============================================================================
156
+ # Content Sync Tasks
157
+ # These tasks enable a safe workflow for syncing SQLite database between
158
+ # local development and production environments.
159
+ # ============================================================================
160
+
161
+ # Guard for sync tasks that copy SQLite database files directly
162
+ # (pull / push / stage / start / finish). Lock / unlock / status are
163
+ # adapter-agnostic and don't need this — they only toggle a Setting.
164
+ sqlite_only = lambda do
165
+ adapter = ActiveRecord::Base.connection_db_config.adapter
166
+ return if adapter == "sqlite3"
167
+
168
+ warn <<~MSG
169
+ lean_cms:sync:* file-copy tasks (pull / push / stage / start / finish)
170
+ assume a SQLite database. The current connection uses #{adapter.inspect}.
171
+
172
+ Use your database's native dump/restore tooling instead:
173
+ Postgres: pg_dump / pg_restore
174
+ MySQL: mysqldump
175
+
176
+ The lock / unlock / status tasks work on any adapter and remain available.
177
+ MSG
178
+ exit 1
179
+ end
180
+
181
+ namespace :sync do
182
+ desc "Lock content editing on this instance"
183
+ task lock: :environment do
184
+ reason = ENV['REASON'] || 'Content sync in progress'
185
+
186
+ if LeanCms::Setting.content_locked?
187
+ puts "Content is already locked."
188
+ lock_info = LeanCms::Setting.content_lock_info
189
+ puts " Locked at: #{lock_info[:locked_at]}"
190
+ puts " Reason: #{lock_info[:reason]}"
191
+ else
192
+ LeanCms::Setting.lock_content!(reason)
193
+ puts "Content editing has been LOCKED."
194
+ puts " Reason: #{reason}"
195
+ puts " Locked at: #{Time.current}"
196
+ puts "\nEditors will not be able to make changes until unlocked."
197
+ end
198
+ end
199
+
200
+ desc "Unlock content editing on this instance"
201
+ task unlock: :environment do
202
+ if LeanCms::Setting.content_locked?
203
+ LeanCms::Setting.unlock_content!
204
+ puts "Content editing has been UNLOCKED."
205
+ puts "Editors can now make changes."
206
+ else
207
+ puts "Content is not locked."
208
+ end
209
+ end
210
+
211
+ desc "Show current lock status"
212
+ task status: :environment do
213
+ if LeanCms::Setting.content_locked?
214
+ lock_info = LeanCms::Setting.content_lock_info
215
+ puts "Content Status: LOCKED"
216
+ puts " Locked at: #{lock_info[:locked_at]}"
217
+ puts " Reason: #{lock_info[:reason]}"
218
+ else
219
+ puts "Content Status: UNLOCKED"
220
+ puts "Editors can make changes."
221
+ end
222
+ end
223
+
224
+ desc "Pull production database to local (run locally, SQLite only)"
225
+ task pull: :environment do
226
+ sqlite_only.call
227
+ require_relative '../lean_cms/sync_helper'
228
+ LeanCms::SyncHelper.pull_from_production
229
+ end
230
+
231
+ desc "Push local database to production (run locally, SQLite only)"
232
+ task push: :environment do
233
+ sqlite_only.call
234
+ require_relative '../lean_cms/sync_helper'
235
+ LeanCms::SyncHelper.push_to_production
236
+ end
237
+
238
+ desc "Stage development DB as production_local for local production testing (SQLite only)"
239
+ task stage: :environment do
240
+ sqlite_only.call
241
+
242
+ dev_db = Rails.root.join('storage', 'development.sqlite3').to_s
243
+ prod_local = Rails.root.join('storage', 'production_local.sqlite3').to_s
244
+
245
+ unless File.exist?(dev_db)
246
+ abort "Development database not found at #{dev_db}"
247
+ end
248
+
249
+ puts "Staging development DB for local production testing..."
250
+ puts "=" * 60
251
+
252
+ # Checkpoint WAL on the dev DB first so the copy is consistent
253
+ puts " Checkpointing development database..."
254
+ system("sqlite3 #{dev_db} 'PRAGMA wal_checkpoint(TRUNCATE);'")
255
+ FileUtils.rm_f("#{dev_db}-shm")
256
+ FileUtils.rm_f("#{dev_db}-wal")
257
+
258
+ # Back up any existing production_local
259
+ if File.exist?(prod_local)
260
+ backup = "#{prod_local}.backup.#{Time.now.strftime('%Y%m%d_%H%M%S')}"
261
+ FileUtils.cp(prod_local, backup)
262
+ puts " Backed up existing production_local to: #{File.basename(backup)}"
263
+ end
264
+
265
+ FileUtils.cp(dev_db, prod_local)
266
+ FileUtils.rm_f("#{prod_local}-shm")
267
+ FileUtils.rm_f("#{prod_local}-wal")
268
+
269
+ puts " Copied development.sqlite3 → production_local.sqlite3"
270
+ puts "\n" + "=" * 60
271
+ puts "Done! Start Rails in production mode against the local DB:"
272
+ puts ""
273
+ puts " RAILS_ENV=production DATABASE_URL=sqlite3:storage/production_local.sqlite3 bin/rails server"
274
+ puts ""
275
+ puts "When happy with the result, push to the real server:"
276
+ puts ""
277
+ puts " bin/rails lean_cms:sync:push"
278
+ puts "=" * 60
279
+ end
280
+
281
+ desc "Full sync workflow: lock -> pull -> (make changes) -> push -> unlock"
282
+ task :workflow do
283
+ puts "LeanCMS Content Sync Workflow"
284
+ puts "=" * 60
285
+ puts "\nNew project / first deploy:"
286
+ puts " 1. Develop in development DB (default)"
287
+ puts " 2. Stage for local production test: bin/rails lean_cms:sync:stage"
288
+ puts " 3. Start production server locally: RAILS_ENV=production DATABASE_URL=sqlite3:storage/production_local.sqlite3 bin/rails server"
289
+ puts " 4. Push to real server: bin/rails lean_cms:sync:push"
290
+ puts ""
291
+ puts "Ongoing sync workflow:"
292
+ puts " 1. Lock production: bin/kamal cms-lock"
293
+ puts " 2. Pull database: bin/rails lean_cms:sync:pull"
294
+ puts " 3. Make local changes"
295
+ puts " 4. Push database: bin/rails lean_cms:sync:push"
296
+ puts " 5. Unlock production: bin/kamal cms-unlock"
297
+ puts "\nOr use the combined commands:"
298
+ puts " bin/rails lean_cms:sync:start - Lock and pull"
299
+ puts " bin/rails lean_cms:sync:finish - Push and unlock"
300
+ puts "=" * 60
301
+ end
302
+
303
+ desc "Start sync: lock production and pull database (SQLite only)"
304
+ task start: :environment do
305
+ sqlite_only.call
306
+ puts "Starting content sync workflow..."
307
+ puts "=" * 60
308
+
309
+ # Lock production via Kamal
310
+ puts "\n1. Locking production..."
311
+ system("kamal app exec 'bin/rails lean_cms:sync:lock'") || abort("Failed to lock production")
312
+
313
+ # Pull database
314
+ puts "\n2. Pulling production database..."
315
+ Rake::Task['lean_cms:sync:pull'].invoke
316
+
317
+ puts "\n" + "=" * 60
318
+ puts "Sync started! Production is locked."
319
+ puts "Make your changes locally, then run: bin/rails lean_cms:sync:finish"
320
+ puts "=" * 60
321
+ end
322
+
323
+ desc "Finish sync: push database and unlock production (SQLite only)"
324
+ task finish: :environment do
325
+ sqlite_only.call
326
+ puts "Finishing content sync workflow..."
327
+ puts "=" * 60
328
+
329
+ # Push database
330
+ puts "\n1. Pushing local database to production..."
331
+ Rake::Task['lean_cms:sync:push'].invoke
332
+
333
+ # Unlock production via Kamal
334
+ puts "\n2. Unlocking production..."
335
+ system("kamal app exec 'bin/rails lean_cms:sync:unlock'") || abort("Failed to unlock production")
336
+
337
+ puts "\n" + "=" * 60
338
+ puts "Sync complete! Production is unlocked and updated."
339
+ puts "=" * 60
340
+ end
341
+ end
342
+
343
+ desc "Optimize images: generate WebP + fallback variants from app/assets/images/source/"
344
+ task optimize_images: :environment do
345
+ require "image_processing/vips"
346
+
347
+ source_dir = Rails.root.join("app/assets/images/source")
348
+ output_dir = Rails.root.join("app/assets/images")
349
+
350
+ unless source_dir.directory?
351
+ puts "No source directory at #{source_dir}."
352
+ puts "Create app/assets/images/source/, place originals there, and re-run."
353
+ exit 0
354
+ end
355
+
356
+ widths = (ENV["WIDTHS"] || "640,1280,1920").split(",").map(&:to_i)
357
+ webp_quality = (ENV["WEBP_QUALITY"] || "80").to_i
358
+ jpeg_quality = (ENV["JPEG_QUALITY"] || "85").to_i
359
+ written = skipped = 0
360
+
361
+ Dir.glob(source_dir.join("*.{jpg,jpeg,png}"), File::FNM_CASEFOLD).uniq.sort.each do |source|
362
+ base = File.basename(source, ".*")
363
+ source_ext = File.extname(source).delete(".").downcase
364
+ fallback = source_ext == "jpeg" ? "jpg" : source_ext
365
+ src_kb = File.size(source) / 1024
366
+
367
+ puts "#{base} (#{src_kb} KB #{source_ext})"
368
+
369
+ widths.each do |w|
370
+ [["webp", webp_quality], [fallback, fallback == "jpg" ? jpeg_quality : nil]].each do |fmt, q|
371
+ out = output_dir.join("#{base}-#{w}.#{fmt}")
372
+ if out.exist? && out.mtime >= File.mtime(source)
373
+ skipped += 1
374
+ next
375
+ end
376
+
377
+ pipeline = ImageProcessing::Vips.source(source).resize_to_limit(w, nil)
378
+ pipeline = pipeline.saver(quality: q) if q
379
+ pipeline.convert(fmt).call(destination: out.to_s)
380
+
381
+ puts " -> #{w}w #{fmt.upcase}: #{File.size(out) / 1024} KB"
382
+ written += 1
383
+ end
384
+ end
385
+ end
386
+
387
+ puts ""
388
+ puts "Wrote #{written} files, skipped #{skipped} up-to-date."
389
+ end
390
+ end