studio-engine 0.85.0 → 0.87.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,454 @@
1
+ # Site footer
2
+
3
+ Every app on studio-engine can end its public pages with the same footer, from
4
+ configuration alone. The gem ships the markup, the styles, the map, the scripts
5
+ and Leaflet itself. The app declares facts.
6
+
7
+ Booking through Google Calendar is its own primitive, usable with or without
8
+ the footer: see [`BOOKING.md`](BOOKING.md). This page covers where the two meet.
9
+
10
+ This page and `BOOKING.md` ship inside the gem (`docs/` in the installed
11
+ studio-engine), so a consumer can cite them by path: `bundle exec gem contents
12
+ studio-engine | grep docs/` finds them. The repository copy is
13
+ <https://github.com/McRitchie-Studio/studio-engine/blob/main/docs/SITE_FOOTER.md>.
14
+
15
+ ## Adopt it
16
+
17
+ The app owns its layout, so the footer is one line the app writes. The engine
18
+ never injects it.
19
+
20
+ ```erb
21
+ <%# app/views/layouts/application.html.erb, at the end of <body> %>
22
+ <%= studio_site_footer %>
23
+ ```
24
+
25
+ **The footer is on by default.** That line, with nothing configured, renders
26
+ the default footer. `config.site_footer` has three states:
27
+
28
+ | `config.site_footer` | What `studio_site_footer` renders |
29
+ |----------------------|-----------------------------------|
30
+ | unset (`nil`) | The default footer |
31
+ | a Hash, or a callable receiving the view | The app's own facts ([Your own facts](#your-own-facts)) |
32
+ | `false` | Nothing: the opt-out |
33
+
34
+ ### The default footer
35
+
36
+ ```ruby
37
+ # config/initializers/studio.rb
38
+ Studio.configure do |config|
39
+ config.app_name = "Example Co" # nothing about the footer: it is already on
40
+ end
41
+ ```
42
+
43
+ It prints only what the engine knows to be true of the app:
44
+
45
+ - **The name**, as the wordmark and the © line: the site identity's title
46
+ (`Studio.site_identity[:title]`), which is `config.app_name` until the
47
+ operator or `config.site_title` says otherwise.
48
+ - **The logo**: the app's "Footer Logo" in `config.theme_logos`, else its
49
+ navbar logo. None configured, none shown.
50
+ - **A legal line**, for the routes the host has: "Privacy Policy" when it has a
51
+ route named `privacy`, "Terms of Service" when it has one named `terms`
52
+ (`get "privacy", to: "legal#privacy", as: :privacy`). A route is linked only
53
+ when a GET reaches it and its path needs no arguments. A host with neither
54
+ gets the © line alone.
55
+
56
+ It has no address, no map, no phone, no email, no tagline, no social row and no
57
+ link columns, and the page names no Leaflet. The engine cannot know those, and
58
+ a guessed link is a dead one. For any of them, add a location (below) or
59
+ declare [your own facts](#your-own-facts).
60
+
61
+ ### A location, in one setting
62
+
63
+ ```ruby
64
+ Studio.configure do |config|
65
+ config.site_footer_address = { street: "123 Example St", city_line: "Washington, DC 20024",
66
+ lat: 38.8894, lng: -77.0352 }
67
+ end
68
+ ```
69
+
70
+ That adds the Location band, and the map when `lat:` and `lng:` are both there,
71
+ to the default footer. The keys are the facts' `address:` keys
72
+ ([The address and the map](#the-address-and-the-map)). It also gives a location
73
+ to an app's own facts that carry none. **An address in the app's own facts
74
+ wins**, whole, and `address: false` there shows none.
75
+
76
+ ### Turning it off
77
+
78
+ ```ruby
79
+ Studio.configure do |config|
80
+ config.site_footer = false # no footer on any page; unset (nil) is the default footer
81
+ end
82
+ ```
83
+
84
+ To keep the footer off some pages rather than all of them, narrow
85
+ [where it shows](#where-it-shows) instead. A facts callable that answers `nil`
86
+ for a request renders no footer for that request.
87
+
88
+ ### Your own facts
89
+
90
+ ```ruby
91
+ # config/initializers/studio.rb
92
+ Studio.configure do |config|
93
+ config.site_footer = ->(view) {
94
+ {
95
+ tagline: "Everything, by example",
96
+ address: { street: "123 Example St", city_line: "Washington, DC 20024",
97
+ lat: 38.8894, lng: -77.0352 },
98
+ social: [ [ "LinkedIn", :linkedin, "https://www.linkedin.com/in/someone/" ],
99
+ [ "Instagram", :instagram, nil ] ],
100
+ columns: [
101
+ [ "Contact", [ [ "team@example.com", "mailto:team@example.com" ],
102
+ [ "Schedule a call", view.studio_booking_path ] ] ],
103
+ [ "Company", [ [ "Home", view.root_path ], [ "Career", nil ],
104
+ [ "Blog", "https://blog.example.com" ] ] ]
105
+ ],
106
+ legal: [ [ "Privacy Policy", view.privacy_path ], [ "Terms of Service", view.terms_path ] ]
107
+ }
108
+ }
109
+ config.site_footer_controllers = %w[landing packages]
110
+ config.booking_url = "https://calendar.google.com/calendar/appointments/schedules/EXAMPLE-SCHEDULE-ID"
111
+ config.draw_booking_routes = true
112
+ end
113
+ ```
114
+
115
+ That is the whole adoption. Nothing is copied into the app's `public/`, and
116
+ nothing is added to its asset manifest or its Tailwind build.
117
+
118
+ ## An app with no database
119
+
120
+ An app with no ActiveRecord (a public site with no accounts, such as rantly)
121
+ can take the engine for its footer alone, with nothing else to set:
122
+
123
+ ```ruby
124
+ # Gemfile
125
+ gem "studio-engine" # pin the first release whose CHANGELOG lists footer-only support
126
+ ```
127
+
128
+ ```ruby
129
+ # config/application.rb: the app's own framework choice, unchanged
130
+ require "rails"
131
+ require "active_model/railtie"
132
+ require "action_controller/railtie"
133
+ require "action_view/railtie"
134
+ # no active_record/railtie
135
+ ```
136
+
137
+ ```ruby
138
+ # config/initializers/studio.rb
139
+ Studio.configure do |config|
140
+ config.site_footer = ->(view) { { name: "Example", columns: [ [ "About", [ [ "Home", view.root_path ] ] ] ] } }
141
+ end
142
+ ```
143
+
144
+ ```erb
145
+ <%# app/views/layouts/application.html.erb, at the end of <body> %>
146
+ <%= studio_site_footer %>
147
+ ```
148
+
149
+ Leave `config.site_footer` unset and the same app gets
150
+ [the default footer](#the-default-footer), its name read from `config.app_name`
151
+ since there is no site-identity table to ask.
152
+
153
+ Do not call `Studio.routes(self)`: everything it draws (sign-in, admin, error
154
+ logs) needs a database. The app also needs an asset pipeline (Propshaft or
155
+ Sprockets), as every Rails 8 app has, and a mapping of the theme tokens onto its
156
+ own palette (see [Colours](#colours)).
157
+
158
+ When the app loads no ActiveRecord (`Studio.active_record?` reads false, because
159
+ `ActiveRecord::Railtie` is not defined), the engine itself:
160
+
161
+ - requires the ActiveSupport core extensions it calls at load time
162
+ (`Integer#minutes` and the like), so the app need not;
163
+ - skips the user-contract check ([`USER_CONTRACT.md`](USER_CONTRACT.md)), even
164
+ when the app defines a `::User` of its own: the contract is what the engine's
165
+ database-backed surfaces call on a user record, and the app has none;
166
+ - keeps every one of its `app/` roots but `app/helpers` out of eager loading
167
+ (controllers, models, mailers, jobs, services, and the two `concerns` roots,
168
+ which Zeitwerk treats as roots of their own), so a production boot does not
169
+ load code that needs ActiveRecord, ActionMailer or ActiveJob. The helpers,
170
+ the footer's among them, still eager load;
171
+ - answers `bin/rails studio_engine:install:migrations` with a no-op that exits 0
172
+ and writes nothing, which is what the hub's release sweep runs in every member
173
+ after an engine publish.
174
+
175
+ An app WITH ActiveRecord sees none of this change.
176
+ `test/integration/footer_only_consumer_test.rb` boots such an app, in
177
+ development and with `eager_load = true`.
178
+
179
+ ## The facts
180
+
181
+ `config.site_footer` is a Hash, or a callable that receives the view (so it can
182
+ use route helpers). Unset (`nil`), the facts are the default footer's; `false`
183
+ means the app has no footer and `studio_site_footer` renders nothing
184
+ ([Adopt it](#adopt-it)).
185
+
186
+ Every key is optional. A missing key removes its part of the footer.
187
+
188
+ | Key | What it prints | When absent |
189
+ |-----|----------------|-------------|
190
+ | `name` | The wordmark, the logo's label and the © line | The site identity's title (`Studio.site_identity[:title]`) |
191
+ | `wordmark` | Two parts, the second in the primary colour: `%w[Example Co]` | `name`, split before its last word |
192
+ | `logo` | An image beside the wordmark: an asset name or a path | The app's "Footer Logo" in `config.theme_logos`, else its navbar logo. `logo: false` shows none |
193
+ | `logo_invert` | `true` inverts a dark mark in light mode | Shown as drawn |
194
+ | `home_path` | Where the logo and wordmark link | `/` |
195
+ | `tagline` | A line under the wordmark | No line |
196
+ | `email` | A `mailto:` link under the tagline | No line. (To list the email in a column instead, write it as a column link.) |
197
+ | `address` | The Location band, and the map | `config.site_footer_address`; with neither, no Location band, no map, and Leaflet is never requested. `address: false` shows none even with the setting |
198
+ | `social` | A row of round icons | No row |
199
+ | `columns` | Link columns under bold headings. A column may carry a width hint: `[ "Contact", links, { width: 1.5 } ]` | Brand only |
200
+ | `legal` | The centred line above the © | The © alone |
201
+ | `booking` | `{ label:, title: }` for the popup's heading and the frame's accessible name | "Schedule a call", and "Schedule a call with <name>" |
202
+
203
+ Rows may be tuples, as above, or hashes (`{ label:, href: }`, `{ heading:, links: }`,
204
+ `{ label:, icon:, url: }`). The rules a row can carry:
205
+
206
+ - **A link with a `nil` path** renders its label disabled ("Coming soon"). Use it
207
+ for a page that does not exist yet.
208
+ - **A link to an `http(s)` URL** opens in a new tab.
209
+ - **A booking link** opens the booking popup instead of leaving the page. A link
210
+ is a booking link when its path is the booking page (the engine's
211
+ `studio_booking_path`, or the app's own `config.booking_path`), or when it
212
+ says so:
213
+ `[ "Schedule a call", contact_path, { booking: true } ]`. Its href stays as the
214
+ fallback.
215
+ - **Only some hrefs are linked.** A link, a social URL, `home_path` and
216
+ `directions_url` may be a relative path or an `http:`, `https:`, `mailto:` or
217
+ `tel:` URL. Anything else, `javascript:` and `data:` included, is never written
218
+ into the page: a link or a social icon prints unlinked, `home_path` falls back
219
+ to `/`, and `directions_url` falls back to the Google Maps default. Each
220
+ refused value is logged once (`[studio.site_footer] ... is not linked`).
221
+ - **The columns sit on a grid with fixed rows.** Below 768px the brand runs
222
+ across the top and the link columns sit two to a row. From 768px every link
223
+ column is in one row under the brand. From 1024px the brand is in that row
224
+ too. A footer with more than four link columns keeps the brand across the top
225
+ and wraps the columns four to a row.
226
+ - **An email address or a URL in a column is never broken mid-word.** A label
227
+ that is an address (no space, and an `@`, a `.` or a `/`) is kept on one line,
228
+ and its track is at least as wide as it is: the column grows to hold it and
229
+ the columns beside it give way. No column moves to another row. On the
230
+ smallest phones (under 360px) the gutter narrows and the address is set a
231
+ little smaller, which holds an address of about 25 characters beside a second
232
+ column at 320px; a longer one runs past the footer's edge there. Under 300px,
233
+ narrower than any phone, the address is allowed to break.
234
+ - **`width:` is a column's track**, as a share of the row, from 768px:
235
+ `{ width: 2 }` as a tuple's third element, or `width:` in a hash row. A number
236
+ from 0.5 to 4; anything else is ignored. Without one, the first column is 1.5
237
+ and the rest are 1 (the brand, from 1024px, is 1.7). Below 768px the two tracks
238
+ are fixed at 1.2 and 1, and with more than four columns the hints are not used.
239
+ - **A social profile with a `nil` URL** renders its icon unlinked, with a dashed
240
+ outline. The engine draws `:linkedin`, `:instagram`, `:x`, `:facebook` and
241
+ `:youtube`; any other icon falls back to the label's first letter.
242
+
243
+ ### The address and the map
244
+
245
+ ```ruby
246
+ address: { street: "123 Example St", city_line: "Washington, DC 20024",
247
+ lat: 38.8894, lng: -77.0352,
248
+ zoom: 15, # optional, default 15
249
+ directions_url: "https://maps.app/..." } # optional
250
+ ```
251
+
252
+ - `street` or `city_line` gives the Location band. Without `directions_url` the
253
+ address links to Google Maps directions for it.
254
+ - `lat` and `lng`, both, give the map. An address without them keeps the band
255
+ and has no map.
256
+ - The map is [Leaflet](https://leafletjs.com) 1.9.4 on OpenStreetMap's keyless
257
+ tiles (`tile.openstreetmap.org`). No account and no API key.
258
+ - Dark mode filters the same tiles in CSS. There is no second tile set.
259
+ - With scripts off, or if Leaflet fails to load, the map element is a link to
260
+ directions.
261
+ - Nothing is requested before the window's `load` event, and nothing until the
262
+ map is within 400px of the viewport. A visit that never scrolls to the footer
263
+ fetches neither Leaflet nor a tile.
264
+ - On a touch device one finger scrolls the page, not the map. The map runs edge
265
+ to edge, so a swipe that began on it would otherwise trap the visitor. Pinch
266
+ still zooms, and the zoom buttons and the directions link remain. With a
267
+ mouse the map drags, and the wheel zooms only after a click on the map.
268
+
269
+ `studio_footer_map class: "h-80"` renders the map alone, for a contact page. It
270
+ maps the footer's address; pass `lat:`/`lng:` to map another. The element has no
271
+ height of its own, so `class:` or `style:` sizes it.
272
+
273
+ Leaflet numbers its own panes and controls up to `z-index: 1000`. The map
274
+ element is its own stacking context (`isolation: isolate`), so none of that
275
+ competes with the navbar or a modal. If you mount Leaflet yourself on another
276
+ element, isolate that element too.
277
+
278
+ Leaflet is served by the engine through the app's asset pipeline
279
+ (`studio/leaflet.js`, `studio/leaflet.css`, both in the engine's precompile
280
+ list), so it works on Heroku with no extra step in either a Sprockets or a
281
+ Propshaft app. The engine's copy of the stylesheet drops Leaflet's default marker
282
+ image and its layers-control image, which are not shipped: mark a point with
283
+ `L.divIcon`, as the footer does.
284
+
285
+ ## Where it shows
286
+
287
+ `studio_site_footer` asks `config.site_footer_visible`, one callable that
288
+ receives the view. Its default:
289
+
290
+ - **A visitor** (signed out) sees the footer on every page.
291
+ - **A signed-in viewer** sees it only on the controllers in
292
+ `config.site_footer_controllers`, by controller name (`"landing"`) or path
293
+ (`"admin/reports"`). Every other signed-in page is a working surface and stays
294
+ full height. The booking page is always included: the engine's `/schedule`,
295
+ or the app's own when `config.booking_path` names it.
296
+
297
+ The default shows the footer on any page a signed-out visitor can reach,
298
+ including an app page that happens to be public. Narrow it by replacing the
299
+ callable, composing with the default rather than restating it:
300
+
301
+ ```ruby
302
+ config.site_footer_visible = ->(view) {
303
+ Studio::SiteFooter.default_visible?(view) && !view.controller_path.start_with?("games/")
304
+ }
305
+ ```
306
+
307
+ `studio_show_site_footer?` gives a view the same answer.
308
+
309
+ ## Booking
310
+
311
+ With `config.booking_url` set, the footer renders the booking popup, and a
312
+ booking link in a column opens it instead of leaving the page (the rule is under
313
+ [The facts](#the-facts)). `booking: { label:, title: }` in the facts sets what the
314
+ links, the popup and the booking page call the act.
315
+
316
+ The frame, its crop, the popup, the `/schedule` page, an app's own booking page
317
+ (`config.booking_path`) and how to set the schedule up in Google are in
318
+ [`BOOKING.md`](BOOKING.md).
319
+
320
+ ## What it costs a page
321
+
322
+ - The footer's CSS and scripts are inline, rendered once per page, and scoped
323
+ (`.ftr-*`, `.booking-*`). They read the theme's custom properties with a
324
+ fallback beside each, so the footer lays out the same in an app whose Tailwind
325
+ build has never seen these views. Do not restyle it with utilities that your
326
+ build may not emit.
327
+ - A page with no address never names Leaflet: the map's styles and its mount
328
+ script are rendered only with a map (`studio/site_footer/_map_assets`). A page
329
+ with no `booking_url` carries no booking script.
330
+ - A facts callable that raises costs the footer, not the page: in production the
331
+ error is logged once and the page renders without a footer. In development and
332
+ test it raises.
333
+
334
+ ## Colours
335
+
336
+ The footer reads the engine's theme tokens, with a fallback beside each:
337
+
338
+ | Token | Paints |
339
+ |-------|--------|
340
+ | `--color-surface-alt` | The band's background |
341
+ | `--color-text-body` | The band's text |
342
+ | `--color-text` | The wordmark, the headings and the Location title |
343
+ | `--color-text-secondary` | The links (through `--ftr-link-ink`) and the tagline |
344
+ | `--color-text-muted` | The © line |
345
+ | `--color-primary`, `--color-primary-500-rgb` | The wordmark accent, link hover, social icons and the map tint |
346
+ | `--color-border` | The rules |
347
+
348
+ **Links are painted at full opacity** in `--ftr-link-ink`, which defaults to
349
+ `--color-text-secondary`. On the engine's default theme that ink clears WCAG AA
350
+ 4.5:1 on the band in both themes (`test/lib/site_footer_link_contrast_test.rb`
351
+ measures it). An app that maps the tokens onto its own palette owes the same:
352
+ set `--color-text-secondary` to an ink that clears 4.5:1 on its
353
+ `--color-surface-alt`, in light and dark. To give the footer's links a colour of
354
+ their own without touching the token, set `--ftr-link-ink` on the footer:
355
+
356
+ ```css
357
+ footer[data-site-footer] { --ftr-link-ink: #4a4a4a; }
358
+ @media (prefers-color-scheme: dark) { footer[data-site-footer] { --ftr-link-ink: #c8c8c8; } }
359
+ ```
360
+
361
+ ## Content Security Policy
362
+
363
+ An app that enforces a policy must allow what the footer fetches from other
364
+ origins (the booking frame's `frame-src` is in [`BOOKING.md`](BOOKING.md#content-security-policy)):
365
+
366
+ | Directive | Source | For |
367
+ |-----------|--------|-----|
368
+ | `img-src` | `https://tile.openstreetmap.org` | The map's tiles |
369
+
370
+ Leaflet itself is same-origin (`script-src 'self'`, `style-src 'self'`), since it
371
+ is served from the app's own assets.
372
+
373
+ The footer's own `<style>` and `<script>` blocks are
374
+ inline and carry no nonce, like the rest of the engine's partials, and Leaflet
375
+ positions its tiles with inline `style` attributes. A policy that forbids inline
376
+ script or style needs `'unsafe-inline'` for them (or hashes), exactly as it does
377
+ for the engine's head partial today.
378
+
379
+ ## Coexisting with an app's own copy
380
+
381
+ An app that still renders a local footer partial uses the same
382
+ `data-footer-map` attribute. The engine's script keeps out of its way: its guard
383
+ is an engine name (`__studioFooterMapsArmed`), and it acts only on
384
+ engine-rendered maps, which carry `data-leaflet-js`. The booking scripts follow
385
+ the same rule ([`BOOKING.md`](BOOKING.md#coexisting-with-an-apps-own-copy)).
386
+
387
+ ## Markup hooks
388
+
389
+ Stable, for tests and for an app's own scripts:
390
+ `footer[data-site-footer]`, `[data-footer-location]`, `[data-footer-map]`. The
391
+ booking hooks are in [`BOOKING.md`](BOOKING.md#markup-hooks).
392
+
393
+ ## Class hooks
394
+
395
+ These class names are stable, and an app may style against them:
396
+
397
+ | Class | Element |
398
+ |-------|---------|
399
+ | `.ftr` | The footer |
400
+ | `.ftr-cols` | The grid of brand and link columns; `--ftr-tracks` is its link-column tracks. `.ftr-cols-many` with more than four |
401
+ | `.ftr-brand` | The brand block |
402
+ | `.ftr-col` | One link column (a `nav`) |
403
+ | `.ftr-heading`, `.ftr-list` | A column's heading and its list |
404
+ | `.ftr-link`, `.ftr-link-solid`, `.ftr-link-disabled` | A link; an address kept on one line; a "coming soon" label. `--ftr-link-ink` on `.ftr` is the link colour |
405
+ | `.ftr-location`, `.ftr-location-title`, `.ftr-address` | The Location band |
406
+ | `.ftr-legal`, `.ftr-copyright` | The legal line and the © line |
407
+ | `.ftr-map` | The map |
408
+
409
+ The rest (`.ftr-wrap`, `.ftr-home`, the social and pin classes) may change.
410
+
411
+ The footer's stylesheet is inline and unlayered, and comes after the app's own
412
+ in the document, so an app rule needs more specificity to win: prefix it with
413
+ `footer[data-site-footer]`.
414
+
415
+ ### An app that overrode 0.83.0's tracks
416
+
417
+ 0.83.0 laid the columns on equal `minmax(0, 1fr)` tracks, and an app could only
418
+ fix the broken address from its own stylesheet, with `grid-template-columns` on
419
+ `footer[data-site-footer] .ftr-cols` and `overflow-wrap: normal` on the mailto
420
+ link. Those rules are more specific than the engine's and still apply.
421
+
422
+ - **Left in place** they draw the same rows as the engine now does, with the
423
+ address whole (a browser spec holds that, at 320, 390, 768, 1024 and 1280px).
424
+ Nothing breaks if the block outlives the upgrade.
425
+ - **Delete the block once the app is on this version.** Not before: on 0.83.0 it
426
+ is the only thing keeping the address whole. While it stays it hides the
427
+ engine's tracks, so a `width:` hint does nothing and the narrow-phone gutter is
428
+ the only engine rule still acting on the row.
429
+
430
+ ## Tests
431
+
432
+ - `test/lib/studio/site_footer_test.rb`: the facts' rules, the default facts, the
433
+ address setting's precedence, and the visibility rule.
434
+ - `test/lib/vendored_leaflet_test.rb`: Leaflet is vendored, precompiled, and
435
+ addressed through the asset pipeline.
436
+ - `test/integration/site_footer_test.rb`: what a host renders, with and without
437
+ each fact, signed in and out; the default footer with nothing set, the
438
+ `false` opt-out, `config.site_footer_address` on the default footer and
439
+ against an app's own address, and the default legal links with and without
440
+ the host's routes.
441
+ - `test/integration/footer_only_consumer_test.rb`: an app with no ActiveRecord
442
+ boots (development and eager-loaded), renders the footer with no Leaflet, and
443
+ runs `studio_engine:install:migrations` to a clean exit; with nothing set it
444
+ renders the default footer.
445
+ - `test/lib/site_footer_link_contrast_test.rb`: the link ink clears 4.5:1 on the
446
+ band, on the default tokens, in both themes.
447
+ - `e2e/site_footer.spec.js`: the map's script and the footer's layout, in a
448
+ browser, with the network closed: which columns share a row at 320, 390, 768,
449
+ 1024 and 1280px with two, three, four and five columns, the address on one
450
+ line inside its column at each, and the line heights. These lab pages load Turbo, so the map's remount after a Turbo visit is
451
+ covered.
452
+
453
+ `/admin/style` shows the footer under Tricks, with this app's own facts, or a
454
+ sample when it has declared none (the default footer or `false`).
data/lib/studio/engine.rb CHANGED
@@ -100,6 +100,76 @@ module Studio
100
100
  load File.expand_path("../tasks/studio_ses.rake", __dir__)
101
101
  end
102
102
 
103
+ # ---- A FOOTER-ONLY CONSUMER: a host with no ActiveRecord ------------------
104
+ #
105
+ # An app with no database can take the engine for its site footer alone
106
+ # (docs/SITE_FOOTER.md, "An app with no database"). Each block below changes
107
+ # nothing for a host that loads ActiveRecord; each one used to be a
108
+ # workaround in the consumer (rantly). Studio.active_record? is the switch.
109
+ # test/integration/footer_only_consumer_test.rb boots that host.
110
+
111
+ # The engine's app/ roots a footer-only host must not EAGER load: every one
112
+ # but app/helpers. Its models subclass ApplicationRecord, its controllers
113
+ # skip :require_authentication, its mailers and jobs subclass ActionMailer
114
+ # and ActiveJob, and the two concerns roots (app/controllers/concerns,
115
+ # app/models/concerns) are roots of their OWN to Zeitwerk: excluding
116
+ # app/models does not exclude app/models/concerns, and Sluggable and
117
+ # Studio::UserProfile sit there. They stay lazily loadable, and nothing in a
118
+ # host that draws no Studio.routes asks for them.
119
+ #
120
+ # Read off the engine's own eager-load paths rather than listed, so a root
121
+ # added later is excluded by default: the footer-only surface is the helpers
122
+ # (studio_site_footer and its siblings), and a new root earns a place beside
123
+ # them by being made to load without ActiveRecord.
124
+ FOOTER_ONLY_EAGER_ROOTS = %w[app/helpers].freeze
125
+
126
+ def self.active_record_dependent_roots
127
+ root_path = root.to_s
128
+ config.all_eager_load_paths.map(&:to_s)
129
+ .select { |path| path.start_with?("#{root_path}/") && File.directory?(path) }
130
+ .reject { |path| FOOTER_ONLY_EAGER_ROOTS.include?(path.delete_prefix("#{root_path}/")) }
131
+ .sort
132
+ end
133
+
134
+ initializer "studio.footer_only_eager_load" do
135
+ next if Studio.active_record?
136
+
137
+ Studio::Engine.active_record_dependent_roots.each do |path|
138
+ Rails.autoloaders.main.do_not_eager_load(path)
139
+ end
140
+ end
141
+
142
+ # `studio_engine:install:migrations` with no ActiveRecord. Rails defines it
143
+ # for every engine that ships db/migrate, and it invokes
144
+ # `railties:install:migrations`, which only ActiveRecord's tasks define, so
145
+ # it raised and rake exited 1. The hub's release sweep runs it in every
146
+ # member after each engine publish (mcritchie-studio bin/release.rb,
147
+ # install_engine_migrations!) and aborts on a non-zero exit. With no
148
+ # ActiveRecord, Rails' version is not drawn (has_migrations? below) and this
149
+ # one says so and exits 0, writing nothing.
150
+ rake_tasks do
151
+ next if Studio.active_record?
152
+
153
+ namespace railtie_name do
154
+ namespace :install do
155
+ desc "No-op: this app loads no ActiveRecord, so it installs none of studio-engine's migrations"
156
+ task :migrations do
157
+ puts "studio-engine: this app loads no ActiveRecord, so there are no migrations to install"
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ # Rails' engine reads this to decide whether to draw
166
+ # `<engine>:install:migrations` (Rails::Engine, its rake_tasks block).
167
+ def has_migrations?
168
+ Studio.active_record? && super
169
+ end
170
+
171
+ public
172
+
103
173
  config.after_initialize do
104
174
  # Configure the IP -> location provider for every app that has the gem,
105
175
  # so geo detection works the same way everywhere: HTTPS (without which
@@ -113,8 +183,17 @@ module Studio
113
183
 
114
184
  # Validate the host app's User model satisfies the engine's contract.
115
185
  # See docs/USER_CONTRACT.md. Opt out with Studio.validate_user_contract = false.
116
- if defined?(::User) && ::User.is_a?(Class) &&
117
- (!defined?(::ActiveRecord::Base) || ::User.ancestors.include?(::ActiveRecord::Base))
186
+ #
187
+ # ONLY A HOST WITH ACTIVERECORD, and only an ActiveRecord User. The
188
+ # contract is what the engine's sign-in, admin and error-log surfaces call
189
+ # on a user RECORD, and every one of them needs a database. A host with no
190
+ # ActiveRecord (a footer-only consumer) has no such surface to break, and
191
+ # a ::User it does define is its own business: rantly's is a sample
192
+ # profile read from YAML. The rule is ActiveRecord's absence rather than
193
+ # "drew no Studio.routes", because the routes are drawn after this check's
194
+ # inputs exist and an app can call the helpers without drawing them.
195
+ if Studio.active_record? && defined?(::User) && ::User.is_a?(Class) &&
196
+ ::User.ancestors.include?(::ActiveRecord::Base)
118
197
  Studio.validate_user_contract!(::User)
119
198
  end
120
199
  end
@@ -7,7 +7,8 @@ require "cgi"
7
7
  # booting the dummy app. See docs/SITE_FOOTER.md.
8
8
  #
9
9
  # The declared value is a Hash, or a callable that receives the view (for route
10
- # helpers). EVERY KEY IS OPTIONAL, and a missing key removes its part of the
10
+ # helpers). Unset (nil) is the DEFAULT footer (`default_facts`), and `false` is
11
+ # no footer. EVERY KEY IS OPTIONAL, and a missing key removes its part of the
11
12
  # footer rather than printing a placeholder:
12
13
  #
13
14
  # config.site_footer = ->(view) {
@@ -105,21 +106,73 @@ module Studio
105
106
  end
106
107
 
107
108
  def validate!(declared)
108
- return if declared.nil? || declared.is_a?(Hash) || declared.respond_to?(:call)
109
+ return if declared.nil? || declared == false || declared.is_a?(Hash) || declared.respond_to?(:call)
109
110
 
110
111
  raise ArgumentError,
111
- "Studio.site_footer must be nil, a Hash, or a callable receiving the view " \
112
+ "Studio.site_footer must be nil (the default footer), false (no footer), a Hash, or a " \
113
+ "callable receiving the view (got #{declared.class}). See docs/SITE_FOOTER.md."
114
+ end
115
+
116
+ def validate_address!(declared)
117
+ return if declared.nil? || declared == false || declared.is_a?(Hash)
118
+
119
+ raise ArgumentError,
120
+ "Studio.site_footer_address must be nil or a Hash of street:, city_line:, lat:, lng: " \
112
121
  "(got #{declared.class}). See docs/SITE_FOOTER.md."
113
122
  end
114
123
 
115
- # The normalized facts, or nil when the app declared no footer.
124
+ # THE DEFAULT FOOTER'S FACTS: what an app that sets nothing gets. Only what
125
+ # is known to be true of the host: a legal line linking the routes in
126
+ # DEFAULT_LEGAL that the host really has. The name and the logo come from
127
+ # `resolve`'s own fallbacks. No address, no email, no tagline, no social row
128
+ # and no link columns: the engine cannot know them, and a guessed link is a
129
+ # dead one.
130
+ #
131
+ # routes: the host's route set (Rails.application.routes), or nil to ask
132
+ # the view alone.
133
+ DEFAULT_LEGAL = [["Privacy Policy", :privacy], ["Terms of Service", :terms]].freeze
134
+
135
+ def default_facts(view, routes: nil)
136
+ legal = DEFAULT_LEGAL.filter_map do |label, route_name|
137
+ path = host_path(view, route_name, routes)
138
+ [label, path] unless path.nil?
139
+ end
140
+ { legal: legal }
141
+ end
142
+
143
+ # The path of the host's route `name`, or nil unless the view can build it
144
+ # with no arguments and (when the route set is given) a GET reaches it.
145
+ def host_path(view, name, routes = nil)
146
+ helper = :"#{name}_path"
147
+ return nil unless view.respond_to?(helper)
148
+ return nil unless routes.nil? || get_route?(routes, name)
149
+
150
+ text(view.public_send(helper))
151
+ rescue StandardError
152
+ nil
153
+ end
154
+
155
+ def get_route?(routes, name)
156
+ route = routes.named_routes[name]
157
+ return false if route.nil? || route.required_parts.any?
158
+
159
+ verbs = route.verb.to_s
160
+ verbs.empty? || verbs.split("|").include?("GET")
161
+ rescue StandardError
162
+ false
163
+ end
164
+
165
+ # The normalized facts, or nil when there is no footer (the declared value,
166
+ # or what its callable answered, is nil or false).
116
167
  #
117
168
  # name: the site name the engine resolved (the site identity's title)
118
169
  # logo: the logo the engine resolved (the navbar logo), or nil
119
170
  # booking_path: the booking page's path (Studio.booking_path_for), or nil
120
- def resolve(declared, view, name: nil, logo: nil, booking_path: nil)
171
+ # address: Studio.site_footer_address: the address of facts that
172
+ # carry none of their own
173
+ def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil)
121
174
  raw = declared.respond_to?(:call) ? declared.call(view) : declared
122
- return nil if raw.nil?
175
+ return nil if raw.nil? || raw == false
123
176
 
124
177
  facts = symbolize(raw)
125
178
  site_name = text(facts[:name]) || text(name)
@@ -133,7 +186,7 @@ module Studio
133
186
  home_path: safe_href(facts[:home_path], "home_path") || "/",
134
187
  tagline: text(facts[:tagline]),
135
188
  email: text(facts[:email]),
136
- address: address(facts),
189
+ address: address_for(facts, address),
137
190
  social: Array(facts[:social]).filter_map { |row| social(row) },
138
191
  columns: Array(facts[:columns]).filter_map { |row| column(row, booking_path) },
139
192
  legal: Array(facts[:legal]).filter_map { |row| link(row, booking_path) },
@@ -205,7 +258,7 @@ module Studio
205
258
  # no address at all: no Location band and no map.
206
259
  def address(facts)
207
260
  declared = facts[:address]
208
- source = declared.nil? ? facts : symbolize(declared)
261
+ source = declared.nil? || declared == false ? facts : symbolize(declared)
209
262
  street = text(source[:street])
210
263
  city_line = text(source[:city_line])
211
264
  return nil if street.nil? && city_line.nil?
@@ -228,6 +281,16 @@ module Studio
228
281
  }
229
282
  end
230
283
 
284
+ # The facts' own address, else the configured one (Studio.site_footer_address).
285
+ # The facts win: an address of their own is used whole, and an `address:` key
286
+ # they wrote, even nil or false, is their answer.
287
+ def address_for(facts, configured)
288
+ own = address(facts)
289
+ return own if !own.nil? || facts.key?(:address) || configured.nil? || configured == false
290
+
291
+ address(symbolize(configured))
292
+ end
293
+
231
294
  def coordinate(value)
232
295
  return nil if value.nil? || value.to_s.strip.empty?
233
296
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.85.0"
2
+ VERSION = "0.87.0"
3
3
  end