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.
- checksums.yaml +7 -0
- data/.cursor/dhh.mdc +698 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/README.md +235 -0
- data/Rakefile +30 -0
- data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
- data/app/components/ruby_cms/admin/admin_page.rb +345 -0
- data/app/components/ruby_cms/admin/base_component.rb +78 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
- data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
- data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
- data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
- data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
- data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
- data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
- data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
- data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
- data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
- data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
- data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
- data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
- data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
- data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
- data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
- data/app/controllers/ruby_cms/errors_controller.rb +35 -0
- data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
- data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
- data/app/helpers/ruby_cms/application_helper.rb +41 -0
- data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
- data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
- data/app/helpers/ruby_cms/settings_helper.rb +160 -0
- data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
- data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
- data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
- data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
- data/app/javascript/controllers/ruby_cms/index.js +104 -0
- data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
- data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
- data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
- data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
- data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
- data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
- data/app/models/concerns/content_block/publishable.rb +54 -0
- data/app/models/concerns/content_block/searchable.rb +22 -0
- data/app/models/content_block.rb +155 -0
- data/app/models/ruby_cms/content_block.rb +8 -0
- data/app/models/ruby_cms/permission.rb +28 -0
- data/app/models/ruby_cms/permittable.rb +39 -0
- data/app/models/ruby_cms/preference.rb +111 -0
- data/app/models/ruby_cms/user_permission.rb +12 -0
- data/app/models/ruby_cms/visitor_error.rb +109 -0
- data/app/services/ruby_cms/analytics/report.rb +362 -0
- data/app/services/ruby_cms/security_tracker.rb +92 -0
- data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
- data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
- data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
- data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
- data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
- data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
- data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
- data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
- data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
- data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
- data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
- data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
- data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
- data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
- data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
- data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
- data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
- data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
- data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
- data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
- data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
- data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
- data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
- data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
- data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
- data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
- data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
- data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
- data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
- data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
- data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
- data/config/database.yml +6 -0
- data/config/importmap.rb +36 -0
- data/config/locales/en.yml +101 -0
- data/config/routes.rb +65 -0
- data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
- data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
- data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
- data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
- data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
- data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
- data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
- data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
- data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
- data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
- data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
- data/exe/ruby_cms +25 -0
- data/lib/generators/ruby_cms/install_generator.rb +1062 -0
- data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
- data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
- data/lib/ruby_cms/app_integration.rb +82 -0
- data/lib/ruby_cms/cli.rb +169 -0
- data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
- data/lib/ruby_cms/content_blocks_sync.rb +329 -0
- data/lib/ruby_cms/css_compiler.rb +35 -0
- data/lib/ruby_cms/engine.rb +498 -0
- data/lib/ruby_cms/settings.rb +145 -0
- data/lib/ruby_cms/settings_registry.rb +289 -0
- data/lib/ruby_cms/version.rb +5 -0
- data/lib/ruby_cms.rb +195 -0
- data/lib/tasks/ruby_cms.rake +27 -0
- data/log/test.log +17875 -0
- data/sig/ruby_cms.rbs +4 -0
- 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
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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).
|