studio-engine 0.37.0 → 0.38.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc5fc673a7b9d072c93f482c90919c0f26836bd5e90194691ca30b5beb7c22d4
4
- data.tar.gz: 9cbedec8a84b9619061217f90bceadbd51485e691351233bb67af6f03231b76e
3
+ metadata.gz: d497957db77f7b61823d1d12422672bf4339d71381163b79a6c42d705abf3492
4
+ data.tar.gz: e31717daca9b7722e4b9e87d44a507d9ec8c8055421e192bb3de78aa31175542
5
5
  SHA512:
6
- metadata.gz: '088bca3db79b1ad97d52bf2478aa08518a6139e73c700cc63d30903c1d31e4fddb391213c2061cd0711d0d5656a7115e53dd10817b094271c459543133e36ea7'
7
- data.tar.gz: 96eb836f896ed9a12dd8c71a308178aa112387bc40f266323dad82db726df9698a4881ff4450c7fa7ce9f42d61cf1bcab9ca80a88940f243891e1cfe0307f3d0
6
+ metadata.gz: 6f75c493e66ebae0fd31571d237fa6a4f1546e9c50143382a4f54bfe88d5614c1f582af6d758cafe93ffc47b744b01d875a280de92823012bf224f028859627d
7
+ data.tar.gz: dfa14e14dbe5796816a0247bd965da477b69e27e8c81750f554e1d2d497fbf486da5d14f1ae900aa26b28dcfe22c14a51f2e83ead2089d98f9667b0142d43b6d
data/CHANGELOG.md CHANGED
@@ -2,11 +2,68 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
4
4
 
5
+ ## Unreleased
6
+
7
+ **The email registry becomes the email catalog.** 0.37 gave every app a shared
8
+ page for its transactional email banners. This folds in the rest — **what each
9
+ email is, and what it actually looks like** — so an app has ONE email manager
10
+ instead of two.
11
+
12
+ **`Studio::EmailImage` is now `Studio::EmailCatalog`.** The old name is a
13
+ **delegating shim** with its full surface intact (`url`, `store`, `record`,
14
+ `register`, `label`, `known?`, `variants`, `resolved_url`, `preview_url`,
15
+ `source`, and the constants), so nothing that names it breaks. It is deprecated
16
+ and will be deleted once no consumer's `main` references it.
17
+
18
+ Named for the prior art it absorbs: `turf-monster` built `::EmailCatalog` +
19
+ `Admin::EmailsController` first, and left a note on both saying this manager
20
+ *"moves into the shared studio-engine email framework (Phase 2)"*. This is
21
+ Phase 2. turf-monster deletes its copy in its adoption task rather than running a
22
+ second email page.
23
+
24
+ **Registry entries carry a type and a preview builder:**
25
+
26
+ ```ruby
27
+ Studio::EmailCatalog.register("winnings",
28
+ label: "Contest winnings",
29
+ description: "Sent when a player wins a contest.",
30
+ type: :marketing, # or :transactional (default)
31
+ preview: -> { ContestMailer.winnings(sample_entry) })
32
+ ```
33
+
34
+ Every keyword is optional, and omitting one on a re-register **keeps** the
35
+ existing value — so a host can attach a preview to an inherited email, or
36
+ relabel it, without restating its artwork. An unknown `type` falls back to
37
+ `:transactional` rather than raising: a typo in an initializer must not take a
38
+ host's boot down over a badge.
39
+
40
+ **New: `/admin/emails/:key`** — one email's own page: its banner, its type, its
41
+ subject, and a **live preview** of the real rendered email in an iframe
42
+ (`/admin/emails/:key/raw`). The emails list links each row to it.
43
+
44
+ **A preview can never take the manager down.** A `preview:` builder is host code
45
+ run against whatever sample data an environment happens to hold — an empty table,
46
+ a moved fixture, a mailer whose signature changed. Every call is contained: a
47
+ raising builder yields `nil`, records why, and renders the reason inside the
48
+ iframe. One broken builder costs one preview, not the page. Builders run **only**
49
+ on that page — listing the emails never executes one.
50
+
51
+ **Consumer note.** Nothing is required to upgrade; the shim keeps 0.37 call sites
52
+ working. To adopt the preview: pass `preview:` when registering, and switch any
53
+ `Studio::EmailImage` reference to `Studio::EmailCatalog`.
54
+
55
+ ### Added
56
+
57
+ - **`Studio::EmailCatalog`** — the email registry, renamed from
58
+ `Studio::EmailImage` and now carrying each entry's `type:` and `preview:`
59
+ callable. The old name stays as a delegating shim.
60
+ - **`/admin/emails/:key`** — one email's own page: its banner, its type, its
61
+ subject, and a live preview of the real rendered email in an iframe.
62
+
5
63
  ## 0.37.0 — 2026-08-10
6
64
 
7
65
  ### Added
8
- - Standard transactional email primitive with a registry-backed catalog, and the
9
- email preview folded into that registry.
66
+ - Standard transactional email primitive with a registry-backed catalog.
10
67
 
11
68
  ### Fixed
12
69
  - The crop confirm is guarded by owner, so one confirmed crop can no longer fan out
data/README.md CHANGED
@@ -24,7 +24,7 @@ Then `bundle install`. The current release is **v0.6.1**; see [`CHANGELOG.md`](.
24
24
  - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls, `studio/banners/impersonation`, and an opt-in `Studio::Impersonation` concern for Act As session conventions.
25
25
  - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
26
26
  - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
27
- - **Transactional emails**: `Studio::EmailImage` registry + the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
27
+ - **Transactional emails**: `Studio::EmailCatalog` every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
28
28
 
29
29
  ## Configuration
30
30
 
@@ -322,11 +322,12 @@ its helper names; drawing them there raises at route-load and kills every route
322
322
  in the app. The gate covers only the page — the registry and the inherited
323
323
  defaults are always on.
324
324
 
325
- ### The registry
325
+ ### The catalog
326
326
 
327
- A registered email is mostly symbolic of a workflow a key, a label, a
328
- description. The only real asset is its image. The engine pre-registers the two
329
- every Studio app sends, so a new app inherits both without declaring anything:
327
+ A registered email carries a key, a label, a description, what **type** it is,
328
+ how to build a **live preview** of it, and its banner image. The engine
329
+ pre-registers the two every Studio app sends, so a new app inherits both without
330
+ declaring anything:
330
331
 
331
332
  | Key | Label |
332
333
  |-----|-------|
@@ -339,19 +340,42 @@ A host adds its own workflows from an initializer, mirroring
339
340
  ```ruby
340
341
  # config/initializers/studio_emails.rb
341
342
  Rails.application.config.to_prepare do
342
- Studio::EmailImage.register("winnings", label: "Contest winnings",
343
- description: "Sent when a player wins a contest.")
344
- Studio::EmailImage.register("wallet_export", label: "Wallet export")
343
+ Studio::EmailCatalog.register("winnings",
344
+ label: "Contest winnings",
345
+ description: "Sent when a player wins a contest.",
346
+ type: :transactional, # or :marketing
347
+ preview: -> { ContestMailer.winnings(Entry.where.not(rank: nil).first) })
348
+
349
+ Studio::EmailCatalog.register("wallet_export", label: "Wallet export")
345
350
  end
346
351
  ```
347
352
 
353
+ Every keyword is optional, and omitting one on a re-register **keeps** the
354
+ existing value — attach a preview to an inherited email, or relabel it, without
355
+ restating its artwork. An unknown `type` falls back to `:transactional` rather
356
+ than raising.
357
+
358
+ > `Studio::EmailImage` is the old name for this module and still works as a
359
+ > delegating shim. It is deprecated; prefer `Studio::EmailCatalog`.
360
+
361
+ ### Live preview
362
+
363
+ `preview:` is any callable returning a `Mail`. It powers `/admin/emails/:key`,
364
+ which renders the real email in an iframe from `/admin/emails/:key/raw`.
365
+
366
+ Builders run **only** on that page — listing the emails never executes one — and
367
+ every call is contained. A builder that raises yields no preview, records why,
368
+ and shows the reason in the frame. One broken builder costs one preview, not the
369
+ manager. That matters because a builder runs against whatever sample data an
370
+ environment happens to hold, which is exactly the thing that rots.
371
+
348
372
  Re-registering an inherited key updates it in place and keeps its position, so
349
373
  relabeling `magic_link` does not reorder the page or drop its default artwork.
350
374
 
351
375
  ### Resolution — inherit, then own
352
376
 
353
377
  ```
354
- Studio::EmailImage.resolved_url(:magic_link)
378
+ Studio::EmailCatalog.resolved_url(:magic_link)
355
379
  1. this app's ImageCache row (its own S3 bucket) -> app-owned override
356
380
  2. the engine's default gem asset -> inherited default
357
381
  3. nil -> sends bannerless
@@ -380,7 +404,7 @@ Three accessors, and picking the wrong one changes what real people receive:
380
404
  | `preview_url(key)` | own → inherited default (root-relative) | the admin page |
381
405
 
382
406
  `url` deliberately does **not** resolve to the default. `turf-monster`'s mailer
383
- reads `Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`
407
+ reads `Studio::EmailCatalog.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`
384
408
  — making `url` resolve would turn that `||` into dead code and swap its committed
385
409
  branded banner for the engine placeholder in live email.
386
410
 
@@ -389,7 +413,7 @@ class UserMailer < ApplicationMailer
389
413
  layout "branded_mailer"
390
414
 
391
415
  def magic_link(email, token)
392
- @banner_url = Studio::EmailImage.resolved_url(:magic_link) # nil renders bannerless
416
+ @banner_url = Studio::EmailCatalog.resolved_url(:magic_link) # nil renders bannerless
393
417
  # ...
394
418
  end
395
419
  end
@@ -432,18 +456,24 @@ This is a non-isolated engine -- app views at the same path automatically overri
432
456
  ## Releasing
433
457
 
434
458
  Engine releases use semantic versions and are published to RubyGems. The full
435
- operator checklist lives in [`docs/RELEASE.md`](./docs/RELEASE.md).
436
-
437
- Short form:
438
-
439
- 1. Update [`CHANGELOG.md`](./CHANGELOG.md) and `lib/studio/version.rb`.
440
- 2. Run `bin/release-check --build`.
441
- 3. Publish the gem only after explicit approval.
442
- 4. Tag the release after RubyGems accepts the gem.
443
- 5. In each consumer app, run `bundle update studio-engine`, verify the lockfile,
444
- and run app smoke checks.
445
-
446
- **Semver guide**
459
+ checklist split by whether you are building or conducting the release — lives
460
+ in [`docs/RELEASE.md`](./docs/RELEASE.md).
461
+
462
+ **Building an engine change?** Do **not** edit `lib/studio/version.rb`. The
463
+ release owns the version, and McRitchie Studio's `bin/dor-check` refuses any PR
464
+ that touches that file. Update [`CHANGELOG.md`](./CHANGELOG.md) under
465
+ `Unreleased` (that is *not* gated), run `bin/release-check --build`, and open
466
+ your PR into `accepted`. That is the whole of your part.
467
+
468
+ **Conducting the release?** Commit the computed version — with `Gemfile.lock`,
469
+ which pins the engine's own path-gem version — directly onto `accepted`, then
470
+ run `bin/release prepare` from mcritchie-studio; it publishes, tags, and bumps
471
+ each consumer's lock. Details and the exact commands are in
472
+ [`docs/RELEASE.md`](./docs/RELEASE.md).
473
+
474
+ **Semver guide** — the release *derives* the bump from its members (a `breaking`
475
+ risk tag → major, a `feature` → minor, otherwise patch), so this is what those
476
+ levels mean, not a menu to pick from:
447
477
  - **PATCH**: bug fix; no API change. Consumers can update the gem with zero diff elsewhere.
448
478
  - **MINOR**: backward-compatible feature add. Consumers may opt in to new APIs.
449
479
  - **MAJOR**: breaking change. Consumers will need code changes alongside the tag bump.
@@ -23,7 +23,7 @@ module Studio
23
23
  MAX_BYTES = 8.megabytes
24
24
 
25
25
  def index
26
- @variants = Studio::EmailImage.variants
26
+ @variants = Studio::EmailCatalog.variants
27
27
  end
28
28
 
29
29
  # The canonical page this one is being retired in favour of, or nil when this
@@ -49,7 +49,7 @@ module Studio
49
49
  # PATCH /admin/email_images/:variant — upload/replace a banner.
50
50
  def update
51
51
  variant = params[:variant].to_s
52
- return head :not_found unless Studio::EmailImage.known?(variant)
52
+ return head :not_found unless Studio::EmailCatalog.known?(variant)
53
53
 
54
54
  file = params[:image]
55
55
  unless valid_image?(file)
@@ -58,8 +58,8 @@ module Studio
58
58
  end
59
59
 
60
60
  rescue_and_log do
61
- Studio::EmailImage.store(variant, io: file, content_type: file.content_type)
62
- redirect_to admin_email_images_path, notice: "#{Studio::EmailImage.label(variant)} banner updated."
61
+ Studio::EmailCatalog.store(variant, io: file, content_type: file.content_type)
62
+ redirect_to admin_email_images_path, notice: "#{Studio::EmailCatalog.label(variant)} banner updated."
63
63
  end
64
64
  rescue StandardError
65
65
  redirect_to admin_email_images_path, alert: "Couldn't save the image. Please try again.", status: :see_other
@@ -4,7 +4,7 @@ module Studio
4
4
  # controller whose view is a bare content wrapper, so it renders inside each
5
5
  # host's application layout and picks up that app's navbar and theme.
6
6
  #
7
- # It lists Studio::EmailImage's registry — one row per registered email, each
7
+ # It lists Studio::EmailCatalog's registry — one row per registered email, each
8
8
  # showing its live banner and whether that banner is the INHERITED engine
9
9
  # default or an APP-OWNED override — and writes an override through the shared
10
10
  # crop modal. Replaces /admin/email_images, which now redirects here.
@@ -15,14 +15,37 @@ module Studio
15
15
  # is currently sending.
16
16
  class EmailsController < ApplicationController
17
17
  before_action :require_admin
18
- before_action :load_entry, only: %i[update destroy]
18
+ before_action :load_entry, only: %i[show raw update destroy]
19
19
  before_action :require_uploads, only: %i[update destroy]
20
20
 
21
21
  MAX_BYTES = 8.megabytes
22
22
 
23
23
  def index
24
- @entries = Studio::EmailImage.entries
25
- @uploads_available = Studio::EmailImage.uploads_available?
24
+ @entries = Studio::EmailCatalog.entries
25
+ @uploads_available = Studio::EmailCatalog.uploads_available?
26
+ end
27
+
28
+ # GET /admin/emails/:key — one email: its banner, its type, and a live
29
+ # preview built from the host's sample data.
30
+ def show
31
+ @entry = Studio::EmailCatalog.entry(@key)
32
+ @subject = Studio::EmailCatalog.preview_subject(@key)
33
+ @preview_error = Studio::EmailCatalog.preview_error(@key)
34
+ @uploads_available = Studio::EmailCatalog.uploads_available?
35
+ end
36
+
37
+ # GET /admin/emails/:key/raw — the rendered email itself, as the iframe
38
+ # source on #show. Layout-less on purpose: this response IS the email.
39
+ #
40
+ # A preview builder is host code run against whatever sample data this
41
+ # environment happens to hold, so it is expected to fail sometimes. It
42
+ # renders the failure as a readable page inside the iframe rather than
43
+ # 500ing, so one broken builder costs one preview, not the manager.
44
+ def raw
45
+ html = Studio::EmailCatalog.preview_html(@key)
46
+ return render(html: preview_unavailable_html.html_safe, layout: false) if html.nil?
47
+
48
+ render html: html.html_safe, layout: false
26
49
  end
27
50
 
28
51
  # PATCH /admin/emails/:key — upload/replace this app's own banner.
@@ -34,8 +57,8 @@ module Studio
34
57
  end
35
58
 
36
59
  rescue_and_log do
37
- Studio::EmailImage.store(@key, io: file, content_type: file.content_type)
38
- redirect_to admin_emails_path, notice: "#{Studio::EmailImage.label(@key)} banner updated.", status: :see_other
60
+ Studio::EmailCatalog.store(@key, io: file, content_type: file.content_type)
61
+ redirect_to admin_emails_path, notice: "#{Studio::EmailCatalog.label(@key)} banner updated.", status: :see_other
39
62
  end
40
63
  rescue StandardError
41
64
  redirect_to admin_emails_path, alert: "Couldn't save the image. Please try again.", status: :see_other
@@ -50,11 +73,11 @@ module Studio
50
73
  # failure is invisible — the admin sees "try again" and nothing reaches
51
74
  # ErrorLog to say why.
52
75
  rescue_and_log do
53
- reverted = Studio::EmailImage.revert(@key)
76
+ reverted = Studio::EmailCatalog.revert(@key)
54
77
  notice = if reverted
55
- "#{Studio::EmailImage.label(@key)} reverted to the inherited default."
78
+ "#{Studio::EmailCatalog.label(@key)} reverted to the inherited default."
56
79
  else
57
- "#{Studio::EmailImage.label(@key)} was already using the inherited default."
80
+ "#{Studio::EmailCatalog.label(@key)} was already using the inherited default."
58
81
  end
59
82
  redirect_to admin_emails_path, notice: notice, status: :see_other
60
83
  end
@@ -66,16 +89,37 @@ module Studio
66
89
 
67
90
  def load_entry
68
91
  @key = params[:key].to_s
69
- head :not_found unless Studio::EmailImage.known?(@key)
92
+ head :not_found unless Studio::EmailCatalog.known?(@key)
70
93
  end
71
94
 
72
95
  def require_uploads
73
- return if Studio::EmailImage.uploads_available?
96
+ return if Studio::EmailCatalog.uploads_available?
74
97
 
75
98
  redirect_to admin_emails_path, status: :see_other,
76
99
  alert: "This app has no object storage configured, so email images can't be changed here yet."
77
100
  end
78
101
 
102
+ # Shown INSIDE the preview iframe when the builder is missing or raised.
103
+ # Deliberately plain inline HTML: the iframe is its own document and does not
104
+ # inherit the host app's stylesheet.
105
+ def preview_unavailable_html
106
+ reason = Studio::EmailCatalog.preview_error(@key)
107
+ message = if reason
108
+ "This email's preview builder raised:<br><code style=\"color:#b91c1c\">" \
109
+ "#{ERB::Util.html_escape(reason)}</code>"
110
+ else
111
+ "No preview is registered for this email. Add a <code>preview:</code> " \
112
+ "callable when registering it to see it rendered here."
113
+ end
114
+
115
+ <<~HTML
116
+ <!doctype html>
117
+ <html><body style="margin:0;padding:32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#334155;background:#f8fafc;">
118
+ <p style="font-size:14px;line-height:1.6;max-width:52ch;">#{message}</p>
119
+ </body></html>
120
+ HTML
121
+ end
122
+
79
123
  def valid_image?(file)
80
124
  file.respond_to?(:content_type) &&
81
125
  file.content_type.to_s.start_with?("image/") &&
@@ -24,7 +24,7 @@ class UserMailer < ApplicationMailer
24
24
  # resolved_url, not url: this app's own upload if it has one, otherwise the
25
25
  # engine's default banner — which is what makes a brand-new app's sign-in
26
26
  # email branded on day one. nil renders bannerless.
27
- @banner_url = Studio::EmailImage.resolved_url(:magic_link)
27
+ @banner_url = Studio::EmailCatalog.resolved_url(:magic_link)
28
28
  @banner_alt = "Your #{@app_name} sign-in link"
29
29
  mail(to: email, subject: "Your #{@app_name} sign-in link")
30
30
  end
@@ -1,6 +1,6 @@
1
1
  class ImageCache < ApplicationRecord
2
2
  # Optional so app-GLOBAL images (no owning record) can be cached too — e.g.
3
- # Studio::EmailImage stores the admin-managed email banners owner-less. Per-
3
+ # Studio::EmailCatalog stores the admin-managed email banners owner-less. Per-
4
4
  # record images (athlete/coach headshots) still set an owner; the
5
5
  # variant-uniqueness scope below keeps both shapes distinct.
6
6
  belongs_to :owner, polymorphic: true, optional: true