studio-engine 0.84.0 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -0
- data/README.md +162 -1
- data/app/controllers/magic_links_controller.rb +1 -1
- data/app/controllers/registrations_controller.rb +4 -2
- data/app/helpers/studio/site_footer_helper.rb +9 -0
- data/app/views/registrations/new.html.erb +59 -36
- data/app/views/sessions/_sso_continue.html.erb +1 -1
- data/app/views/sessions/new.html.erb +6 -3
- data/app/views/studio/_confirm_interstitial.html.erb +1 -1
- data/app/views/studio/site_footer/_assets.html.erb +27 -159
- data/app/views/studio/site_footer/_map.html.erb +4 -4
- data/app/views/studio/site_footer/_map_assets.html.erb +157 -0
- data/docs/BOOKING.md +377 -0
- data/docs/SITE_FOOTER.md +375 -0
- data/lib/active_storage/service/studio_trash_s3_service.rb +64 -0
- data/lib/studio/auth_labels.rb +72 -0
- data/lib/studio/engine.rb +81 -2
- data/lib/studio/s3/trash.rb +248 -0
- data/lib/studio/s3.rb +56 -3
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +34 -0
- data/lib/tasks/studio_trash.rake +83 -0
- data/studio-engine.gemspec +8 -1
- metadata +9 -2
data/docs/SITE_FOOTER.md
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
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
|
+
```ruby
|
|
18
|
+
# config/initializers/studio.rb
|
|
19
|
+
Studio.configure do |config|
|
|
20
|
+
config.site_footer = ->(view) {
|
|
21
|
+
{
|
|
22
|
+
tagline: "Everything, by example",
|
|
23
|
+
address: { street: "123 Example St", city_line: "Washington, DC 20024",
|
|
24
|
+
lat: 38.8894, lng: -77.0352 },
|
|
25
|
+
social: [ [ "LinkedIn", :linkedin, "https://www.linkedin.com/in/someone/" ],
|
|
26
|
+
[ "Instagram", :instagram, nil ] ],
|
|
27
|
+
columns: [
|
|
28
|
+
[ "Contact", [ [ "team@example.com", "mailto:team@example.com" ],
|
|
29
|
+
[ "Schedule a call", view.studio_booking_path ] ] ],
|
|
30
|
+
[ "Company", [ [ "Home", view.root_path ], [ "Career", nil ],
|
|
31
|
+
[ "Blog", "https://blog.example.com" ] ] ]
|
|
32
|
+
],
|
|
33
|
+
legal: [ [ "Privacy Policy", view.privacy_path ], [ "Terms of Service", view.terms_path ] ]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
config.site_footer_controllers = %w[landing packages]
|
|
37
|
+
config.booking_url = "https://calendar.google.com/calendar/appointments/schedules/EXAMPLE-SCHEDULE-ID"
|
|
38
|
+
config.draw_booking_routes = true
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```erb
|
|
43
|
+
<%# app/views/layouts/application.html.erb, at the end of <body> %>
|
|
44
|
+
<%= studio_site_footer %>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That is the whole adoption. Nothing is copied into the app's `public/`, and
|
|
48
|
+
nothing is added to its asset manifest or its Tailwind build.
|
|
49
|
+
|
|
50
|
+
## An app with no database
|
|
51
|
+
|
|
52
|
+
An app with no ActiveRecord (a public site with no accounts, such as rantly)
|
|
53
|
+
can take the engine for its footer alone, with nothing else to set:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# Gemfile
|
|
57
|
+
gem "studio-engine" # pin the first release whose CHANGELOG lists footer-only support
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# config/application.rb: the app's own framework choice, unchanged
|
|
62
|
+
require "rails"
|
|
63
|
+
require "active_model/railtie"
|
|
64
|
+
require "action_controller/railtie"
|
|
65
|
+
require "action_view/railtie"
|
|
66
|
+
# no active_record/railtie
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# config/initializers/studio.rb
|
|
71
|
+
Studio.configure do |config|
|
|
72
|
+
config.site_footer = ->(view) { { name: "Example", columns: [ [ "About", [ [ "Home", view.root_path ] ] ] ] } }
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```erb
|
|
77
|
+
<%# app/views/layouts/application.html.erb, at the end of <body> %>
|
|
78
|
+
<%= studio_site_footer %>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Do not call `Studio.routes(self)`: everything it draws (sign-in, admin, error
|
|
82
|
+
logs) needs a database. The app also needs an asset pipeline (Propshaft or
|
|
83
|
+
Sprockets), as every Rails 8 app has, and a mapping of the theme tokens onto its
|
|
84
|
+
own palette (see [Colours](#colours)).
|
|
85
|
+
|
|
86
|
+
When the app loads no ActiveRecord (`Studio.active_record?` reads false, because
|
|
87
|
+
`ActiveRecord::Railtie` is not defined), the engine itself:
|
|
88
|
+
|
|
89
|
+
- requires the ActiveSupport core extensions it calls at load time
|
|
90
|
+
(`Integer#minutes` and the like), so the app need not;
|
|
91
|
+
- skips the user-contract check ([`USER_CONTRACT.md`](USER_CONTRACT.md)), even
|
|
92
|
+
when the app defines a `::User` of its own: the contract is what the engine's
|
|
93
|
+
database-backed surfaces call on a user record, and the app has none;
|
|
94
|
+
- keeps every one of its `app/` roots but `app/helpers` out of eager loading
|
|
95
|
+
(controllers, models, mailers, jobs, services, and the two `concerns` roots,
|
|
96
|
+
which Zeitwerk treats as roots of their own), so a production boot does not
|
|
97
|
+
load code that needs ActiveRecord, ActionMailer or ActiveJob. The helpers,
|
|
98
|
+
the footer's among them, still eager load;
|
|
99
|
+
- answers `bin/rails studio_engine:install:migrations` with a no-op that exits 0
|
|
100
|
+
and writes nothing, which is what the hub's release sweep runs in every member
|
|
101
|
+
after an engine publish.
|
|
102
|
+
|
|
103
|
+
An app WITH ActiveRecord sees none of this change.
|
|
104
|
+
`test/integration/footer_only_consumer_test.rb` boots such an app, in
|
|
105
|
+
development and with `eager_load = true`.
|
|
106
|
+
|
|
107
|
+
## The facts
|
|
108
|
+
|
|
109
|
+
`config.site_footer` is a Hash, or a callable that receives the view (so it can
|
|
110
|
+
use route helpers). `nil`, the default, means the app has no footer and
|
|
111
|
+
`studio_site_footer` renders nothing.
|
|
112
|
+
|
|
113
|
+
Every key is optional. A missing key removes its part of the footer.
|
|
114
|
+
|
|
115
|
+
| Key | What it prints | When absent |
|
|
116
|
+
|-----|----------------|-------------|
|
|
117
|
+
| `name` | The wordmark, the logo's label and the © line | The site identity's title (`Studio.site_identity[:title]`) |
|
|
118
|
+
| `wordmark` | Two parts, the second in the primary colour: `%w[Example Co]` | `name`, split before its last word |
|
|
119
|
+
| `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 |
|
|
120
|
+
| `logo_invert` | `true` inverts a dark mark in light mode | Shown as drawn |
|
|
121
|
+
| `home_path` | Where the logo and wordmark link | `/` |
|
|
122
|
+
| `tagline` | A line under the wordmark | No line |
|
|
123
|
+
| `email` | A `mailto:` link under the tagline | No line. (To list the email in a column instead, write it as a column link.) |
|
|
124
|
+
| `address` | The Location band, and the map | No Location band, no map, and Leaflet is never requested |
|
|
125
|
+
| `social` | A row of round icons | No row |
|
|
126
|
+
| `columns` | Link columns under bold headings. A column may carry a width hint: `[ "Contact", links, { width: 1.5 } ]` | Brand only |
|
|
127
|
+
| `legal` | The centred line above the © | The © alone |
|
|
128
|
+
| `booking` | `{ label:, title: }` for the popup's heading and the frame's accessible name | "Schedule a call", and "Schedule a call with <name>" |
|
|
129
|
+
|
|
130
|
+
Rows may be tuples, as above, or hashes (`{ label:, href: }`, `{ heading:, links: }`,
|
|
131
|
+
`{ label:, icon:, url: }`). The rules a row can carry:
|
|
132
|
+
|
|
133
|
+
- **A link with a `nil` path** renders its label disabled ("Coming soon"). Use it
|
|
134
|
+
for a page that does not exist yet.
|
|
135
|
+
- **A link to an `http(s)` URL** opens in a new tab.
|
|
136
|
+
- **A booking link** opens the booking popup instead of leaving the page. A link
|
|
137
|
+
is a booking link when its path is the booking page (the engine's
|
|
138
|
+
`studio_booking_path`, or the app's own `config.booking_path`), or when it
|
|
139
|
+
says so:
|
|
140
|
+
`[ "Schedule a call", contact_path, { booking: true } ]`. Its href stays as the
|
|
141
|
+
fallback.
|
|
142
|
+
- **Only some hrefs are linked.** A link, a social URL, `home_path` and
|
|
143
|
+
`directions_url` may be a relative path or an `http:`, `https:`, `mailto:` or
|
|
144
|
+
`tel:` URL. Anything else, `javascript:` and `data:` included, is never written
|
|
145
|
+
into the page: a link or a social icon prints unlinked, `home_path` falls back
|
|
146
|
+
to `/`, and `directions_url` falls back to the Google Maps default. Each
|
|
147
|
+
refused value is logged once (`[studio.site_footer] ... is not linked`).
|
|
148
|
+
- **The columns sit on a grid with fixed rows.** Below 768px the brand runs
|
|
149
|
+
across the top and the link columns sit two to a row. From 768px every link
|
|
150
|
+
column is in one row under the brand. From 1024px the brand is in that row
|
|
151
|
+
too. A footer with more than four link columns keeps the brand across the top
|
|
152
|
+
and wraps the columns four to a row.
|
|
153
|
+
- **An email address or a URL in a column is never broken mid-word.** A label
|
|
154
|
+
that is an address (no space, and an `@`, a `.` or a `/`) is kept on one line,
|
|
155
|
+
and its track is at least as wide as it is: the column grows to hold it and
|
|
156
|
+
the columns beside it give way. No column moves to another row. On the
|
|
157
|
+
smallest phones (under 360px) the gutter narrows and the address is set a
|
|
158
|
+
little smaller, which holds an address of about 25 characters beside a second
|
|
159
|
+
column at 320px; a longer one runs past the footer's edge there. Under 300px,
|
|
160
|
+
narrower than any phone, the address is allowed to break.
|
|
161
|
+
- **`width:` is a column's track**, as a share of the row, from 768px:
|
|
162
|
+
`{ width: 2 }` as a tuple's third element, or `width:` in a hash row. A number
|
|
163
|
+
from 0.5 to 4; anything else is ignored. Without one, the first column is 1.5
|
|
164
|
+
and the rest are 1 (the brand, from 1024px, is 1.7). Below 768px the two tracks
|
|
165
|
+
are fixed at 1.2 and 1, and with more than four columns the hints are not used.
|
|
166
|
+
- **A social profile with a `nil` URL** renders its icon unlinked, with a dashed
|
|
167
|
+
outline. The engine draws `:linkedin`, `:instagram`, `:x`, `:facebook` and
|
|
168
|
+
`:youtube`; any other icon falls back to the label's first letter.
|
|
169
|
+
|
|
170
|
+
### The address and the map
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
address: { street: "123 Example St", city_line: "Washington, DC 20024",
|
|
174
|
+
lat: 38.8894, lng: -77.0352,
|
|
175
|
+
zoom: 15, # optional, default 15
|
|
176
|
+
directions_url: "https://maps.app/..." } # optional
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- `street` or `city_line` gives the Location band. Without `directions_url` the
|
|
180
|
+
address links to Google Maps directions for it.
|
|
181
|
+
- `lat` and `lng`, both, give the map. An address without them keeps the band
|
|
182
|
+
and has no map.
|
|
183
|
+
- The map is [Leaflet](https://leafletjs.com) 1.9.4 on OpenStreetMap's keyless
|
|
184
|
+
tiles (`tile.openstreetmap.org`). No account and no API key.
|
|
185
|
+
- Dark mode filters the same tiles in CSS. There is no second tile set.
|
|
186
|
+
- With scripts off, or if Leaflet fails to load, the map element is a link to
|
|
187
|
+
directions.
|
|
188
|
+
- Nothing is requested before the window's `load` event, and nothing until the
|
|
189
|
+
map is within 400px of the viewport. A visit that never scrolls to the footer
|
|
190
|
+
fetches neither Leaflet nor a tile.
|
|
191
|
+
- On a touch device one finger scrolls the page, not the map. The map runs edge
|
|
192
|
+
to edge, so a swipe that began on it would otherwise trap the visitor. Pinch
|
|
193
|
+
still zooms, and the zoom buttons and the directions link remain. With a
|
|
194
|
+
mouse the map drags, and the wheel zooms only after a click on the map.
|
|
195
|
+
|
|
196
|
+
`studio_footer_map class: "h-80"` renders the map alone, for a contact page. It
|
|
197
|
+
maps the footer's address; pass `lat:`/`lng:` to map another. The element has no
|
|
198
|
+
height of its own, so `class:` or `style:` sizes it.
|
|
199
|
+
|
|
200
|
+
Leaflet numbers its own panes and controls up to `z-index: 1000`. The map
|
|
201
|
+
element is its own stacking context (`isolation: isolate`), so none of that
|
|
202
|
+
competes with the navbar or a modal. If you mount Leaflet yourself on another
|
|
203
|
+
element, isolate that element too.
|
|
204
|
+
|
|
205
|
+
Leaflet is served by the engine through the app's asset pipeline
|
|
206
|
+
(`studio/leaflet.js`, `studio/leaflet.css`, both in the engine's precompile
|
|
207
|
+
list), so it works on Heroku with no extra step in either a Sprockets or a
|
|
208
|
+
Propshaft app. The engine's copy of the stylesheet drops Leaflet's default marker
|
|
209
|
+
image and its layers-control image, which are not shipped: mark a point with
|
|
210
|
+
`L.divIcon`, as the footer does.
|
|
211
|
+
|
|
212
|
+
## Where it shows
|
|
213
|
+
|
|
214
|
+
`studio_site_footer` asks `config.site_footer_visible`, one callable that
|
|
215
|
+
receives the view. Its default:
|
|
216
|
+
|
|
217
|
+
- **A visitor** (signed out) sees the footer on every page.
|
|
218
|
+
- **A signed-in viewer** sees it only on the controllers in
|
|
219
|
+
`config.site_footer_controllers`, by controller name (`"landing"`) or path
|
|
220
|
+
(`"admin/reports"`). Every other signed-in page is a working surface and stays
|
|
221
|
+
full height. The booking page is always included: the engine's `/schedule`,
|
|
222
|
+
or the app's own when `config.booking_path` names it.
|
|
223
|
+
|
|
224
|
+
The default shows the footer on any page a signed-out visitor can reach,
|
|
225
|
+
including an app page that happens to be public. Narrow it by replacing the
|
|
226
|
+
callable, composing with the default rather than restating it:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
config.site_footer_visible = ->(view) {
|
|
230
|
+
Studio::SiteFooter.default_visible?(view) && !view.controller_path.start_with?("games/")
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`studio_show_site_footer?` gives a view the same answer.
|
|
235
|
+
|
|
236
|
+
## Booking
|
|
237
|
+
|
|
238
|
+
With `config.booking_url` set, the footer renders the booking popup, and a
|
|
239
|
+
booking link in a column opens it instead of leaving the page (the rule is under
|
|
240
|
+
[The facts](#the-facts)). `booking: { label:, title: }` in the facts sets what the
|
|
241
|
+
links, the popup and the booking page call the act.
|
|
242
|
+
|
|
243
|
+
The frame, its crop, the popup, the `/schedule` page, an app's own booking page
|
|
244
|
+
(`config.booking_path`) and how to set the schedule up in Google are in
|
|
245
|
+
[`BOOKING.md`](BOOKING.md).
|
|
246
|
+
|
|
247
|
+
## What it costs a page
|
|
248
|
+
|
|
249
|
+
- The footer's CSS and scripts are inline, rendered once per page, and scoped
|
|
250
|
+
(`.ftr-*`, `.booking-*`). They read the theme's custom properties with a
|
|
251
|
+
fallback beside each, so the footer lays out the same in an app whose Tailwind
|
|
252
|
+
build has never seen these views. Do not restyle it with utilities that your
|
|
253
|
+
build may not emit.
|
|
254
|
+
- A page with no address never names Leaflet: the map's styles and its mount
|
|
255
|
+
script are rendered only with a map (`studio/site_footer/_map_assets`). A page
|
|
256
|
+
with no `booking_url` carries no booking script.
|
|
257
|
+
- A facts callable that raises costs the footer, not the page: in production the
|
|
258
|
+
error is logged once and the page renders without a footer. In development and
|
|
259
|
+
test it raises.
|
|
260
|
+
|
|
261
|
+
## Colours
|
|
262
|
+
|
|
263
|
+
The footer reads the engine's theme tokens, with a fallback beside each:
|
|
264
|
+
|
|
265
|
+
| Token | Paints |
|
|
266
|
+
|-------|--------|
|
|
267
|
+
| `--color-surface-alt` | The band's background |
|
|
268
|
+
| `--color-text-body` | The band's text |
|
|
269
|
+
| `--color-text` | The wordmark, the headings and the Location title |
|
|
270
|
+
| `--color-text-secondary` | The links (through `--ftr-link-ink`) and the tagline |
|
|
271
|
+
| `--color-text-muted` | The © line |
|
|
272
|
+
| `--color-primary`, `--color-primary-500-rgb` | The wordmark accent, link hover, social icons and the map tint |
|
|
273
|
+
| `--color-border` | The rules |
|
|
274
|
+
|
|
275
|
+
**Links are painted at full opacity** in `--ftr-link-ink`, which defaults to
|
|
276
|
+
`--color-text-secondary`. On the engine's default theme that ink clears WCAG AA
|
|
277
|
+
4.5:1 on the band in both themes (`test/lib/site_footer_link_contrast_test.rb`
|
|
278
|
+
measures it). An app that maps the tokens onto its own palette owes the same:
|
|
279
|
+
set `--color-text-secondary` to an ink that clears 4.5:1 on its
|
|
280
|
+
`--color-surface-alt`, in light and dark. To give the footer's links a colour of
|
|
281
|
+
their own without touching the token, set `--ftr-link-ink` on the footer:
|
|
282
|
+
|
|
283
|
+
```css
|
|
284
|
+
footer[data-site-footer] { --ftr-link-ink: #4a4a4a; }
|
|
285
|
+
@media (prefers-color-scheme: dark) { footer[data-site-footer] { --ftr-link-ink: #c8c8c8; } }
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Content Security Policy
|
|
289
|
+
|
|
290
|
+
An app that enforces a policy must allow what the footer fetches from other
|
|
291
|
+
origins (the booking frame's `frame-src` is in [`BOOKING.md`](BOOKING.md#content-security-policy)):
|
|
292
|
+
|
|
293
|
+
| Directive | Source | For |
|
|
294
|
+
|-----------|--------|-----|
|
|
295
|
+
| `img-src` | `https://tile.openstreetmap.org` | The map's tiles |
|
|
296
|
+
|
|
297
|
+
Leaflet itself is same-origin (`script-src 'self'`, `style-src 'self'`), since it
|
|
298
|
+
is served from the app's own assets.
|
|
299
|
+
|
|
300
|
+
The footer's own `<style>` and `<script>` blocks are
|
|
301
|
+
inline and carry no nonce, like the rest of the engine's partials, and Leaflet
|
|
302
|
+
positions its tiles with inline `style` attributes. A policy that forbids inline
|
|
303
|
+
script or style needs `'unsafe-inline'` for them (or hashes), exactly as it does
|
|
304
|
+
for the engine's head partial today.
|
|
305
|
+
|
|
306
|
+
## Coexisting with an app's own copy
|
|
307
|
+
|
|
308
|
+
An app that still renders a local footer partial uses the same
|
|
309
|
+
`data-footer-map` attribute. The engine's script keeps out of its way: its guard
|
|
310
|
+
is an engine name (`__studioFooterMapsArmed`), and it acts only on
|
|
311
|
+
engine-rendered maps, which carry `data-leaflet-js`. The booking scripts follow
|
|
312
|
+
the same rule ([`BOOKING.md`](BOOKING.md#coexisting-with-an-apps-own-copy)).
|
|
313
|
+
|
|
314
|
+
## Markup hooks
|
|
315
|
+
|
|
316
|
+
Stable, for tests and for an app's own scripts:
|
|
317
|
+
`footer[data-site-footer]`, `[data-footer-location]`, `[data-footer-map]`. The
|
|
318
|
+
booking hooks are in [`BOOKING.md`](BOOKING.md#markup-hooks).
|
|
319
|
+
|
|
320
|
+
## Class hooks
|
|
321
|
+
|
|
322
|
+
These class names are stable, and an app may style against them:
|
|
323
|
+
|
|
324
|
+
| Class | Element |
|
|
325
|
+
|-------|---------|
|
|
326
|
+
| `.ftr` | The footer |
|
|
327
|
+
| `.ftr-cols` | The grid of brand and link columns; `--ftr-tracks` is its link-column tracks. `.ftr-cols-many` with more than four |
|
|
328
|
+
| `.ftr-brand` | The brand block |
|
|
329
|
+
| `.ftr-col` | One link column (a `nav`) |
|
|
330
|
+
| `.ftr-heading`, `.ftr-list` | A column's heading and its list |
|
|
331
|
+
| `.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 |
|
|
332
|
+
| `.ftr-location`, `.ftr-location-title`, `.ftr-address` | The Location band |
|
|
333
|
+
| `.ftr-legal`, `.ftr-copyright` | The legal line and the © line |
|
|
334
|
+
| `.ftr-map` | The map |
|
|
335
|
+
|
|
336
|
+
The rest (`.ftr-wrap`, `.ftr-home`, the social and pin classes) may change.
|
|
337
|
+
|
|
338
|
+
The footer's stylesheet is inline and unlayered, and comes after the app's own
|
|
339
|
+
in the document, so an app rule needs more specificity to win: prefix it with
|
|
340
|
+
`footer[data-site-footer]`.
|
|
341
|
+
|
|
342
|
+
### An app that overrode 0.83.0's tracks
|
|
343
|
+
|
|
344
|
+
0.83.0 laid the columns on equal `minmax(0, 1fr)` tracks, and an app could only
|
|
345
|
+
fix the broken address from its own stylesheet, with `grid-template-columns` on
|
|
346
|
+
`footer[data-site-footer] .ftr-cols` and `overflow-wrap: normal` on the mailto
|
|
347
|
+
link. Those rules are more specific than the engine's and still apply.
|
|
348
|
+
|
|
349
|
+
- **Left in place** they draw the same rows as the engine now does, with the
|
|
350
|
+
address whole (a browser spec holds that, at 320, 390, 768, 1024 and 1280px).
|
|
351
|
+
Nothing breaks if the block outlives the upgrade.
|
|
352
|
+
- **Delete the block once the app is on this version.** Not before: on 0.83.0 it
|
|
353
|
+
is the only thing keeping the address whole. While it stays it hides the
|
|
354
|
+
engine's tracks, so a `width:` hint does nothing and the narrow-phone gutter is
|
|
355
|
+
the only engine rule still acting on the row.
|
|
356
|
+
|
|
357
|
+
## Tests
|
|
358
|
+
|
|
359
|
+
- `test/lib/studio/site_footer_test.rb`: the facts' rules and the visibility rule.
|
|
360
|
+
- `test/lib/vendored_leaflet_test.rb`: Leaflet is vendored, precompiled, and
|
|
361
|
+
addressed through the asset pipeline.
|
|
362
|
+
- `test/integration/site_footer_test.rb`: what a host renders, with and without
|
|
363
|
+
each fact, signed in and out.
|
|
364
|
+
- `test/integration/footer_only_consumer_test.rb`: an app with no ActiveRecord
|
|
365
|
+
boots (development and eager-loaded), renders the footer with no Leaflet, and
|
|
366
|
+
runs `studio_engine:install:migrations` to a clean exit.
|
|
367
|
+
- `test/lib/site_footer_link_contrast_test.rb`: the link ink clears 4.5:1 on the
|
|
368
|
+
band, on the default tokens, in both themes.
|
|
369
|
+
- `e2e/site_footer.spec.js`: the map's script and the footer's layout, in a
|
|
370
|
+
browser, with the network closed: which columns share a row at 320, 390, 768,
|
|
371
|
+
1024 and 1280px with two, three, four and five columns, the address on one
|
|
372
|
+
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
|
|
373
|
+
covered.
|
|
374
|
+
|
|
375
|
+
`/admin/style` shows the footer under Tricks, with this app's facts or a sample.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/service/s3_service"
|
|
4
|
+
require_relative "../../studio/s3"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
# Active Storage's S3 service with a three-day grace period on delete. An app
|
|
8
|
+
# opts in from config/storage.yml by naming it instead of S3:
|
|
9
|
+
#
|
|
10
|
+
# r2_production:
|
|
11
|
+
# service: StudioTrashS3
|
|
12
|
+
# bucket: turf-monster-production
|
|
13
|
+
# endpoint: <%= ENV["R2_ENDPOINT"] %>
|
|
14
|
+
# ...
|
|
15
|
+
#
|
|
16
|
+
# Active Storage resolves `service: StudioTrashS3` by requiring
|
|
17
|
+
# "active_storage/service/studio_trash_s3_service", which is why this file
|
|
18
|
+
# lives at lib/active_storage/service/ on the gem's load path. Nothing
|
|
19
|
+
# autoloads it; Zeitwerk never sees lib/.
|
|
20
|
+
#
|
|
21
|
+
# delete(key) — what Blob#purge and a replaced attachment end in — MOVES the
|
|
22
|
+
# object under trash/ (Studio::S3::Trash) instead of deleting it. By the time
|
|
23
|
+
# it runs the blob row is already destroyed, so what the trash copy records
|
|
24
|
+
# about the blob (content type, byte size, checksum, filename when the upload
|
|
25
|
+
# carried one) is read back off the object itself.
|
|
26
|
+
#
|
|
27
|
+
# delete_prefixed(prefix) stays a HARD delete. Active Storage calls it for
|
|
28
|
+
# "variants/<key>/": derived images, regenerated on demand from the original,
|
|
29
|
+
# which is the object that matters and the one trashed.
|
|
30
|
+
#
|
|
31
|
+
# Both refuse a "*-production" bucket from a non-production process.
|
|
32
|
+
class Service::StudioTrashS3Service < Service::S3Service
|
|
33
|
+
def delete(key)
|
|
34
|
+
instrument :delete, key: key do
|
|
35
|
+
Studio::S3.guard_production_bucket!(bucket.name)
|
|
36
|
+
Studio::S3::Trash.trash!(client: client.client, bucket: bucket.name, key: key,
|
|
37
|
+
env: Studio::S3.deletion_environment)
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
# Logged, then RE-RAISED. Blob#purge has already destroyed the row, so a
|
|
40
|
+
# raise here leaks the object rather than losing it: the failure that
|
|
41
|
+
# costs storage, never the one that costs a user's file.
|
|
42
|
+
log_trash_failure(key, error)
|
|
43
|
+
raise
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete_prefixed(prefix)
|
|
48
|
+
Studio::S3.guard_production_bucket!(bucket.name)
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def log_trash_failure(key, error)
|
|
55
|
+
message = "[StudioTrashS3] trash of #{bucket.name}/#{key} failed, object left in place: " \
|
|
56
|
+
"#{error.class}: #{error.message}"
|
|
57
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
58
|
+
Rails.logger.error(message)
|
|
59
|
+
else
|
|
60
|
+
warn message
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The words the engine's own auth pages say for "sign in", derived from the one
|
|
4
|
+
# setting the navbar already reads, Studio.sign_in_label (default "Log in";
|
|
5
|
+
# lib/studio/navbar_identity.rb). An app that says "Sign in" in its navbar gets
|
|
6
|
+
# "Sign in" on /login, /signup and the magic-link pages too, never a mix.
|
|
7
|
+
#
|
|
8
|
+
# Every form is derived from the label as written:
|
|
9
|
+
#
|
|
10
|
+
# label "Sign in" link "Sign in" button "Sign In"
|
|
11
|
+
# prompt "Sign in to continue"
|
|
12
|
+
# to_app "Sign in to Cyvasse"
|
|
13
|
+
# or_below "or sign in below"
|
|
14
|
+
# link_noun "sign-in link"
|
|
15
|
+
#
|
|
16
|
+
# THE DEFAULT IS PINNED, NOT DERIVED. With the default label the pages print
|
|
17
|
+
# exactly what they printed before this setting reached them, and that text was
|
|
18
|
+
# itself mixed: "Log in to continue" and "Log In" beside "Sign in to <App>",
|
|
19
|
+
# "or sign in below" and "sign-in link". So the three forms that said "sign in"
|
|
20
|
+
# under the default keep saying it while the label is the default, and follow the
|
|
21
|
+
# label only once an app configures another one. An app that never configures
|
|
22
|
+
# the label renders byte-identical pages (test/integration/auth_page_labels_render_test.rb).
|
|
23
|
+
#
|
|
24
|
+
# Pure Ruby, so the unit suite covers it without the dummy app.
|
|
25
|
+
require_relative "navbar_identity"
|
|
26
|
+
|
|
27
|
+
module Studio
|
|
28
|
+
module AuthLabels
|
|
29
|
+
DEFAULT = NavbarIdentity::DEFAULT_SIGN_IN_LABEL
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def configured?(label)
|
|
34
|
+
label.to_s != DEFAULT
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The sign-up page's "Already have an account?" link.
|
|
38
|
+
def link(label)
|
|
39
|
+
label.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The login form's submit button, in title case: "Log In", "Sign In".
|
|
43
|
+
def button(label)
|
|
44
|
+
label.to_s.split(" ").map { |word| capitalize_first(word) }.join(" ")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The login page's line under the app name.
|
|
48
|
+
def prompt(label)
|
|
49
|
+
"#{label} to continue"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The magic-link confirm page's fallback button.
|
|
53
|
+
def to_app(label, app_name)
|
|
54
|
+
"#{configured?(label) ? label : 'Sign in'} to #{app_name}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The divider under the SSO "Continue as" button.
|
|
58
|
+
def or_below(label)
|
|
59
|
+
configured?(label) ? "or #{label.to_s.downcase} below" : "or sign in below"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The emailed link's noun: the magic-link button and the "sent" notice.
|
|
63
|
+
def link_noun(label)
|
|
64
|
+
configured?(label) ? "#{label.to_s.downcase.tr(' ', '-')} link" : "sign-in link"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only the first letter changes, so an already-capital word is untouched.
|
|
68
|
+
def capitalize_first(word)
|
|
69
|
+
word.sub(/\A\p{Ll}/) { |c| c.upcase }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
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
|
-
|
|
117
|
-
|
|
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
|