studio-engine 0.118.0 → 0.119.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.
@@ -0,0 +1,16 @@
1
+ <%# locals: (label:, delay:, max_wait:, size:, options: {}) %>
2
+ <%#
3
+ The page-load overlay (Studio::LoaderHelper#studio_loader_overlay). Rendered
4
+ hidden, once per page; studio/loader shows it when a visit the user started
5
+ or a form marked data-studio-loader-overlay outlasts data-delay ms, clears it
6
+ after data-max-wait ms at most, and window.StudioLoader.show(caption) /
7
+ hide() drive it for any other long wait. The caption line is empty until a
8
+ caller names one. It never marks itself aria-busy: that would mute the
9
+ "Loading" status inside it.
10
+ %>
11
+ <%= tag.div(class: "studio-loader-overlay", hidden: true, data: { studio_loader_overlay: "", delay: delay, max_wait: max_wait }) do %>
12
+ <div class="studio-loader-overlay-panel">
13
+ <%= studio_loader(size: size, label: label, **options) %>
14
+ <p class="studio-loader-caption" data-studio-loader-caption></p>
15
+ </div>
16
+ <% end %>
@@ -48,9 +48,9 @@
48
48
  <% end %>
49
49
  </div>
50
50
  <% else %>
51
- <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
52
- class: "btn btn-google gap-3 w-full" do %>
53
- <%= render "components/google_logo" %>
54
- Link Google Account
55
- <% end %>
51
+ <%# A POST to OmniAuth once the app has its OAuth client, the setup modal
52
+ until then (studio/google_sign_in/_button). Already signed in, so the
53
+ modal offers "Not now" rather than a way back to email. %>
54
+ <%= render "studio/google_sign_in/button", label: "Link Google Account",
55
+ button_class: "btn btn-google gap-3 w-full", email_fallback: false %>
56
56
  <% end %>
@@ -185,6 +185,65 @@
185
185
  </div>
186
186
  </section>
187
187
 
188
+ <!-- Branded loader -->
189
+ <%
190
+ loader_config = Studio.loader
191
+ # An ERB tag as text, built so this file never holds a closing tag inside one.
192
+ erb_tag = ->(code) { "<" + "%= #{code} %" + ">" }
193
+ loader_art_styles = Studio::Loader::ART_STYLES.keys.select do |style|
194
+ loader_config[:art] && Studio::Loader.art(loader_config[:art], style)
195
+ end
196
+ %>
197
+ <section id="loader" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
198
+ <div class="space-y-1">
199
+ <h3 class="text-xl font-bold text-heading">Branded loader</h3>
200
+ <p class="text-muted text-sm max-w-2xl">
201
+ This app's own icon in its theme colours, in the style <code class="font-mono text-2xs">config.loader</code>
202
+ names (<strong><%= loader_config[:style].to_s.tr("_", " ") %></strong>). Page loads, long waits and pending
203
+ buttons all draw it. One class, <code class="font-mono text-2xs">.studio-loader</code>, sized by
204
+ <code class="font-mono text-2xs">--studio-loader-size</code>. studio-engine docs/LOADER.md.
205
+ </p>
206
+ </div>
207
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
208
+ <%= render layout: "style/specimen",
209
+ locals: { klass: ".studio-loader (this app's style)",
210
+ usage: [erb_tag.call("studio_loader"), erb_tag.call('studio_loader size: 20, label: "Saving"')].join("\n") } do %>
211
+ <%= studio_loader %>
212
+ <%= studio_loader size: 32 %>
213
+ <%= studio_loader size: 20 %>
214
+ <% end %>
215
+
216
+ <%= render layout: "style/specimen",
217
+ locals: { klass: "studio_loader_pending (inside a busy button)",
218
+ usage: ["<button class=\"btn btn-primary\">", " <span data-loader-idle>Save</span>", " #{erb_tag.call('studio_loader_pending')}", "</button>"].join("\n") } do %>
219
+ <button type="button" class="btn btn-primary" aria-busy="true">
220
+ <%= studio_loader_pending %><span class="ml-2">Saving…</span>
221
+ </button>
222
+ <% end %>
223
+
224
+ <%= render layout: "style/specimen",
225
+ locals: { klass: "studio_loader_overlay (the page-load overlay)",
226
+ usage: ["#{erb_tag.call('studio_loader_overlay')} (once, in the layout body)", 'window.StudioLoader.show("Caption") / .hide()'].join("\n") } do %>
227
+ <button type="button" class="btn btn-secondary" data-test="style-show-loader-overlay"
228
+ x-data x-on:click="window.StudioLoader && window.StudioLoader.show('Loading your page…'); setTimeout(() => window.StudioLoader && window.StudioLoader.hide(), 3000)">
229
+ Show the overlay for 3 seconds
230
+ </button>
231
+ <% end %>
232
+
233
+ <% Studio::Loader::STYLES.each do |style| %>
234
+ <% signature = Studio::Loader.art_style?(style) %>
235
+ <% unavailable = signature && !loader_art_styles.include?(style) %>
236
+ <%= render layout: "style/specimen",
237
+ locals: { klass: %(style: :#{style}#{signature ? " (signature, needs art)" : ""}),
238
+ usage: %(config.loader = #{signature ? "{ style: :#{style}, art: \"/brand/loader-#{style}.svg\" }" : ":#{style}"}),
239
+ disabled: unavailable, disabled_label: "needs this app's art file" } do %>
240
+ <%= studio_loader style: style, size: 72 %>
241
+ <%= studio_loader style: style, size: 28 %>
242
+ <% end %>
243
+ <% end %>
244
+ </div>
245
+ </section>
246
+
188
247
  <!-- Motion primitives -->
189
248
  <section class="space-y-5">
190
249
  <div class="space-y-1">
data/lib/studio/engine.rb CHANGED
@@ -11,6 +11,15 @@ require_relative "component_gallery"
11
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
+ # Google sign-in ships ready (Studio::GoogleSignIn): the strategy, and the CSRF
15
+ # check on its POST-only request phase. Required here because Bundler.require
16
+ # loads only the gems a host's Gemfile names, and a new app names none of these.
17
+ # The CSRF gem registers a Railtie, so it has to load before the app initializes,
18
+ # which a require at engine load does. Idempotent beside a host's own require.
19
+ require "omniauth"
20
+ require "omniauth-google-oauth2"
21
+ require "omniauth/rails_csrf_protection"
22
+ require_relative "google_sign_in"
14
23
 
15
24
  module Studio
16
25
  class Engine < ::Rails::Engine
@@ -274,6 +283,22 @@ module Studio
274
283
  .sort
275
284
  end
276
285
 
286
+ # GOOGLE SIGN-IN, the engine's registration (Studio::GoogleSignIn). The
287
+ # middleware is added to every host and decides per request whether it is
288
+ # the google_oauth2 strategy or a pass-through: a host whose own
289
+ # config/initializers/omniauth.rb registers google_oauth2 keeps its own, and
290
+ # the engine never registers a second. It cannot decide here, because the
291
+ # host's initializers have not run yet. `use` appends, and this runs before
292
+ # the host's initializers, so the engine's middleware sits outside the
293
+ # host's OmniAuth::Builder and hands the request on to it.
294
+ #
295
+ # The request phase is POST-only by OmniAuth 2's own default
296
+ # (allowed_request_methods [:post]), and omniauth-rails_csrf_protection
297
+ # checks the token on it; nothing here changes either.
298
+ initializer "studio.google_sign_in" do |app|
299
+ app.config.middleware.use Studio::GoogleSignIn::Middleware
300
+ end
301
+
277
302
  initializer "studio.footer_only_eager_load" do
278
303
  next if Studio.active_record?
279
304
 
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Google sign-in that ships ready with every app (docs/GOOGLE_AUTH_SETUP.md).
5
+ #
6
+ # Alex McRitchie, 2026-10-29: "I want this to be automatic with new apps. Even
7
+ # if there is still manual work to be done, please have the link for google
8
+ # login. If there is manual work to be done to properly setup Google, the link
9
+ # should show a modal requesting access to Google auth."
10
+ #
11
+ # So an app that bundles studio-engine gets, with nothing of its own:
12
+ #
13
+ # * the google_oauth2 OmniAuth strategy, registered by the engine's
14
+ # Middleware below, UNLESS the host's own initializer registered one;
15
+ # * a default User.from_omniauth (Studio::OmniauthUser), which a host's own
16
+ # definition overrides;
17
+ # * the "Sign in with Google" button on /login, /signup and /profile. While
18
+ # the OAuth client is not configured (ready? false) the button opens a
19
+ # modal that asks the team to finish setup, instead of posting to a
20
+ # strategy that would send the person to a Google error page.
21
+ #
22
+ # THE ONE MANUAL STEP that cannot ship in a gem: register the app's callback
23
+ # (redirect_uri below) on the shared production OAuth client and set
24
+ # GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. The modal's "Request Google setup"
25
+ # mails the team exactly that.
26
+ #
27
+ # Pure Ruby apart from the methods that read Rails.application or OmniAuth,
28
+ # each of which guards on the constant, so the unit suite loads this file as is.
29
+ module GoogleSignIn
30
+ PROVIDER = "google_oauth2"
31
+ REQUEST_PATH = "/auth/google_oauth2"
32
+ CALLBACK_PATH = "/auth/google_oauth2/callback"
33
+ SCOPE = "email,profile"
34
+ PROMPT = "select_account"
35
+ CREDENTIAL_KEYS = %w[GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET].freeze
36
+
37
+ # How long a "Request Google setup" claim holds. The key is per day, so this
38
+ # only has to outlive the day it names.
39
+ REQUEST_TTL_SECONDS = 26 * 60 * 60
40
+
41
+ # Records what an OmniAuth::Builder block registers without registering
42
+ # anything: `provider` notes its first argument, and every other call a
43
+ # host's block makes (configure, on_failure, options) is swallowed.
44
+ class ProviderRecorder
45
+ attr_reader :providers
46
+
47
+ def initialize
48
+ @providers = []
49
+ end
50
+
51
+ def provider(klass, *_args, **_options, &_block)
52
+ @providers << klass
53
+ nil
54
+ end
55
+
56
+ def method_missing(*_args, **_options, &_block)
57
+ nil
58
+ end
59
+
60
+ def respond_to_missing?(*_args)
61
+ true
62
+ end
63
+ end
64
+
65
+ # The engine's half of the registration: a Rack middleware the engine adds
66
+ # to every host's stack (Studio::Engine, "studio.google_sign_in"). It is the
67
+ # google_oauth2 strategy when the engine owns Google, and a pass-through
68
+ # otherwise:
69
+ #
70
+ # * the host registered its own provider -> pass through; the host's wins
71
+ # * no credentials and not OmniAuth test mode -> pass through, so POST
72
+ # /auth/google_oauth2 answers 404 rather than a Google error page
73
+ # * otherwise -> the strategy, built from the credentials of the moment
74
+ #
75
+ # Deciding per request rather than at boot is what makes host detection
76
+ # possible: the engine's initializers run BEFORE the host's
77
+ # config/initializers, so at boot the host's OmniAuth::Builder is not yet on
78
+ # the stack to be seen.
79
+ class Middleware
80
+ def initialize(app)
81
+ @app = app
82
+ @strategies = {}
83
+ @lock = Mutex.new
84
+ end
85
+
86
+ def call(env)
87
+ return @app.call(env) unless GoogleSignIn.engine_registers?
88
+
89
+ strategy(*GoogleSignIn.credentials).call(env)
90
+ end
91
+
92
+ private
93
+
94
+ # The strategy's callback host is pinned when the app names one (see
95
+ # GoogleSignIn.pinned_full_host), and otherwise OmniAuth's allowed_hosts
96
+ # is seeded from config.hosts (GoogleSignIn.seed_allowed_hosts!), so a
97
+ # forged Host or X-Forwarded-Host never becomes the redirect_uri Google
98
+ # sees. Either one also settles OmniAuth 2.3's request-host warning.
99
+ def strategy(client_id, client_secret)
100
+ @lock.synchronize do
101
+ @strategies[[client_id, client_secret]] ||= begin
102
+ options = { scope: SCOPE, prompt: PROMPT }
103
+ if (full_host = GoogleSignIn.pinned_full_host)
104
+ options[:full_host] = full_host
105
+ else
106
+ GoogleSignIn.seed_allowed_hosts!
107
+ end
108
+ ::OmniAuth::Strategies::GoogleOauth2.new(@app, client_id, client_secret, **options)
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ class << self
115
+ # True when the app holds an OAuth client: both env vars present.
116
+ def ready?(env: ENV)
117
+ CREDENTIAL_KEYS.all? { |key| !env[key].to_s.strip.empty? }
118
+ end
119
+
120
+ def credentials(env: ENV)
121
+ CREDENTIAL_KEYS.map { |key| env[key].to_s.strip.then { |value| value.empty? ? nil : value } }
122
+ end
123
+
124
+ # OmniAuth's test mode answers every request with the mock, so the button
125
+ # posts there whatever the credentials say.
126
+ def mock_mode?
127
+ defined?(::OmniAuth) && ::OmniAuth.config.test_mode ? true : false
128
+ end
129
+
130
+ # Whether the Google button POSTs to the strategy (true) or opens the
131
+ # setup modal (false). A host that registered its own provider owns its
132
+ # readiness, so its button keeps posting exactly as it did before the
133
+ # engine shipped one: hub, turf-monster, moms-app, cyvasse and
134
+ # acquisition-studio test their buttons as POST forms with no credentials.
135
+ def button_posts?(env: ENV)
136
+ ready?(env: env) || mock_mode? || host_registered?
137
+ end
138
+
139
+ # Whether the engine's own strategy answers /auth/google_oauth2.
140
+ def engine_registers?(env: ENV)
141
+ return false if host_registered?
142
+
143
+ ready?(env: env) || mock_mode?
144
+ end
145
+
146
+ # :host, :engine or :none: who answers /auth/google_oauth2 right now.
147
+ def registration(env: ENV)
148
+ return :host if host_registered?
149
+
150
+ engine_registers?(env: env) ? :engine : :none
151
+ end
152
+
153
+ # The callback a Google OAuth client must list for this host.
154
+ def redirect_uri(host:, scheme: "https")
155
+ "#{scheme}://#{host}#{CALLBACK_PATH}"
156
+ end
157
+
158
+ # ---- The app's canonical host: from configuration, NEVER the request ----
159
+ #
160
+ # "Request Google setup" mails ops a redirect URI to add to the shared
161
+ # production OAuth client, so that URI must not come from a header a
162
+ # stranger controls (Host, X-Forwarded-Host). In order:
163
+ #
164
+ # 1. Studio.google_canonical_host, set in config/initializers/studio.rb
165
+ # 2. ENV["APP_HOST"], the deployed apps' canonical host (the hub's
166
+ # CanonicalHost reads the same variable)
167
+ # 3. the first config.hosts entry that is a plain hostname: a String of
168
+ # letters, digits, dots and hyphens, no port, no leading dot
169
+ #
170
+ # nil when none is configured; the email then says so instead of naming
171
+ # a host.
172
+ PLAIN_HOSTNAME = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\z/i
173
+
174
+ def canonical_host(env: ENV, configured: configured_canonical_host, hosts: rails_config_hosts)
175
+ explicit = normalize_host(configured) || normalize_host(env["APP_HOST"])
176
+ return explicit if explicit
177
+
178
+ Array(hosts).find { |entry| entry.is_a?(String) && PLAIN_HOSTNAME.match?(entry) }&.downcase
179
+ end
180
+
181
+ # Scheme and host only, lowercased: "https://App.Example/x" -> "app.example".
182
+ # A port an operator wrote into APP_HOST is kept; it is configuration.
183
+ def normalize_host(value)
184
+ text = value.to_s.strip.sub(%r{\A[a-z][a-z0-9+.-]*://}i, "").split("/").first.to_s.downcase
185
+ text.empty? ? nil : text
186
+ end
187
+
188
+ def canonical_scheme(production: rails_production?)
189
+ production ? "https" : "http"
190
+ end
191
+
192
+ # The redirect URI to register, from the canonical host; nil when the app
193
+ # names none.
194
+ def canonical_redirect_uri(env: ENV, production: rails_production?)
195
+ host = canonical_host(env: env)
196
+ host && redirect_uri(host: host, scheme: canonical_scheme(production: production))
197
+ end
198
+
199
+ # The strategy's full_host: pinned only in production and only from an
200
+ # EXPLICIT host (Studio.google_canonical_host or APP_HOST). A desk on
201
+ # localhost:<port> must keep its own port in the callback, and a QA app's
202
+ # config.hosts may list the production host first.
203
+ def pinned_full_host(env: ENV, production: rails_production?)
204
+ return nil unless production
205
+
206
+ host = normalize_host(configured_canonical_host) || normalize_host(env["APP_HOST"])
207
+ host && "https://#{host}"
208
+ end
209
+
210
+ # OmniAuth.config.allowed_hosts mirrored from config.hosts, so OmniAuth
211
+ # builds callbacks only on hosts the app already accepts. Left alone when
212
+ # the app set OmniAuth's own host config, when config.hosts is empty, or
213
+ # when it admits every host anyway (an IPAddr range, as development has).
214
+ # A leading-dot entry (".example.com") becomes the same subdomain pattern
215
+ # Rails gives it; ports are dropped, since OmniAuth compares bare hosts.
216
+ def allowed_hosts_from(hosts)
217
+ entries = Array(hosts)
218
+ return nil if entries.empty?
219
+ return nil if entries.any? { |entry| !entry.is_a?(String) && !entry.is_a?(Regexp) }
220
+
221
+ entries.map do |entry|
222
+ next entry if entry.is_a?(Regexp)
223
+
224
+ bare = entry.downcase.sub(/:\d+\z/, "")
225
+ bare.start_with?(".") ? /(?:[a-z0-9-]+\.)*#{Regexp.escape(bare.delete_prefix("."))}/i : bare
226
+ end.uniq
227
+ end
228
+
229
+ def seed_allowed_hosts!(hosts: rails_config_hosts)
230
+ return unless defined?(::OmniAuth)
231
+ return if ::OmniAuth.config.full_host || ::OmniAuth.config.allowed_hosts
232
+
233
+ allowed = allowed_hosts_from(hosts)
234
+ ::OmniAuth.config.allowed_hosts = allowed if allowed
235
+ end
236
+
237
+ # The once-a-day key: the app, not the request's host, so a forged Host or
238
+ # port cannot mint a fresh day.
239
+ def app_key
240
+ name = defined?(Studio) && Studio.respond_to?(:app_name) ? Studio.app_name.to_s : ""
241
+ key = name.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
242
+ key.empty? ? "app" : key
243
+ end
244
+
245
+ # Does a built middleware stack already register google_oauth2? Reads each
246
+ # entry's class and block (ActionDispatch::MiddlewareStack::Middleware):
247
+ #
248
+ # * the strategy used directly (`use OmniAuth::Strategies::GoogleOauth2`)
249
+ # * an OmniAuth::Builder whose block calls `provider :google_oauth2`,
250
+ # found by evaluating the block against a ProviderRecorder
251
+ #
252
+ # A Builder registering only other providers does not count, so the engine
253
+ # still supplies Google beside them. A block the recorder cannot evaluate
254
+ # counts as registering Google: two strategies on one path is the failure
255
+ # this exists to prevent, so doubt resolves toward the host.
256
+ def host_registers_google?(middlewares)
257
+ Array(middlewares).any? do |entry|
258
+ klass = entry.respond_to?(:klass) ? entry.klass : entry
259
+ name = klass.to_s
260
+ next false if name == Middleware.name
261
+ next true if google_provider?(klass)
262
+ next false unless omniauth_builder?(klass)
263
+
264
+ builder_registers_google?(entry.respond_to?(:block) ? entry.block : nil)
265
+ end
266
+ end
267
+
268
+ def builder_registers_google?(block)
269
+ return false unless block
270
+
271
+ recorder = ProviderRecorder.new
272
+ recorder.instance_eval(&block)
273
+ recorder.providers.any? { |provider| google_provider?(provider) }
274
+ rescue StandardError, ScriptError
275
+ true
276
+ end
277
+
278
+ def google_provider?(provider)
279
+ name = provider.to_s
280
+ name == PROVIDER || name.end_with?("::GoogleOauth2")
281
+ end
282
+
283
+ def omniauth_builder?(klass)
284
+ return true if klass.to_s == "OmniAuth::Builder"
285
+
286
+ defined?(::OmniAuth::Builder) && klass.is_a?(Class) && klass < ::OmniAuth::Builder ? true : false
287
+ end
288
+
289
+ # Memoized once the app's stack is built; nil-safe before that (and in a
290
+ # process with no Rails app), answering false without memoizing.
291
+ def host_registered?
292
+ return @host_registered unless @host_registered.nil?
293
+
294
+ stack = rails_middleware_stack
295
+ return false unless stack
296
+
297
+ @host_registered = host_registers_google?(stack.middlewares)
298
+ end
299
+
300
+ def reset!
301
+ @host_registered = nil
302
+ @fallback_store = nil
303
+ end
304
+
305
+ # "Request Google setup" is answered once per APP per day (key: app_key,
306
+ # never a request header). Returns true for the request that claims the
307
+ # day, false for every later one.
308
+ #
309
+ # Checked with exist? before the unless_exist write, because not every
310
+ # cache store honours unless_exist; the two together leave only a race
311
+ # between simultaneous first clicks, which costs one extra email.
312
+ def claim_daily_request(app = app_key, store: request_store, today: current_date)
313
+ key = "studio/google_setup_request/#{app.to_s.downcase}/#{today.iso8601}"
314
+ return false if store.exist?(key)
315
+
316
+ store.write(key, today.iso8601, unless_exist: true, expires_in: REQUEST_TTL_SECONDS) ? true : false
317
+ end
318
+
319
+ # The app's Rails.cache, unless that is a NullStore, which remembers
320
+ # nothing and would let every click mail the team. Then a store of this
321
+ # process's own: a per-dyno limit, still a limit.
322
+ def request_store
323
+ cache = defined?(::Rails) && ::Rails.respond_to?(:cache) ? ::Rails.cache : nil
324
+ return cache if cache && !(defined?(::ActiveSupport::Cache::NullStore) && cache.is_a?(::ActiveSupport::Cache::NullStore))
325
+
326
+ @fallback_store ||= begin
327
+ require "active_support/cache"
328
+ ::ActiveSupport::Cache::MemoryStore.new
329
+ end
330
+ end
331
+
332
+ # The app's day (Time.zone) where ActiveSupport has one, else the system's.
333
+ def current_date
334
+ Date.respond_to?(:current) ? Date.current : Date.today
335
+ end
336
+
337
+ private
338
+
339
+ def configured_canonical_host
340
+ defined?(Studio) && Studio.respond_to?(:google_canonical_host) ? Studio.google_canonical_host : nil
341
+ end
342
+
343
+ def rails_config_hosts
344
+ return [] unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
345
+
346
+ Array(::Rails.application.config.hosts)
347
+ end
348
+
349
+ def rails_production?
350
+ defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.production? ? true : false
351
+ end
352
+
353
+ def rails_middleware_stack
354
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
355
+
356
+ stack = ::Rails.application.config.middleware
357
+ stack.respond_to?(:middlewares) ? stack : nil
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Studio.loader: the branded loader every app draws from its OWN icon and theme
4
+ # colours (docs/LOADER.md). Pure Ruby apart from the art reader, so the unit
5
+ # suite covers the config without the dummy app.
6
+ #
7
+ # The config is a style name, or a Hash when the style needs more:
8
+ #
9
+ # config.loader = :breathe # the default
10
+ # config.loader = { style: :orbit, icon: "/brand/icon-dark.svg" }
11
+ # config.loader = { style: :sparkle, art: "/brand/loader-sparkle.svg" }
12
+ #
13
+ # GENERIC styles draw the icon as an image and nothing else, so they work on any
14
+ # app with no per-app art: breathe, liquid, orbit, steel.
15
+ #
16
+ # SIGNATURE styles animate PARTS of the icon, which no image can expose, so the
17
+ # app names an ART file: an SVG in the icon's own viewBox holding only the
18
+ # animated layers, each marked with data-loader:
19
+ #
20
+ # weld_bead one <path data-loader="outline" d="..."/> the line the bead runs
21
+ # sparkle each <path data-loader="spark" d="..."/> in the sweep order
22
+ #
23
+ # The icon is drawn underneath as an image; the art is inlined over it. An art
24
+ # file that is missing, outside public/, or has no marked layer falls back to
25
+ # breathe, so a typo can never blank the loader.
26
+ module Studio
27
+ module Loader
28
+ class InvalidConfig < ArgumentError; end
29
+
30
+ SETTING = "Studio.loader"
31
+ GENERIC_STYLES = %i[breathe liquid orbit steel].freeze
32
+ ART_STYLES = { weld_bead: "outline", sparkle: "spark" }.freeze
33
+ STYLES = (GENERIC_STYLES + ART_STYLES.keys).freeze
34
+ DEFAULT_STYLE = :breathe
35
+ KEYS = %i[style icon art].freeze
36
+
37
+ # The icon the loader draws when the app names none: the navbar brand's
38
+ # logo, else the theme's "Navbar Logo" (Studio.logo_for), which every app
39
+ # already declares.
40
+ def self.default_icon
41
+ brand = Studio.respond_to?(:navbar_brand) ? Studio.navbar_brand : nil
42
+ (brand && brand[:logo]) || Studio.logo_for("Navbar Logo")
43
+ end
44
+
45
+ module_function
46
+
47
+ # The config as { style:, icon:, art: } (icon and art nil when not given).
48
+ def normalize(config)
49
+ config = { style: config } if config.is_a?(Symbol) || config.is_a?(String)
50
+ config = { style: DEFAULT_STYLE } if config.nil?
51
+ raise InvalidConfig, "#{SETTING} must be a style Symbol or a Hash (got #{config.class})" unless config.is_a?(Hash)
52
+
53
+ config = config.to_h { |key, value| [key.to_sym, value] }
54
+ unknown = config.keys - KEYS
55
+ raise InvalidConfig, "#{SETTING} has unknown key :#{unknown.first} (takes #{KEYS.join(', ')})" if unknown.any?
56
+
57
+ style = style!(config[:style] || DEFAULT_STYLE)
58
+ icon = path!(config[:icon], "#{SETTING}[:icon]")
59
+ art = path!(config[:art], "#{SETTING}[:art]")
60
+ if ART_STYLES.key?(style) && art.nil?
61
+ raise InvalidConfig, "#{SETTING} style :#{style} animates parts of the icon, so it needs an art: file " \
62
+ "(an SVG of <path data-loader=\"#{ART_STYLES[style]}\"> layers; docs/LOADER.md)"
63
+ end
64
+
65
+ { style: style, icon: icon, art: art }.freeze
66
+ end
67
+
68
+ def style!(value)
69
+ style = value.to_s.strip.tr("-", "_").to_sym
70
+ return style if STYLES.include?(style)
71
+
72
+ raise InvalidConfig, "#{SETTING} style must be one of #{STYLES.map(&:inspect).join(', ')} (got #{value.inspect})"
73
+ end
74
+
75
+ def path!(value, label)
76
+ return nil if value.nil?
77
+ return value.strip if value.is_a?(String) && !value.strip.empty?
78
+
79
+ raise InvalidConfig, "#{label} must be a non-blank path (got #{value.inspect})"
80
+ end
81
+
82
+ def art_style?(style) = ART_STYLES.key?(style)
83
+
84
+ # ---- The art file ------------------------------------------------------
85
+ #
86
+ # Read from the host's public/ (the path is the URL path the app would serve
87
+ # it at), parsed once per file version. Returns
88
+ # { view_box: "0 0 w h", width: Float, layers: [Nokogiri::XML::Element] }
89
+ # with only the layers marked for `style`, or nil when there are none.
90
+ @art_cache = {}
91
+ @art_mutex = Mutex.new
92
+
93
+ class << self
94
+ attr_reader :art_cache
95
+ end
96
+
97
+ def art(path, style, root: public_root)
98
+ mark = ART_STYLES[style]
99
+ file = public_file(path, root)
100
+ return nil unless mark && file
101
+
102
+ key = [file, File.mtime(file).to_f, mark]
103
+ Loader.instance_variable_get(:@art_mutex).synchronize do
104
+ Loader.art_cache[key] ||= parse_art(File.read(file), mark)
105
+ end
106
+ rescue SystemCallError
107
+ nil
108
+ end
109
+
110
+ def parse_art(source, mark)
111
+ require "nokogiri"
112
+ doc = Nokogiri::XML(source)
113
+ svg = doc.root
114
+ return nil unless svg&.name == "svg"
115
+
116
+ layers = doc.xpath("//*[@data-loader='#{mark}']").select { |node| node.name == "path" && node["d"].to_s.strip != "" }
117
+ return nil if layers.empty?
118
+
119
+ view_box = svg["viewBox"].to_s.strip
120
+ numbers = view_box.split(/[\s,]+/).map { |n| Float(n, exception: false) }
121
+ return nil unless numbers.size == 4 && numbers.all? && numbers[2].positive? && numbers[3].positive?
122
+
123
+ {
124
+ view_box: numbers.map { |n| format_number(n) }.join(" "),
125
+ width: numbers[2],
126
+ layers: layers.map { |node| { d: node["d"].strip, transform: inherited_transform(node) } }.freeze
127
+ }.freeze
128
+ end
129
+
130
+ # The transforms of the layer and every group above it, outermost first, so a
131
+ # layer copied out of a <g transform> keeps its place.
132
+ def inherited_transform(node)
133
+ chain = []
134
+ while node.respond_to?(:name) && node.name != "svg" && node.name != "document"
135
+ chain.unshift(node["transform"]) if node["transform"].to_s.strip != ""
136
+ node = node.parent
137
+ end
138
+ chain.empty? ? nil : chain.join(" ")
139
+ end
140
+
141
+ def format_number(n) = (n % 1).zero? ? n.to_i.to_s : n.to_s
142
+
143
+ def public_root
144
+ defined?(::Rails) && ::Rails.respond_to?(:public_path) && ::Rails.public_path ? ::Rails.public_path.to_s : nil
145
+ end
146
+
147
+ # The file behind a public URL path, refusing anything that escapes public/.
148
+ def public_file(path, root)
149
+ return nil if root.nil? || path.nil? || path.match?(%r{\A[a-z]+:}i)
150
+
151
+ clean = path.split(/[?#]/).first.to_s.delete_prefix("/")
152
+ full = File.expand_path(clean, root)
153
+ return nil unless full.start_with?(File.expand_path(root) + File::SEPARATOR)
154
+
155
+ File.file?(full) ? full : nil
156
+ end
157
+ end
158
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.118.0"
2
+ VERSION = "0.119.0"
3
3
  end