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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2737532cb60e04de35ec057552292f8307aa863eec2413751cdf4080b079d712
4
- data.tar.gz: cd3a407b3e61463686f245a4e93a30fdbdd67250a2fd6ca09a9a2abbbd507e46
3
+ metadata.gz: baeae6743d5d1c8b5f2157367e1ca7df20e40318cfbeed9961360df5cd3a86b8
4
+ data.tar.gz: 965e67b42b3e04a07b4607dee0994b5b69c8f76dccb648ae4947e26c5d43c5e1
5
5
  SHA512:
6
- metadata.gz: 912655e55dd7e8a93eace9c7a237390144087b72611d88a08075ea8190961a0ba3f0266e8c724c093505cf3fc69596bb6eacce5019347a1c4d9bffd02552d13a
7
- data.tar.gz: cbf461654e119d53e2d7c626b34014d3769f0a536a0d46ecd32c4af25255ff981997e4a8c14ab2d784c27966c5f7085be608250b974e2f1d46f8e14b06c513e8
6
+ metadata.gz: 881ca49a5a383eceae0cebc3a8fd4ecfe1ddafdefe4591e4afed541c205b74346d2b590c54dc1635d7c3c2e96b61714a9304de2ce9886016a1c765a440b2eda9
7
+ data.tar.gz: 1df9079552520f39f9659ce644044342253eed53853fe1145fe0e0ed35c5dd5517d21312557de8ac55494c91acb68912d9b8fa7981275ed3b309e271fb662868
data/CHANGELOG.md CHANGED
@@ -4,6 +4,120 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.82.0 — 2026-09-30
8
+
9
+ ### Added
10
+
11
+ - **A public user page at `/u/:username`, and the helpers that link to it.** It
12
+ shows the user's avatar and username and nothing else (no name, email or
13
+ wallet), and it is where a click on a username in any Studio app should lead.
14
+ See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
15
+ - **Link preview.** The page calls `link_preview image: user.avatar, title:
16
+ username`, so it unfurls with the avatar when one is set and with the site
17
+ identity's image when not. The avatar URL is permanent (a public service's own
18
+ URL, else Rails' storage proxy), never signed and expiring. With
19
+ `Studio::LinkPreviewBots` included, preview bots get the slim page carrying it.
20
+ - **Lookup.** Case-insensitive, with an exact spelling first. It reads
21
+ `username` only, never the email or the slug, so it cannot reveal whether an
22
+ address has an account. A `User#public_profile_visible?` that answers false
23
+ hides an account. Every miss gets the same friendly page with a `404` status.
24
+ - **Public.** The page skips `require_authentication` and renders in the host's
25
+ own layout.
26
+ - **Helpers.** `studio_user_profile_path(user)`, `studio_user_profile_url(user)`
27
+ and `link_to_user_profile(user, text = nil, **html_options, &block)` take a user
28
+ or a username. They answer `nil` (the link helper renders a plain `<span>`)
29
+ when the route is not drawn or the user has no username, so any view can call
30
+ them. The route helper is `studio_public_user_path(username:)`; the rules are
31
+ `Studio::PublicUser`.
32
+ - `components/_avatar` gains an `xl` size and an optional `alt:` local. Existing
33
+ calls render the same.
34
+ - **Opt-in:** `config.draw_public_user_routes` defaults to `false`, so an app that
35
+ owns `/u` is not broken. No consumer owns `/u` or the helper name today.
36
+
37
+ **Adopting, per app** (this release changes none of them on its own):
38
+
39
+ 1. **cyvasse, turf-monster** (already have `users.username` with a
40
+ `lower(username)` index): set `config.draw_public_user_routes = true` in
41
+ `config/initializers/studio.rb`. Define `User#public_profile_visible?` if some
42
+ accounts must not have a page (cyvasse's merged-away accounts, say). Then
43
+ point username links at `link_to_user_profile(user)`.
44
+ 2. **mcritchie-studio, mcritchie-industries** (no `username` column): add
45
+ `users.username` with a unique `lower(username)` index, backfill it
46
+ (`Studio::UsernameGenerator.generate` drafts one), then do step 1. Do not
47
+ reuse `slug`: it is keyed on the email.
48
+ 3. For an avatar unfurl on a private bucket, nothing more is needed: the proxy
49
+ URL serves it. An app with a public-read service gets that service's URL
50
+ automatically.
51
+
52
+ - **Site identity and link previews: a core primitive for every app.** Each app
53
+ now has a site identity, `Studio::SiteIdentity`: a title, a description and an
54
+ image. The operator edits all three at `/admin/link_preview`, beside a live card
55
+ drawn the way an unfurl draws it (image, domain, title, description). Every page
56
+ unfurls with it unless the page overrides it, and a missing override image falls
57
+ back to the site image. Lifted from turf-monster's `OgHelper`, `SiteSetting` and
58
+ `OgImageAttachable`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
59
+ - **Reusable copy.** `Studio.site_identity(base_url:)` (and `studio_site_identity`
60
+ in a view) returns `{ title:, description:, image_url: }`: the operator's saved
61
+ value, then the drafted `config.site_title` / `config.site_description`, then
62
+ `Studio.app_name`. Read it for a meta description, share text or an email footer.
63
+ - **One page override.** `link_preview image:, title:, description:` from any
64
+ view. `image:` takes a URL, a path, or an Active Storage attachment, so
65
+ `link_preview image: user.avatar` works whether or not the user has one.
66
+ turf-monster's `content_for(:title)`, `content_for(:meta_description)` and
67
+ `content_for(:og_image)` keys are honoured beneath it.
68
+ - **Permanent image URLs.** A public service answers its own URL; any other
69
+ service goes through Rails' storage proxy, never a signed, expiring URL. Name a
70
+ public service with `config.link_preview_image_service`.
71
+ - **Preview bots get a slim page.** `include Studio::LinkPreviewBots` serves the
72
+ known fetchers (iMessage, Facebook, X, Discord, Slack, LinkedIn, WhatsApp,
73
+ Telegram, Applebot, Skype, Reddit, Embedly) the page's head tags and a one-card
74
+ body, under Apple LinkPresentation's 1 MiB limit (1,048,000 bytes previews,
75
+ 1,049,000 fails). People and in-app browsers always get the full page.
76
+ - **Opt-in by installation, and duplicate-safe.** Under the new default
77
+ `config.link_preview_tags = :auto`, the head emits the tags only once the app
78
+ has installed the `studio_site_identities` table AND no template under its
79
+ `app/views` writes its own `og:title` or `og:image` (scanned once per process,
80
+ logged when it holds the tags off). So turf-monster and cyvasse, which write
81
+ their own, get no second set even after `studio_engine:install:migrations`
82
+ brings the table in. Set `true` to override the scan, `false` to keep the head
83
+ silent. The slim bot page also keeps only the first of any duplicated tag.
84
+ - **One adoption step.** `bin/rails g studio:site_identity --title "..."
85
+ --description "..."` copies only this migration (in the form
86
+ `studio_engine:install:migrations` writes, so that task skips it later), writes
87
+ the drafted copy into `config/initializers/studio.rb`, and includes
88
+ `Studio::LinkPreviewBots` in `ApplicationController`. `--own-tags` also sets
89
+ `config.link_preview_tags = false`. It is idempotent.
90
+ - **Drafted defaults.** An agent drafts the title and description when setting an
91
+ app up; the operator edits them on the page, and a saved value wins.
92
+ `Studio::SiteIdentity.seed!(title:, description:)` carries a draft into the row
93
+ from `db/seeds.rb` or a release task, filling only blank fields.
94
+ - `/admin/link_preview` is drawn by default (`config.draw_link_preview_routes`);
95
+ no consumer owns `admin_link_preview_path` or `admin_link_preview_image_path`.
96
+ It needs Active Storage for the upload; without it the page still edits the
97
+ words.
98
+
99
+ **Adopting, per app** (each is one short change; this release changes none of
100
+ them on its own):
101
+
102
+ 1. **mcritchie-studio, mcritchie-industries** (no og tags of their own):
103
+ `bin/rails g studio:site_identity --title "..." --description "..."`, then
104
+ `bin/rails db:migrate`. The tags come from `layouts/studio/head`, which both
105
+ layouts already render. Optionally add a 1200 × 630 `public/og.png` as the
106
+ last-resort image. Set the image at `/admin/link_preview`.
107
+ 2. **cyvasse** (writes og tags in `layouts/_seo.html.erb`): run the generator with
108
+ `--own-tags` and migrate. Then in `_seo`, replace the og:/twitter: tags with
109
+ `link_preview title: page.title, description: page.description, image: og_image`
110
+ (keep description, robots, canonical and JSON-LD, which are SEO, not preview),
111
+ and delete `config.link_preview_tags = false`.
112
+ 3. **turf-monster** (writes og tags through `OgHelper` and
113
+ `layouts/_link_preview_meta`): run the generator with `--own-tags`, set
114
+ `config.link_preview_image_service = OgImageAttachable::PUBLIC_OG_SERVICE`, and
115
+ migrate. Copy `SiteSetting`'s title, description and image into
116
+ `Studio::SiteIdentity`. Then delete the `_link_preview_meta` renders, `OgHelper`'s
117
+ resolution, `SiteSetting`'s og fields and the app-local `LinkPreviewBot` (the
118
+ engine concern replaces it; contest pages keep `content_for(:og_image)` or move
119
+ to `link_preview`), and delete `config.link_preview_tags = false`.
120
+
7
121
  ## 0.81.1 — 2026-09-30
8
122
 
9
123
  ### Fixed
data/README.md CHANGED
@@ -38,6 +38,8 @@ resolved.
38
38
  - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
39
39
  - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
40
40
  - **Geo**: `Studio::GeoDetection` places every visitor (IP → country + subdivision, session-cached), `Studio::GeoSetting` stores the operator's blocked countries and regions, `require_geo_allowed` locks whichever surfaces an app chooses, and the shared badge + `/admin/geo` manager ship with it. See [`docs/GEO.md`](docs/GEO.md).
41
+ - **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
42
+ - **Public user page**: `/u/:username` shows a user's avatar and username (nothing else), and unfurls with their avatar, falling back to the site image. Link any username to it with `link_to_user_profile(user)` or `studio_user_profile_path(user)`. Opt in with `config.draw_public_user_routes = true`. See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
41
43
  - **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
42
44
 
43
45
  ## Configuration
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Serve link-preview fetchers (iMessage, Slack, Discord, X, WhatsApp...) a
5
+ # SLIM document — the page's own identity tags and a one-card body — instead
6
+ # of the full page.
7
+ #
8
+ # class ApplicationController < ActionController::Base
9
+ # include Studio::LinkPreviewBots
10
+ # end
11
+ #
12
+ # WHY: Apple's LinkPresentation (the iMessage unfurler) aborts any HTML page
13
+ # over 1 MiB (WebKitErrorDomain 102, "Frame load interrupted"; measured
14
+ # 2026-09-30: 1,048,000 bytes previewed, 1,049,000 failed). A page carrying a
15
+ # big inline script or template passes that without anyone noticing, and every
16
+ # link to it stops previewing in Messages while Discord keeps working.
17
+ #
18
+ # HOW: the action and its view run as usual — so a page's `link_preview`
19
+ # override is set — and the full page renders. Then, for a known fetcher only,
20
+ # the body is replaced by Studio::LinkPreview.slim_document, which lifts the
21
+ # <title>, meta and icon tags out of that page's <head> and drops every script,
22
+ # style and template. Built from the rendered page, the slim document cannot
23
+ # drift from what a person's page says, and it works under ANY layout.
24
+ #
25
+ # DUPLICATE-SAFE: a tag the page emits twice (an app's own og tags and the
26
+ # engine's) reaches the fetcher once, the first occurrence.
27
+ #
28
+ # People, in-app browsers and unknown agents are never matched and always get
29
+ # the full page. The allow-list is Studio::LinkPreview::BOT_TOKENS.
30
+ module LinkPreviewBots
31
+ extend ActiveSupport::Concern
32
+
33
+ SLIM_HEADER = "X-Studio-Link-Preview"
34
+
35
+ included do
36
+ after_action :serve_link_preview_slim_document
37
+
38
+ helper_method :link_preview_bot_request? if respond_to?(:helper_method)
39
+ end
40
+
41
+ # A preview fetcher reading a page. No format check: an unfurler often sends
42
+ # `Accept: */*`, which Rails reads as Mime::ALL rather than html, though the
43
+ # response it gets is HTML — the after_action checks the RESPONSE instead.
44
+ def link_preview_bot_request?
45
+ (request.get? || request.head?) && Studio::LinkPreview.bot?(request.user_agent)
46
+ end
47
+
48
+ private
49
+
50
+ def serve_link_preview_slim_document
51
+ return unless link_preview_bot_request?
52
+ return unless response.status == 200
53
+ return unless response.media_type == "text/html"
54
+
55
+ body = response.body
56
+ return unless body.is_a?(String) && !body.empty?
57
+
58
+ response.body = Studio::LinkPreview.slim_document(body, url: request.original_url)
59
+ response.headers[SLIM_HEADER] = "slim"
60
+ # A shared cache must never hand the slim page to a person.
61
+ response.headers["Vary"] = [response.headers["Vary"], "User-Agent"].compact.join(", ")
62
+ rescue StandardError => e
63
+ # 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}")
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /u/:username — the public page a username links to in every Studio app.
5
+ #
6
+ # It shows the avatar and the username, and nothing else: no name, no email,
7
+ # no wallet. Its link preview is the avatar when one is set, else the site
8
+ # identity's image (Studio::LinkPreviewHelper#link_preview does the fallback).
9
+ #
10
+ # Public by design, so it skips the host's sign-in gate (mcritchie-industries
11
+ # and cyvasse gate every controller through Studio::ErrorHandling). It inherits
12
+ # the host's ApplicationController for the same reason ProfilesController
13
+ # does: the page renders inside the app's own layout, navbar and theme, and a
14
+ # host that includes Studio::LinkPreviewBots serves unfurlers the slim page.
15
+ #
16
+ # An unknown username gets a friendly page with a real 404 status. Every miss
17
+ # looks the same (unknown, hidden, or an app with no username column), and the
18
+ # lookup never touches the email, so the page cannot be used to learn whether
19
+ # an account exists behind an address. Drawn only when the host opts in:
20
+ # Studio.draw_public_user_routes.
21
+ class PublicUsersController < ::ApplicationController
22
+ skip_before_action :require_authentication, raise: false
23
+
24
+ def show
25
+ @public_user = Studio::PublicUser.find(params[:username])
26
+ return if @public_user
27
+
28
+ @requested_username = params[:username].to_s
29
+ render :not_found, status: :not_found
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /admin/link_preview — the operator edits the SITE IDENTITY (Studio::
5
+ # SiteIdentity): the image, title and description an unfurl shows for any page
6
+ # that does not name its own, and the copy Studio.site_identity hands any
7
+ # other reader. All three on one page, beside a live card drawn the way an
8
+ # unfurl draws it.
9
+ #
10
+ # A plain host-inherited controller whose view is a bare content wrapper, like
11
+ # /admin/geo and /admin/emails, so it renders inside each host's application
12
+ # layout. Lifted from turf-monster's admin dashboard (update_link_preview and
13
+ # update_link_preview_image), which ran it in production first.
14
+ #
15
+ # The image and the words post SEPARATELY: the cropper submits a form holding
16
+ # only the image, the text form holds only the words. So #update changes only
17
+ # the fields a request actually carried.
18
+ class SiteIdentitiesController < ApplicationController
19
+ before_action :require_admin
20
+
21
+ IMAGE_TYPES = Studio::SiteIdentity::IMAGE_TYPES
22
+ MAX_IMAGE_BYTES = Studio::SiteIdentity::MAX_IMAGE_BYTES
23
+
24
+ def edit
25
+ @installed = Studio::SiteIdentity.table_ready?
26
+ @setting = @installed ? Studio::SiteIdentity.current : nil
27
+ @uploads_available = @installed && @setting.respond_to?(:image)
28
+ @default_image_url = default_image_url
29
+ @static_image_url = static_image_url
30
+ @domain = request.host
31
+ end
32
+
33
+ def update
34
+ return redirect_to(admin_link_preview_path, alert: not_installed_message, status: :see_other) unless installed?
35
+
36
+ attrs = params.fetch(:site_identity, {})
37
+ file = attrs[:image]
38
+
39
+ if attrs.key?(:image) && !valid_image?(file)
40
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, WebP or GIF under 8 MB."
41
+ return redirect_to admin_link_preview_path, alert: message, status: :see_other
42
+ end
43
+
44
+ setting = Studio::SiteIdentity.current!
45
+ rescue_and_log(target: setting) do
46
+ setting.title = attrs[:title].to_s.strip.presence if attrs.key?(:title)
47
+ setting.description = attrs[:description].to_s.strip.presence if attrs.key?(:description)
48
+ setting.save!
49
+ setting.image.attach(file) if file.present?
50
+ # An attach does not touch the row, so after_commit does not fire for it.
51
+ Studio::SiteIdentity.bust_cache!
52
+ end
53
+
54
+ redirect_to admin_link_preview_path, status: :see_other,
55
+ notice: file.present? ? "Default link-preview image updated." : "Link-preview defaults updated."
56
+ rescue ActiveRecord::RecordInvalid => e
57
+ redirect_to admin_link_preview_path, status: :see_other, alert: e.record.errors.full_messages.to_sentence
58
+ rescue StandardError
59
+ redirect_to admin_link_preview_path, status: :see_other, alert: "Couldn't save the link preview. Please try again."
60
+ end
61
+
62
+ # DELETE /admin/link_preview/image — drop the uploaded default, so previews
63
+ # fall back to the static image (or none).
64
+ def destroy_image
65
+ return redirect_to(admin_link_preview_path, alert: not_installed_message, status: :see_other) unless installed?
66
+
67
+ setting = Studio::SiteIdentity.current
68
+ if setting&.persisted? && setting.image_attached?
69
+ rescue_and_log(target: setting) do
70
+ setting.image.purge
71
+ Studio::SiteIdentity.bust_cache!
72
+ end
73
+ end
74
+
75
+ redirect_to admin_link_preview_path, status: :see_other, notice: "Default link-preview image removed."
76
+ rescue StandardError
77
+ redirect_to admin_link_preview_path, status: :see_other, alert: "Couldn't remove the image. Please try again."
78
+ end
79
+
80
+ private
81
+
82
+ def installed?
83
+ Studio::SiteIdentity.table_ready?
84
+ end
85
+
86
+ def not_installed_message
87
+ "Install the site identity table first: bin/rails g studio:site_identity && bin/rails db:migrate"
88
+ end
89
+
90
+ def valid_image?(file)
91
+ file.respond_to?(:content_type) && IMAGE_TYPES.include?(file.content_type.to_s) &&
92
+ file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_IMAGE_BYTES
93
+ end
94
+
95
+ # The uploaded image as the card shows it: the same URL the head emits.
96
+ def default_image_url
97
+ stored = Studio::SiteIdentity.stored
98
+ Studio::LinkPreview.absolute_url(stored[:image_url] || stored[:image_path], base_url: request.base_url)
99
+ rescue StandardError
100
+ nil
101
+ end
102
+
103
+ def static_image_url
104
+ Studio::LinkPreview.absolute_url(Studio::SiteIdentity.static_image, base_url: request.base_url)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # View-side link preview: the ONE override a page uses, and the tags the head
5
+ # renders. The policy (which rung wins) is Studio::LinkPreview.resolve; this
6
+ # module only gathers the rungs, and it is where the request's base URL and
7
+ # the asset paths are known.
8
+ #
9
+ # Method names are prefixed (`link_preview`, `studio_link_preview*`) for the
10
+ # reason Studio::GeoHelper gives: every helper module is included into every
11
+ # view, so an unprefixed name would collide with a host's own helper.
12
+ module LinkPreviewHelper
13
+ OVERRIDE_KEYS = %i[image title description].freeze
14
+
15
+ # THE PAGE OVERRIDE. Call it from any view (the layout renders later, so it
16
+ # sees the call):
17
+ #
18
+ # <% link_preview image: @contest.banner, title: @contest.name,
19
+ # description: @contest.tagline %>
20
+ #
21
+ # `image:` takes a URL, a root-relative path, or an Active Storage
22
+ # attachment/blob (a user's avatar). An image that is nil, blank, or an
23
+ # attachment with nothing attached falls back to the operator's default, so
24
+ # a page can pass `image: user.avatar` without asking whether there is one.
25
+ # Only the keys passed are set; a second call overrides just its own keys.
26
+ # Returns nil, so `<%= link_preview ... %>` prints nothing either.
27
+ def link_preview(**overrides)
28
+ unknown = overrides.keys - OVERRIDE_KEYS
29
+ raise ArgumentError, "link_preview takes #{OVERRIDE_KEYS.join(", ")}; got #{unknown.join(", ")}" if unknown.any?
30
+
31
+ (@studio_link_preview_overrides ||= {}).merge!(overrides)
32
+ nil
33
+ end
34
+
35
+ # 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
+ #
39
+ # Rungs, most specific first: the page's `link_preview`, then the page's
40
+ # own content_for(:title) / content_for(:meta_description) /
41
+ # content_for(:og_image) (turf-monster's existing keys, honoured so its
42
+ # pages keep their overrides on adoption), then the site identity.
43
+ def studio_link_preview
44
+ overrides = @studio_link_preview_overrides || {}
45
+ stored = studio_site_identity_stored
46
+
47
+ preview = Studio::LinkPreview.resolve(
48
+ site_name: Studio.app_name,
49
+ titles: [overrides[:title], studio_link_preview_content(:title), stored[:title], Studio.site_title],
50
+ descriptions: [overrides[:description], studio_link_preview_content(:meta_description),
51
+ stored[:description], Studio.site_description],
52
+ page_images: [studio_link_preview_image_location(overrides[:image]), studio_link_preview_content(:og_image)],
53
+ default_image: stored[:image_url] || stored[:image_path],
54
+ static_image: Studio::SiteIdentity.static_image
55
+ )
56
+
57
+ preview.merge(
58
+ image: Studio::LinkPreview.absolute_url(preview[:image], base_url: studio_request_base_url) || preview[:image],
59
+ site_name: Studio.app_name.to_s,
60
+ url: (request.original_url if respond_to?(:request) && request)
61
+ )
62
+ end
63
+
64
+ # The app's IDENTITY COPY — { title:, description:, image_url: } — for any
65
+ # view that wants the words, not the tags: a meta description, share text,
66
+ # an email footer. The site-wide answer, ignoring this page's overrides.
67
+ # Studio.site_identity is the same call outside a view.
68
+ def studio_site_identity
69
+ Studio.site_identity(base_url: studio_request_base_url)
70
+ end
71
+
72
+ # The og:/twitter: tags for this page. layouts/studio/_head renders this
73
+ # when Studio.link_preview_tags? says so; an app that set
74
+ # `link_preview_tags = false` can render it wherever it likes.
75
+ def studio_link_preview_tags
76
+ render "layouts/studio/link_preview_tags", preview: studio_link_preview
77
+ rescue StandardError => e
78
+ Rails.logger&.warn("[studio.link_preview] tags skipped: #{e.class}: #{e.message}")
79
+ "".html_safe
80
+ end
81
+
82
+ private
83
+
84
+ def studio_site_identity_stored
85
+ Studio::SiteIdentity.stored
86
+ rescue StandardError => e
87
+ Rails.logger&.warn("[studio.link_preview] site identity unavailable: #{e.class}: #{e.message}")
88
+ {}
89
+ end
90
+
91
+ def studio_link_preview_image_location(image)
92
+ return nil if image.nil?
93
+ return image.to_s if image.is_a?(String) || image.is_a?(Symbol)
94
+
95
+ location = Studio::SiteIdentity.image_location(image)
96
+ location && (location[:url] || location[:path])
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
101
+ # content_for holds ESCAPED text in a SafeBuffer, and the resolver's strip
102
+ # returns a plain String the tag partial escapes again ("Pass &amp;amp; Run").
103
+ # Hand the resolver the plain text instead.
104
+ def studio_link_preview_content(key)
105
+ value = content_for(key)
106
+ value && CGI.unescapeHTML(value.to_str)
107
+ end
108
+
109
+ def studio_request_base_url
110
+ respond_to?(:request) && request ? request.base_url : nil
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Links to the public user page (/u/:username). Clicking a username anywhere
5
+ # in a Studio app should land here, so every app reaches it the same way:
6
+ #
7
+ # <%= link_to_user_profile(entry.user) %> # text: display_name
8
+ # <%= link_to_user_profile(user, "@#{user.username}", class: "font-semibold") %>
9
+ # <%= link_to_user_profile(user) do %> ...avatar... <% end %>
10
+ # studio_user_profile_path(user) # => "/u/alex", or nil
11
+ #
12
+ # The path helpers answer nil, and link_to_user_profile renders its text in a
13
+ # plain <span> with no link, whenever there is no page to go to: the app has
14
+ # not drawn the route (Studio.draw_public_user_routes), or the user has no
15
+ # username. So a view can call them for every user without asking first.
16
+ #
17
+ # Prefixed names for the reason Studio::GeoHelper gives: every helper module is
18
+ # included into every view. The ROUTE helper is studio_public_user_path; these
19
+ # take a user rather than a username, which is why they are named apart.
20
+ module PublicUserHelper
21
+ def studio_user_profile_path(user, **options)
22
+ studio_user_profile_location(:path, user, options)
23
+ end
24
+
25
+ def studio_user_profile_url(user, **options)
26
+ studio_user_profile_location(:url, user, options)
27
+ end
28
+
29
+ def link_to_user_profile(user, name = nil, html_options = nil, **options, &block)
30
+ html_options, name = name, nil if name.is_a?(Hash)
31
+ html_options = (html_options || {}).merge(options)
32
+ path = studio_user_profile_path(user)
33
+
34
+ content = block ? capture(&block) : (name || studio_user_profile_label(user))
35
+ if path
36
+ html_options[:data] = { public_user_link: "" }.merge(html_options[:data] || {})
37
+ link_to(content, path, html_options)
38
+ else
39
+ content_tag(:span, content, html_options)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def studio_user_profile_location(kind, user, options)
46
+ return nil unless respond_to?(:"studio_public_user_#{kind}")
47
+
48
+ username = Studio::PublicUser.username_for(user)
49
+ return nil if username.nil?
50
+
51
+ public_send(:"studio_public_user_#{kind}", username: username, **options)
52
+ end
53
+
54
+ def studio_user_profile_label(user)
55
+ return user.to_s if user.is_a?(String) || user.is_a?(Symbol)
56
+
57
+ label = user.display_name if user.respond_to?(:display_name)
58
+ label.presence || Studio::PublicUser.username_for(user).to_s
59
+ end
60
+ end
61
+ end