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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +136 -0
- data/README.md +4 -0
- data/app/assets/javascripts/studio/leaflet.js +42 -0
- data/app/assets/stylesheets/studio/leaflet.css +693 -0
- data/app/controllers/studio/bookings_controller.rb +22 -0
- data/app/helpers/studio/site_footer_helper.rb +188 -0
- data/app/views/studio/booking/_assets.html.erb +196 -0
- data/app/views/studio/booking/_frame.html.erb +44 -0
- data/app/views/studio/booking/_popup.html.erb +34 -0
- data/app/views/studio/bookings/show.html.erb +22 -0
- data/app/views/studio/site_footer/_assets.html.erb +232 -0
- data/app/views/studio/site_footer/_footer.html.erb +100 -0
- data/app/views/studio/site_footer/_link.html.erb +16 -0
- data/app/views/studio/site_footer/_map.html.erb +22 -0
- data/app/views/studio/site_footer/_social_icon.html.erb +23 -0
- data/app/views/style/_site_footer.html.erb +102 -0
- data/app/views/style/_tricks.html.erb +3 -0
- data/lib/studio/booking.rb +207 -0
- data/lib/studio/engine.rb +2 -0
- data/lib/studio/site_footer.rb +350 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +132 -0
- metadata +17 -1
|
@@ -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
|
@@ -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
|
data/lib/studio/version.rb
CHANGED