plutonium 0.50.0 → 0.51.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,395 @@
1
+ # Controller
2
+
3
+ Plutonium controllers ship full CRUD out of the box; nearly all customization belongs elsewhere. The controller stays thin — when in doubt, push the change to the definition (UI) or the policy (auth).
4
+
5
+ ## 🚨 Critical
6
+
7
+ - **Don't override CRUD actions.** Use hooks (`resource_params`, `redirect_url_after_submit`, presentation hooks). Overriding `create` / `update` usually breaks authorization, params filtering, or both.
8
+ - **Named custom routes only.** Always pass `as:` — without it, `resource_url_for` can't build URLs (critical for nested resources).
9
+ - **Authorization is verified after every action** — if you write a custom action, you MUST call `authorize_current!` yourself or use `skip_verify_authorize_current` / `skip_verify_authorize_current!`.
10
+ - **Cross-resource queries: use `authorized_resource_scope(OtherModel)`, not raw `where`.** Otherwise you bypass that resource's tenancy and visibility rules.
11
+
12
+ ## Base classes
13
+
14
+ ```ruby
15
+ # app/controllers/resource_controller.rb — installed once
16
+ class ResourceController < ApplicationController
17
+ include Plutonium::Resource::Controller
18
+ end
19
+
20
+ # app/controllers/posts_controller.rb — per resource, generated
21
+ class PostsController < ::ResourceController
22
+ # Empty — all CRUD inherited
23
+ end
24
+ ```
25
+
26
+ Portal-specific overrides:
27
+
28
+ ```ruby
29
+ # packages/admin_portal/app/controllers/admin_portal/resource_controller.rb
30
+ module AdminPortal
31
+ class ResourceController < ::ResourceController
32
+ include AdminPortal::Concerns::Controller
33
+ end
34
+ end
35
+
36
+ # packages/admin_portal/app/controllers/admin_portal/posts_controller.rb
37
+ class AdminPortal::PostsController < ResourceController
38
+ # Portal-specific customizations
39
+ end
40
+ ```
41
+
42
+ ## What you get for free
43
+
44
+ | Action | Method | Path | Purpose |
45
+ |---|---|---|---|
46
+ | `index` | GET | `/posts` | List with pagination, search, filters, sorting |
47
+ | `show` | GET | `/posts/:id` | Display a single record |
48
+ | `new` | GET | `/posts/new` | Form |
49
+ | `create` | POST | `/posts` | Create |
50
+ | `edit` | GET | `/posts/:id/edit` | Form |
51
+ | `update` | PATCH/PUT | `/posts/:id` | Update |
52
+ | `destroy` | DELETE | `/posts/:id` | Delete |
53
+
54
+ Plus interactive-action routes for every action declared in the definition (`/posts/:id/record_actions/publish`, etc.).
55
+
56
+ ## Where customization belongs
57
+
58
+ | Concern | Lives in |
59
+ |---|---|
60
+ | Field rendering (inputs, displays, columns) | [Definition](/reference/resource/definition) |
61
+ | Search, filters, scopes, sorting | [Query](/reference/resource/query) |
62
+ | Custom operations (publish, archive, import) | [Interaction](./interactions) + action on definition |
63
+ | Authorization rules | [Policy](./policies) |
64
+ | Form / show / page chrome | Definition (custom page classes — see [UI › Pages](/reference/ui/pages)) |
65
+ | **Custom redirect logic** | **[Controller hook](#redirect-hooks)** |
66
+ | **Param munging** | **[Controller hook](#parameter-hook)** |
67
+ | **Custom index query shape** | **[Controller hook](#index-query-hook)** |
68
+ | **Presentation of parent/entity fields** | **[Controller hook](#presentation-hooks)** |
69
+
70
+ ## Override hooks
71
+
72
+ All hooks are private methods. Override only the ones you need.
73
+
74
+ ### Redirect hooks
75
+
76
+ ```ruby
77
+ class PostsController < ::ResourceController
78
+ private
79
+
80
+ # Where to go after create/update: "show" (default), "edit", "new", "index"
81
+ def preferred_action_after_submit = "edit"
82
+
83
+ # Custom URL after create/update (overrides preferred_action_after_submit)
84
+ def redirect_url_after_submit = posts_path
85
+
86
+ # Custom URL after destroy
87
+ def redirect_url_after_destroy = posts_path
88
+ end
89
+ ```
90
+
91
+ ### Parameter hook
92
+
93
+ ```ruby
94
+ def resource_params
95
+ params = super
96
+ params[:tags] = params[:tags].split(",") if params[:tags].is_a?(String)
97
+ params
98
+ end
99
+ ```
100
+
101
+ ### Index query hook
102
+
103
+ ```ruby
104
+ def filtered_resource_collection
105
+ base = current_authorized_scope
106
+ base = base.featured if params[:featured]
107
+ current_query_object.apply(base, raw_resource_query_params)
108
+ end
109
+ ```
110
+
111
+ ### Presentation hooks
112
+
113
+ Control whether parent / scoped-entity fields appear in forms and displays. Defaults are `false` (hidden, since they're inferred from the URL/portal).
114
+
115
+ ```ruby
116
+ def present_parent? = true # show parent field on displays
117
+ def submit_parent? = true # include in forms (defaults to tracking present_parent?)
118
+ def present_scoped_entity? = true
119
+ def submit_scoped_entity? = true
120
+ ```
121
+
122
+ Conditional pattern — show parent only when accessed standalone:
123
+
124
+ ```ruby
125
+ def present_parent?
126
+ current_parent.nil?
127
+ end
128
+ ```
129
+
130
+ ## Lifecycle callbacks
131
+
132
+ Standard Rails callbacks work:
133
+
134
+ ```ruby
135
+ class PostsController < ::ResourceController
136
+ before_action :check_quota, only: [:create]
137
+
138
+ private
139
+
140
+ def check_quota
141
+ if current_user.posts.count >= 100
142
+ redirect_to resource_url_for(resource_class), alert: "Post limit reached"
143
+ end
144
+ end
145
+ end
146
+ ```
147
+
148
+ ## Custom actions
149
+
150
+ Prefer **interactive actions** (definition + interaction — see [Resource › Actions](/reference/resource/actions)) for anything with business logic. The only reasons to hand-write a controller action: unusual response shapes, external service callbacks, etc.
151
+
152
+ ```ruby
153
+ class PostsController < ::ResourceController
154
+ def publish
155
+ authorize_current!(resource_record!, to: :publish?)
156
+ resource_record!.update!(published: true)
157
+ redirect_to resource_url_for(resource_record!), notice: "Published!"
158
+ end
159
+ end
160
+ ```
161
+
162
+ Route must be named:
163
+
164
+ ```ruby
165
+ register_resource Post do
166
+ member { post :publish, as: :publish } # ← `as:` required
167
+ end
168
+ ```
169
+
170
+ ::: warning Always name custom routes
171
+ Without `as:`, `resource_url_for` can't build the URL — particularly critical for nested resources.
172
+ :::
173
+
174
+ ## Key methods
175
+
176
+ ### Resource access
177
+
178
+ ```ruby
179
+ resource_class # The model class
180
+ resource_record! # Current record (raises RecordNotFound if not found)
181
+ resource_record? # Current record (nil if not found)
182
+ resource_params # Permitted params for create/update
183
+ current_parent # Parent record for nested routes
184
+ current_scoped_entity # Tenant entity for the current portal (nil if not scoped)
185
+ ```
186
+
187
+ ### Authorization
188
+
189
+ **Current resource:**
190
+
191
+ ```ruby
192
+ authorize_current!(record, to: :action?) # check permission, raises if denied
193
+ current_policy # Policy instance for current resource
194
+ permitted_attributes # Allowed attributes for the current action
195
+ current_authorized_scope # Scoped collection the user can access
196
+ ```
197
+
198
+ **Other resources** — cross-resource auth. Use these, NOT raw `where` / `find`:
199
+
200
+ ```ruby
201
+ authorize! other_record, to: :show? # ActionPolicy — raises if denied
202
+ allowed_to?(:show?, other_record) # Boolean check
203
+ policy_for(OtherModel) # Policy instance for class or record
204
+ policy_for(other_record).show?
205
+
206
+ authorized_resource_scope(OtherModel) # Scope on the model class
207
+ authorized_resource_scope(OtherModel, relation: OtherModel.published) # On a relation
208
+ authorized_resource_scope(OtherModel, type: :create) # Different action
209
+ ```
210
+
211
+ `authorized_resource_scope` applies the *other* resource's `relation_scope` AND the current policy context (entity scope, etc.). **Always prefer it over `OtherModel.all` / raw `where`** in cross-resource controller code — otherwise you bypass that resource's tenancy and visibility rules.
212
+
213
+ ### Definition access
214
+
215
+ ```ruby
216
+ current_definition
217
+ ```
218
+
219
+ ### UI builders (rarely needed in controllers)
220
+
221
+ ```ruby
222
+ build_form
223
+ build_detail
224
+ build_collection
225
+ ```
226
+
227
+ ### URL generation
228
+
229
+ ```ruby
230
+ resource_url_for(@post) # show URL
231
+ resource_url_for(@post, action: :edit)
232
+ resource_url_for(Post) # index URL
233
+ resource_url_for(Post, action: :new)
234
+
235
+ # Nested
236
+ resource_url_for(@comment, parent: @post)
237
+ resource_url_for(Comment, action: :new, parent: @post)
238
+
239
+ # Cross-package
240
+ resource_url_for(@post, package: AdminPortal)
241
+
242
+ # Interactive actions
243
+ resource_url_for(@post, interaction: :publish)
244
+ resource_url_for(Post, interaction: :import)
245
+ resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
246
+ resource_url_for(@post, parent: @user, interaction: :publish)
247
+ ```
248
+
249
+ ## Nested resources
250
+
251
+ Routes prefixed `nested_` automatically resolve the parent. See [Tenancy › Nested resources](/reference/tenancy/nested-resources) for the full surface; the controller-side methods:
252
+
253
+ ```ruby
254
+ current_parent # parent record
255
+ current_nested_association # :posts
256
+ parent_route_param # :user_id
257
+ parent_input_param # :user
258
+ ```
259
+
260
+ Parent fields are excluded from forms/displays by default. Toggle with the [presentation hooks](#presentation-hooks).
261
+
262
+ Custom parent resolution:
263
+
264
+ ```ruby
265
+ def current_parent
266
+ @current_parent ||= Company.friendly.find(params[:company_id])
267
+ end
268
+ ```
269
+
270
+ ## Entity scoping (multi-tenancy)
271
+
272
+ When a portal calls `scope_to_entity Organization, strategy: :path`, controllers in that portal automatically:
273
+
274
+ - Scope queries to the entity
275
+ - Exclude the entity field from forms (detected by association class)
276
+ - Inject the entity on create/update
277
+ - Expose `current_scoped_entity`
278
+
279
+ Plutonium auto-detects which `belongs_to` association points to the scoped class, even when `param_key` differs from the association name:
280
+
281
+ ```ruby
282
+ # Portal config
283
+ scope_to_entity Competition::Team, param_key: :team
284
+
285
+ # Model — association name differs from param_key, but Plutonium finds by class
286
+ class Match < ApplicationRecord
287
+ belongs_to :competition_team
288
+ end
289
+ ```
290
+
291
+ ### Multiple associations to the same class
292
+
293
+ If a model has two associations pointing at the scoped entity class, Plutonium raises:
294
+
295
+ ```
296
+ Match has multiple associations to Competition::Team: home_team, away_team.
297
+ Plutonium cannot auto-detect which one to use for entity scoping.
298
+ Override `scoped_entity_association` in your controller to specify the association.
299
+ ```
300
+
301
+ Override:
302
+
303
+ ```ruby
304
+ class MatchesController < ::ResourceController
305
+ private
306
+ def scoped_entity_association = :home_team
307
+ end
308
+ ```
309
+
310
+ Full mechanics in [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
311
+
312
+ ## Authorization verification
313
+
314
+ After-action callbacks ensure authorization happened:
315
+
316
+ ```ruby
317
+ verify_authorize_current # all actions — `authorize_current!` must have been called
318
+ verify_current_authorized_scope # all except :new and :create — scope must have been loaded
319
+ ```
320
+
321
+ Skip only when handling auth manually. Two forms:
322
+
323
+ ```ruby
324
+ # Class-level — across multiple actions
325
+ class PostsController < ::ResourceController
326
+ skip_verify_authorize_current only: [:preview]
327
+ skip_verify_current_authorized_scope only: [:preview]
328
+
329
+ def preview
330
+ # auth handled manually
331
+ end
332
+ end
333
+
334
+ # Per-action — bang methods, inside the action body
335
+ def preview
336
+ skip_verify_authorize_current!
337
+ skip_verify_current_authorized_scope!
338
+ # auth handled manually
339
+ end
340
+ ```
341
+
342
+ Prefer the per-action bang form when only one action skips — keeps the exception co-located with the code that needs it.
343
+
344
+ ## Response formats
345
+
346
+ Controllers respond to:
347
+
348
+ - HTML (default)
349
+ - JSON (via RABL templates)
350
+ - Turbo Stream (for Hotwire)
351
+
352
+ ## Error handling
353
+
354
+ ```ruby
355
+ class PostsController < ::ResourceController
356
+ rescue_from ActiveRecord::RecordNotFound do
357
+ redirect_to resource_url_for(resource_class), alert: "Post not found"
358
+ end
359
+
360
+ rescue_from ActionPolicy::Unauthorized do
361
+ redirect_to resource_url_for(resource_class), alert: "Not authorized"
362
+ end
363
+ end
364
+ ```
365
+
366
+ ## Specifying resource class
367
+
368
+ The resource class is inferred from the controller name. Override if needed:
369
+
370
+ ```ruby
371
+ class LegacyPostsController < ::ResourceController
372
+ controller_for Post
373
+ end
374
+ ```
375
+
376
+ ## Portal-specific controllers
377
+
378
+ Each portal can override:
379
+
380
+ ```ruby
381
+ class AdminPortal::PostsController < ResourceController
382
+ private
383
+ def preferred_action_after_submit = "index"
384
+ end
385
+ ```
386
+
387
+ See [App › Portals](/reference/app/portals) for the full portal controller story.
388
+
389
+ ## Related
390
+
391
+ - [Policies](./policies) — authorization called from controllers
392
+ - [Interactions](./interactions) — business logic for custom actions
393
+ - [Resource › Definition](/reference/resource/definition) — UI config (where most "controller-like" tweaks belong)
394
+ - [Resource › Actions](/reference/resource/actions) — registering interactive actions
395
+ - [Tenancy › Nested resources](/reference/tenancy/nested-resources) — parent/child routing
@@ -0,0 +1,22 @@
1
+ # Behavior Reference
2
+
3
+ The behavior layer is intentionally thin:
4
+
5
+ - **[Controllers](./controllers) route** — handle requests, redirect after submit, transform params.
6
+ - **[Policies](./policies) authorize** — decide who can do what, which fields they can see, which records they can access.
7
+ - **[Interactions](./interactions) act** — encapsulate business logic for custom operations (publish, archive, import, send invitation).
8
+
9
+ Registering an action and rendering it lives in [Resource › Definition](/reference/resource/definition) and [Resource › Actions](/reference/resource/actions). This section covers **writing** the controller hook, policy method, or interaction class behind it.
10
+
11
+ For multi-tenant `relation_scope` and entity scoping, see [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
12
+
13
+ ## At a glance
14
+
15
+ | Concern | Where it lives |
16
+ |---|---|
17
+ | Field rendering (inputs, displays, columns, search/filters) | [Definition](/reference/resource/definition) |
18
+ | Custom operations (publish, archive, import) | [Interaction](./interactions) + [Action](/reference/resource/actions) on the definition |
19
+ | Authorization rules | [Policy](./policies) |
20
+ | Tenant scoping (`relation_scope`) | [Policy](./policies) + [Tenancy](/reference/tenancy/entity-scoping) |
21
+ | Custom redirect logic, param munging, custom index query shape | [Controller hook](./controllers) |
22
+ | Presentation of parent/entity fields | [Controller presentation hooks](./controllers#presentation-hooks) |