studio-engine 0.82.1 → 0.84.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,207 @@
1
+ # frozen_string_literal: true
2
+
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
+ #
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
+ # 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
+
67
+ module_function
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
+
180
+ def validate!(url)
181
+ return if url.nil? || url.to_s.strip.empty?
182
+ return if url.to_s.strip.match?(%r{\Ahttps://\S+\z})
183
+
184
+ raise ArgumentError,
185
+ "Studio.booking_url must be an https URL (got #{url.inspect}). Use the appointment " \
186
+ "schedule's public link. See docs/BOOKING.md."
187
+ end
188
+
189
+ # The public page, with any embed parameter removed. nil when unset.
190
+ def page_url(url)
191
+ string = url.to_s.strip
192
+ return nil if string.empty?
193
+
194
+ base, query = string.split("?", 2)
195
+ kept = query.to_s.split("&").reject { |pair| pair.empty? || pair == EMBED_PARAM }
196
+ kept.empty? ? base : "#{base}?#{kept.join('&')}"
197
+ end
198
+
199
+ # The embeddable page: the public page plus gv=true. nil when unset.
200
+ def embed_url(url)
201
+ page = page_url(url)
202
+ return nil if page.nil?
203
+
204
+ "#{page}#{page.include?('?') ? '&' : '?'}#{EMBED_PARAM}"
205
+ end
206
+ end
207
+ 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
@@ -0,0 +1,350 @@
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
+ # True on the app's OWN booking page (Studio.booking_path), which keeps the
98
+ # footer for a signed-in viewer as the engine's page does. Matched by the
99
+ # request's path, because the app's controller could be named anything.
100
+ def booking_page?(view, booking_path)
101
+ return false if booking_path.nil?
102
+ return false unless view.respond_to?(:request) && view.request.respond_to?(:path)
103
+
104
+ Studio::Booking.same_path?(view.request.path, booking_path)
105
+ end
106
+
107
+ def validate!(declared)
108
+ return if declared.nil? || declared.is_a?(Hash) || declared.respond_to?(:call)
109
+
110
+ raise ArgumentError,
111
+ "Studio.site_footer must be nil, a Hash, or a callable receiving the view " \
112
+ "(got #{declared.class}). See docs/SITE_FOOTER.md."
113
+ end
114
+
115
+ # The normalized facts, or nil when the app declared no footer.
116
+ #
117
+ # name: the site name the engine resolved (the site identity's title)
118
+ # logo: the logo the engine resolved (the navbar logo), or nil
119
+ # 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)
121
+ raw = declared.respond_to?(:call) ? declared.call(view) : declared
122
+ return nil if raw.nil?
123
+
124
+ facts = symbolize(raw)
125
+ site_name = text(facts[:name]) || text(name)
126
+ booking = symbolize(facts[:booking] || {})
127
+
128
+ {
129
+ name: site_name,
130
+ wordmark: wordmark(facts[:wordmark], site_name),
131
+ logo: facts.key?(:logo) ? text(facts[:logo]) : text(logo),
132
+ logo_invert: facts[:logo_invert] ? true : false,
133
+ home_path: safe_href(facts[:home_path], "home_path") || "/",
134
+ tagline: text(facts[:tagline]),
135
+ email: text(facts[:email]),
136
+ address: address(facts),
137
+ social: Array(facts[:social]).filter_map { |row| social(row) },
138
+ columns: Array(facts[:columns]).filter_map { |row| column(row, booking_path) },
139
+ legal: Array(facts[:legal]).filter_map { |row| link(row, booking_path) },
140
+ booking_label: text(booking[:label]) || DEFAULT_BOOKING_LABEL,
141
+ booking_title: text(booking[:title])
142
+ }
143
+ end
144
+
145
+ # Where the footer shows. A visitor always gets it; a signed-in viewer gets
146
+ # it only on the controllers the app lists, because every other signed-in
147
+ # page is a working surface. `controllers` entries match the controller's
148
+ # name ("landing") or its path ("studio/bookings").
149
+ def visible?(logged_in:, controller_name:, controller_path: nil, controllers: [])
150
+ return true unless logged_in
151
+
152
+ listed = Array(controllers).map(&:to_s)
153
+ listed.include?(controller_name.to_s) || (!controller_path.nil? && listed.include?(controller_path.to_s))
154
+ end
155
+
156
+ # The default rule, asked of a view: the answer Studio.site_footer_visible
157
+ # gives until an app replaces it. An app narrowing the rule composes with it:
158
+ #
159
+ # config.site_footer_visible = ->(view) {
160
+ # Studio::SiteFooter.default_visible?(view) && !view.controller_path.start_with?("app/")
161
+ # }
162
+ #
163
+ # `booking_path` is the app's own booking page (Studio.booking_path, resolved
164
+ # for this view); the engine's page is exempt by its controller instead.
165
+ def default_visible?(view, controllers: nil, booking_path: nil)
166
+ controllers = Studio.site_footer_controllers if controllers.nil? && defined?(Studio.site_footer_controllers)
167
+ if booking_path.nil? && Studio.respond_to?(:booking_path) && Studio.booking_path
168
+ booking_path = Studio.booking_path_for(view)
169
+ end
170
+ return true if booking_page?(view, booking_path)
171
+
172
+ visible?(
173
+ logged_in: view.respond_to?(:logged_in?) && view.logged_in? ? true : false,
174
+ controller_name: view.respond_to?(:controller_name) ? view.controller_name : nil,
175
+ controller_path: view.respond_to?(:controller_path) ? view.controller_path : nil,
176
+ controllers: Array(controllers) + OWN_CONTROLLERS
177
+ )
178
+ end
179
+
180
+ def validate_visible!(rule)
181
+ return if rule.respond_to?(:call)
182
+
183
+ raise ArgumentError,
184
+ "Studio.site_footer_visible must be a callable receiving the view (got #{rule.class}). " \
185
+ "See docs/SITE_FOOTER.md."
186
+ end
187
+
188
+ # ---- normalization ------------------------------------------------------
189
+
190
+ # Two parts: everything but the last word, then the last word, which the
191
+ # footer prints in the primary colour (the navbar splits app_name the same
192
+ # way). A one-word name has an empty first part.
193
+ def wordmark(declared, name)
194
+ parts = Array(declared).map { |part| text(part) }.compact
195
+ return [parts[0..-2].join(" "), parts.last] if parts.length >= 2
196
+
197
+ words = (parts.first || name).to_s.split
198
+ return nil if words.empty?
199
+
200
+ [words[0..-2].join(" "), words.last]
201
+ end
202
+
203
+ # The address may be nested under :address or written flat (street:,
204
+ # city_line:, lat:, lng:, directions_url:). No street and no city line means
205
+ # no address at all: no Location band and no map.
206
+ def address(facts)
207
+ declared = facts[:address]
208
+ source = declared.nil? ? facts : symbolize(declared)
209
+ street = text(source[:street])
210
+ city_line = text(source[:city_line])
211
+ return nil if street.nil? && city_line.nil?
212
+
213
+ full = [street, city_line].compact.join(", ")
214
+ lat = coordinate(source[:lat])
215
+ lng = coordinate(source[:lng])
216
+ {
217
+ street: street,
218
+ city_line: city_line,
219
+ full: full,
220
+ lat: lat,
221
+ lng: lng,
222
+ map: !lat.nil? && !lng.nil?,
223
+ zoom: (source[:zoom] || DEFAULT_ZOOM).to_i,
224
+ # A directions_url that may not be linked falls back to the default, so
225
+ # the address and the map's no-script link still lead somewhere.
226
+ directions_url: safe_href(source[:directions_url], "directions_url") ||
227
+ "https://www.google.com/maps/dir/?api=1&destination=#{CGI.escape(full)}"
228
+ }
229
+ end
230
+
231
+ def coordinate(value)
232
+ return nil if value.nil? || value.to_s.strip.empty?
233
+
234
+ Float(value)
235
+ rescue ArgumentError, TypeError
236
+ nil
237
+ end
238
+
239
+ # [ label, icon, url ] or { label:, icon:, url: }. A nil url renders the
240
+ # icon unlinked.
241
+ def social(row)
242
+ label, icon, url =
243
+ if row.respond_to?(:to_h) && !row.is_a?(Array)
244
+ hash = symbolize(row)
245
+ [hash[:label], hash[:icon], hash[:url] || hash[:href]]
246
+ else
247
+ Array(row)
248
+ end
249
+ label = text(label)
250
+ return nil if label.nil?
251
+
252
+ { label: label, icon: (icon || label).to_s.downcase.to_sym, url: safe_href(url, "social url"),
253
+ unlinked: !text(url).nil? && safe_href(url, "social url").nil? }
254
+ end
255
+
256
+ # [ heading, links ], [ heading, links, { width: 1.5 } ] or { heading:,
257
+ # links:, width: }. `width` is an optional hint: this column's share of the
258
+ # row against the others' 1 (see `tracks`).
259
+ def column(row, booking_path)
260
+ heading, links, options =
261
+ if row.respond_to?(:to_h) && !row.is_a?(Array)
262
+ hash = symbolize(row)
263
+ [hash[:heading] || hash[:title], hash[:links], hash]
264
+ else
265
+ Array(row)
266
+ end
267
+ heading = text(heading)
268
+ links = Array(links).filter_map { |link_row| link(link_row, booking_path) }
269
+ return nil if heading.nil? && links.empty?
270
+
271
+ options = options.respond_to?(:to_h) && !options.is_a?(Array) ? symbolize(options) : {}
272
+ { heading: heading, links: links, width: column_width(options[:width]) }
273
+ end
274
+
275
+ # A column's width hint, or nil for the default share. Anything that is not a
276
+ # number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
277
+ MIN_COLUMN_WIDTH = 0.5
278
+ MAX_COLUMN_WIDTH = 4
279
+
280
+ def column_width(value)
281
+ return nil unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
282
+ return nil unless value >= MIN_COLUMN_WIDTH && value <= MAX_COLUMN_WIDTH
283
+
284
+ value.to_f
285
+ end
286
+
287
+ # True for a label that is an address rather than words: no space in it, and
288
+ # an "@", a "." or a "/". It is kept on one line (.ftr-link-solid).
289
+ def solid_label?(label)
290
+ string = label.to_s
291
+ !string.match?(/\s/) && string.match?(%r{[@./]})
292
+ end
293
+
294
+ # THE LINK COLUMNS' GRID TRACKS from 768px, one per column: its `width:`
295
+ # hint as an fr share, else FIRST_COLUMN_WIDTH for the first (the one that
296
+ # usually holds an email address) and 1 for the rest. nil when there are no
297
+ # columns, or more than MAX_ROW_COLUMNS: those wrap as equal tracks.
298
+ FIRST_COLUMN_WIDTH = 1.5
299
+ MAX_ROW_COLUMNS = 4
300
+
301
+ def tracks(columns)
302
+ columns = Array(columns)
303
+ return nil if columns.empty? || columns.size > MAX_ROW_COLUMNS
304
+
305
+ columns.each_with_index.map do |column, index|
306
+ "#{format('%g', column[:width] || (index.zero? ? FIRST_COLUMN_WIDTH : 1))}fr"
307
+ end.join(" ")
308
+ end
309
+
310
+ # [ label, href ], [ label, href, { booking: true } ] or { label:, href:,
311
+ # booking: }. A nil href is a disabled label; an http(s) href opens in a new
312
+ # tab; `booking: true`, or an href equal to the booking page's path, opens
313
+ # the booking popup (the href stays as the fallback).
314
+ def link(row, booking_path = nil)
315
+ label, href, options =
316
+ if row.respond_to?(:to_h) && !row.is_a?(Array)
317
+ hash = symbolize(row)
318
+ [hash[:label], hash[:href] || hash[:path] || hash[:url], hash]
319
+ else
320
+ Array(row)
321
+ end
322
+ label = text(label)
323
+ return nil if label.nil?
324
+
325
+ declared = text(href)
326
+ href = safe_href(href, "link href")
327
+ options = options.respond_to?(:to_h) ? symbolize(options) : {}
328
+ booking = !href.nil? && (options[:booking] ? true : (!booking_path.nil? && href == booking_path))
329
+ {
330
+ label: label,
331
+ href: href,
332
+ # A nil href is a page that does not exist yet. A REFUSED href is not
333
+ # that: the label prints plain, with no "coming soon".
334
+ disabled: declared.nil?,
335
+ unlinked: !declared.nil? && href.nil?,
336
+ external: !href.nil? && href.match?(%r{\Ahttps?://}i),
337
+ booking: booking
338
+ }
339
+ end
340
+
341
+ def text(value)
342
+ string = value.to_s.strip
343
+ value.nil? || value == false || string.empty? ? nil : string
344
+ end
345
+
346
+ def symbolize(hash)
347
+ hash.to_h.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
348
+ end
349
+ end
350
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.82.1"
2
+ VERSION = "0.84.0"
3
3
  end