studio-engine 0.81.1 → 0.82.1

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.
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,136 @@ 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 app's CANONICAL base URL for previews, e.g. "https://cyvasse.xyz". When
520
+ # set, og:url is this base plus the page's path (no query string), and every
521
+ # relative preview image is made absolute on it — so a share through a
522
+ # herokuapp.com host or a ?utm_ link does not split one page into many URLs in
523
+ # an unfurler's share counts. nil (the default) uses the request's base URL,
524
+ # and og:url is the URL as requested. Set it in production only, or a local
525
+ # page's card points at production:
526
+ #
527
+ # config.link_preview_base_url = "https://cyvasse.xyz" if Rails.env.production?
528
+ mattr_accessor :link_preview_base_url, default: nil
529
+
530
+ # THE SITE IDENTITY'S DRAFTED DEFAULTS — this app's title and description as
531
+ # written in code, under whatever the operator saves at /admin/link_preview.
532
+ # The standing convention is that an agent drafts these when it sets an app up
533
+ # (`bin/rails g studio:site_identity --title "..." --description "..."` writes
534
+ # them here) and Alex edits them on the page. nil title means Studio.app_name;
535
+ # nil description means none. Read the resolved answer through
536
+ # Studio.site_identity, never these directly.
537
+ mattr_accessor :site_title, default: nil
538
+ mattr_accessor :site_description, default: nil
539
+
540
+ # Draw /admin/link_preview from Studio.routes. ON by default: no consumer owns
541
+ # these paths or helper names (admin_link_preview, admin_link_preview_image),
542
+ # checked 2026-09-30 across mcritchie-studio, turf-monster, cyvasse and
543
+ # mcritchie-industries. The page explains itself when the table is missing.
544
+ mattr_accessor :draw_link_preview_routes, default: true
545
+
546
+ # Draw the public user page, GET /u/:username (Studio::PublicUsersController,
547
+ # route helper studio_public_user_path; link to it with the view helpers
548
+ # studio_user_profile_path(user) / link_to_user_profile(user)). See
549
+ # docs/PUBLIC_USER_PAGE.md.
550
+ #
551
+ # OFF by default, like every route surface a consumer might already own. No
552
+ # consumer owns /u or the helper name today (checked 2026-09-30 across
553
+ # mcritchie-studio, mcritchie-industries, cyvasse and turf-monster), but the
554
+ # page also needs a `username` column, which the two hub apps do not have yet
555
+ # (their slug is keyed on the email, so it can never stand in). Each app's
556
+ # adoption turns it on:
557
+ #
558
+ # config.draw_public_user_routes = true
559
+ mattr_accessor :draw_public_user_routes, default: false
560
+
561
+ # THE APP'S IDENTITY COPY, resolved: { title:, description:, image_url: }.
562
+ # The operator's saved value (Studio::SiteIdentity, edited at
563
+ # /admin/link_preview) wins, then the drafted Studio.site_title /
564
+ # site_description, then Studio.app_name for the title. image_url is the
565
+ # uploaded image, else the static fallback, else nil — absolute when
566
+ # `base_url` is given (pass request.base_url) or the image lives on a public
567
+ # service. Reuse it anywhere the app needs to say what it is: a meta
568
+ # description, share text, an email footer. Views have `studio_site_identity`.
569
+ # Never raises; an app without the table answers from the drafted defaults.
570
+ def self.site_identity(base_url: nil)
571
+ Studio::SiteIdentity.resolved(base_url: base_url)
572
+ rescue StandardError
573
+ { title: site_title.presence || app_name.to_s, description: site_description.presence, image_url: nil }
574
+ end
575
+
576
+ # :auto emits when the table is installed AND no view of this app writes its
577
+ # own og tags (Studio.link_preview_own_tags_file); true/false are taken as
578
+ # given.
579
+ def self.link_preview_tags?
580
+ case link_preview_tags
581
+ when :auto, "auto", nil then Studio::SiteIdentity.table_ready? && link_preview_own_tags_file.nil?
582
+ else !!link_preview_tags
583
+ end
584
+ rescue StandardError
585
+ false
586
+ end
587
+
588
+ # The host view that writes its own og tags, or nil — scanned once per process
589
+ # (app/views is fixed at deploy) and logged once when it keeps :auto off.
590
+ def self.link_preview_own_tags_file
591
+ return @link_preview_own_tags_file if defined?(@link_preview_own_tags_file)
592
+
593
+ views = defined?(Rails.root) && Rails.root ? Rails.root.join("app/views") : nil
594
+ found = Studio::LinkPreview.own_tag_file(views)
595
+ if found && defined?(Rails.logger) && Rails.logger
596
+ Rails.logger.info("[studio.link_preview] #{found} writes its own og tags, so the engine's head tags " \
597
+ "stay off under link_preview_tags = :auto. Set it to true once they are removed.")
598
+ end
599
+ @link_preview_own_tags_file = found
600
+ end
601
+
602
+ def self.reset_link_preview_own_tags!
603
+ remove_instance_variable(:@link_preview_own_tags_file) if defined?(@link_preview_own_tags_file)
604
+ end
605
+
474
606
  # Whether the engine configures Geocoder on boot (provider, HTTPS, timeout, and
475
607
  # a Rails.cache-backed IP cache). An app that configures Geocoder itself sets
476
608
  # this false; an app with no geocoder gem is unaffected either way.
@@ -1110,6 +1242,25 @@ module Studio
1110
1242
  patch "admin/geo", to: "studio/geo_settings#update", as: :admin_geo_update
1111
1243
  post "admin/geo/toggle", to: "studio/geo_settings#toggle_override", as: :admin_geo_toggle
1112
1244
  end
1245
+
1246
+ # The link-preview default (/admin/link_preview): the image, title and
1247
+ # description every page unfurls with unless it overrides them. ON by
1248
+ # default (Studio.draw_link_preview_routes) because no consumer owns these
1249
+ # names; an app can still switch it off from its initializer.
1250
+ if Studio.draw_link_preview_routes
1251
+ get "admin/link_preview", to: "studio/site_identities#edit", as: :admin_link_preview
1252
+ patch "admin/link_preview", to: "studio/site_identities#update"
1253
+ delete "admin/link_preview/image", to: "studio/site_identities#destroy_image", as: :admin_link_preview_image
1254
+ end
1255
+ # The public user page (/u/:username): avatar and username only, and the
1256
+ # user's avatar as its link-preview image. OPT-IN — see
1257
+ # Studio.draw_public_user_routes. `format: false` because usernames may
1258
+ # carry a dot, which would otherwise be read as a format extension.
1259
+ if Studio.draw_public_user_routes
1260
+ get "u/:username", to: "studio/public_users#show", as: :studio_public_user,
1261
+ format: false, constraints: { username: %r{[^/]+} }
1262
+ end
1263
+
1113
1264
  # The living style guide. Canonical at /admin/style (StyleController#index);
1114
1265
  # /admin/design_system redirects here but KEEPS its admin_design_system_path
1115
1266
  # helper so a shipped host sidebar link on the old helper still resolves.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.81.1
4
+ version: 0.82.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -323,6 +323,7 @@ files:
323
323
  - app/controllers/concerns/studio/geo_detection.rb
324
324
  - app/controllers/concerns/studio/impersonation.rb
325
325
  - app/controllers/concerns/studio/link_consumption.rb
326
+ - app/controllers/concerns/studio/link_preview_bots.rb
326
327
  - app/controllers/concerns/studio/magic_link_issuing.rb
327
328
  - app/controllers/concerns/studio/session_drift.rb
328
329
  - app/controllers/error_logs_controller.rb
@@ -343,13 +344,17 @@ files:
343
344
  - app/controllers/studio/models_controller.rb
344
345
  - app/controllers/studio/onboarding_controller.rb
345
346
  - app/controllers/studio/profiles_controller.rb
347
+ - app/controllers/studio/public_users_controller.rb
346
348
  - app/controllers/studio/session_states_controller.rb
349
+ - app/controllers/studio/site_identities_controller.rb
347
350
  - app/controllers/style_controller.rb
348
351
  - app/controllers/theme_settings_controller.rb
349
352
  - app/helpers/studio/admin_models_table_helper.rb
350
353
  - app/helpers/studio/at_time_helper.rb
351
354
  - app/helpers/studio/fizz_helper.rb
352
355
  - app/helpers/studio/geo_helper.rb
356
+ - app/helpers/studio/link_preview_helper.rb
357
+ - app/helpers/studio/public_user_helper.rb
353
358
  - app/helpers/studio_email_delivery_helper.rb
354
359
  - app/helpers/studio_navbar_helper.rb
355
360
  - app/helpers/studio_sidebar_helper.rb
@@ -376,6 +381,7 @@ files:
376
381
  - app/models/studio/knowledge_expectation.rb
377
382
  - app/models/studio/link.rb
378
383
  - app/models/studio/model_page.rb
384
+ - app/models/studio/site_identity.rb
379
385
  - app/models/theme_setting.rb
380
386
  - app/services/google_oauth_validator.rb
381
387
  - app/services/studio/banner.rb
@@ -408,6 +414,7 @@ files:
408
414
  - app/views/layouts/branded_mailer.html.erb
409
415
  - app/views/layouts/studio/_flash.html.erb
410
416
  - app/views/layouts/studio/_head.html.erb
417
+ - app/views/layouts/studio/_link_preview_tags.html.erb
411
418
  - app/views/layouts/studio/_smooth_load.html.erb
412
419
  - app/views/navbar/show.html.erb
413
420
  - app/views/registrations/new.html.erb
@@ -520,6 +527,9 @@ files:
520
527
  - app/views/studio/profiles/_save_controls.html.erb
521
528
  - app/views/studio/profiles/edit.html.erb
522
529
  - app/views/studio/profiles/show.html.erb
530
+ - app/views/studio/public_users/not_found.html.erb
531
+ - app/views/studio/public_users/show.html.erb
532
+ - app/views/studio/site_identities/edit.html.erb
523
533
  - app/views/style/_host.html.erb
524
534
  - app/views/style/_modal_specimen.html.erb
525
535
  - app/views/style/_modals.html.erb
@@ -559,6 +569,8 @@ files:
559
569
  - db/migrate/20260901000001_create_studio_knowledge_docs.rb
560
570
  - db/migrate/20260902000001_create_studio_knowledge_expectations.rb
561
571
  - db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
572
+ - db/migrate/20260930120000_create_studio_site_identities.rb
573
+ - lib/generators/studio/site_identity/site_identity_generator.rb
562
574
  - lib/studio-engine.rb
563
575
  - lib/studio.rb
564
576
  - lib/studio/cable.rb
@@ -574,6 +586,7 @@ files:
574
586
  - lib/studio/ip_locations.rb
575
587
  - lib/studio/js_identifier.rb
576
588
  - lib/studio/js_literal.rb
589
+ - lib/studio/link_preview.rb
577
590
  - lib/studio/link_resolution.rb
578
591
  - lib/studio/link_token.rb
579
592
  - lib/studio/log_rotation.rb
@@ -586,6 +599,7 @@ files:
586
599
  - lib/studio/partial_block.rb
587
600
  - lib/studio/profile_image.rb
588
601
  - lib/studio/profile_sections.rb
602
+ - lib/studio/public_user.rb
589
603
  - lib/studio/redis.rb
590
604
  - lib/studio/s3.rb
591
605
  - lib/studio/session_fingerprint.rb