studio-engine 0.83.0 → 0.85.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.
@@ -11,20 +11,43 @@
11
11
  border-top: 1px solid var(--ftr-line); }
12
12
  .ftr a { transition: color .15s ease, opacity .15s ease; }
13
13
  .ftr-wrap { max-width: 72rem; margin-inline: auto; padding-inline: 1rem; }
14
- .ftr-link { color: inherit; opacity: .78; text-decoration: none; overflow-wrap: anywhere; }
14
+ .ftr-link { color: inherit; opacity: .78; text-decoration: none; }
15
+ /* A label that is an address (an email address, a URL) stays on one line: a browser
16
+ would otherwise wrap it after a hyphen or a slash. Its track is at least as
17
+ wide as it is (the grid below), and the tracks beside it give way. It is
18
+ broken only on a screen narrower than any phone, where no layout holds it. */
19
+ .ftr-link-solid { white-space: nowrap; }
20
+ @media (max-width: 299px) { .ftr-link-solid { white-space: normal; overflow-wrap: anywhere; } }
15
21
  .ftr-link-disabled { opacity: .38; cursor: not-allowed; }
16
22
  .ftr-link-plain { opacity: .78; }
17
23
  .ftr-link:hover, .ftr-link:focus-visible { opacity: 1; color: var(--ftr-primary); }
18
24
 
19
- /* Brand, then the link columns. Two tracks on a phone with the brand across
20
- both; from 768px the links sit in one row (at most four across); from
21
- 1024px the brand joins that row. --ftr-n is the number of link columns. */
22
- .ftr-cols { display: grid; gap: 3rem 2rem; padding-block: 4rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
25
+ /* Brand, then the link columns (.ftr-col), on a grid with explicit tracks.
26
+ below 768px: the brand across the top, the link columns two to a row
27
+ from 768px: the brand across the top, every link column in one row
28
+ from 1024px: the brand in that row too, at 1.7 shares
29
+ A track is `Nfr`, never `minmax(0, Nfr)`: an fr track is at least as wide as
30
+ its longest unbreakable word, so the column holding an email address grows
31
+ to hold it and its neighbours give way. Nothing moves to another row.
32
+ --ftr-tracks is one track per link column (Studio::SiteFooter.tracks): the
33
+ first is wider by default, and a column's `width:` hint sets its own.
34
+ A footer with more than four link columns (.ftr-cols-many) keeps the brand
35
+ across the top and wraps the columns four to a row. */
36
+ .ftr-cols { display: grid; gap: 3rem 2rem; padding-block: 4rem; grid-template-columns: 1.2fr 1fr; }
23
37
  .ftr-brand { grid-column: 1 / -1; }
24
- @media (min-width: 768px) { .ftr-cols { grid-template-columns: repeat(var(--ftr-n-md, 4), minmax(0, 1fr)); } }
38
+ /* The smallest phones (320px): two columns still share a row beside an email
39
+ address, by a narrower gutter and a slightly smaller address. */
40
+ @media (max-width: 359px) {
41
+ .ftr-cols { column-gap: 1rem; }
42
+ .ftr-col .ftr-link-solid { font-size: .875rem; }
43
+ }
44
+ @media (min-width: 768px) {
45
+ .ftr-cols { grid-template-columns: var(--ftr-tracks, 1fr); }
46
+ .ftr-cols-many { grid-template-columns: repeat(4, 1fr); }
47
+ }
25
48
  @media (min-width: 1024px) {
26
- .ftr-cols { grid-template-columns: minmax(0, 1.7fr) repeat(var(--ftr-n, 4), minmax(0, 1fr)); }
27
- .ftr-brand { grid-column: auto; }
49
+ .ftr-cols:not(.ftr-cols-many) { grid-template-columns: 1.7fr var(--ftr-tracks,); }
50
+ .ftr-cols:not(.ftr-cols-many) .ftr-brand { grid-column: auto; }
28
51
  }
29
52
  .ftr-home { display: inline-flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; }
30
53
  .ftr-logo { width: 3rem; height: 3rem; object-fit: contain; }
@@ -47,9 +70,9 @@
47
70
  .ftr-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
48
71
 
49
72
  .ftr-location { border-top: 1px solid var(--ftr-line); padding-block: 3.5rem 2rem; text-align: center; }
50
- .ftr-location-title { margin: 0; font-size: 1.875rem; font-weight: 800; letter-spacing: -.025em; color: var(--ftr-ink); }
73
+ .ftr-location-title { margin: 0; font-size: 1.875rem; line-height: 2.25rem; font-weight: 800; letter-spacing: -.025em; color: var(--ftr-ink); }
51
74
  .ftr-address { margin-top: 1rem; font-style: normal; line-height: 1.625; }
52
- .ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; }
75
+ .ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; line-height: 1.25rem; }
53
76
  .ftr-legal p { margin: 0; }
54
77
  .ftr-legal .ftr-copyright { margin-top: .5rem; color: var(--color-text-muted, inherit); }
55
78
  /* With no map between them, a rule separates the legal line from what is above. */
@@ -13,7 +13,8 @@
13
13
  <%= studio_site_footer_assets %>
14
14
  <footer class="ftr" data-site-footer>
15
15
  <div class="ftr-wrap">
16
- <div class="ftr-cols" style="--ftr-n: <%= columns.size %>; --ftr-n-md: <%= [ columns.size, 4 ].min.clamp(1, 4) %>;">
16
+ <% tracks = Studio::SiteFooter.tracks(columns) %>
17
+ <div class="ftr-cols<%= " ftr-cols-many" if columns.size > Studio::SiteFooter::MAX_ROW_COLUMNS %>"<% if tracks %> style="--ftr-tracks: <%= tracks %>"<% end %>>
17
18
  <div class="ftr-brand">
18
19
  <% if facts[:logo] || facts[:wordmark] %>
19
20
  <a href="<%= facts[:home_path] %>" class="ftr-home" aria-label="<%= facts[:name] %>">
@@ -29,7 +30,7 @@
29
30
  <p class="ftr-tagline"><%= facts[:tagline] %></p>
30
31
  <% end %>
31
32
  <% if facts[:email] %>
32
- <p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link"><%= facts[:email] %></a></p>
33
+ <p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link ftr-link-solid"><%= facts[:email] %></a></p>
33
34
  <% end %>
34
35
  <% if facts[:social].any? %>
35
36
  <ul class="ftr-socials" aria-label="Social profiles">
@@ -48,7 +49,7 @@
48
49
  </div>
49
50
 
50
51
  <% columns.each do |column| %>
51
- <nav aria-label="<%= column[:heading] || "Footer" %>">
52
+ <nav class="ftr-col" aria-label="<%= column[:heading] || "Footer" %>">
52
53
  <% if column[:heading] %>
53
54
  <h2 class="ftr-heading"><%= column[:heading] %></h2>
54
55
  <% end %>
@@ -8,7 +8,9 @@
8
8
  <% elsif link[:disabled] %>
9
9
  <span class="ftr-link-disabled" aria-disabled="true" title="Coming soon"><%= link[:label] %></span>
10
10
  <% else %>
11
- <%= link_to link[:label], link[:href], { class: "ftr-link" }
11
+ <%# An email address or a URL as a label is one word however many hyphens and
12
+ slashes it holds: .ftr-link-solid keeps it on one line. %>
13
+ <%= link_to link[:label], link[:href], { class: Studio::SiteFooter.solid_label?(link[:label]) ? "ftr-link ftr-link-solid" : "ftr-link" }
12
14
  .merge(link[:external] ? { target: "_blank", rel: "noopener" } : {})
13
15
  .merge(link[:booking] && booking ? { data: { booking_popup: true, studio_booking: true } } : {}) %>
14
16
  <% end %>
@@ -1,12 +1,16 @@
1
1
  <%# The Site footer and booking group of the Tricks board (admin/style).
2
2
  The primitives a public site ends with: the footer, its map, and the Google
3
- Calendar booking frame, popup and link (docs/SITE_FOOTER.md). Each card
3
+ Calendar booking frame, popup and link (docs/SITE_FOOTER.md,
4
+ docs/BOOKING.md). Each card
4
5
  gives the helper to call; the preview below them is the real footer partial,
5
6
  rendered with THIS app's facts when it has declared any and with a sample
6
7
  when it has not, so the group is never empty.
7
8
 
8
- The booking frame is described, not embedded: a style guide should not ask
9
- Google for a calendar every time an admin scrolls past. %>
9
+ The booking frame is DRAWN, not embedded: a style guide should not ask
10
+ Google for a calendar every time an admin scrolls past. Its two states, whole
11
+ and cropped, are quarter-scale drawings of a made-up schedule (a 720px page
12
+ whose box runs from 200 to 600), cropped by the same arithmetic the real
13
+ wrapper uses (Studio::Booking.crop). No real schedule's numbers are here. %>
10
14
  <%
11
15
  own_facts = studio_site_footer_facts
12
16
  sample = {
@@ -20,6 +24,12 @@
20
24
  }
21
25
  preview = own_facts || Studio::SiteFooter.resolve(sample, self, logo: Studio.logo_for("Footer Logo"))
22
26
  booking = studio_booking?
27
+ own_crop = studio_booking_crop
28
+ sample_crop = Studio::Booking.crop(top: 200, bottom: 600, frame_height: 720)
29
+ scale = 0.25
30
+ # The made-up page, top to bottom: [label, from, to, is it the box?].
31
+ sample_page = [ [ "Google's header", 0, 200, false ], [ "Select an appointment time", 200, 600, true ],
32
+ [ "Google's credit lines", 600, 720, false ] ]
23
33
  %>
24
34
  <section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
25
35
  <div class="space-y-1">
@@ -34,7 +44,8 @@
34
44
  This app:
35
45
  footer <strong class="text-body"><%= own_facts ? "declared" : "not declared" %></strong> &middot;
36
46
  booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> &middot;
37
- booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong>
47
+ booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong> &middot;
48
+ frame <strong class="text-body"><%= own_crop ? "cropped at rest" : "whole at rest" %></strong>
38
49
  </p>
39
50
  </div>
40
51
 
@@ -59,14 +70,26 @@
59
70
  <% end %>
60
71
  <% end %>
61
72
 
62
- <%= render layout: "style/specimen",
63
- locals: { klass: "studio_booking_frame",
64
- disabled: !booking, disabled_label: "no booking_url",
65
- usage: %(studio_booking_frame # crop: false shows Google's whole page at rest) } do %>
66
- <p class="text-sm text-secondary text-center">
67
- Google's booking page inline. Asked for only after the page has loaded and the frame is near
68
- the viewport; cropped to the slot picker until it is used.
69
- </p>
73
+ <% [ [ "studio_booking_frame", nil, "whole",
74
+ %(studio_booking_frame # whole at rest: the default, and crop: false),
75
+ "Whole at rest, the default. Google's page as Google draws it: its header, the slot picker, its credit lines." ],
76
+ [ "config.booking_crop", sample_crop, "cropped",
77
+ %(config.booking_crop = { top: 200, bottom: 600, frame_height: 720 } # bin/booking-crop-measure prints yours),
78
+ "Cropped at rest to the slot picker, from numbers measured on the app's own schedule. Opens to the whole page when it is used." ]
79
+ ].each do |klass, crop, state, usage, caption| %>
80
+ <%= render layout: "style/specimen",
81
+ locals: { klass: klass, disabled: !booking, disabled_label: "no booking_url", usage: usage } do %>
82
+ <div class="w-full space-y-3" data-booking-specimen="<%= state %>">
83
+ <div style="margin-inline: auto; width: 11rem; overflow: hidden; background: #fff; border-radius: .5rem; border: 1px solid rgb(127 127 127 / .35); height: <%= ((crop ? crop[:window] : 720) * scale).round %>px;">
84
+ <div style="margin-top: -<%= ((crop ? crop[:offset] : 0) * scale).round %>px;">
85
+ <% sample_page.each do |label, from, to, box| %>
86
+ <div style="box-sizing: border-box; height: <%= ((to - from) * scale).round %>px; display: flex; align-items: center; justify-content: center; padding: 0 .5rem; text-align: center; font-size: .625rem; line-height: 1.2; color: #374151;<%= box ? " margin: 0 .35rem; border: 1px solid rgb(17 24 39 / .45); border-radius: .35rem; font-weight: 700;" : " background: rgb(17 24 39 / .06);" %>"><%= label %></div>
87
+ <% end %>
88
+ </div>
89
+ </div>
90
+ <p class="text-sm text-secondary text-center"><%= caption %></p>
91
+ </div>
92
+ <% end %>
70
93
  <% end %>
71
94
  </div>
72
95
 
@@ -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
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Studio.booking_url — the app's Google Calendar appointment schedule — and the
4
- # two URLs the booking primitives derive from it. Pure Ruby. See
5
- # docs/SITE_FOOTER.md.
3
+ # Studio.booking_url — the app's Google Calendar appointment schedule — the two
4
+ # URLs the booking primitives derive from it, and the frame's crop
5
+ # (Studio.booking_crop). Pure Ruby. See docs/BOOKING.md.
6
6
  #
7
7
  # The configured value is the schedule's PUBLIC page, the URL Google gives you
8
8
  # to share, without `?gv=true`:
@@ -15,15 +15,175 @@ module Studio
15
15
  module Booking
16
16
  EMBED_PARAM = "gv=true"
17
17
 
18
+ # THE CROP. At rest the inline frame can show only Google's "Select an
19
+ # appointment time" box, with Google's header above it and its credit lines
20
+ # below clipped away. Where that box sits is a fact about ONE schedule (its
21
+ # header is as tall as its title, description and meeting line; its box is as
22
+ # tall as its fullest day), so the app measures its own and declares it:
23
+ #
24
+ # config.booking_crop = { top: 200, bottom: 600, frame_height: 720 }
25
+ #
26
+ # All three are pixels in Google's own page, at 640px wide or more:
27
+ #
28
+ # top: the box's top edge. Stable for a schedule.
29
+ # bottom: the box's bottom edge ON ITS FULLEST DAY. Not stable: the box
30
+ # grows a row per appointment slot, so it is shorter on a day
31
+ # with fewer slots left. Declare the tallest it gets.
32
+ # frame_height: the whole page's height on that fullest day. The frame is
33
+ # this tall once it opens.
34
+ #
35
+ # `bin/booking-crop-measure <booking url>` prints the Hash.
36
+ #
37
+ # The window is [top - CROP_MARGIN, bottom + CROP_MARGIN], held inside the
38
+ # frame. Because `bottom` is the tallest the box gets, a day with fewer slots
39
+ # shows a strip of Google's credit lines under a shorter box; it never cuts
40
+ # the box. A value that cannot be a crop (not a number, negative, bottom not
41
+ # below top, a box that ends outside the frame, a window that hides nothing)
42
+ # is reported once and the frame shows whole.
43
+ CROP_MARGIN = 6
44
+ CROP_KEYS = %i[top bottom frame_height].freeze
45
+ REPORTED_LIMIT = 100
46
+
47
+ class << self
48
+ # Where a refused crop is reported: a callable taking the message. The
49
+ # default writes one warning to the Rails log (or to stderr without Rails).
50
+ attr_writer :reporter
51
+
52
+ def reporter
53
+ @reporter ||= lambda do |message|
54
+ if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
55
+ ::Rails.logger.warn(message)
56
+ else
57
+ warn(message)
58
+ end
59
+ end
60
+ end
61
+
62
+ def reported
63
+ @reported ||= {}
64
+ end
65
+ end
66
+
18
67
  module_function
19
68
 
69
+ def validate_crop!(declared)
70
+ return if declared.nil? || declared == false || declared.is_a?(Hash)
71
+
72
+ raise ArgumentError,
73
+ "Studio.booking_crop must be nil, false, or a Hash of top:, bottom: and frame_height: " \
74
+ "(got #{declared.inspect}). See docs/BOOKING.md."
75
+ end
76
+
77
+ # The crop to render: { offset:, window:, frame_height: } in whole pixels, or
78
+ # nil for the whole frame at rest. `offset` is how far the frame is pulled up
79
+ # behind its wrapper and `window` is the wrapper's height.
80
+ def crop(declared)
81
+ return nil if declared.nil? || declared == false
82
+ return refuse_crop(declared, "it is not a Hash") unless declared.is_a?(Hash)
83
+
84
+ values = CROP_KEYS.to_h { |key| [key, declared[key] || declared[key.to_s]] }
85
+ missing = values.reject { |_key, value| value.is_a?(Numeric) && value.respond_to?(:round) && value.finite? }.keys
86
+ return refuse_crop(declared, "#{missing.join(', ')} must be a number") unless missing.empty?
87
+
88
+ top, bottom, frame_height = values.values_at(*CROP_KEYS).map(&:round)
89
+ return refuse_crop(declared, "top is negative") if top.negative?
90
+ return refuse_crop(declared, "bottom is not below top") unless bottom > top
91
+ return refuse_crop(declared, "bottom is outside the frame") if bottom > frame_height
92
+
93
+ offset = [top - CROP_MARGIN, 0].max
94
+ window = [bottom + CROP_MARGIN, frame_height].min - offset
95
+ return refuse_crop(declared, "the window is the whole frame") unless window < frame_height
96
+
97
+ { offset: offset, window: window, frame_height: frame_height }
98
+ end
99
+
100
+ # The crop as the custom properties the wrapper carries, so two frames with
101
+ # different crops can share a page (studio/booking/_assets reads them).
102
+ def crop_style(crop)
103
+ return nil if crop.nil?
104
+
105
+ "--booking-crop-offset: #{crop[:offset]}px; --booking-crop-window: #{crop[:window]}px; " \
106
+ "--booking-frame-height: #{crop[:frame_height]}px"
107
+ end
108
+
109
+ def refuse_crop(declared, why)
110
+ report_once("[studio.booking] booking crop #{declared.inspect} is ignored (#{why}); the frame shows whole. " \
111
+ "See docs/BOOKING.md.")
112
+ nil
113
+ end
114
+
115
+ # A LOCAL PATH, and nothing else, may be the booking page: it is written into
116
+ # hrefs (the footer's booking links, `studio_booking_link`). It begins with
117
+ # one "/" and carries no control character. That refuses a scheme
118
+ # ("javascript:", "https:") and a protocol-relative "//host", which a browser
119
+ # reads as another site.
120
+ def local_path?(path)
121
+ string = path.to_s
122
+ string.start_with?("/") && !string.start_with?("//") && !string.match?(/[\x00-\x1f\x7f\\]/)
123
+ end
124
+
125
+ # Studio.booking_path: nil, a local path, or a callable receiving the view.
126
+ def validate_path!(declared)
127
+ return if declared.nil? || declared.respond_to?(:call)
128
+ return if declared.is_a?(String) && (declared.strip.empty? || local_path?(declared.strip))
129
+
130
+ raise ArgumentError,
131
+ "Studio.booking_path must be nil, a path beginning with one \"/\", or a callable receiving the view " \
132
+ "(got #{declared.inspect}). See docs/BOOKING.md."
133
+ end
134
+
135
+ # The declared value as it is stored: a stripped path, a callable, or nil.
136
+ def normalize_path(declared)
137
+ validate_path!(declared)
138
+ return declared unless declared.is_a?(String)
139
+
140
+ declared.strip.empty? ? nil : declared.strip
141
+ end
142
+
143
+ # The booking page's path for a view: the app's own (`declared`, a path or
144
+ # a callable receiving the view), else the engine's /schedule when it is
145
+ # `drawn`, else nil. A callable may decline with nil. What a callable returns
146
+ # is held to the same rule as a declared String: anything but a local path is
147
+ # reported once and treated as no answer.
148
+ def path_for(declared, view, drawn: false)
149
+ unless declared.nil?
150
+ path = (declared.respond_to?(:call) ? declared.call(view) : declared).to_s.strip
151
+ return path if local_path?(path)
152
+
153
+ refuse_path(path) unless path.empty?
154
+ end
155
+
156
+ drawn && view.respond_to?(:studio_booking_path) ? view.studio_booking_path : nil
157
+ end
158
+
159
+ def refuse_path(path)
160
+ report_once("[studio.booking] booking_path #{path.inspect} is ignored: it is not a local path " \
161
+ "(one leading \"/\"). See docs/BOOKING.md.")
162
+ nil
163
+ end
164
+
165
+ def report_once(message)
166
+ seen = Studio::Booking.reported
167
+ return if seen.key?(message)
168
+
169
+ seen[message] = true if seen.size < REPORTED_LIMIT
170
+ Studio::Booking.reporter.call(message)
171
+ end
172
+
173
+ # True when `current` (a request path) is the page `declared` names. A query
174
+ # string, a fragment and a trailing slash on either side do not count.
175
+ def same_path?(current, declared)
176
+ a, b = [current, declared].map { |path| path.to_s.strip.sub(/[?#].*\z/m, "").sub(%r{(?<=.)/+\z}, "") }
177
+ !a.empty? && a == b
178
+ end
179
+
20
180
  def validate!(url)
21
181
  return if url.nil? || url.to_s.strip.empty?
22
182
  return if url.to_s.strip.match?(%r{\Ahttps://\S+\z})
23
183
 
24
184
  raise ArgumentError,
25
185
  "Studio.booking_url must be an https URL (got #{url.inspect}). Use the appointment " \
26
- "schedule's public link. See docs/SITE_FOOTER.md."
186
+ "schedule's public link. See docs/BOOKING.md."
27
187
  end
28
188
 
29
189
  # The public page, with any embed parameter removed. nil when unset.