ruby_cms 0.1.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 (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. metadata +223 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fde25c9e808732c737831592c660669073f083ee0239c0720dde030ca22a6b43
4
+ data.tar.gz: 115b6917a2697b2f084d45f5e831e1af2c6648d7248d1e0fbc29012e57c81822
5
+ SHA512:
6
+ metadata.gz: c3817701da2b69b4d1aab96739439325657e07625a54c38d0de6557c19bf1263602929e293ce289843c38911c5a7333c7ca61d926083ff19ba85a3512731c403
7
+ data.tar.gz: 8348f1923aa1d88d06d030e65c8191511145b0c23c62bf1f691ca7adb8ffd50fcbff7778c9a75c5c489b4ca13a291555d2d9d4588eef001bea7eeafd63e7fc71
data/.cursor/dhh.mdc ADDED
@@ -0,0 +1,698 @@
1
+ ---
2
+ alwaysApply: true
3
+ ---
4
+
5
+ # The Unofficial 37signals/DHH Rails Style Guide
6
+
7
+ Based on analysis of the Fizzy codebase - 37signals' open-source project management tool.
8
+
9
+ **Source:** https://gist.github.com/marckohlbrugge/d363fb90c89f71bd0c816d24d7642aca
10
+ **Extended Repo:** https://github.com/marckohlbrugge/unofficial-37signals-coding-style-guide
11
+
12
+ ## Philosophy Overview
13
+
14
+ The 37signals approach can be summarized as: **"Vanilla Rails is plenty."** They maximize what Rails gives you out of the box, minimize dependencies, and resist abstractions until absolutely necessary.
15
+
16
+ Core principles:
17
+
18
+ - **Rich domain models** over service objects
19
+ - **CRUD controllers** over custom actions
20
+ - **Concerns** for horizontal code sharing
21
+ - **Records as state** over boolean columns
22
+ - **Database-backed everything** (no Redis)
23
+ - **Build it yourself** before reaching for gems
24
+
25
+ ## Dependencies & What's Notably Absent
26
+
27
+ ### What They Use
28
+
29
+ ```ruby
30
+ # Gemfile
31
+ # Core Rails (running edge!)
32
+ gem "rails", github: "rails/rails", branch: "main"
33
+
34
+ # Their Hotwire stack
35
+ gem "turbo-rails"
36
+ gem "stimulus-rails"
37
+ gem "importmap-rails"
38
+ gem "propshaft"
39
+
40
+ # Database-backed infrastructure (NO Redis!)
41
+ gem "solid_queue" # Jobs
42
+ gem "solid_cache" # Caching
43
+ gem "solid_cable" # WebSockets
44
+
45
+ # Their own gems
46
+ gem "geared_pagination"
47
+ gem "lexxy" # Rich text
48
+ gem "mittens" # Email
49
+
50
+ # Minimal, focused gems
51
+ gem "bcrypt" # Password hashing
52
+ gem "rqrcode" # QR codes
53
+ gem "redcarpet" # Markdown
54
+ ```
55
+
56
+ ### What's Notably ABSENT
57
+
58
+ DO NOT USE:
59
+
60
+ | Gem/Pattern | Why They Avoid It |
61
+ | ------------------ | ------------------------------------------------------ |
62
+ | devise | Auth is ~150 lines of custom code. Devise is overkill. |
63
+ | pundit/cancancan | Authorization lives in models (can_administer_card?) |
64
+ | dry-rb gems | Over-engineered for most Rails apps |
65
+ | interactor/command | Service objects are rarely needed |
66
+ | view_component | ERB partials are fine |
67
+ | sidekiq | Solid Queue uses the database (no Redis) |
68
+ | redis | Database-backed everything |
69
+ | elasticsearch | Custom sharded MySQL full-text search |
70
+ | graphql | REST with Turbo is sufficient |
71
+ | rspec | Minitest is simpler and faster |
72
+
73
+ ## Routing: Everything is CRUD
74
+
75
+ ### The Core Principle
76
+
77
+ Every action maps to a CRUD verb. When something doesn't fit, create a new resource.
78
+
79
+ ```ruby
80
+ # ❌ BAD: Custom actions on existing resource
81
+ resources :cards do
82
+ post :close
83
+ post :reopen
84
+ post :archive
85
+ post :gild
86
+ end
87
+
88
+ # ✅ GOOD: New resources for each state change
89
+ resources :cards do
90
+ resource :closure # POST to close, DELETE to reopen
91
+ resource :goldness # POST to gild, DELETE to ungild
92
+ resource :not_now # POST to postpone
93
+ resource :pin # POST to pin, DELETE to unpin
94
+ resource :watch # POST to watch, DELETE to unwatch
95
+ end
96
+ ```
97
+
98
+ ### Real Examples from Fizzy Routes
99
+
100
+ ```ruby
101
+ # config/routes.rb
102
+ resources :cards do
103
+ scope module: :cards do
104
+ resource :board # Moving card to different board
105
+ resource :closure # Closing/reopening
106
+ resource :column # Assigning to workflow column
107
+ resource :goldness # Highlighting as important
108
+ resource :image # Managing header image
109
+ resource :not_now # Postponing
110
+ resource :pin # Pinning to sidebar
111
+ resource :publish # Publishing draft
112
+ resource :reading # Marking as read
113
+ resource :triage # Triaging
114
+ resource :watch # Subscribing to updates
115
+
116
+ resources :assignments # Managing assignees
117
+ resources :steps # Checklist items
118
+ resources :taggings # Tags
119
+
120
+ resources :comments do
121
+ resources :reactions # Emoji reactions
122
+ end
123
+ end
124
+ end
125
+ ```
126
+
127
+ ### Namespace for Context
128
+
129
+ ```ruby
130
+ # Board-specific resources
131
+ resources :boards do
132
+ scope module: :boards do
133
+ resource :publication # Publishing board
134
+ resource :closure # Archiving board
135
+ end
136
+ end
137
+ ```
138
+
139
+ ## Controller Design
140
+
141
+ ### Thin Controllers, Rich Models
142
+
143
+ Controllers orchestrate, models contain business logic:
144
+
145
+ ```ruby
146
+ class Cards::ClosuresController < ApplicationController
147
+ include CardScoped # Provides @card, @board, render_card_replacement
148
+
149
+ def create
150
+ @card.close # Business logic in model
151
+
152
+ respond_to do |format|
153
+ format.turbo_stream { render_card_replacement }
154
+ format.json { head :no_content }
155
+ end
156
+ end
157
+ end
158
+ ```
159
+
160
+ ### Controller Concerns Pattern
161
+
162
+ Create focused concerns for:
163
+
164
+ - Resource scoping (CardScoped, BoardScoped)
165
+ - Request context (CurrentRequest, CurrentTimezone)
166
+ - Security (BlockSearchEngineIndexing, RequestForgeryProtection)
167
+ - View helpers (TurboFlash, ViewTransitions)
168
+
169
+ ```ruby
170
+ module CardScoped
171
+ extend ActiveSupport::Concern
172
+
173
+ included do
174
+ before_action :set_card, :set_board
175
+ end
176
+
177
+ private
178
+
179
+ def set_card
180
+ @card = Current.user.accessible_cards.find_by!(number: params[:card_id])
181
+ end
182
+
183
+ def render_card_replacement
184
+ # Shared rendering logic
185
+ end
186
+ end
187
+ ```
188
+
189
+ ## Model Layer & Concerns
190
+
191
+ ### Heavy Use of Concerns
192
+
193
+ Models include many focused concerns for horizontal behavior:
194
+
195
+ ```ruby
196
+ class Card < ApplicationRecord
197
+ include Assignable, Closeable, Eventable, Searchable, Watchable
198
+
199
+ belongs_to :account, default: -> { board.account }
200
+ belongs_to :creator, class_name: "User", default: -> { Current.user }
201
+ end
202
+ ```
203
+
204
+ ### Concern Structure
205
+
206
+ Each concern is self-contained with associations, scopes, and methods:
207
+
208
+ ```ruby
209
+ module Card::Closeable
210
+ extend ActiveSupport::Concern
211
+
212
+ included do
213
+ has_one :closure, dependent: :destroy
214
+
215
+ scope :closed, -> { joins(:closure) }
216
+ scope :open, -> { where.missing(:closure) }
217
+ end
218
+
219
+ def closed?
220
+ closure.present?
221
+ end
222
+
223
+ def close(user: Current.user)
224
+ transaction do
225
+ create_closure!(user: user)
226
+ track_event :closed, creator: user
227
+ end
228
+ end
229
+ end
230
+ ```
231
+
232
+ ### State as Records, Not Booleans
233
+
234
+ Instead of boolean columns, create separate records:
235
+
236
+ ```ruby
237
+ # ❌ BAD: Boolean column
238
+ closed: boolean
239
+
240
+ # ✅ GOOD: Separate record
241
+ class Closure < ApplicationRecord
242
+ belongs_to :card, touch: true
243
+ belongs_to :user, optional: true
244
+ end
245
+
246
+ class Card < ApplicationRecord
247
+ has_one :closure, dependent: :destroy
248
+
249
+ scope :closed, -> { joins(:closure) }
250
+ scope :open, -> { where.missing(:closure) }
251
+ end
252
+ ```
253
+
254
+ Benefits: timestamps, who did it, easy scoping
255
+
256
+ ## Scope Naming Conventions
257
+
258
+ Use standard scope names for consistency:
259
+
260
+ ```ruby
261
+ scope :chronologically, -> { order created_at: :asc }
262
+ scope :reverse_chronologically, -> { order created_at: :desc }
263
+ scope :alphabetically, -> { order name: :asc }
264
+ scope :latest, -> { order last_active_at: :desc }
265
+ ```
266
+
267
+ ### Preloading Scopes
268
+
269
+ Use `preloaded` as a standard name for eager loading:
270
+
271
+ ```ruby
272
+ # app/models/card.rb
273
+ scope :with_users, -> {
274
+ preload(creator: [:avatar_attachment, :account],
275
+ assignees: [:avatar_attachment, :account])
276
+ }
277
+
278
+ scope :preloaded, -> {
279
+ with_users
280
+ .preload(:column, :tags, :steps, :closure, :goldness, :activity_spike,
281
+ :image_attachment, board: [:entropy, :columns],
282
+ not_now: [:user])
283
+ .with_rich_text_description_and_embeds
284
+ }
285
+
286
+ # app/models/comment.rb
287
+ scope :preloaded, -> {
288
+ with_rich_text_body.includes(reactions: :reacter)
289
+ }
290
+
291
+ # app/models/notification.rb
292
+ scope :preloaded, -> {
293
+ preload(:creator, :account,
294
+ source: [:board, :creator, { eventable: [:closure, :board, :assignments] }])
295
+ }
296
+ ```
297
+
298
+ ### Parameterized Scopes
299
+
300
+ ```ruby
301
+ scope :indexed_by, ->(index) do
302
+ case index.to_s
303
+ when "all" then all
304
+ when "closed" then closed
305
+ when "open" then open
306
+ when "not_now" then not_now
307
+ else all
308
+ end
309
+ end
310
+
311
+ scope :sorted_by, ->(sort) do
312
+ case sort.to_s
313
+ when "latest" then latest
314
+ when "oldest" then chronologically
315
+ else latest
316
+ end
317
+ end
318
+ ```
319
+
320
+ ## Model Callbacks: Used Sparingly
321
+
322
+ Only 38 callback occurrences across 30 files - callbacks are used but not overused.
323
+
324
+ ### Common Callback Uses
325
+
326
+ ```ruby
327
+ # After commit for async work
328
+ after_commit :relay_later, on: :create
329
+
330
+ # Before save for derived data
331
+ before_save :set_defaults
332
+
333
+ # After create for side effects
334
+ after_create_commit :broadcast_new_record
335
+ ```
336
+
337
+ ### What They Avoid
338
+
339
+ - No complex callback chains
340
+ - No `before_validation` for business logic
341
+ - No callbacks that call external services synchronously
342
+ - Prefer explicit method calls over implicit callbacks
343
+
344
+ ## Authentication Without Devise
345
+
346
+ Build custom auth with:
347
+
348
+ - Magic link or passwordless authentication
349
+ - Session model with signed cookies
350
+ - `Current` for request context
351
+ - Controller concern for authentication logic
352
+
353
+ ```ruby
354
+ module Authentication
355
+ extend ActiveSupport::Concern
356
+
357
+ included do
358
+ before_action :require_authentication
359
+ helper_method :authenticated?
360
+ end
361
+
362
+ private
363
+
364
+ def require_authentication
365
+ resume_session || request_authentication
366
+ end
367
+
368
+ def set_current_session(session)
369
+ Current.session = session
370
+ cookies.signed.permanent[:session_token] = {
371
+ value: session.signed_id,
372
+ httponly: true,
373
+ same_site: :lax
374
+ }
375
+ end
376
+ end
377
+ ```
378
+
379
+ ## Background Jobs
380
+
381
+ ### Shallow Jobs, Rich Models
382
+
383
+ Jobs just call model methods:
384
+
385
+ ```ruby
386
+ class NotifyRecipientsJob < ApplicationJob
387
+ def perform(notifiable)
388
+ notifiable.notify_recipients # Model does the work
389
+ end
390
+ end
391
+ ```
392
+
393
+ ### `_later` and `_now` Convention
394
+
395
+ ```ruby
396
+ module Card::Readable
397
+ def mark_as_read_later(user:)
398
+ MarkCardAsReadJob.perform_later(self, user)
399
+ end
400
+
401
+ def mark_as_read_now(user:)
402
+ readings.find_or_create_by!(user: user).touch
403
+ end
404
+ end
405
+ ```
406
+
407
+ ### Use Solid Queue
408
+
409
+ Database-backed jobs (no Redis):
410
+
411
+ ```yaml
412
+ # config/recurring.yml
413
+ production:
414
+ deliver_notifications:
415
+ command: "Notification::Bundle.deliver_all_later"
416
+ schedule: every 30 minutes
417
+ ```
418
+
419
+ ## Testing Approach
420
+
421
+ - Use Minitest (not RSpec) - simpler and faster
422
+ - Use fixtures for test data (located in `test/fixtures/`)
423
+ - Try to minimize mocks - test full behavior
424
+ - Use `render_views` in controller tests
425
+ - Test models, controllers, services, and jobs
426
+
427
+ ## HTTP Caching Patterns
428
+
429
+ Use HTTP ETags for efficient caching:
430
+
431
+ ```ruby
432
+ class CardsController < ApplicationController
433
+ def show
434
+ fresh_when etag: [@card, Current.user]
435
+ end
436
+ end
437
+
438
+ # In ApplicationController
439
+ etag { "v1" } # Bust all caches on deploy
440
+ ```
441
+
442
+ ## Fragment Caching Patterns
443
+
444
+ Use fragment caching with proper cache keys:
445
+
446
+ ```erb
447
+ <%# Cache with model and user context %>
448
+ <% cache [@card, Current.user] do %>
449
+ <%= render @card %>
450
+ <% end %>
451
+
452
+ <%# Cache collections with proper invalidation %>
453
+ <% cache [@cards.maximum(:updated_at), Current.user] do %>
454
+ <%= render @cards %>
455
+ <% end %>
456
+ ```
457
+
458
+ ## Event Tracking & Activity System
459
+
460
+ Track all important actions as events:
461
+
462
+ ```ruby
463
+ module Eventable
464
+ def track_event(action, creator: Current.user, **particulars)
465
+ events.create!(
466
+ action: "#{model_name.element}_#{action}",
467
+ creator: creator,
468
+ particulars: particulars
469
+ )
470
+ end
471
+ end
472
+ ```
473
+
474
+ ## PORO Patterns
475
+
476
+ Namespace under parent model for presentation/business logic:
477
+
478
+ ```ruby
479
+ # app/models/event/description.rb
480
+ class Event::Description
481
+ attr_reader :event
482
+
483
+ def initialize(event)
484
+ @event = event
485
+ end
486
+
487
+ def to_html
488
+ # Format event for display
489
+ end
490
+ end
491
+ ```
492
+
493
+ ## View Helpers Pattern
494
+
495
+ Use view helpers for presentation logic, not decorators:
496
+
497
+ ```ruby
498
+ # app/helpers/cards_helper.rb
499
+ module CardsHelper
500
+ def card_article_tag(card, **options, &block)
501
+ classes = [
502
+ options.delete(:class),
503
+ ("golden-effect" if card.golden?),
504
+ ("card--postponed" if card.postponed?)
505
+ ].compact.join(" ")
506
+
507
+ tag.article(class: classes, **options, &block)
508
+ end
509
+ end
510
+ ```
511
+
512
+ ## Multi-Tenancy
513
+
514
+ Use URL-based tenancy with `Current.account`:
515
+
516
+ ```ruby
517
+ # Middleware extracts account from /{account_id}/...
518
+ Current.account = Account.find_by(external_id: account_id)
519
+
520
+ # Every model has account_id
521
+ belongs_to :account, default: -> { parent.account }
522
+ ```
523
+
524
+ ## Stimulus Controller Patterns
525
+
526
+ Keep controllers small and focused:
527
+
528
+ - Single responsibility (one behavior per controller)
529
+ - Configuration via `static values` and `static classes`
530
+ - Events for communication (`this.dispatch("show")`)
531
+ - Private methods with `#`
532
+ - No dependencies - vanilla JS only
533
+
534
+ ```javascript
535
+ // copy_to_clipboard_controller.js
536
+ export default class extends Controller {
537
+ static values = { content: String };
538
+ static classes = ["success"];
539
+
540
+ async copy(event) {
541
+ event.preventDefault();
542
+ await navigator.clipboard.writeText(this.contentValue);
543
+ this.element.classList.add(this.successClass);
544
+ }
545
+ }
546
+ ```
547
+
548
+ ## Modern CSS (No Preprocessors)
549
+
550
+ Use native CSS with modern features:
551
+
552
+ ```css
553
+ /* CSS Layers for specificity control */
554
+ @layer reset, base, components, utilities;
555
+
556
+ /* OKLCH colors for perceptual uniformity */
557
+ :root {
558
+ --lch-blue: 66% 0.196 257.82;
559
+ --color-link: oklch(var(--lch-blue));
560
+ }
561
+
562
+ /* Native nesting */
563
+ .btn {
564
+ background: var(--btn-bg);
565
+
566
+ &:hover {
567
+ filter: brightness(1.1);
568
+ }
569
+ }
570
+
571
+ /* Modern features: :has(), container queries, @starting-style */
572
+ ```
573
+
574
+ ## PWA & Push Notifications
575
+
576
+ ### Minimal Service Worker
577
+
578
+ ```javascript
579
+ // app/views/pwa/service_worker.js
580
+ self.addEventListener("fetch", (event) => {
581
+ if (event.request.method !== "GET") return;
582
+
583
+ if (event.request.destination === "document") {
584
+ event.respondWith(
585
+ fetch(event.request, { cache: "no-cache" }).catch(() =>
586
+ caches.match(event.request)
587
+ ) // Offline fallback
588
+ );
589
+ }
590
+ });
591
+
592
+ // Push notifications
593
+ self.addEventListener("push", async (event) => {
594
+ const data = await event.data.json();
595
+ event.waitUntil(
596
+ Promise.all([showNotification(data), updateBadgeCount(data.options)])
597
+ );
598
+ });
599
+
600
+ // App badge count
601
+ async function updateBadgeCount({ data: { badge } }) {
602
+ return self.navigator.setAppBadge?.(badge || 0);
603
+ }
604
+
605
+ // Notification click opens app
606
+ self.addEventListener("notificationclick", (event) => {
607
+ event.notification.close();
608
+ const url = new URL(event.notification.data.path, self.location.origin).href;
609
+ event.waitUntil(openURL(url));
610
+ });
611
+ ```
612
+
613
+ ### Web Push Gem
614
+
615
+ Uses `web-push` gem for server-side push:
616
+
617
+ ```ruby
618
+ # Gemfile
619
+ gem "web-push"
620
+ ```
621
+
622
+ ## Notable Gems They DO Use
623
+
624
+ While avoiding heavyweight dependencies, these gems made the cut:
625
+
626
+ | Gem | Purpose |
627
+ | -------------------- | --------------------------------------- |
628
+ | geared_pagination | DHH's cursor-based pagination |
629
+ | propshaft | Asset pipeline (simpler than Sprockets) |
630
+ | solid_queue | Database-backed job queue |
631
+ | solid_cache | Database-backed Rails cache |
632
+ | solid_cable | Database-backed Action Cable |
633
+ | thruster | HTTP/2 proxy for Puma |
634
+ | kamal | Docker deployment |
635
+ | redcarpet + rouge | Markdown + syntax highlighting |
636
+ | rqrcode | QR code generation |
637
+ | lexxy | Rich text editor (Basecamp's) |
638
+ | platform_agent | User agent parsing |
639
+ | web-push | Push notifications |
640
+ | mission_control-jobs | Job monitoring UI |
641
+ | autotuner | Automatic Ruby GC tuning |
642
+
643
+ ## CSP Configuration: Extensible via ENV
644
+
645
+ Make Content Security Policy extensible via environment variables:
646
+
647
+ ```ruby
648
+ # config/initializers/content_security_policy.rb
649
+ # Helper to get additional CSP sources from ENV or config.x
650
+ sources = ->(directive) do
651
+ env_key = "CSP_#{directive.to_s.upcase}"
652
+
653
+ value = if ENV.key?(env_key)
654
+ ENV[env_key]
655
+ else
656
+ config.x.content_security_policy.send(directive)
657
+ end
658
+
659
+ # Supports: nil, string, space-separated string, or array
660
+ case value
661
+ when nil then []
662
+ when Array then value
663
+ when String then value.split
664
+ else []
665
+ end
666
+ end
667
+ ```
668
+
669
+ This allows:
670
+
671
+ - Base CSP defined in code
672
+ - Extensions via ENV vars (`CSP_SCRIPT_SRC="https://cdn.example.com"`)
673
+ - Config overrides for multi-tenant SaaS
674
+
675
+ ## Code Evolution Patterns
676
+
677
+ - **Ship incrementally** - Many small commits, not big releases
678
+ - **Tests ship with features** - Not TDD, not afterthought, but together
679
+ - **Refactor toward consistency** - Establish patterns, then update old code
680
+ - **CSS uses the platform** - Native CSS layers, nesting, OKLCH - no preprocessors
681
+ - **Design tokens everywhere** - CSS variables for colors, spacing, typography
682
+
683
+ ## Summary: The 37signals Way
684
+
685
+ 1. **Start with vanilla Rails** - Don't add abstractions until you feel the pain
686
+ 2. **Models are rich** - Business logic lives in models, not services
687
+ 3. **Controllers are thin** - Just orchestration and response formatting
688
+ 4. **Everything is CRUD** - New resource over new action
689
+ 5. **State is records** - Not boolean columns
690
+ 6. **Concerns are compositions** - Horizontal behavior sharing
691
+ 7. **Build before buying** - Auth, search, jobs - all custom
692
+ 8. **Database is king** - No Redis, no Elasticsearch
693
+ 9. **Test with fixtures** - Deterministic, fast, simple
694
+ 10. **Use the platform** - Modern CSS, native browser APIs
695
+
696
+ **The best code is the code you don't write. The second best is the code that's obviously correct.**
697
+
698
+ The 37signals codebase optimizes for both.
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.2
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-01-25
4
+
5
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "ruby_cms" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["codebyjob@gmail.com"](mailto:"codebyjob@gmail.com).