studio-engine 0.81.1 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Studio
6
+ module Generators
7
+ # Adopt the site identity + link-preview primitive in one repeatable step:
8
+ #
9
+ # bin/rails g studio:site_identity \
10
+ # --title "Turf Monster" \
11
+ # --description "Skill-based pick'em contests with transparent payouts."
12
+ # bin/rails db:migrate
13
+ #
14
+ # What it does, each step idempotent (a second run changes nothing):
15
+ #
16
+ # 1. copies the engine's studio_site_identities migration into
17
+ # db/migrate, in exactly the form `studio_engine:install:migrations`
18
+ # writes (so that task later sees it and skips it) — and ONLY that
19
+ # migration, not every pending engine migration;
20
+ # 2. writes the DRAFTED title and description into
21
+ # config/initializers/studio.rb as config.site_title /
22
+ # config.site_description (the standing convention: an agent drafts them
23
+ # when it sets an app up, Alex edits them at /admin/link_preview);
24
+ # 3. includes Studio::LinkPreviewBots in ApplicationController, so preview
25
+ # fetchers get the slim page under Apple's 1 MiB limit.
26
+ #
27
+ # --own-tags is for an app that still writes its own og tags (turf-monster,
28
+ # cyvasse): it sets config.link_preview_tags = false, so installing the table
29
+ # does not add a second set before the app's own are deleted.
30
+ class SiteIdentityGenerator < Rails::Generators::Base
31
+ MIGRATION_NAME = "create_studio_site_identities"
32
+ ENGINE_SCOPE = "studio_engine"
33
+ INITIALIZER = "config/initializers/studio.rb"
34
+ CONTROLLER = "app/controllers/application_controller.rb"
35
+
36
+ class_option :title, type: :string, desc: "The drafted site title (config.site_title)"
37
+ class_option :description, type: :string, desc: "The drafted site description (config.site_description)"
38
+ class_option :own_tags, type: :boolean, default: false,
39
+ desc: "This app still writes its own og tags: set config.link_preview_tags = false"
40
+ class_option :skip_bots, type: :boolean, default: false,
41
+ desc: "Do not include Studio::LinkPreviewBots in ApplicationController"
42
+
43
+ def copy_migration
44
+ existing = Dir.glob(File.join(destination_root, "db/migrate/*_#{MIGRATION_NAME}{,.#{ENGINE_SCOPE}}.rb"))
45
+ if existing.any?
46
+ say_status :identical, relative(existing.first), :blue
47
+ return
48
+ end
49
+
50
+ source = self.class.engine_migration
51
+ original_version = File.basename(source)[/\A\d+/]
52
+ body = "# This migration comes from #{ENGINE_SCOPE} (originally #{original_version})\n#{File.read(source)}"
53
+ create_file "db/migrate/#{self.class.next_version}_#{MIGRATION_NAME}.#{ENGINE_SCOPE}.rb", body
54
+ end
55
+
56
+ def configure_initializer
57
+ path = File.join(destination_root, INITIALIZER)
58
+ unless File.exist?(path)
59
+ say_status :skip, "#{INITIALIZER} not found — add the site_title/site_description lines by hand", :yellow
60
+ return
61
+ end
62
+
63
+ content = File.read(path)
64
+ if content.include?("config.site_title")
65
+ say_status :identical, "#{INITIALIZER} (config.site_title already set)", :blue
66
+ return
67
+ end
68
+
69
+ anchor = content[/^Studio\.configure do \|(\w+)\|\n/]
70
+ unless anchor
71
+ say_status :skip, "#{INITIALIZER} has no `Studio.configure do |config|` block", :yellow
72
+ return
73
+ end
74
+
75
+ inject_into_file INITIALIZER, initializer_block(anchor[/\|(\w+)\|/, 1]), after: anchor
76
+ end
77
+
78
+ def include_bot_concern
79
+ return if options[:skip_bots]
80
+
81
+ path = File.join(destination_root, CONTROLLER)
82
+ return say_status(:skip, "#{CONTROLLER} not found", :yellow) unless File.exist?(path)
83
+ return say_status(:identical, "#{CONTROLLER} (Studio::LinkPreviewBots)", :blue) if File.read(path).include?("Studio::LinkPreviewBots")
84
+
85
+ inject_into_class CONTROLLER, "ApplicationController", <<~RUBY
86
+ # Preview fetchers (iMessage, Slack, Discord, X...) get a slim page under
87
+ # Apple's 1 MiB limit. studio-engine docs/LINK_PREVIEW.md.
88
+ include Studio::LinkPreviewBots
89
+ RUBY
90
+ end
91
+
92
+ def next_steps
93
+ say <<~TEXT
94
+
95
+ Site identity installed. Next:
96
+ bin/rails db:migrate
97
+ Visit /admin/link_preview to set the image and edit the title and description.
98
+ Read the copy anywhere with Studio.site_identity (or studio_site_identity in a view).
99
+ TEXT
100
+ end
101
+
102
+ def self.engine_migration
103
+ File.expand_path("../../../../db/migrate/20260930120000_#{MIGRATION_NAME}.rb", __dir__)
104
+ end
105
+
106
+ def self.next_version
107
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
108
+ end
109
+
110
+ private
111
+
112
+ def relative(path)
113
+ path.delete_prefix("#{destination_root}/")
114
+ end
115
+
116
+ def initializer_block(var)
117
+ title = options[:title].to_s.strip
118
+ description = options[:description].to_s.strip
119
+ lines = []
120
+ lines << ""
121
+ lines << " # ---- Site identity + link preview (studio-engine docs/LINK_PREVIEW.md) ----"
122
+ lines << " # The DRAFTED title and description; the operator edits them at"
123
+ lines << " # /admin/link_preview, and Studio.site_identity reads the result."
124
+ lines << (title.empty? ? " # #{var}.site_title = \"Draft the site title\"" : " #{var}.site_title = #{title.inspect}")
125
+ lines << (description.empty? ? " # #{var}.site_description = \"Draft one or two sentences\"" : " #{var}.site_description = #{description.inspect}")
126
+ if options[:own_tags]
127
+ lines << " # This app still writes its own og tags. Delete them, then remove this line."
128
+ lines << " #{var}.link_preview_tags = false"
129
+ end
130
+ lines << ""
131
+ lines.join("\n") + "\n"
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Studio
6
+ # The house link-preview primitive: what an unfurl (iMessage, Slack, Discord,
7
+ # X, WhatsApp...) shows when someone pastes a link to this app.
8
+ #
9
+ # Deliberately PURE, like Studio::Geo: plain strings in, plain strings and
10
+ # hashes out, no request and no database. The pieces that need the world live
11
+ # beside it:
12
+ #
13
+ # Studio::SiteIdentity (model) the operator's default image, title and
14
+ # description, set at /admin/link_preview
15
+ # Studio::LinkPreviewHelper (view helper) the page override
16
+ # (`link_preview`) and the tags the head renders
17
+ # Studio::LinkPreviewBots (controller concern) the slim document preview
18
+ # fetchers get instead of the full page
19
+ #
20
+ # Lifted from turf-monster (OgHelper, SiteSetting, OgImageAttachable and the
21
+ # app-local LinkPreviewBot of task imessage-link-preview-fix), which carried
22
+ # all of this in production first. See docs/LINK_PREVIEW.md.
23
+ module LinkPreview
24
+ # Apple's LinkPresentation (the iMessage unfurler, which runs on the SENDER's
25
+ # phone) aborts any HTML page over this many bytes with WebKitErrorDomain 102,
26
+ # "Frame load interrupted". Measured 2026-09-30: 1,048,000 bytes previewed,
27
+ # 1,049,000 failed. The slim document is held under it.
28
+ MAX_DOCUMENT_BYTES = 1_048_576
29
+
30
+ # The preview fetchers, by User-Agent token. An ALLOW-LIST: only an agent
31
+ # named here gets the slim document; a person, or an agent nobody named,
32
+ # always gets the full page. Each token is a product's own FETCHER, not its
33
+ # in-app browser: Facebook's in-app browser sends FBAN/FBAV, LinkedIn's sends
34
+ # LinkedInApp, X's sends "Twitter for iPhone", and none of those match.
35
+ #
36
+ # iMessage has no token of its own. LinkPresentation sends an old-Safari UA
37
+ # suffixed "facebookexternalhit/1.1 Facebot Twitterbot/1.0", so it rides the
38
+ # Facebook and X tokens.
39
+ BOT_TOKENS = [
40
+ "facebookexternalhit", # Facebook, Messenger; also Apple LinkPresentation (iMessage)
41
+ "Facebot", # Facebook; also iMessage
42
+ "Twitterbot", # X; also iMessage
43
+ "Discordbot",
44
+ "Slackbot-LinkExpanding",
45
+ "LinkedInBot",
46
+ "WhatsApp/", # the fetcher sends WhatsApp/<version>
47
+ "TelegramBot",
48
+ "Applebot", # Siri / Spotlight suggestions
49
+ "SkypeUriPreview", # Skype and Teams
50
+ "redditbot",
51
+ "Embedly"
52
+ ].freeze
53
+
54
+ BOT_PATTERN = Regexp.union(BOT_TOKENS.map { |token| /#{Regexp.escape(token)}/i }).freeze
55
+
56
+ # What the slim document keeps from the page's head. Everything a fetcher
57
+ # reads for a card, and nothing it executes: no script, style or template.
58
+ KEPT_META_NAMES = %w[
59
+ description robots theme-color application-name author keywords
60
+ ].freeze
61
+ KEPT_LINK_RELS = %w[
62
+ canonical icon shortcut\ icon apple-touch-icon apple-touch-icon-precomposed manifest mask-icon image_src
63
+ ].freeze
64
+
65
+ module_function
66
+
67
+ # The first view template under `views_root` that writes its own og:title or
68
+ # og:image, or nil. Studio.link_preview_tags? asks this under :auto, so an
69
+ # app that still writes its own preview tags (turf-monster's
70
+ # layouts/_link_preview_meta, cyvasse's layouts/_seo) does not get a second
71
+ # set the day it installs the engine's migrations — which docs/
72
+ # NEW_APP_SETUP.md tells every app to do after every upgrade.
73
+ #
74
+ # Deliberately BROAD: any mention counts, a comment included. A false
75
+ # positive only keeps the engine's tags off (an app sets
76
+ # link_preview_tags = true to override); a false negative would double them.
77
+ OWN_TAG_PATTERN = /og:(?:title|image)\b/
78
+ TEMPLATE_GLOB = "**/*.{erb,haml,slim}"
79
+
80
+ def own_tag_file(views_root)
81
+ root = views_root.to_s
82
+ return nil if root.empty? || !File.directory?(root)
83
+
84
+ Dir.glob(File.join(root, TEMPLATE_GLOB)).sort.find do |path|
85
+ File.read(path, encoding: "UTF-8").scrub.match?(OWN_TAG_PATTERN)
86
+ rescue SystemCallError
87
+ false
88
+ end
89
+ end
90
+
91
+ # Is this User-Agent a link-preview fetcher? Blank is never a bot.
92
+ def bot?(user_agent)
93
+ ua = user_agent.to_s
94
+ return false if ua.strip.empty?
95
+
96
+ BOT_PATTERN.match?(ua)
97
+ end
98
+
99
+ # THE RESOLUTION CHAIN. Each argument is a list of rungs, most specific
100
+ # first; the first present rung wins.
101
+ #
102
+ # title page override(s) -> operator default -> site name
103
+ # description page override(s) -> operator default -> none (tag omitted)
104
+ # image page override(s) -> operator default -> static fallback -> none
105
+ #
106
+ # A page override with NO image (a user with no avatar) is simply a blank
107
+ # rung, so it falls through to the default: that is the whole override rule.
108
+ # `image_source` says which rung answered (:page, :default, :static, :none).
109
+ def resolve(site_name:, titles: [], descriptions: [], page_images: [], default_image: nil, static_image: nil)
110
+ page_image = first_present(page_images)
111
+ image, source =
112
+ if page_image then [page_image, :page]
113
+ elsif present?(default_image) then [default_image.to_s, :default]
114
+ elsif present?(static_image) then [static_image.to_s, :static]
115
+ else [nil, :none]
116
+ end
117
+
118
+ {
119
+ title: first_present(titles) || site_name.to_s,
120
+ description: first_present(descriptions),
121
+ image: image,
122
+ image_source: source
123
+ }
124
+ end
125
+
126
+ # An image URL a fetcher can follow from anywhere. Unfurlers resolve nothing
127
+ # relative, so a root-relative path is joined to the request's base URL; a
128
+ # protocol-relative one is given https. Absolute URLs pass through.
129
+ def absolute_url(url, base_url:)
130
+ value = url.to_s.strip
131
+ return nil if value.empty?
132
+ return "https:#{value}" if value.start_with?("//")
133
+ return "#{base_url.to_s.chomp("/")}#{value}" if value.start_with?("/")
134
+
135
+ value
136
+ end
137
+
138
+ # The whole response a preview fetcher gets: the page's own identity tags,
139
+ # lifted out of its <head>, and a one-card body. Built FROM the rendered
140
+ # page, so the slim document cannot drift from what a person's page says.
141
+ #
142
+ # DUPLICATE-SAFE: a meta tag named twice (an app emitting its own og tags
143
+ # AND the engine's) is kept once, the FIRST occurrence — the one the page
144
+ # put first, which is the one an app that owns its tags writes.
145
+ def slim_document(html, url: nil)
146
+ source = html.to_s.dup.force_encoding(Encoding::UTF_8).scrub
147
+ head = source[%r{<head\b[^>]*>(.*?)</head\s*>}mi, 1] || source
148
+ head = head.gsub(%r{<(script|style|template|noscript)\b.*?</\1\s*>}mi, "").gsub(/<!--.*?-->/m, "")
149
+ lang = source[/<html\b[^>]*\blang\s*=\s*["']([^"']+)["']/i, 1]
150
+ title = head[%r{<title\b[^>]*>(.*?)</title\s*>}mi, 1]&.strip
151
+
152
+ tags = kept_tags(head)
153
+ document = build_document(tags, title: title, lang: lang, url: url)
154
+ return document if document.bytesize < MAX_DOCUMENT_BYTES
155
+
156
+ # A head that is itself enormous (an inline data: icon, say). Keep only
157
+ # what an unfurl card is made of.
158
+ core = tags.select { |tag| tag_key(tag).to_s.match?(/\A(og:|twitter:|description\z)/) }
159
+ build_document(core, title: title, lang: lang, url: url)
160
+ end
161
+
162
+ def kept_tags(head)
163
+ seen = {}
164
+ head.scan(/<meta\b[^>]*>|<link\b[^>]*>/i).select do |tag|
165
+ next false unless keep_tag?(tag)
166
+
167
+ key = tag_key(tag)
168
+ next true if key.nil?
169
+ next false if seen[key]
170
+
171
+ seen[key] = true
172
+ end
173
+ end
174
+
175
+ def keep_tag?(tag)
176
+ if tag.match?(/\A<link/i)
177
+ rel = attribute(tag, "rel").to_s.downcase.strip
178
+ return KEPT_LINK_RELS.include?(rel)
179
+ end
180
+ return false if attribute(tag, "charset") # the document writes its own
181
+
182
+ property = attribute(tag, "property").to_s
183
+ return true unless property.empty?
184
+
185
+ name = attribute(tag, "name").to_s.downcase
186
+ name.start_with?("twitter:") || KEPT_META_NAMES.include?(name)
187
+ end
188
+
189
+ # The identity a duplicate is judged by: a meta's property or name, a link's
190
+ # rel. Case-folded, because <meta name="Description"> is the same claim.
191
+ def tag_key(tag)
192
+ if tag.match?(/\A<link/i)
193
+ rel = attribute(tag, "rel")
194
+ return rel ? "link:#{rel.downcase}:#{attribute(tag, "sizes")}" : nil
195
+ end
196
+ (attribute(tag, "property") || attribute(tag, "name"))&.downcase
197
+ end
198
+
199
+ def attribute(tag, name)
200
+ tag[/\s#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i, 1] || tag[/\s#{Regexp.escape(name)}\s*=\s*'([^']*)'/i, 1]
201
+ end
202
+
203
+ def build_document(tags, title:, lang:, url:)
204
+ og_title = content_of(tags, "og:title") || title
205
+ og_description = content_of(tags, "og:description") || content_of(tags, "description")
206
+ lang_attr = lang ? %( lang="#{ERB::Util.html_escape(lang)}") : ""
207
+
208
+ body = +""
209
+ body << "<h1>#{og_title}</h1>\n" if og_title
210
+ body << "<p>#{og_description}</p>\n" if og_description
211
+ body << %(<p><a href="#{ERB::Util.html_escape(url)}">#{og_title || ERB::Util.html_escape(url)}</a></p>\n) if url
212
+
213
+ <<~HTML
214
+ <!DOCTYPE html>
215
+ <html#{lang_attr}>
216
+ <head>
217
+ <meta charset="utf-8">
218
+ #{"<title>#{title}</title>\n" if title}#{tags.join("\n")}
219
+ </head>
220
+ <body>
221
+ #{body}</body>
222
+ </html>
223
+ HTML
224
+ end
225
+
226
+ # Already escaped: the value came out of a rendered attribute, so it is
227
+ # emitted back into markup exactly as the page wrote it.
228
+ def content_of(tags, key)
229
+ tag = tags.find { |t| tag_key(t) == key }
230
+ tag && attribute(tag, "content")
231
+ end
232
+
233
+ def first_present(values)
234
+ Array(values).each do |value|
235
+ return value.to_s.strip if present?(value)
236
+ end
237
+ nil
238
+ end
239
+
240
+ def present?(value)
241
+ !value.nil? && !value.to_s.strip.empty?
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The public user page's two rules, in one place: which account a /u/:username
5
+ # URL names, and which username a link to an account carries.
6
+ #
7
+ # THE KEY IS `username` AND NOTHING ELSE. It is the one identity field a person
8
+ # chose to show other people. The hub apps' `slug` is keyed on the email, and a
9
+ # lookup by email would tell a stranger whether an address has an account, so
10
+ # neither ever stands in: an app without a username column serves no public
11
+ # pages (every lookup misses, every link helper answers nil) until it adds one.
12
+ #
13
+ # Pure lookups, no request: Studio::PublicUsersController and
14
+ # Studio::PublicUserHelper both call these, so the page and the links to it
15
+ # cannot disagree about what a username is.
16
+ module PublicUser
17
+ COLUMN = "username"
18
+
19
+ module_function
20
+
21
+ # Whether the host's User model can have a public page at all.
22
+ def supported?(user_class = default_user_class)
23
+ return false if user_class.nil?
24
+
25
+ user_class.respond_to?(:column_names) && user_class.column_names.include?(COLUMN)
26
+ rescue StandardError
27
+ false
28
+ end
29
+
30
+ # The account a /u/:username URL names, or nil. Case-insensitive: /u/Alex
31
+ # and /u/alex are the same page. An exact match wins first, because an app
32
+ # whose lower(username) index is not unique (cyvasse's) can hold "Alex" and
33
+ # "alex" as two people, and the one who is exactly named should get the page.
34
+ #
35
+ # A host that must hide an account (frozen, merged away, banned) defines
36
+ # `public_profile_visible?` on User; false answers nil, the same miss an
37
+ # unknown username gets, so the page never says why.
38
+ def find(username, user_class: default_user_class)
39
+ name = username.to_s.strip
40
+ return nil if name.empty? || !supported?(user_class)
41
+
42
+ user = user_class.find_by(COLUMN => name) ||
43
+ user_class.where(user_class.arel_table[COLUMN].lower.eq(name.downcase)).order(:id).first
44
+ return nil if user.nil?
45
+ return nil if user.respond_to?(:public_profile_visible?) && !user.public_profile_visible?
46
+
47
+ user
48
+ end
49
+
50
+ # The username a link to this account carries, or nil when it has none.
51
+ # Takes a user, or a username string as given.
52
+ def username_for(user_or_username)
53
+ value = if user_or_username.is_a?(String) || user_or_username.is_a?(Symbol)
54
+ user_or_username.to_s
55
+ elsif user_or_username.respond_to?(COLUMN)
56
+ user_or_username.public_send(COLUMN).to_s
57
+ end
58
+ value&.strip.presence
59
+ end
60
+
61
+ def default_user_class
62
+ defined?(::User) ? ::User : nil
63
+ end
64
+ end
65
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.81.1"
2
+ VERSION = "0.82.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -3,6 +3,7 @@ require "studio/log_rotation"
3
3
  require "studio/ip_locations"
4
4
  require "studio/geo"
5
5
  require "studio/geo/lookup"
6
+ require "studio/link_preview"
6
7
  require "studio/engine"
7
8
  require "studio/color_scale"
8
9
  require "studio/environment_banner"
@@ -19,6 +20,7 @@ require "studio/profile_image"
19
20
  require "studio/oauth_identity"
20
21
  require "studio/newsletter"
21
22
  require "studio/username_generator"
23
+ require "studio/public_user"
22
24
  require "studio/name_parts"
23
25
  require "studio/s3"
24
26
  require "studio/image_cache"
@@ -471,6 +473,125 @@ module Studio
471
473
  # is loading — taking its entire route set down, not just this page.
472
474
  mattr_accessor :draw_geo_routes, default: false
473
475
 
476
+ # ---- Link preview (Studio::LinkPreview, SiteIdentity, LinkPreviewHelper)
477
+ #
478
+ # What an unfurl shows when someone pastes a link to this app: the SITE
479
+ # IDENTITY (Studio::SiteIdentity — image, title, description) the operator sets
480
+ # at /admin/link_preview, which any page may override (`link_preview image:
481
+ # ..., title: ...`). See docs/LINK_PREVIEW.md.
482
+
483
+ # Whether layouts/studio/_head emits the og:/twitter: tags.
484
+ #
485
+ # :auto (default) emit once this app has INSTALLED the engine's
486
+ # studio_site_identities table — installing the
487
+ # migration is the adoption act — and no template under
488
+ # app/views writes its own og:title/og:image. An app that
489
+ # emits its own tags (turf-monster, cyvasse) gets no second
490
+ # set, even after `install:migrations` brings the table in.
491
+ # true always emit (the static fallback and the site name still
492
+ # answer with no table).
493
+ # false never emit from the head; the app renders
494
+ # `studio_link_preview_tags` itself, or owns its tags.
495
+ #
496
+ # An app whose own tags live somewhere the scan cannot see (a helper that
497
+ # builds them in Ruby) sets this false until its adoption deletes them.
498
+ mattr_accessor :link_preview_tags, default: :auto
499
+
500
+ # The Active Storage service the DEFAULT image is attached to. nil = the app's
501
+ # default service. Unfurlers cache the og:image URL and re-fetch it days later,
502
+ # so the tag must be a PERMANENT URL: a service whose `public?` is true answers
503
+ # its own public URL; any other service is served through Rails' storage PROXY
504
+ # route (a permanent URL on this app's own domain, streaming from the private
505
+ # bucket). An app with a public-read service names it here, e.g. turf-monster:
506
+ #
507
+ # config.link_preview_image_service = OgImageAttachable::PUBLIC_OG_SERVICE
508
+ #
509
+ # Read once, when Studio::SiteIdentity loads (has_one_attached's service
510
+ # is a literal fixed at class load), so set it in the initializer.
511
+ mattr_accessor :link_preview_image_service, default: nil
512
+
513
+ # The last image rung, when neither the page nor the operator supplied one. A
514
+ # root-relative path is used only when the file exists under public/, so an app
515
+ # without it emits no og:image rather than a broken one. An absolute URL is
516
+ # trusted as given.
517
+ mattr_accessor :link_preview_fallback_image, default: "/og.png"
518
+
519
+ # THE SITE IDENTITY'S DRAFTED DEFAULTS — this app's title and description as
520
+ # written in code, under whatever the operator saves at /admin/link_preview.
521
+ # The standing convention is that an agent drafts these when it sets an app up
522
+ # (`bin/rails g studio:site_identity --title "..." --description "..."` writes
523
+ # them here) and Alex edits them on the page. nil title means Studio.app_name;
524
+ # nil description means none. Read the resolved answer through
525
+ # Studio.site_identity, never these directly.
526
+ mattr_accessor :site_title, default: nil
527
+ mattr_accessor :site_description, default: nil
528
+
529
+ # Draw /admin/link_preview from Studio.routes. ON by default: no consumer owns
530
+ # these paths or helper names (admin_link_preview, admin_link_preview_image),
531
+ # checked 2026-09-30 across mcritchie-studio, turf-monster, cyvasse and
532
+ # mcritchie-industries. The page explains itself when the table is missing.
533
+ mattr_accessor :draw_link_preview_routes, default: true
534
+
535
+ # Draw the public user page, GET /u/:username (Studio::PublicUsersController,
536
+ # route helper studio_public_user_path; link to it with the view helpers
537
+ # studio_user_profile_path(user) / link_to_user_profile(user)). See
538
+ # docs/PUBLIC_USER_PAGE.md.
539
+ #
540
+ # OFF by default, like every route surface a consumer might already own. No
541
+ # consumer owns /u or the helper name today (checked 2026-09-30 across
542
+ # mcritchie-studio, mcritchie-industries, cyvasse and turf-monster), but the
543
+ # page also needs a `username` column, which the two hub apps do not have yet
544
+ # (their slug is keyed on the email, so it can never stand in). Each app's
545
+ # adoption turns it on:
546
+ #
547
+ # config.draw_public_user_routes = true
548
+ mattr_accessor :draw_public_user_routes, default: false
549
+
550
+ # THE APP'S IDENTITY COPY, resolved: { title:, description:, image_url: }.
551
+ # The operator's saved value (Studio::SiteIdentity, edited at
552
+ # /admin/link_preview) wins, then the drafted Studio.site_title /
553
+ # site_description, then Studio.app_name for the title. image_url is the
554
+ # uploaded image, else the static fallback, else nil — absolute when
555
+ # `base_url` is given (pass request.base_url) or the image lives on a public
556
+ # service. Reuse it anywhere the app needs to say what it is: a meta
557
+ # description, share text, an email footer. Views have `studio_site_identity`.
558
+ # Never raises; an app without the table answers from the drafted defaults.
559
+ def self.site_identity(base_url: nil)
560
+ Studio::SiteIdentity.resolved(base_url: base_url)
561
+ rescue StandardError
562
+ { title: site_title.presence || app_name.to_s, description: site_description.presence, image_url: nil }
563
+ end
564
+
565
+ # :auto emits when the table is installed AND no view of this app writes its
566
+ # own og tags (Studio.link_preview_own_tags_file); true/false are taken as
567
+ # given.
568
+ def self.link_preview_tags?
569
+ case link_preview_tags
570
+ when :auto, "auto", nil then Studio::SiteIdentity.table_ready? && link_preview_own_tags_file.nil?
571
+ else !!link_preview_tags
572
+ end
573
+ rescue StandardError
574
+ false
575
+ end
576
+
577
+ # The host view that writes its own og tags, or nil — scanned once per process
578
+ # (app/views is fixed at deploy) and logged once when it keeps :auto off.
579
+ def self.link_preview_own_tags_file
580
+ return @link_preview_own_tags_file if defined?(@link_preview_own_tags_file)
581
+
582
+ views = defined?(Rails.root) && Rails.root ? Rails.root.join("app/views") : nil
583
+ found = Studio::LinkPreview.own_tag_file(views)
584
+ if found && defined?(Rails.logger) && Rails.logger
585
+ Rails.logger.info("[studio.link_preview] #{found} writes its own og tags, so the engine's head tags " \
586
+ "stay off under link_preview_tags = :auto. Set it to true once they are removed.")
587
+ end
588
+ @link_preview_own_tags_file = found
589
+ end
590
+
591
+ def self.reset_link_preview_own_tags!
592
+ remove_instance_variable(:@link_preview_own_tags_file) if defined?(@link_preview_own_tags_file)
593
+ end
594
+
474
595
  # Whether the engine configures Geocoder on boot (provider, HTTPS, timeout, and
475
596
  # a Rails.cache-backed IP cache). An app that configures Geocoder itself sets
476
597
  # this false; an app with no geocoder gem is unaffected either way.
@@ -1110,6 +1231,25 @@ module Studio
1110
1231
  patch "admin/geo", to: "studio/geo_settings#update", as: :admin_geo_update
1111
1232
  post "admin/geo/toggle", to: "studio/geo_settings#toggle_override", as: :admin_geo_toggle
1112
1233
  end
1234
+
1235
+ # The link-preview default (/admin/link_preview): the image, title and
1236
+ # description every page unfurls with unless it overrides them. ON by
1237
+ # default (Studio.draw_link_preview_routes) because no consumer owns these
1238
+ # names; an app can still switch it off from its initializer.
1239
+ if Studio.draw_link_preview_routes
1240
+ get "admin/link_preview", to: "studio/site_identities#edit", as: :admin_link_preview
1241
+ patch "admin/link_preview", to: "studio/site_identities#update"
1242
+ delete "admin/link_preview/image", to: "studio/site_identities#destroy_image", as: :admin_link_preview_image
1243
+ end
1244
+ # The public user page (/u/:username): avatar and username only, and the
1245
+ # user's avatar as its link-preview image. OPT-IN — see
1246
+ # Studio.draw_public_user_routes. `format: false` because usernames may
1247
+ # carry a dot, which would otherwise be read as a format extension.
1248
+ if Studio.draw_public_user_routes
1249
+ get "u/:username", to: "studio/public_users#show", as: :studio_public_user,
1250
+ format: false, constraints: { username: %r{[^/]+} }
1251
+ end
1252
+
1113
1253
  # The living style guide. Canonical at /admin/style (StyleController#index);
1114
1254
  # /admin/design_system redirects here but KEEPS its admin_design_system_path
1115
1255
  # helper so a shipped host sidebar link on the old helper still resolves.