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
|
@@ -27,6 +27,14 @@ module Studio
|
|
|
27
27
|
#
|
|
28
28
|
# People, in-app browsers and unknown agents are never matched and always get
|
|
29
29
|
# the full page. The allow-list is Studio::LinkPreview::BOT_TOKENS.
|
|
30
|
+
#
|
|
31
|
+
# ALLOW_BROWSER: Rails 8's `allow_browser versions: :modern` (in every new
|
|
32
|
+
# app's ApplicationController) answers Apple's LinkPresentation — which sends a
|
|
33
|
+
# Safari 9.0.1 User-Agent — with a 406, so no iMessage preview. Including this
|
|
34
|
+
# concern exempts preview fetchers from every allow_browser guard on the
|
|
35
|
+
# controller, GET and HEAD only, with no app code. An app that already wrote
|
|
36
|
+
# `allow_browser ..., unless: :link_preview_bot_request?` keeps working: its
|
|
37
|
+
# guard never runs for a fetcher, so this override is never reached.
|
|
30
38
|
module LinkPreviewBots
|
|
31
39
|
extend ActiveSupport::Concern
|
|
32
40
|
|
|
@@ -47,6 +55,24 @@ module Studio
|
|
|
47
55
|
|
|
48
56
|
private
|
|
49
57
|
|
|
58
|
+
# Rails' allow_browser macro installs
|
|
59
|
+
# `before_action -> { allow_browser(versions:, block:) }`, which dispatches to
|
|
60
|
+
# the PRIVATE instance method ActionController::AllowBrowser#allow_browser (the
|
|
61
|
+
# same in Rails 7.2.3 and 8.1.4). This concern is included below
|
|
62
|
+
# ActionController::Base, so it sits above that module in the ancestor chain and
|
|
63
|
+
# this method answers first: a preview fetcher's GET/HEAD skips the browser
|
|
64
|
+
# check, everything else goes to Rails. Overriding the method rather than
|
|
65
|
+
# skipping the callback covers every allow_browser declaration (this
|
|
66
|
+
# controller, a parent or a subclass, before or after the include), and a
|
|
67
|
+
# lambda callback cannot be named to skip_before_action anyway. The integration
|
|
68
|
+
# suite pins the method, so a Rails rename fails loudly
|
|
69
|
+
# (test/integration/link_preview_test.rb).
|
|
70
|
+
def allow_browser(*args, **kwargs, &block)
|
|
71
|
+
return if link_preview_bot_request?
|
|
72
|
+
|
|
73
|
+
super
|
|
74
|
+
end
|
|
75
|
+
|
|
50
76
|
def serve_link_preview_slim_document
|
|
51
77
|
return unless link_preview_bot_request?
|
|
52
78
|
return unless response.status == 200
|
|
@@ -55,13 +81,30 @@ module Studio
|
|
|
55
81
|
body = response.body
|
|
56
82
|
return unless body.is_a?(String) && !body.empty?
|
|
57
83
|
|
|
58
|
-
response.body = Studio::LinkPreview.slim_document(body, url:
|
|
84
|
+
response.body = Studio::LinkPreview.slim_document(body, url: studio_link_preview_page_url)
|
|
59
85
|
response.headers[SLIM_HEADER] = "slim"
|
|
60
86
|
# A shared cache must never hand the slim page to a person.
|
|
61
87
|
response.headers["Vary"] = [response.headers["Vary"], "User-Agent"].compact.join(", ")
|
|
62
88
|
rescue StandardError => e
|
|
63
89
|
# The full page is still a valid (if large) answer; never 500 a fetcher.
|
|
64
|
-
|
|
90
|
+
# The failure goes to ErrorLog (and Sentry, when the host loads it) so it
|
|
91
|
+
# is triaged rather than lost in a log line; never re-raised, because the
|
|
92
|
+
# response is already rendered and the fetcher must still get the page.
|
|
93
|
+
studio_link_preview_capture(e)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def studio_link_preview_capture(error)
|
|
97
|
+
ErrorLog.capture!(error)
|
|
98
|
+
rescue StandardError, NameError => capture_error
|
|
99
|
+
Rails.logger&.warn("[studio.link_preview] slim render skipped: #{error.class}: #{error.message} " \
|
|
100
|
+
"(ErrorLog unavailable: #{capture_error.class})")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The page's canonical URL for the slim card's link: Studio.link_preview_base_url
|
|
104
|
+
# plus the path when the app pins one, else the URL as requested.
|
|
105
|
+
def studio_link_preview_page_url
|
|
106
|
+
Studio::LinkPreview.page_url(base_url: Studio.link_preview_base_url,
|
|
107
|
+
request_url: request.original_url, path: request.path)
|
|
65
108
|
end
|
|
66
109
|
end
|
|
67
110
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# /schedule — the app's booking page: Google Calendar's appointment page
|
|
5
|
+
# inline (studio/booking/_frame), inside the host's own layout.
|
|
6
|
+
#
|
|
7
|
+
# Public by design: it is where every "Schedule a call" link lands when the
|
|
8
|
+
# popup cannot open, so it skips the host's sign-in gate
|
|
9
|
+
# (mcritchie-industries and cyvasse gate every controller through
|
|
10
|
+
# Studio::ErrorHandling). It inherits the host's ApplicationController so the
|
|
11
|
+
# page renders in the app's layout, navbar and theme.
|
|
12
|
+
#
|
|
13
|
+
# Drawn only when the host opts in (Studio.draw_booking_routes). With no
|
|
14
|
+
# Studio.booking_url there is nothing to book, and it answers 404.
|
|
15
|
+
class BookingsController < ::ApplicationController
|
|
16
|
+
skip_before_action :require_authentication, raise: false
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
head :not_found if Studio.booking_url.blank?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -27,7 +27,7 @@ module Studio
|
|
|
27
27
|
@uploads_available = @installed && @setting.respond_to?(:image)
|
|
28
28
|
@default_image_url = default_image_url
|
|
29
29
|
@static_image_url = static_image_url
|
|
30
|
-
@domain =
|
|
30
|
+
@domain = preview_domain
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def update
|
|
@@ -95,13 +95,25 @@ module Studio
|
|
|
95
95
|
# The uploaded image as the card shows it: the same URL the head emits.
|
|
96
96
|
def default_image_url
|
|
97
97
|
stored = Studio::SiteIdentity.stored
|
|
98
|
-
Studio::LinkPreview.absolute_url(stored[:image_url] || stored[:image_path], base_url:
|
|
98
|
+
Studio::LinkPreview.absolute_url(stored[:image_url] || stored[:image_path], base_url: preview_base_url)
|
|
99
99
|
rescue StandardError
|
|
100
100
|
nil
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
# The same base the head's tags are built on (Studio.link_preview_base_url,
|
|
104
|
+
# else this request's), so the card shows the domain and URL an unfurl does.
|
|
105
|
+
def preview_base_url
|
|
106
|
+
Studio::LinkPreview.base_url(pinned: Studio.link_preview_base_url, request_base_url: request.base_url)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def preview_domain
|
|
110
|
+
URI.parse(preview_base_url.to_s).host || request.host
|
|
111
|
+
rescue URI::InvalidURIError
|
|
112
|
+
request.host
|
|
113
|
+
end
|
|
114
|
+
|
|
103
115
|
def static_image_url
|
|
104
|
-
Studio::LinkPreview.absolute_url(Studio::SiteIdentity.static_image, base_url:
|
|
116
|
+
Studio::LinkPreview.absolute_url(Studio::SiteIdentity.static_image, base_url: preview_base_url)
|
|
105
117
|
end
|
|
106
118
|
end
|
|
107
119
|
end
|
|
@@ -10,7 +10,7 @@ module Studio
|
|
|
10
10
|
# reason Studio::GeoHelper gives: every helper module is included into every
|
|
11
11
|
# view, so an unprefixed name would collide with a host's own helper.
|
|
12
12
|
module LinkPreviewHelper
|
|
13
|
-
OVERRIDE_KEYS = %i[image title description].freeze
|
|
13
|
+
OVERRIDE_KEYS = %i[image title description image_alt].freeze
|
|
14
14
|
|
|
15
15
|
# THE PAGE OVERRIDE. Call it from any view (the layout renders later, so it
|
|
16
16
|
# sees the call):
|
|
@@ -22,6 +22,8 @@ module Studio
|
|
|
22
22
|
# attachment/blob (a user's avatar). An image that is nil, blank, or an
|
|
23
23
|
# attachment with nothing attached falls back to the operator's default, so
|
|
24
24
|
# a page can pass `image: user.avatar` without asking whether there is one.
|
|
25
|
+
# `image_alt:` is the image's text alternative (og:image:alt); without it the
|
|
26
|
+
# card's title stands in, which is what the picture illustrates.
|
|
25
27
|
# Only the keys passed are set; a second call overrides just its own keys.
|
|
26
28
|
# Returns nil, so `<%= link_preview ... %>` prints nothing either.
|
|
27
29
|
def link_preview(**overrides)
|
|
@@ -33,8 +35,14 @@ module Studio
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# The resolved preview for this page:
|
|
36
|
-
# { title:, description:, image:, image_source:,
|
|
37
|
-
#
|
|
38
|
+
# { title:, description:, image:, image_source:, image_width:, image_height:,
|
|
39
|
+
# image_alt:, site_name:, url: }.
|
|
40
|
+
# `image` is absolute (or nil). `image_width`/`image_height` are the
|
|
41
|
+
# winning image's pixel size when it is KNOWN — an attachment whose blob has
|
|
42
|
+
# been analyzed, or the static fallback file — and nil otherwise; a URL handed
|
|
43
|
+
# in as a string is never fetched to measure it. `url` is the page's og:url
|
|
44
|
+
# (Studio.link_preview_base_url plus the path when the app pins one).
|
|
45
|
+
# Never raises: a preview must not 500 a page.
|
|
38
46
|
#
|
|
39
47
|
# Rungs, most specific first: the page's `link_preview`, then the page's
|
|
40
48
|
# own content_for(:title) / content_for(:meta_description) /
|
|
@@ -43,21 +51,27 @@ module Studio
|
|
|
43
51
|
def studio_link_preview
|
|
44
52
|
overrides = @studio_link_preview_overrides || {}
|
|
45
53
|
stored = studio_site_identity_stored
|
|
54
|
+
override_image = studio_link_preview_image_location(overrides[:image])
|
|
46
55
|
|
|
47
56
|
preview = Studio::LinkPreview.resolve(
|
|
48
57
|
site_name: Studio.app_name,
|
|
49
58
|
titles: [overrides[:title], studio_link_preview_content(:title), stored[:title], Studio.site_title],
|
|
50
59
|
descriptions: [overrides[:description], studio_link_preview_content(:meta_description),
|
|
51
60
|
stored[:description], Studio.site_description],
|
|
52
|
-
page_images: [
|
|
61
|
+
page_images: [override_image, studio_link_preview_content(:og_image)],
|
|
53
62
|
default_image: stored[:image_url] || stored[:image_path],
|
|
54
63
|
static_image: Studio::SiteIdentity.static_image
|
|
55
64
|
)
|
|
56
65
|
|
|
66
|
+
width, height = studio_link_preview_image_size(preview[:image_source], overrides[:image], override_image, stored)
|
|
67
|
+
|
|
57
68
|
preview.merge(
|
|
58
69
|
image: Studio::LinkPreview.absolute_url(preview[:image], base_url: studio_request_base_url) || preview[:image],
|
|
70
|
+
image_width: width,
|
|
71
|
+
image_height: height,
|
|
72
|
+
image_alt: (overrides[:image_alt].to_s.strip.presence || preview[:title] if preview[:image]),
|
|
59
73
|
site_name: Studio.app_name.to_s,
|
|
60
|
-
url:
|
|
74
|
+
url: studio_link_preview_page_url
|
|
61
75
|
)
|
|
62
76
|
end
|
|
63
77
|
|
|
@@ -106,8 +120,42 @@ module Studio
|
|
|
106
120
|
value && CGI.unescapeHTML(value.to_str)
|
|
107
121
|
end
|
|
108
122
|
|
|
123
|
+
# The app's pinned canonical base (Studio.link_preview_base_url), else the
|
|
124
|
+
# request's. Every preview URL is built on it.
|
|
109
125
|
def studio_request_base_url
|
|
110
|
-
|
|
126
|
+
Studio::LinkPreview.base_url(pinned: Studio.link_preview_base_url,
|
|
127
|
+
request_base_url: (request.base_url if studio_link_preview_request?))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def studio_link_preview_page_url
|
|
131
|
+
return nil unless studio_link_preview_request?
|
|
132
|
+
|
|
133
|
+
Studio::LinkPreview.page_url(base_url: Studio.link_preview_base_url,
|
|
134
|
+
request_url: request.original_url, path: request.path)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def studio_link_preview_request?
|
|
138
|
+
respond_to?(:request) && request ? true : false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# [width, height] of the image that won, when it is known without a fetch.
|
|
142
|
+
# The override rung counts only when the page handed in an ATTACHMENT (its
|
|
143
|
+
# blob's analyzed metadata); a URL string or a content_for(:og_image) is
|
|
144
|
+
# unmeasured, so its tags are left out rather than guessed.
|
|
145
|
+
def studio_link_preview_image_size(source, override, override_location, stored)
|
|
146
|
+
case source
|
|
147
|
+
when :page
|
|
148
|
+
return nil if override_location.nil? || override.is_a?(String) || override.is_a?(Symbol)
|
|
149
|
+
|
|
150
|
+
Studio::SiteIdentity.image_dimensions(override)
|
|
151
|
+
when :default
|
|
152
|
+
width, height = stored[:image_width], stored[:image_height]
|
|
153
|
+
width && height ? [width, height] : nil
|
|
154
|
+
when :static
|
|
155
|
+
Studio::SiteIdentity.static_image_dimensions
|
|
156
|
+
end
|
|
157
|
+
rescue StandardError
|
|
158
|
+
nil
|
|
111
159
|
end
|
|
112
160
|
end
|
|
113
161
|
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The site footer and the booking primitives, as view helpers. See
|
|
5
|
+
# docs/SITE_FOOTER.md.
|
|
6
|
+
#
|
|
7
|
+
# <%= studio_site_footer %> the footer, where it should show
|
|
8
|
+
# <%= studio_booking_frame %> Google's booking page, inline
|
|
9
|
+
# <%= studio_booking_link "Book a call", class: "btn btn-primary" %>
|
|
10
|
+
# <%= studio_booking_popup %> the dialog those links open
|
|
11
|
+
# <%= studio_footer_map %> the map alone, e.g. on a contact page
|
|
12
|
+
#
|
|
13
|
+
# Every one of them renders NOTHING when its configuration is absent
|
|
14
|
+
# (Studio.site_footer, Studio.booking_url), so a layout or a shared view can
|
|
15
|
+
# call them before an app has declared anything.
|
|
16
|
+
#
|
|
17
|
+
# Prefixed names for the reason Studio::GeoHelper gives: every helper module is
|
|
18
|
+
# included into every view.
|
|
19
|
+
module SiteFooterHelper
|
|
20
|
+
# The resolved facts (Studio::SiteFooter.resolve), or nil. Resolved once per
|
|
21
|
+
# request.
|
|
22
|
+
#
|
|
23
|
+
# A callable that RAISES must not take every page down with it, because the
|
|
24
|
+
# footer is in the layout. In production the error is logged once per process
|
|
25
|
+
# and the page renders with no footer; locally it raises, so whoever wrote the
|
|
26
|
+
# callable sees it.
|
|
27
|
+
def studio_site_footer_facts
|
|
28
|
+
return @_studio_site_footer_facts if defined?(@_studio_site_footer_facts)
|
|
29
|
+
|
|
30
|
+
@_studio_site_footer_facts =
|
|
31
|
+
begin
|
|
32
|
+
Studio.site_footer_for(self)
|
|
33
|
+
rescue StandardError => error
|
|
34
|
+
raise if studio_site_footer_raise?
|
|
35
|
+
|
|
36
|
+
studio_site_footer_report(error)
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Where the footer shows: Studio.site_footer_visible, asked of this view. By
|
|
42
|
+
# default that is every page for a visitor and, for a signed-in viewer, only
|
|
43
|
+
# the controllers in Studio.site_footer_controllers (plus the engine's booking
|
|
44
|
+
# page). Always false for an app that declared no footer.
|
|
45
|
+
def studio_show_site_footer?
|
|
46
|
+
return false if studio_site_footer_facts.nil?
|
|
47
|
+
|
|
48
|
+
Studio.site_footer_visible.call(self) ? true : false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The one layout line. Renders the footer (and, with a booking_url, the
|
|
52
|
+
# booking popup) where studio_show_site_footer? says it belongs.
|
|
53
|
+
def studio_site_footer
|
|
54
|
+
return unless studio_show_site_footer?
|
|
55
|
+
|
|
56
|
+
render "studio/site_footer/footer", facts: studio_site_footer_facts
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The live map alone. With no arguments it maps the footer's address;
|
|
60
|
+
# pass lat:/lng: (and street:/city_line:/directions_url:) to map another.
|
|
61
|
+
# `class:` and `style:` size it: the element has no height of its own.
|
|
62
|
+
def studio_footer_map(address = nil, **options)
|
|
63
|
+
address = address ? Studio::SiteFooter.address(Studio::SiteFooter.symbolize(address)) : studio_site_footer_facts&.dig(:address)
|
|
64
|
+
return unless address && address[:map]
|
|
65
|
+
|
|
66
|
+
render "studio/site_footer/map", address: address, classes: options[:class], style: options[:style],
|
|
67
|
+
zoom: options[:zoom], controls: options.fetch(:controls, true)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ---- booking ------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def studio_booking? = Studio.booking_url.present?
|
|
73
|
+
|
|
74
|
+
# Google's embeddable page for the configured schedule (with gv=true).
|
|
75
|
+
def studio_booking_embed_url = Studio::Booking.embed_url(Studio.booking_url)
|
|
76
|
+
|
|
77
|
+
# Where a booking link goes when the popup cannot open (scripts off, a
|
|
78
|
+
# modified click, a page with no dialog): the app's booking page when the
|
|
79
|
+
# engine draws it, else Google's own page.
|
|
80
|
+
def studio_booking_fallback_path
|
|
81
|
+
return studio_booking_path if Studio.draw_booking_routes && respond_to?(:studio_booking_path)
|
|
82
|
+
|
|
83
|
+
Studio.booking_url
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The frame's accessible name.
|
|
87
|
+
def studio_booking_title(title = nil)
|
|
88
|
+
title.presence || studio_site_footer_facts&.dig(:booking_title) ||
|
|
89
|
+
"#{studio_booking_label} with #{studio_site_footer_name}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# What the booking links, the popup and the booking page call the act.
|
|
93
|
+
def studio_booking_label
|
|
94
|
+
studio_site_footer_facts&.dig(:booking_label) || Studio::SiteFooter::DEFAULT_BOOKING_LABEL
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The site's name: the footer's, else the site identity's title.
|
|
98
|
+
def studio_site_footer_name
|
|
99
|
+
studio_site_footer_facts&.dig(:name) || Studio.site_identity[:title]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The inline booking frame. `crop: false` shows Google's whole page at rest.
|
|
103
|
+
def studio_booking_frame(title: nil, crop: true)
|
|
104
|
+
return unless studio_booking?
|
|
105
|
+
|
|
106
|
+
render "studio/booking/frame", title: studio_booking_title(title), crop: crop
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The booking dialog, once per page however often it is asked for. The
|
|
110
|
+
# footer renders it; call this yourself only on a page that has booking
|
|
111
|
+
# links and no footer.
|
|
112
|
+
def studio_booking_popup(label: nil, title: nil)
|
|
113
|
+
return unless studio_booking?
|
|
114
|
+
return if @_studio_booking_popup_rendered
|
|
115
|
+
|
|
116
|
+
@_studio_booking_popup_rendered = true
|
|
117
|
+
render "studio/booking/popup",
|
|
118
|
+
label: label.presence || studio_booking_label,
|
|
119
|
+
title: studio_booking_title(title)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A link that opens the booking popup in place. Its href is the fallback, so
|
|
123
|
+
# it is an ordinary link wherever the popup is unavailable. With no
|
|
124
|
+
# booking_url and no href there is nowhere to go, and it renders nothing.
|
|
125
|
+
#
|
|
126
|
+
# <%= studio_booking_link %> "Schedule a call"
|
|
127
|
+
# <%= studio_booking_link "Book a call", class: "btn btn-primary" %>
|
|
128
|
+
# <%= studio_booking_link "Book a call", contact_path %> your own fallback
|
|
129
|
+
def studio_booking_link(name = nil, href = nil, html_options = nil, **options, &block)
|
|
130
|
+
html_options, href = href, nil if href.is_a?(Hash)
|
|
131
|
+
html_options, name = name, nil if name.is_a?(Hash)
|
|
132
|
+
html_options = (html_options || {}).merge(options)
|
|
133
|
+
href ||= studio_booking_fallback_path
|
|
134
|
+
return if href.blank?
|
|
135
|
+
|
|
136
|
+
html_options[:data] = { booking_popup: true, studio_booking: true }.merge(html_options[:data] || {}) if studio_booking?
|
|
137
|
+
content = block ? capture(&block) : (name || studio_booking_label)
|
|
138
|
+
link_to(content, href, html_options)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# ---- once-per-page assets -----------------------------------------------
|
|
142
|
+
|
|
143
|
+
def studio_site_footer_assets
|
|
144
|
+
return if @_studio_site_footer_assets_rendered
|
|
145
|
+
|
|
146
|
+
@_studio_site_footer_assets_rendered = true
|
|
147
|
+
render "studio/site_footer/assets"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def studio_booking_assets
|
|
151
|
+
return if @_studio_booking_assets_rendered
|
|
152
|
+
|
|
153
|
+
@_studio_booking_assets_rendered = true
|
|
154
|
+
render "studio/booking/assets"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def studio_site_footer_raise?
|
|
160
|
+
defined?(Rails) && Rails.respond_to?(:env) && (Rails.env.development? || Rails.env.test?)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def studio_site_footer_report(error)
|
|
164
|
+
return if Studio::SiteFooterHelper.reported
|
|
165
|
+
|
|
166
|
+
Studio::SiteFooterHelper.reported = true
|
|
167
|
+
if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
168
|
+
::ErrorLog.capture!(error)
|
|
169
|
+
elsif defined?(Rails.logger) && Rails.logger
|
|
170
|
+
Rails.logger.error("[studio.site_footer] #{error.class}: #{error.message}")
|
|
171
|
+
end
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
class << self
|
|
177
|
+
attr_accessor :reported
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -33,7 +33,7 @@ module Studio
|
|
|
33
33
|
|
|
34
34
|
# Read on EVERY page render (the head's tags), edited a few times a year. So
|
|
35
35
|
# the stored values are cached, and busted on any write — see .stored.
|
|
36
|
-
CACHE_KEY_PREFIX = "studio/site_identity/
|
|
36
|
+
CACHE_KEY_PREFIX = "studio/site_identity/v2" # v2: adds image_width/image_height
|
|
37
37
|
CACHE_TTL = 1.hour
|
|
38
38
|
|
|
39
39
|
TITLE_MAX = 200
|
|
@@ -194,12 +194,45 @@ module Studio
|
|
|
194
194
|
|
|
195
195
|
def reset_static_image!
|
|
196
196
|
@static_files = nil
|
|
197
|
+
@static_dimensions = nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# [width, height] of the static fallback, read from the file's header, or
|
|
201
|
+
# nil when there is no local file (an absolute URL is never fetched) or its
|
|
202
|
+
# format is not one Studio::LinkPreview.image_dimensions reads. Memoized
|
|
203
|
+
# per path like static_image.
|
|
204
|
+
def static_image_dimensions
|
|
205
|
+
path = static_image
|
|
206
|
+
return nil if path.nil? || !path.start_with?("/") || path.start_with?("//")
|
|
207
|
+
|
|
208
|
+
@static_dimensions ||= Concurrent::Map.new
|
|
209
|
+
dims = @static_dimensions.compute_if_absent(path) do
|
|
210
|
+
Studio::LinkPreview.image_dimensions(Rails.public_path.join(path.delete_prefix("/"))) || false
|
|
211
|
+
end
|
|
212
|
+
dims || nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# [width, height] from an attachment's or blob's ANALYZED metadata, or nil
|
|
216
|
+
# before Active Storage's analyze job has run (or for a non-image).
|
|
217
|
+
def image_dimensions(attachable)
|
|
218
|
+
return nil if attachable.nil?
|
|
219
|
+
return nil if attachable.respond_to?(:attached?) && !attachable.attached?
|
|
220
|
+
|
|
221
|
+
blob = attachable.respond_to?(:blob) ? attachable.blob : attachable
|
|
222
|
+
metadata = blob.respond_to?(:metadata) ? blob.metadata : nil
|
|
223
|
+
return nil unless metadata.is_a?(Hash)
|
|
224
|
+
|
|
225
|
+
width = metadata["width"] || metadata[:width]
|
|
226
|
+
height = metadata["height"] || metadata[:height]
|
|
227
|
+
width.is_a?(Integer) && height.is_a?(Integer) && width.positive? && height.positive? ? [width, height] : nil
|
|
228
|
+
rescue StandardError
|
|
229
|
+
nil
|
|
197
230
|
end
|
|
198
231
|
|
|
199
232
|
private
|
|
200
233
|
|
|
201
234
|
def empty_stored
|
|
202
|
-
{ title: nil, description: nil, image_url: nil, image_path: nil }
|
|
235
|
+
{ title: nil, description: nil, image_url: nil, image_path: nil, image_width: nil, image_height: nil }
|
|
203
236
|
end
|
|
204
237
|
|
|
205
238
|
def compute_stored
|
|
@@ -207,11 +240,14 @@ module Studio
|
|
|
207
240
|
return empty_stored if row.nil?
|
|
208
241
|
|
|
209
242
|
location = row.respond_to?(:image) ? image_location(row.image) : nil
|
|
243
|
+
width, height = (image_dimensions(row.image) if location)
|
|
210
244
|
{
|
|
211
245
|
title: row.title.presence,
|
|
212
246
|
description: row.description.presence,
|
|
213
247
|
image_url: location&.dig(:url),
|
|
214
|
-
image_path: location&.dig(:path)
|
|
248
|
+
image_path: location&.dig(:path),
|
|
249
|
+
image_width: width,
|
|
250
|
+
image_height: height
|
|
215
251
|
}
|
|
216
252
|
end
|
|
217
253
|
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Studio::LinkPreviewHelper#studio_link_preview: the page override, else the
|
|
4
4
|
operator default from /admin/link_preview, else the static fallback.
|
|
5
5
|
Lifted from turf-monster's layouts/_link_preview_meta. og:image is absolute
|
|
6
|
-
and permanent; the tag is omitted rather than emitted empty.
|
|
6
|
+
and permanent; the tag is omitted rather than emitted empty. og:image:width
|
|
7
|
+
and og:image:height appear only when the image's size is known. %>
|
|
7
8
|
<meta property="og:site_name" content="<%= preview[:site_name] %>">
|
|
8
9
|
<meta property="og:type" content="website">
|
|
9
10
|
<meta property="og:title" content="<%= preview[:title] %>">
|
|
@@ -15,6 +16,13 @@
|
|
|
15
16
|
<% end %>
|
|
16
17
|
<% if preview[:image] %>
|
|
17
18
|
<meta property="og:image" content="<%= preview[:image] %>">
|
|
19
|
+
<% if preview[:image_width] && preview[:image_height] %>
|
|
20
|
+
<meta property="og:image:width" content="<%= preview[:image_width] %>">
|
|
21
|
+
<meta property="og:image:height" content="<%= preview[:image_height] %>">
|
|
22
|
+
<% end %>
|
|
23
|
+
<% if preview[:image_alt] %>
|
|
24
|
+
<meta property="og:image:alt" content="<%= preview[:image_alt] %>">
|
|
25
|
+
<% end %>
|
|
18
26
|
<% end %>
|
|
19
27
|
<meta name="twitter:card" content="<%= preview[:image] ? "summary_large_image" : "summary" %>">
|
|
20
28
|
<meta name="twitter:title" content="<%= preview[:title] %>">
|
|
@@ -23,4 +31,7 @@
|
|
|
23
31
|
<% end %>
|
|
24
32
|
<% if preview[:image] %>
|
|
25
33
|
<meta name="twitter:image" content="<%= preview[:image] %>">
|
|
34
|
+
<% if preview[:image_alt] %>
|
|
35
|
+
<meta name="twitter:image:alt" content="<%= preview[:image_alt] %>">
|
|
36
|
+
<% end %>
|
|
26
37
|
<% end %>
|