studio-engine 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/README.md +3 -0
  4. data/app/assets/javascripts/studio/leaflet.js +42 -0
  5. data/app/assets/stylesheets/studio/leaflet.css +693 -0
  6. data/app/controllers/concerns/studio/link_preview_bots.rb +45 -2
  7. data/app/controllers/studio/bookings_controller.rb +22 -0
  8. data/app/controllers/studio/site_identities_controller.rb +15 -3
  9. data/app/helpers/studio/link_preview_helper.rb +54 -6
  10. data/app/helpers/studio/site_footer_helper.rb +180 -0
  11. data/app/models/studio/site_identity.rb +39 -3
  12. data/app/views/layouts/studio/_link_preview_tags.html.erb +12 -1
  13. data/app/views/studio/booking/_assets.html.erb +162 -0
  14. data/app/views/studio/booking/_frame.html.erb +41 -0
  15. data/app/views/studio/booking/_popup.html.erb +34 -0
  16. data/app/views/studio/bookings/show.html.erb +20 -0
  17. data/app/views/studio/site_footer/_assets.html.erb +209 -0
  18. data/app/views/studio/site_footer/_footer.html.erb +99 -0
  19. data/app/views/studio/site_footer/_link.html.erb +14 -0
  20. data/app/views/studio/site_footer/_map.html.erb +22 -0
  21. data/app/views/studio/site_footer/_social_icon.html.erb +23 -0
  22. data/app/views/style/_site_footer.html.erb +79 -0
  23. data/app/views/style/_tricks.html.erb +3 -0
  24. data/lib/generators/studio/site_identity/site_identity_generator.rb +6 -2
  25. data/lib/studio/booking.rb +47 -0
  26. data/lib/studio/engine.rb +2 -0
  27. data/lib/studio/link_preview.rb +91 -0
  28. data/lib/studio/site_footer.rb +294 -0
  29. data/lib/studio/version.rb +1 -1
  30. data/lib/studio.rb +105 -0
  31. metadata +18 -2
@@ -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: request.original_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
- Rails.logger&.warn("[studio.link_preview] slim render skipped: #{e.class}: #{e.message}")
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 = request.host
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: request.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: request.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:, site_name:, url: }.
37
- # `image` is absolute (or nil). Never raises: a preview must not 500 a page.
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: [studio_link_preview_image_location(overrides[:image]), studio_link_preview_content(:og_image)],
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: (request.original_url if respond_to?(:request) && request)
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
- respond_to?(:request) && request ? request.base_url : nil
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/v1"
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 %>