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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +3 -0
- data/app/assets/javascripts/studio/leaflet.js +42 -0
- data/app/assets/stylesheets/studio/leaflet.css +693 -0
- data/app/controllers/concerns/studio/link_preview_bots.rb +45 -2
- data/app/controllers/studio/bookings_controller.rb +22 -0
- data/app/controllers/studio/site_identities_controller.rb +15 -3
- data/app/helpers/studio/link_preview_helper.rb +54 -6
- data/app/helpers/studio/site_footer_helper.rb +180 -0
- data/app/models/studio/site_identity.rb +39 -3
- data/app/views/layouts/studio/_link_preview_tags.html.erb +12 -1
- data/app/views/studio/booking/_assets.html.erb +162 -0
- data/app/views/studio/booking/_frame.html.erb +41 -0
- data/app/views/studio/booking/_popup.html.erb +34 -0
- data/app/views/studio/bookings/show.html.erb +20 -0
- data/app/views/studio/site_footer/_assets.html.erb +209 -0
- data/app/views/studio/site_footer/_footer.html.erb +99 -0
- data/app/views/studio/site_footer/_link.html.erb +14 -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 +79 -0
- data/app/views/style/_tricks.html.erb +3 -0
- data/lib/generators/studio/site_identity/site_identity_generator.rb +6 -2
- data/lib/studio/booking.rb +47 -0
- data/lib/studio/engine.rb +2 -0
- data/lib/studio/link_preview.rb +91 -0
- data/lib/studio/site_footer.rb +294 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +105 -0
- 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> ·
|
|
36
|
+
booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> ·
|
|
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>
|
|
@@ -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
|
|
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
|
|
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
data/lib/studio/link_preview.rb
CHANGED
|
@@ -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
|
data/lib/studio/version.rb
CHANGED