studio-engine 0.95.0 → 0.96.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.
@@ -7,10 +7,9 @@
7
7
 
8
8
  resolveAt = max(min_duration, actual_completion)
9
9
 
10
- This ships the convention on window.StudioModals, defined ONCE here and
11
- rendered by BOTH the shared modal host (studio/modals/_host) and the living
12
- style guide (style/_modals) — so every consumer app and the /admin/style demo
13
- page share a single definition:
10
+ studio/modal_host publishes it on window.StudioModals on every page, merged
11
+ over anything an app defined first, so every consumer app and the
12
+ /admin/style demo page share a single definition:
14
13
 
15
14
  MIN_LOAD_MS — the standard default minimum (1400 ms). A caller may pass its
16
15
  own, but reach for this so every load modal feels consistent.
@@ -29,27 +28,7 @@
29
28
  min_duration exactly:
30
29
  StudioModals.holdAtLeast(StudioModals.MIN_LOAD_MS).then(advance);
31
30
 
32
- Idempotent (|| guards): never clobber a definition an app already shipped, and
33
- safe to render more than once on a page.
31
+ THIS PARTIAL RENDERS NOTHING. It carried the convention as an inline script
32
+ until the modal host moved to studio/modal_host; it stays, empty, so an app
33
+ that still renders it by name gets no missing-template error.
34
34
  %>
35
- <script>
36
- (function () {
37
- window.StudioModals = window.StudioModals || {};
38
- // Standard minimum-visible duration for a load spinner, in ms. ~1200–1500
39
- // reads as "processing → done"; below ~1s the spinner flashes and reads as a
40
- // glitch. Callers may override per-modal, but default to this for consistency.
41
- window.StudioModals.MIN_LOAD_MS = window.StudioModals.MIN_LOAD_MS || 1400;
42
- // holdAtLeast(minMs).then(cb) — cb fires at max(minMs, elapsed-since-call).
43
- // Stamp the moment a loading view becomes visible; the anti-flicker floor.
44
- window.StudioModals.holdAtLeast = window.StudioModals.holdAtLeast || function (minMs) {
45
- var startedAt = Date.now();
46
- return {
47
- then: function (callback) {
48
- var remaining = Math.max(0, minMs - (Date.now() - startedAt));
49
- if (remaining === 0) { callback(); return; }
50
- setTimeout(callback, remaining);
51
- }
52
- };
53
- };
54
- })();
55
- </script>
@@ -24,6 +24,9 @@
24
24
 
25
25
  Both go away when the page owns its host.
26
26
 
27
+ The behaviour (the stack, the focus trap shared with the app's host, the
28
+ cleanup) is studio/modal_host; this partial renders markup only.
29
+
27
30
  Animations come from the shared motion layer
28
31
  (app/assets/tailwind/studio_engine/engine-motion.css) — the .modal-card-* and
29
32
  .modal-backdrop-* classes — rather than an inline copy, so this stays small.
@@ -95,263 +98,17 @@
95
98
  body.modal-open { overflow: visible; }
96
99
  </style>
97
100
 
98
- <script>
99
- (function() {
100
- var STORE_NAME = '<%= scoped_store %>';
101
- // Keep in sync with .modal-card-unmount in engine-motion.css — the entry is
102
- // spliced off the stack only after the exit animation has played.
103
- var CLOSE_ANIM_MS = 220;
104
-
105
- // window.StudioModals.holdAtLeast(ms) — the minimum-visible-duration
106
- // convention shared with the app-level host, so a spinner can't flash past
107
- // the user. Defined defensively: this page may be the only host on it.
108
- window.StudioModals = window.StudioModals || {};
109
- window.StudioModals.holdAtLeast = window.StudioModals.holdAtLeast || function(minMs) {
110
- var startedAt = Date.now();
111
- return {
112
- then: function(callback) {
113
- var remaining = Math.max(0, minMs - (Date.now() - startedAt));
114
- if (remaining === 0) { callback(); return; }
115
- setTimeout(callback, remaining);
116
- }
117
- };
118
- };
119
-
120
- // Named, so the dual-guard below can call it directly on a Turbo visit.
121
- // Alpine's deferred script fires alpine:init exactly ONCE per full document
122
- // load; this registration lives in a PAGE BODY, so it was absent during that
123
- // one init. Without the guard, a Turbo Drive visit to this page leaves the
124
- // store undefined and every x-data on it throws.
125
- var _returnFocusTo = null;
126
- // The backdrop node, kept in the same closure and for the same proxy reason.
127
- var _backdropEl = null;
128
-
129
- function registerScopedStore() {
130
- if (Alpine.store(STORE_NAME)) return;
131
-
132
- Alpine.store(STORE_NAME, {
133
- stack: [],
134
-
135
- // FOCUS — the same contract as the shared host (studio/modals/_host).
136
- // Both hosts render [role=dialog]; a fix in only one of them leaves every
137
- // page that brings its own store still trapping keyboard users behind an
138
- // overlay, which is the "engine-wide" half of this defect.
139
- //
140
- // The return target is held in the closure below, NOT on the store: an
141
- // Alpine store is a reactive Proxy, so a DOM node assigned to it reads back
142
- // as a proxy and every identity check against it (===, contains) is false.
143
- captureFocus: function(el) {
144
- _returnFocusTo = document.activeElement;
145
- _backdropEl = el;
146
- if (el && el.focus) el.focus();
147
- },
148
-
149
- // RE-FOCUS AFTER A REPLACE, without re-capturing the return target. See
150
- // the long note in _host.html.erb: captureFocus runs from x-init on the
151
- // backdrop, and a replace keeps current() truthy, so the outer template
152
- // never re-mounts and x-init never re-runs. The inner content DOES
153
- // re-mount and unmounts the focused control, activeElement falls back to
154
- // the document body, and the tab handler bound on the backdrop stops seeing
155
- // the key. (Written out rather than as a literal tag: this comment ships
156
- // inside the rendered script, and studio/emails asserts the page emits no
157
- // body element of its own — a tag in prose trips it.)
158
- // _returnFocusTo is untouched on purpose — re-capturing would overwrite
159
- // the opener with a node inside the dialog.
160
- refocus: function() {
161
- var run = function() {
162
- if (_backdropEl && _backdropEl.focus && document.contains(_backdropEl)) {
163
- _backdropEl.focus();
164
- }
165
- };
166
- if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(run);
167
- else setTimeout(run, 0);
168
- },
169
-
170
- releaseFocus: function() {
171
- var target = _returnFocusTo;
172
- _returnFocusTo = null;
173
- _backdropEl = null;
174
- if (target && target.focus && document.contains(target)) target.focus();
175
- },
176
-
177
- focusables: function(el) {
178
- if (!el) return [];
179
- var sel = 'a[href], button:not([disabled]), input:not([disabled]), ' +
180
- 'select:not([disabled]), textarea:not([disabled]), [tabindex]';
181
- return Array.prototype.slice.call(el.querySelectorAll(sel)).filter(function(n) {
182
- return n.tabIndex >= 0 && n.offsetParent !== null;
183
- });
184
- },
185
-
186
- cycleFocus: function(el, event) {
187
- var items = this.focusables(el);
188
- if (items.length === 0) { if (el && el.focus) el.focus(); return; }
189
-
190
- var idx = items.indexOf(document.activeElement);
191
- var next;
192
- if (event && event.shiftKey) {
193
- next = idx <= 0 ? items[items.length - 1] : items[idx - 1];
194
- } else {
195
- next = (idx === -1 || idx === items.length - 1) ? items[0] : items[idx + 1];
196
- }
197
- next.focus();
198
- },
199
-
200
- dialogLabel: function() {
201
- var entry = this.current();
202
- if (!entry) return 'Dialog';
203
- var p = entry.props || {};
204
- // TRIMMED, not merely truthy. `title: ' '` is a truthy string, so it used to
205
- // pass straight through — and the accessible-name computation then trims it to
206
- // empty and announces a bare "dialog", the exact outcome this fallback chain
207
- // exists to prevent. An empty string already fell through (it is falsy); a
208
- // whitespace-only one did not, which is the more likely typo of the two.
209
- var named = function(v) {
210
- return (typeof v === 'string' && v.trim() !== '') ? v : null;
211
- };
212
- return named(p.ariaLabel) || named(p.title) || String(entry.id).replace(/[-_]/g, ' ');
213
- },
214
-
215
- // open(id, props, opts) — opts.replace swaps the top entry in place
216
- // (what submitFormWithProgress does to turn crop-photo into saving).
217
- open: function(id, props, opts) {
218
- props = props || {};
219
- opts = opts || {};
220
- var entry = { id: id, props: props };
221
-
222
- // Did this call re-mount the INNER content template without re-mounting the
223
- // OUTER one? That is the whole question the focus trap turns on.
224
- var remounted = false;
225
- if (opts.replace && this.stack.length > 0) {
226
- this.stack.splice(this.stack.length - 1, 1, entry);
227
- remounted = true;
228
- } else {
229
- // THE COMMENT THIS REPLACES SAID "only on a REPLACE", on the premise that a
230
- // fresh push takes current() falsy -> truthy so the outer template mounts and
231
- // x-init captures for us. That is TRUE only when the stack was EMPTY, and
232
- // silently false for a stacked push: current() was already truthy, so the
233
- // outer template does not re-mount, x-init never re-runs, and the inner
234
- // template unmounts whatever was focused. Focus lands on the document body — not a
235
- // descendant of the backdrop — so the @keydown.tab handler bound there stops
236
- // firing and native tabbing walks straight onto the page behind.
237
- remounted = this.stack.length > 0;
238
- this.stack.push(entry);
239
- }
240
- this._sync();
241
- if (remounted) this.refocus();
242
- },
243
-
244
- swap: function(id, props, opts) {
245
- opts = opts || {};
246
- opts.replace = true;
247
- this.open(id, props, opts);
248
- },
249
-
250
- // Flip _closing so the exit animation plays, then splice.
251
- close: function() {
252
- var entry = this.current();
253
- if (!entry) return;
254
-
255
- var self = this;
256
- entry._closing = true;
257
- setTimeout(function() {
258
- var index = self.stack.indexOf(entry);
259
- if (index !== -1) self.stack.splice(index, 1);
260
- self._sync();
261
- // Only when the LAST modal leaves — a stacked flow unmounts one dialog
262
- // and mounts the next, and restoring in between yanks focus out of a
263
- // flow the user is still inside.
264
- //
265
- // NOT RESTORING IS NOT THE SAME AS DOING NOTHING. Closing down TO an
266
- // underlying modal re-mounts the inner template, so focus falls to the document body
267
- // with that dialog still open — the trap is off while the user is still
268
- // inside it. The gate was right; the else was missing.
269
- if (self.stack.length === 0) self.releaseFocus();
270
- else self.refocus();
271
- }, CLOSE_ANIM_MS);
272
- },
273
-
274
- closeAll: function() {
275
- // Teardown path (Turbo/bfcache): drop the target rather than restoring
276
- // into a document that is about to be replaced.
277
- _returnFocusTo = null;
278
- this.stack = [];
279
- this._sync();
280
- },
281
-
282
- // Keeps dismissible: false entries — a still-in-flight upload must not
283
- // silently lose the card its promise will resolve against.
284
- closeAllDismissible: function() {
285
- this.stack = this.stack.filter(function(entry) {
286
- return entry.props && entry.props.dismissible === false;
287
- });
288
- this._sync();
289
- // Same re-mount, same release: a surviving non-dismissible card is still on
290
- // screen and the cards dropped from under it took focus with them. Nothing is
291
- // done when the stack empties — deliberately unlike close(): this is the
292
- // Turbo/bfcache teardown path, where closeAll() already declines to restore
293
- // into a document about to be replaced.
294
- if (this.stack.length > 0) this.refocus();
295
- },
296
-
297
- // isOpen(id) — on the stack in ANY lifecycle state, including a card
298
- // mid-close (close() flips _closing at once and splices only after
299
- // CLOSE_ANIM_MS). isLive() below is the "still up" question.
300
- isOpen: function(id) {
301
- return this.stack.some(function(entry) { return entry.id === id; });
302
- },
303
-
304
- // isLive(id) / isLive([id, ...]) — on the stack and NOT on its way
305
- // out. Mirrors the shared host's primitive so a page-scoped store
306
- // answers the same question the same way; see studio/modals/_host
307
- // for the full lifecycle rationale. Tests _closing ALONE: a leaving
308
- // swap() entry carries _swappingOut AND _closing, so _swappingOut on
309
- // its own must never count as leaving.
310
- isLive: function(id) {
311
- var ids = Array.isArray(id) ? id : [id];
312
- return this.stack.some(function(entry) {
313
- return entry && !entry._closing && ids.indexOf(entry.id) !== -1;
314
- });
315
- },
316
-
317
- current: function() {
318
- return this.stack.length ? this.stack[this.stack.length - 1] : null;
319
- },
320
-
321
- // Mount on open, unmount while closing. _settled pins the entry after
322
- // its first render so a stale Alpine re-evaluation cannot re-fire the
323
- // bounce-in keyframe mid-life (which reads as a flash).
324
- cardClasses: function() {
325
- var entry = this.current();
326
- if (!entry) return '';
327
- if (entry._closing) return 'modal-card-unmount';
328
- if (!entry._settled) { entry._settled = true; }
329
- return 'modal-card-mount';
330
- },
331
-
332
- _sync: function() {
333
- document.body.classList.toggle('modal-open', this.stack.length > 0);
334
- }
335
- });
336
- }
337
-
338
- if (window.Alpine) { registerScopedStore(); }
339
- else { document.addEventListener('alpine:init', registerScopedStore); }
340
-
341
- // bfcache + Turbo snapshot cleanup, so a modal left open does not reappear
342
- // on a back navigation.
343
- function clearStaleScopedModals() {
344
- if (!window.Alpine || !Alpine.store) return;
345
-
346
- var store = Alpine.store(STORE_NAME);
347
- if (store && typeof store.closeAllDismissible === 'function') store.closeAllDismissible();
348
- }
349
- window.addEventListener('pageshow', function(e) { if (e.persisted) clearStaleScopedModals(); });
350
- document.addEventListener('turbo:before-cache', clearStaleScopedModals);
351
- })();
352
- </script>
353
-
354
- <template x-if="$store.<%= scoped_store %>.current()">
101
+ <%# THE STORE IS NOT HERE. $store.<store>, its focus trap and its bfcache / Turbo
102
+ snapshot cleanup are studio/modal_host (createScopedModalStore), bound to this
103
+ host by the modal-host controller on the template below. The module registers
104
+ the store from the template's data attributes before Alpine starts on a full
105
+ load and before a Turbo visit renders this page, so a page that brings its own
106
+ host needs no script of its own. %>
107
+
108
+ <template x-if="$store.<%= scoped_store %>.current()"
109
+ data-studio-controller="modal-host"
110
+ data-modal-host-store-value="<%= scoped_store %>"
111
+ data-modal-host-scoped-value="true">
355
112
  <div class="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center p-4 modal-backdrop-mount"
356
113
  :class="$store.<%= scoped_store %>.current()?._closing && 'modal-backdrop-unmount'"
357
114
  style="background:rgba(0,0,0,0.6)"
@@ -14,8 +14,8 @@
14
14
  ERROR INK, not a static red. The error paragraph is text-danger-ink, the
15
15
  per-theme red Studio::ThemeResolver derives to a 4.5:1 target. It replaced
16
16
  text-red-400. Measured through the resolver on turf-monster's real palette
17
- (primary 4BAF50), on the modal card, which is bg-surface: danger-ink is 4.50
18
- on the dark card 3C3853 and 5.76 on the light card ffffff, clearing AA on
17
+ (primary 4BAF50), on the modal card, which is bg-surface: danger-ink is 4.89
18
+ on the dark card 3C3853 and 6.49 on the light card ffffff, clearing AA on
19
19
  both; text-red-400 was 3.86 and 2.89, failing both. Those are Tailwind v4's
20
20
  red-400, about FF6467, which is what these apps compile; the v3 hex F87171
21
21
  gives 4.03 and 2.77 and fails the same way. No STATIC red clears AA
@@ -1,7 +1,7 @@
1
1
  <%#
2
2
  Reusable processing / loading card. Spinner + title + optional message.
3
3
 
4
- Minimum-visible-duration convention (studio/modals/_load_convention):
4
+ Minimum-visible-duration convention (studio/modal_host, window.StudioModals):
5
5
  a load spinner must show for AT LEAST a floor, but longer if the real async
6
6
  runs longer — resolveAt = max(min_duration, actual_completion) — so it never
7
7
  flashes past the user ("did anything happen?") yet never cuts a slow op short.
@@ -212,9 +212,8 @@
212
212
  var SWAP_IN_MS = 220;
213
213
 
214
214
  // THE LIVE REGISTRY IS window.ModalAnimations, NOT THIS TABLE. This is a
215
- // fallback only, for the case where the guide renders without the shared
216
- // host (studio/modals/_host) that publishes the registry — both consumer
217
- // layouts mount it, so in practice the lookup below always finds it.
215
+ // fallback only, for the case where studio/modal_host, which publishes
216
+ // the registry on every page, failed to load.
218
217
  //
219
218
  // It used to be a hard-coded local copy, and that made the Enter/Leave
220
219
  // simulator below a LIAR: the simulator builds its controls from the live
@@ -362,11 +361,10 @@
362
361
  </script>
363
362
 
364
363
  <%# The minimum-visible-duration convention (window.StudioModals.holdAtLeast +
365
- MIN_LOAD_MS) — the SAME single definition the shared modal host renders, so
366
- the demo load modals here (payment/entry processing, on-chain tx, saving)
367
- honor resolveAt = max(min_duration, actual) and auto-resolve after
368
- min_duration in demo mode. A consumer app already has it via the host. %>
369
- <%= render "studio/modals/load_convention" %>
364
+ MIN_LOAD_MS) is studio/modal_host's, published on every page, so the demo
365
+ load modals here (payment/entry processing, on-chain tx, saving) honor
366
+ resolveAt = max(min_duration, actual) and auto-resolve after min_duration
367
+ in demo mode. %>
370
368
 
371
369
  <%# Cropper.js + the cropPhotoModal factory — makes the Profile crop / upload
372
370
  modals openable on this page (loads cropper.js via <script defer> and
@@ -1642,7 +1640,7 @@
1642
1640
  </div>
1643
1641
  </div>
1644
1642
 
1645
- <%# The anti-flicker floor from studio/modals/_load_convention. %>
1643
+ <%# The anti-flicker floor from studio/modal_host (window.StudioModals.holdAtLeast). %>
1646
1644
  <div class="card p-5 space-y-3">
1647
1645
  <h4 class="text-heading font-semibold text-sm">Hold at least</h4>
1648
1646
  <p class="text-secondary text-xs">
@@ -1806,13 +1804,12 @@
1806
1804
  });
1807
1805
  }
1808
1806
 
1809
- // WHEN TO BUILD. window.ModalAnimations is published by the shared host's
1810
- // inline script, which a host layout may render AFTER this one — so the
1811
- // build waits for the rest of the body either way:
1812
- // full load — DOMContentLoaded, the host script has run by then;
1807
+ // WHEN TO BUILD. window.ModalAnimations is published by studio/modal_host,
1808
+ // a module, which runs after the document has parsed and before
1809
+ // DOMContentLoaded, so the build waits for the rest of the load either way:
1810
+ // full load — DOMContentLoaded, the module has run by then;
1813
1811
  // Turbo visit — DOMContentLoaded already fired and never fires again, so
1814
- // fall through to a macrotask, which runs after the body
1815
- // swap finishes inserting its scripts.
1812
+ // fall through to a macrotask; the registry is already up.
1816
1813
  // turbo:load covers the remaining visits; the build is idempotent, so the
1817
1814
  // overlap between these paths is redundant work and nothing worse.
1818
1815
  if (document.readyState === 'loading') {
@@ -0,0 +1,19 @@
1
+ # A meeting's recording, stored beside its transcript. The document row stays
2
+ # the transcript (s3_key); these four columns point at ONE recording object in
3
+ # the same private bucket, written by Studio::KnowledgeDoc#attach_recording!.
4
+ #
5
+ # recording_source_url is the external page the recording came from (the
6
+ # notetaker's share page), kept as a link. It is never fetched.
7
+ #
8
+ # Installed per consumer; do not hand-copy. An app that has not installed this
9
+ # yet keeps working: Studio::KnowledgeDoc#recording? answers false without the
10
+ # columns.
11
+ class AddRecordingToStudioKnowledgeDocs < ActiveRecord::Migration[7.2]
12
+ def change
13
+ add_column :studio_knowledge_docs, :recording_key, :string
14
+ add_column :studio_knowledge_docs, :recording_mime_type, :string
15
+ add_column :studio_knowledge_docs, :recording_byte_size, :bigint
16
+ add_column :studio_knowledge_docs, :recording_source_url, :text
17
+ add_index :studio_knowledge_docs, :recording_key, unique: true
18
+ end
19
+ end
@@ -16,6 +16,8 @@ module Studio
16
16
  # engine never requires it; a host adds `gem "lookbook"` after
17
17
  # studio-engine). Such an app draws it in development and test, and in
18
18
  # production only if it also sets Studio.lookbook_in_production = true.
19
+ # A host that lists lookbook BEFORE studio-engine draws nothing and is
20
+ # told so at boot (LOAD_ORDER_WARNING).
19
21
  # Everywhere else there is no route, no ViewComponent preview route, no
20
22
  # /lookbook-assets middleware, and no Lookbook in memory.
21
23
  # 2. WHO SEES IT. An admin with a live session. Anyone else gets 404, not a
@@ -40,6 +42,11 @@ module Studio
40
42
  # preview's Ruby and template) and :params (live inputs from the URL).
41
43
  MAIN_PANELS = %i[preview output].freeze
42
44
  DRAWER_PANELS = %i[notes].freeze
45
+ # Printed at boot by a host whose bundle loads lookbook before the engine.
46
+ LOAD_ORDER_WARNING =
47
+ "studio-engine: lookbook was required before studio-engine, so the component gallery " \
48
+ "(#{MOUNT_PATH}) is NOT mounted. Move `gem \"lookbook\"` below `gem \"studio-engine\"` " \
49
+ "in the Gemfile and restart."
43
50
 
44
51
  module_function
45
52
 
@@ -48,10 +55,13 @@ module Studio
48
55
  # lib/studio/engine.rb).
49
56
  mattr_accessor :lookbook_loaded_before_engine, default: false
50
57
 
51
- # Whether this app draws the gallery. Pure: the env, the flag and whether
52
- # lookbook is loaded are passed in, so the rule unit-tests without Rails.
53
- def mounted?(env:, in_production:, lookbook_loaded:)
58
+ # Whether this app draws the gallery. Pure: the env, the flag, whether
59
+ # lookbook is loaded and whether it loaded in order are passed in, so the
60
+ # rule unit-tests without Rails. Out of order it is not drawn: Lookbook
61
+ # turned previews on before ViewComponent decided its preview routes.
62
+ def mounted?(env:, in_production:, lookbook_loaded:, load_order_ok: true)
54
63
  return false unless lookbook_loaded
64
+ return false unless load_order_ok
55
65
 
56
66
  !env.to_s.casecmp?("production") || in_production == true
57
67
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Studio
6
+ # The "Email image generator" link at the top of /admin/emails: where an
7
+ # operator goes to make a new banner with the app's own artwork. Each app
8
+ # points it at its own generator, from config/initializers/studio.rb:
9
+ #
10
+ # config.email_manager_generator_url = "https://example.com/email-art"
11
+ # # or, worked out per request:
12
+ # config.email_manager_generator_url = ->(request) { "#{request.base_url}/admin/email-art" }
13
+ #
14
+ # config.email_manager_generator_label = "Header generator" # optional
15
+ # config.email_manager_generator_description = "Open it, copy ..." # optional
16
+ #
17
+ # Unset, nil or blank means no link, and the page renders exactly as it did
18
+ # before the setting existed.
19
+ #
20
+ # The URL is written into an href, so only an absolute http or https URL with
21
+ # a host is accepted. A configured String that is anything else (a
22
+ # `javascript:` URL, a relative path, a bare word) raises at boot, where the
23
+ # mistake is cheap to see. A callable is judged on what it RETURNS, at render
24
+ # time, and an unusable answer renders no link rather than breaking the page.
25
+ #
26
+ # Pure Ruby, so it loads and unit-tests without Rails.
27
+ module EmailImageGenerator
28
+ DEFAULT_LABEL = "Email image generator"
29
+
30
+ # Generic on purpose: every consuming app shows it unless it says otherwise.
31
+ DEFAULT_DESCRIPTION = "Make a new header with %<app>s's character model: " \
32
+ "open the generator, copy the prompt, paste it into Claude Code."
33
+
34
+ SCHEMES = %w[http https].freeze
35
+
36
+ # Whitespace, a control character, a quote, an angle bracket or a backslash
37
+ # anywhere. URI's parser tolerates several of these, a browser strips tab and
38
+ # newline from an href and reads "\\" as "/", and the view escapes the rest
39
+ # anyway; a URL that carries one is refused rather than reasoned about.
40
+ UNSAFE_CHARACTER = /[\x00-\x20\x7f"'<>\\`]/
41
+
42
+ # The link to render: url, label and description, all plain strings (the
43
+ # view escapes them).
44
+ Link = Struct.new(:url, :label, :description, keyword_init: true)
45
+
46
+ module_function
47
+
48
+ # An absolute http(s) URL with a host.
49
+ def web_url?(value)
50
+ return false unless value.is_a?(String)
51
+
52
+ string = value.strip
53
+ return false if string.empty? || string.match?(UNSAFE_CHARACTER)
54
+
55
+ uri = URI.parse(string)
56
+ SCHEMES.include?(uri.scheme.to_s.downcase) && !uri.host.to_s.empty?
57
+ rescue URI::InvalidURIError
58
+ false
59
+ end
60
+
61
+ # The setter's check. nil, a blank String and a callable pass; any other
62
+ # value must be a web URL.
63
+ def validate_url!(value)
64
+ return if value.nil? || value.respond_to?(:call)
65
+ return if value.is_a?(String) && value.strip.empty?
66
+ return if web_url?(value)
67
+
68
+ raise ArgumentError,
69
+ "Studio.email_manager_generator_url must be an http(s) URL, a callable returning one, " \
70
+ "or nil (got #{value.inspect})."
71
+ end
72
+
73
+ # What the setter stores: nil for blank, a stripped String, or the callable.
74
+ def normalize_url(value)
75
+ validate_url!(value)
76
+ return value if value.respond_to?(:call)
77
+
78
+ string = value.to_s.strip
79
+ string.empty? ? nil : string
80
+ end
81
+
82
+ # The URL for this request, or nil when there is no usable one. A callable
83
+ # receives the request (or nothing, if it takes no argument).
84
+ def resolve_url(configured, request = nil)
85
+ value =
86
+ if configured.respond_to?(:call)
87
+ call_configured(configured, request)
88
+ else
89
+ configured
90
+ end
91
+ web_url?(value) ? value.strip : nil
92
+ end
93
+
94
+ # A callable that raises draws no link rather than a 500 on /admin/emails;
95
+ # the exception goes to ErrorLog when the host has one (this file is pure Ruby).
96
+ def call_configured(callable, request)
97
+ arity = callable.respond_to?(:arity) ? callable.arity : 1
98
+ arity.zero? ? callable.call : callable.call(request)
99
+ rescue StandardError => e
100
+ begin
101
+ ::ErrorLog.capture!(e) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ nil
106
+ end
107
+
108
+ # The Link to render, or nil when no link is configured.
109
+ def link(url:, label: nil, description: nil, app_name: nil, request: nil)
110
+ resolved = resolve_url(url, request)
111
+ return nil if resolved.nil?
112
+
113
+ Link.new(
114
+ url: resolved,
115
+ label: present(label) || DEFAULT_LABEL,
116
+ description: present(description) || format(DEFAULT_DESCRIPTION, app: present(app_name) || "this app")
117
+ )
118
+ end
119
+
120
+ def present(value)
121
+ string = value.to_s.strip
122
+ string.empty? ? nil : string
123
+ end
124
+ end
125
+ end
data/lib/studio/engine.rb CHANGED
@@ -8,7 +8,7 @@ require_relative "component_gallery"
8
8
  # view_component is required first: its after_initialize, which decides whether
9
9
  # to draw its own preview routes, must run before Lookbook's, which turns
10
10
  # previews on for its own use. Studio::ComponentGallery.load_order_ok? records
11
- # the order this file saw.
11
+ # the order this file saw; out of order, the gallery is not mounted.
12
12
  Studio::ComponentGallery.lookbook_loaded_before_engine = defined?(::Lookbook::Engine) ? true : false
13
13
  require "view_component"
14
14
 
@@ -148,10 +148,7 @@ module Studio
148
148
  initializer "studio.component_gallery", after: "lookbook.assets.serve" do
149
149
  next unless Studio::ComponentGallery.lookbook_loaded?
150
150
 
151
- unless Studio::ComponentGallery.load_order_ok?
152
- warn "studio-engine: lookbook was required before studio-engine; list it after " \
153
- "studio-engine in the Gemfile so ViewComponent decides its preview routes first"
154
- end
151
+ warn Studio::ComponentGallery::LOAD_ORDER_WARNING unless Studio::ComponentGallery.load_order_ok?
155
152
  Studio::ComponentGallery.configure_lookbook!(::Lookbook.config)
156
153
  config.app_middleware.delete(::Rack::Static)
157
154
  end