studio-engine 0.82.0 → 0.83.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/README.md +3 -0
  4. data/app/assets/javascripts/studio/leaflet.js +42 -0
  5. data/app/assets/stylesheets/studio/leaflet.css +693 -0
  6. data/app/controllers/concerns/studio/link_preview_bots.rb +45 -2
  7. data/app/controllers/studio/bookings_controller.rb +22 -0
  8. data/app/controllers/studio/site_identities_controller.rb +15 -3
  9. data/app/helpers/studio/link_preview_helper.rb +54 -6
  10. data/app/helpers/studio/site_footer_helper.rb +180 -0
  11. data/app/models/studio/site_identity.rb +39 -3
  12. data/app/views/layouts/studio/_link_preview_tags.html.erb +12 -1
  13. data/app/views/studio/booking/_assets.html.erb +162 -0
  14. data/app/views/studio/booking/_frame.html.erb +41 -0
  15. data/app/views/studio/booking/_popup.html.erb +34 -0
  16. data/app/views/studio/bookings/show.html.erb +20 -0
  17. data/app/views/studio/site_footer/_assets.html.erb +209 -0
  18. data/app/views/studio/site_footer/_footer.html.erb +99 -0
  19. data/app/views/studio/site_footer/_link.html.erb +14 -0
  20. data/app/views/studio/site_footer/_map.html.erb +22 -0
  21. data/app/views/studio/site_footer/_social_icon.html.erb +23 -0
  22. data/app/views/style/_site_footer.html.erb +79 -0
  23. data/app/views/style/_tricks.html.erb +3 -0
  24. data/lib/generators/studio/site_identity/site_identity_generator.rb +6 -2
  25. data/lib/studio/booking.rb +47 -0
  26. data/lib/studio/engine.rb +2 -0
  27. data/lib/studio/link_preview.rb +91 -0
  28. data/lib/studio/site_footer.rb +294 -0
  29. data/lib/studio/version.rb +1 -1
  30. data/lib/studio.rb +105 -0
  31. metadata +18 -2
@@ -0,0 +1,79 @@
1
+ <%# The Site footer and booking group of the Tricks board (admin/style).
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
4
+ gives the helper to call; the preview below them is the real footer partial,
5
+ rendered with THIS app's facts when it has declared any and with a sample
6
+ when it has not, so the group is never empty.
7
+
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. %>
10
+ <%
11
+ own_facts = studio_site_footer_facts
12
+ sample = {
13
+ name: Studio.site_identity[:title], tagline: "What this app is, in a line",
14
+ email: "team@example.com",
15
+ address: { street: "123 Example St", city_line: "Washington, DC 20024", lat: 38.8894, lng: -77.0352 },
16
+ social: [ [ "LinkedIn", :linkedin, "https://www.linkedin.com/" ], [ "Instagram", :instagram, nil ], [ "X", :x, "https://x.com/" ] ],
17
+ columns: [ [ "Contact", [ [ "Schedule a call", "#site-footer", { booking: true } ], [ "Contact", "#site-footer" ] ] ],
18
+ [ "Company", [ [ "Home", "/" ], [ "Career", nil ], [ "Blog", "https://example.com" ] ] ] ],
19
+ legal: [ [ "Privacy Policy", "#site-footer" ], [ "Terms of Service", "#site-footer" ] ]
20
+ }
21
+ preview = own_facts || Studio::SiteFooter.resolve(sample, self, logo: Studio.logo_for("Footer Logo"))
22
+ booking = studio_booking?
23
+ %>
24
+ <section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
25
+ <div class="space-y-1">
26
+ <h3 class="text-xl font-bold text-heading">Site footer and booking</h3>
27
+ <p class="text-muted text-sm max-w-2xl">
28
+ One layout line renders the public site's footer from
29
+ <code class="font-mono text-2xs">config.site_footer</code>; every part is optional. With
30
+ <code class="font-mono text-2xs">config.booking_url</code> the same pages get Google Calendar's
31
+ booking page inline, in a popup and on <code class="font-mono text-2xs">/schedule</code>.
32
+ </p>
33
+ <p class="text-muted text-sm" data-site-footer-status>
34
+ This app:
35
+ footer <strong class="text-body"><%= own_facts ? "declared" : "not declared" %></strong> &middot;
36
+ 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>
38
+ </p>
39
+ </div>
40
+
41
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
42
+ <%= render layout: "style/specimen",
43
+ locals: { klass: "studio_site_footer",
44
+ usage: %(studio_site_footer # one line, at the end of the layout body) } do %>
45
+ <p class="text-sm text-secondary text-center">
46
+ The footer, where it should show: every page for a visitor, listed controllers when signed in.
47
+ Previewed below.
48
+ </p>
49
+ <% end %>
50
+
51
+ <%= render layout: "style/specimen",
52
+ locals: { klass: "studio_booking_link",
53
+ disabled: !booking, disabled_label: "no booking_url",
54
+ usage: %(studio_booking_link "Schedule a call", class: "btn btn-primary") } do %>
55
+ <% if booking %>
56
+ <%= studio_booking_link "Schedule a call", class: "btn btn-primary" %>
57
+ <% else %>
58
+ <span class="btn btn-primary">Schedule a call</span>
59
+ <% end %>
60
+ <% end %>
61
+
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>
70
+ <% end %>
71
+ </div>
72
+
73
+ <div class="space-y-2">
74
+ <p class="label-upper"><%= own_facts ? "This app's footer" : "Sample footer (this app declares none)" %></p>
75
+ <div class="card overflow-hidden" data-site-footer-preview>
76
+ <%= render "studio/site_footer/footer", facts: preview %>
77
+ </div>
78
+ </div>
79
+ </section>
@@ -497,4 +497,7 @@
497
497
  <% end %>
498
498
  </div>
499
499
  </section>
500
+
501
+ <!-- Site footer and booking -->
502
+ <%= render "style/site_footer" %>
500
503
  </section>
@@ -82,9 +82,13 @@ module Studio
82
82
  return say_status(:skip, "#{CONTROLLER} not found", :yellow) unless File.exist?(path)
83
83
  return say_status(:identical, "#{CONTROLLER} (Studio::LinkPreviewBots)", :blue) if File.read(path).include?("Studio::LinkPreviewBots")
84
84
 
85
- inject_into_class CONTROLLER, "ApplicationController", <<~RUBY
85
+ # inject_into_class inserts the text as given, right after the class
86
+ # line, so the body's two-space indent is written here (a bare heredoc
87
+ # landed at column 0 in cyvasse).
88
+ inject_into_class CONTROLLER, "ApplicationController", <<~RUBY.gsub(/^(?=.)/, " ")
86
89
  # Preview fetchers (iMessage, Slack, Discord, X...) get a slim page under
87
- # Apple's 1 MiB limit. studio-engine docs/LINK_PREVIEW.md.
90
+ # Apple's 1 MiB limit, and are exempt from allow_browser. studio-engine
91
+ # docs/LINK_PREVIEW.md.
88
92
  include Studio::LinkPreviewBots
89
93
  RUBY
90
94
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
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.
6
+ #
7
+ # The configured value is the schedule's PUBLIC page, the URL Google gives you
8
+ # to share, without `?gv=true`:
9
+ #
10
+ # config.booking_url = "https://calendar.google.com/calendar/appointments/schedules/EXAMPLE-SCHEDULE-ID"
11
+ #
12
+ # `gv=true` is what makes Google serve the embeddable page, so the frame and the
13
+ # popup add it; the "open the booking page" link does not.
14
+ module Studio
15
+ module Booking
16
+ EMBED_PARAM = "gv=true"
17
+
18
+ module_function
19
+
20
+ def validate!(url)
21
+ return if url.nil? || url.to_s.strip.empty?
22
+ return if url.to_s.strip.match?(%r{\Ahttps://\S+\z})
23
+
24
+ raise ArgumentError,
25
+ "Studio.booking_url must be an https URL (got #{url.inspect}). Use the appointment " \
26
+ "schedule's public link. See docs/SITE_FOOTER.md."
27
+ end
28
+
29
+ # The public page, with any embed parameter removed. nil when unset.
30
+ def page_url(url)
31
+ string = url.to_s.strip
32
+ return nil if string.empty?
33
+
34
+ base, query = string.split("?", 2)
35
+ kept = query.to_s.split("&").reject { |pair| pair.empty? || pair == EMBED_PARAM }
36
+ kept.empty? ? base : "#{base}?#{kept.join('&')}"
37
+ end
38
+
39
+ # The embeddable page: the public page plus gv=true. nil when unset.
40
+ def embed_url(url)
41
+ page = page_url(url)
42
+ return nil if page.nil?
43
+
44
+ "#{page}#{page.include?('?') ? '&' : '?'}#{EMBED_PARAM}"
45
+ end
46
+ end
47
+ end
data/lib/studio/engine.rb CHANGED
@@ -47,6 +47,8 @@ module Studio
47
47
  studio/canvas_confetti.js
48
48
  studio/studio_confetti.js
49
49
  studio/sortable.js
50
+ studio/leaflet.js
51
+ studio/leaflet.css
50
52
  studio/session.js
51
53
  studio/montserrat-latin.woff2
52
54
  studio/montserrat-latin-ext.woff2
@@ -135,6 +135,97 @@ module Studio
135
135
  value
136
136
  end
137
137
 
138
+ # The base URL every preview URL is built on: the app's PINNED canonical
139
+ # base (Studio.link_preview_base_url) when it set one, else the request's.
140
+ # Trailing slash dropped, so a path joins cleanly. nil when neither exists.
141
+ def base_url(pinned:, request_base_url:)
142
+ pinned_value = pinned.to_s.strip.chomp("/")
143
+ return pinned_value unless pinned_value.empty?
144
+
145
+ request_value = request_base_url.to_s.strip.chomp("/")
146
+ request_value.empty? ? nil : request_value
147
+ end
148
+
149
+ # The page's og:url. With a pinned base it is that base plus the PATH: no
150
+ # herokuapp host, no ?utm_ query, so every share of a page counts as one URL.
151
+ # Without one it is the URL as requested, as it always was.
152
+ def page_url(base_url:, request_url:, path:)
153
+ pinned_value = base_url.to_s.strip.chomp("/")
154
+ return request_url.to_s.empty? ? nil : request_url.to_s if pinned_value.empty?
155
+
156
+ "#{pinned_value}#{path.to_s.empty? ? "/" : path}"
157
+ end
158
+
159
+ # [width, height] of a PNG, GIF, JPEG or WebP file, read from its header
160
+ # only, or nil when the file is missing, unreadable or another format. Lets
161
+ # the static fallback (public/og.png) emit og:image:width/height, which
162
+ # WhatsApp and others use to lay the card out before the image arrives.
163
+ def image_dimensions(path)
164
+ File.open(path.to_s, "rb") { |file| dimensions_from(file.read(64 * 1024).to_s.b) }
165
+ rescue SystemCallError, IOError
166
+ nil
167
+ end
168
+
169
+ def dimensions_from(bytes)
170
+ dims =
171
+ if bytes.start_with?("\x89PNG\r\n\x1A\n".b) && bytes.bytesize >= 24
172
+ bytes[16, 8].unpack("NN")
173
+ elsif bytes.start_with?("GIF8") && bytes.bytesize >= 10
174
+ bytes[6, 4].unpack("vv")
175
+ elsif bytes.start_with?("\xFF\xD8".b)
176
+ jpeg_dimensions(bytes)
177
+ elsif bytes.start_with?("RIFF") && bytes[8, 4] == "WEBP"
178
+ webp_dimensions(bytes)
179
+ end
180
+ dims && dims.all? { |n| n.is_a?(Integer) && n.positive? } ? dims : nil
181
+ end
182
+
183
+ # Walk the JPEG segments to the first start-of-frame marker.
184
+ JPEG_SOF_MARKERS = [0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF].freeze
185
+
186
+ def jpeg_dimensions(bytes)
187
+ offset = 2
188
+ while offset + 9 <= bytes.bytesize
189
+ return nil unless bytes.getbyte(offset) == 0xFF
190
+
191
+ marker = bytes.getbyte(offset + 1)
192
+ if marker == 0xFF # fill byte
193
+ offset += 1
194
+ next
195
+ end
196
+ length = bytes[offset + 2, 2].unpack1("n")
197
+ if JPEG_SOF_MARKERS.include?(marker)
198
+ height, width = bytes[offset + 5, 4].unpack("nn")
199
+ return [width, height]
200
+ end
201
+ return nil if length.nil? || length < 2
202
+
203
+ offset += 2 + length
204
+ end
205
+ nil
206
+ end
207
+
208
+ def webp_dimensions(bytes)
209
+ case bytes[12, 4]
210
+ when "VP8 "
211
+ return nil if bytes.bytesize < 30
212
+
213
+ width, height = bytes[26, 4].unpack("vv")
214
+ [width & 0x3FFF, height & 0x3FFF]
215
+ when "VP8L"
216
+ return nil if bytes.bytesize < 25
217
+
218
+ bits = bytes[21, 4].unpack1("V")
219
+ [(bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1]
220
+ when "VP8X"
221
+ return nil if bytes.bytesize < 30
222
+
223
+ w = bytes[24, 3].unpack("CCC")
224
+ h = bytes[27, 3].unpack("CCC")
225
+ [(w[0] | (w[1] << 8) | (w[2] << 16)) + 1, (h[0] | (h[1] << 8) | (h[2] << 16)) + 1]
226
+ end
227
+ end
228
+
138
229
  # The whole response a preview fetcher gets: the page's own identity tags,
139
230
  # lifted out of its <head>, and a one-card body. Built FROM the rendered
140
231
  # page, so the slim document cannot drift from what a person's page says.
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ # Resolves Studio.site_footer — the facts the site footer prints — for a view.
6
+ # Pure Ruby (no Rails dependency), so the unit suite exercises the rules without
7
+ # booting the dummy app. See docs/SITE_FOOTER.md.
8
+ #
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
11
+ # footer rather than printing a placeholder:
12
+ #
13
+ # config.site_footer = ->(view) {
14
+ # { tagline: "Everything, by example",
15
+ # address: { street: "123 Example St", city_line: "Washington, DC 20024",
16
+ # lat: 38.8894, lng: -77.0352 },
17
+ # email: "team@example.com",
18
+ # social: [ [ "LinkedIn", :linkedin, "https://www.linkedin.com/in/someone/" ],
19
+ # [ "Instagram", :instagram, nil ] ], # nil url: unlinked
20
+ # columns: [ [ "Company", [ [ "Home", view.root_path ],
21
+ # [ "Career", nil ], # nil path: disabled
22
+ # [ "Blog", "https://blog.example.com" ] ] ] ],
23
+ # legal: [ [ "Privacy Policy", "/privacy" ], [ "Terms of Service", "/terms" ] ] }
24
+ # }
25
+ #
26
+ # Rows may be written as tuples (above) or as hashes ({ label:, href: }); both
27
+ # normalize to the same symbol-keyed shape, which is what the partials read.
28
+ module Studio
29
+ module SiteFooter
30
+ DEFAULT_BOOKING_LABEL = "Schedule a call"
31
+ DEFAULT_ZOOM = 15
32
+
33
+ # The engine's own booking page keeps the footer for a signed-in viewer.
34
+ OWN_CONTROLLERS = %w[studio/bookings].freeze
35
+
36
+ # The only schemes a footer href may carry. Anything else, notably
37
+ # `javascript:` and `data:`, is never written into an href: the label is
38
+ # printed unlinked and the rejection is reported once.
39
+ SAFE_SCHEMES = %w[http https mailto tel].freeze
40
+ REPORTED_LIMIT = 100
41
+
42
+ class << self
43
+ # Where a rejected href is reported: a callable taking the message. The
44
+ # default writes one warning to the Rails log (or to stderr without Rails).
45
+ attr_writer :reporter
46
+
47
+ def reporter
48
+ @reporter ||= lambda do |message|
49
+ if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
50
+ ::Rails.logger.warn(message)
51
+ else
52
+ warn(message)
53
+ end
54
+ end
55
+ end
56
+
57
+ def reported
58
+ @reported ||= {}
59
+ end
60
+
61
+ def reset_reported!
62
+ @reported = {}
63
+ end
64
+ end
65
+
66
+ module_function
67
+
68
+ # A relative path (no scheme), or one of SAFE_SCHEMES. A control character
69
+ # anywhere fails it: browsers drop tabs and newlines inside a scheme, so
70
+ # "java\tscript:" would otherwise read as a relative path here and as
71
+ # script there.
72
+ def safe_href?(href)
73
+ string = href.to_s
74
+ return false if string.match?(/[\x00-\x1f\x7f]/)
75
+
76
+ scheme = string[/\A\s*([a-z][a-z0-9+.\-]*):/i, 1]
77
+ scheme.nil? || SAFE_SCHEMES.include?(scheme.downcase)
78
+ end
79
+
80
+ # The href when it may be linked, else nil, reported once per value.
81
+ def safe_href(value, where)
82
+ raw = value.nil? || value == false ? nil : value.to_s
83
+ return nil if raw.nil? || raw.strip.empty?
84
+ return raw.strip if safe_href?(raw)
85
+
86
+ key = "#{where}:#{raw}"
87
+ unless SiteFooter.reported.key?(key) || SiteFooter.reported.size >= REPORTED_LIMIT
88
+ SiteFooter.reported[key] = true
89
+ SiteFooter.reporter.call(
90
+ "[studio.site_footer] #{where} #{raw[0, 80].inspect} is not linked: only relative paths, " \
91
+ "http:, https:, mailto: and tel: are allowed"
92
+ )
93
+ end
94
+ nil
95
+ end
96
+
97
+ def validate!(declared)
98
+ return if declared.nil? || declared.is_a?(Hash) || declared.respond_to?(:call)
99
+
100
+ raise ArgumentError,
101
+ "Studio.site_footer must be nil, a Hash, or a callable receiving the view " \
102
+ "(got #{declared.class}). See docs/SITE_FOOTER.md."
103
+ end
104
+
105
+ # The normalized facts, or nil when the app declared no footer.
106
+ #
107
+ # name: the site name the engine resolved (the site identity's title)
108
+ # logo: the logo the engine resolved (the navbar logo), or nil
109
+ # booking_path: the booking page's path when the engine draws it, or nil
110
+ def resolve(declared, view, name: nil, logo: nil, booking_path: nil)
111
+ raw = declared.respond_to?(:call) ? declared.call(view) : declared
112
+ return nil if raw.nil?
113
+
114
+ facts = symbolize(raw)
115
+ site_name = text(facts[:name]) || text(name)
116
+ booking = symbolize(facts[:booking] || {})
117
+
118
+ {
119
+ name: site_name,
120
+ wordmark: wordmark(facts[:wordmark], site_name),
121
+ logo: facts.key?(:logo) ? text(facts[:logo]) : text(logo),
122
+ logo_invert: facts[:logo_invert] ? true : false,
123
+ home_path: safe_href(facts[:home_path], "home_path") || "/",
124
+ tagline: text(facts[:tagline]),
125
+ email: text(facts[:email]),
126
+ address: address(facts),
127
+ social: Array(facts[:social]).filter_map { |row| social(row) },
128
+ columns: Array(facts[:columns]).filter_map { |row| column(row, booking_path) },
129
+ legal: Array(facts[:legal]).filter_map { |row| link(row, booking_path) },
130
+ booking_label: text(booking[:label]) || DEFAULT_BOOKING_LABEL,
131
+ booking_title: text(booking[:title])
132
+ }
133
+ end
134
+
135
+ # Where the footer shows. A visitor always gets it; a signed-in viewer gets
136
+ # it only on the controllers the app lists, because every other signed-in
137
+ # page is a working surface. `controllers` entries match the controller's
138
+ # name ("landing") or its path ("studio/bookings").
139
+ def visible?(logged_in:, controller_name:, controller_path: nil, controllers: [])
140
+ return true unless logged_in
141
+
142
+ listed = Array(controllers).map(&:to_s)
143
+ listed.include?(controller_name.to_s) || (!controller_path.nil? && listed.include?(controller_path.to_s))
144
+ end
145
+
146
+ # The default rule, asked of a view: the answer Studio.site_footer_visible
147
+ # gives until an app replaces it. An app narrowing the rule composes with it:
148
+ #
149
+ # config.site_footer_visible = ->(view) {
150
+ # Studio::SiteFooter.default_visible?(view) && !view.controller_path.start_with?("app/")
151
+ # }
152
+ def default_visible?(view, controllers: nil)
153
+ controllers = Studio.site_footer_controllers if controllers.nil? && defined?(Studio.site_footer_controllers)
154
+ visible?(
155
+ logged_in: view.respond_to?(:logged_in?) && view.logged_in? ? true : false,
156
+ controller_name: view.respond_to?(:controller_name) ? view.controller_name : nil,
157
+ controller_path: view.respond_to?(:controller_path) ? view.controller_path : nil,
158
+ controllers: Array(controllers) + OWN_CONTROLLERS
159
+ )
160
+ end
161
+
162
+ def validate_visible!(rule)
163
+ return if rule.respond_to?(:call)
164
+
165
+ raise ArgumentError,
166
+ "Studio.site_footer_visible must be a callable receiving the view (got #{rule.class}). " \
167
+ "See docs/SITE_FOOTER.md."
168
+ end
169
+
170
+ # ---- normalization ------------------------------------------------------
171
+
172
+ # Two parts: everything but the last word, then the last word, which the
173
+ # footer prints in the primary colour (the navbar splits app_name the same
174
+ # way). A one-word name has an empty first part.
175
+ def wordmark(declared, name)
176
+ parts = Array(declared).map { |part| text(part) }.compact
177
+ return [parts[0..-2].join(" "), parts.last] if parts.length >= 2
178
+
179
+ words = (parts.first || name).to_s.split
180
+ return nil if words.empty?
181
+
182
+ [words[0..-2].join(" "), words.last]
183
+ end
184
+
185
+ # The address may be nested under :address or written flat (street:,
186
+ # city_line:, lat:, lng:, directions_url:). No street and no city line means
187
+ # no address at all: no Location band and no map.
188
+ def address(facts)
189
+ declared = facts[:address]
190
+ source = declared.nil? ? facts : symbolize(declared)
191
+ street = text(source[:street])
192
+ city_line = text(source[:city_line])
193
+ return nil if street.nil? && city_line.nil?
194
+
195
+ full = [street, city_line].compact.join(", ")
196
+ lat = coordinate(source[:lat])
197
+ lng = coordinate(source[:lng])
198
+ {
199
+ street: street,
200
+ city_line: city_line,
201
+ full: full,
202
+ lat: lat,
203
+ lng: lng,
204
+ map: !lat.nil? && !lng.nil?,
205
+ zoom: (source[:zoom] || DEFAULT_ZOOM).to_i,
206
+ # A directions_url that may not be linked falls back to the default, so
207
+ # the address and the map's no-script link still lead somewhere.
208
+ directions_url: safe_href(source[:directions_url], "directions_url") ||
209
+ "https://www.google.com/maps/dir/?api=1&destination=#{CGI.escape(full)}"
210
+ }
211
+ end
212
+
213
+ def coordinate(value)
214
+ return nil if value.nil? || value.to_s.strip.empty?
215
+
216
+ Float(value)
217
+ rescue ArgumentError, TypeError
218
+ nil
219
+ end
220
+
221
+ # [ label, icon, url ] or { label:, icon:, url: }. A nil url renders the
222
+ # icon unlinked.
223
+ def social(row)
224
+ label, icon, url =
225
+ if row.respond_to?(:to_h) && !row.is_a?(Array)
226
+ hash = symbolize(row)
227
+ [hash[:label], hash[:icon], hash[:url] || hash[:href]]
228
+ else
229
+ Array(row)
230
+ end
231
+ label = text(label)
232
+ return nil if label.nil?
233
+
234
+ { label: label, icon: (icon || label).to_s.downcase.to_sym, url: safe_href(url, "social url"),
235
+ unlinked: !text(url).nil? && safe_href(url, "social url").nil? }
236
+ end
237
+
238
+ # [ heading, links ] or { heading:, links: }.
239
+ def column(row, booking_path)
240
+ heading, links =
241
+ if row.respond_to?(:to_h) && !row.is_a?(Array)
242
+ hash = symbolize(row)
243
+ [hash[:heading] || hash[:title], hash[:links]]
244
+ else
245
+ Array(row)
246
+ end
247
+ heading = text(heading)
248
+ links = Array(links).filter_map { |link_row| link(link_row, booking_path) }
249
+ return nil if heading.nil? && links.empty?
250
+
251
+ { heading: heading, links: links }
252
+ end
253
+
254
+ # [ label, href ], [ label, href, { booking: true } ] or { label:, href:,
255
+ # booking: }. A nil href is a disabled label; an http(s) href opens in a new
256
+ # tab; `booking: true`, or an href equal to the engine's booking page, opens
257
+ # the booking popup (the href stays as the fallback).
258
+ def link(row, booking_path = nil)
259
+ label, href, options =
260
+ if row.respond_to?(:to_h) && !row.is_a?(Array)
261
+ hash = symbolize(row)
262
+ [hash[:label], hash[:href] || hash[:path] || hash[:url], hash]
263
+ else
264
+ Array(row)
265
+ end
266
+ label = text(label)
267
+ return nil if label.nil?
268
+
269
+ declared = text(href)
270
+ href = safe_href(href, "link href")
271
+ options = options.respond_to?(:to_h) ? symbolize(options) : {}
272
+ booking = !href.nil? && (options[:booking] ? true : (!booking_path.nil? && href == booking_path))
273
+ {
274
+ label: label,
275
+ href: href,
276
+ # A nil href is a page that does not exist yet. A REFUSED href is not
277
+ # that: the label prints plain, with no "coming soon".
278
+ disabled: declared.nil?,
279
+ unlinked: !declared.nil? && href.nil?,
280
+ external: !href.nil? && href.match?(%r{\Ahttps?://}i),
281
+ booking: booking
282
+ }
283
+ end
284
+
285
+ def text(value)
286
+ string = value.to_s.strip
287
+ value.nil? || value == false || string.empty? ? nil : string
288
+ end
289
+
290
+ def symbolize(hash)
291
+ hash.to_h.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
292
+ end
293
+ end
294
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.82.0"
2
+ VERSION = "0.83.0"
3
3
  end