studio-engine 0.134.0 → 0.135.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: 28427df3b5517371576bbd0cc5c159220393748f14c7a31ed39214a223fd0b86
4
- data.tar.gz: 2268ea35dc8219f05a5dc2f1e28c639478538782ba664d69125f7e63556ddfac
3
+ metadata.gz: 1b8bd497e5ba5d6c113a0e6c221b03200f395b1c3d4032aa1327691b7c584c34
4
+ data.tar.gz: 0a64eb7f3a70bcbe03a50550d36a1d8da938498d91d5cf0a1fcb257845a76759
5
5
  SHA512:
6
- metadata.gz: 7c48c6796c363df39d913088657f89ac9ee7ffec99e3b3f685095006890c405ec3de5f8aaee936a50d3aa1a4c4250a445de484c0debff38166917bd6a3f08f34
7
- data.tar.gz: 3fe116b2ea6ebfd22e920cacb672ead1e4f999e7c0d5f15b31ad208653e104627d6305c65d2cc24b021041bff9f3e31d9d3569841d8fc51f88daae1b1d6e18a7
6
+ metadata.gz: c325e02d5e2f0c2a9123ab2b342281b59d27635e48cca92396f792cd39a6fc403cf6e79bfc2e920f891ef9dcc30063e0460e393db83e7148470d3895615b4aad
7
+ data.tar.gz: 96424698d12cf9d7d60bfee31c6f8d97f5bb464439bb421d8f47ba3ab78f1ce359e9a01bd543f3baab4c45d502c73e117ed3074d4bc306c40582b9819ab3aa53
data/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.135.0 — 2026-11-07
8
+
9
+ ### Fixed
10
+
11
+ - The not-found page's og:url is the app's home, not the address it was asked for: `link_preview` takes `url:` (a path or an absolute URL), and a 404 at a hidden route no longer names that route in its link-preview tags.
12
+
13
+ ### Added
14
+
15
+ - **A friendly not-found page.** `config.exceptions_app = Studio::ExceptionsApp.new` in `config/application.rb` gives a lost visitor the app's name, brand mark, a short message and a button home, inside the app's own layout, for an unknown address and a missing record alike. The status stays 404. A JSON request still gets Rails' 404 JSON, and other statuses keep `public/<status>.html`. If the page cannot render, or the host answers anything but a 404, the static `public/404.html` answers instead, so it cannot loop or change the status. An app with its own wall lists `"studio/not_found" => %w[show]`. See `docs/NOT_FOUND_PAGE.md`.
16
+
7
17
  ## 0.134.0 — 2026-11-07
8
18
 
9
19
  ### Changed
data/README.md CHANGED
@@ -45,6 +45,7 @@ resolved.
45
45
  - **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).
46
46
  - **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).
47
47
  - **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off; `config.site_footer_chat_path` adds a standard Chat link to the Contact column. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
48
+ - **Not-found page**: `config.exceptions_app = Studio::ExceptionsApp.new` in `config/application.rb` gives a lost visitor a friendly 404 with the app's name, brand mark and a button home, inside the app's layout. The status stays 404, JSON requests keep Rails' 404 JSON, and `public/404.html` is the fallback if the page cannot render. See [`docs/NOT_FOUND_PAGE.md`](docs/NOT_FOUND_PAGE.md).
48
49
  - **Booking**: a simple scheduler on Google Calendar appointment schedules, with or without the footer. With `config.booking_url`, `studio_booking_frame` embeds Google's booking page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule` (or name the app's own page with `config.booking_path`). `config.booking_crop` crops the frame to the slot picker at rest, from numbers `bin/booking-crop-measure` reads off the app's own schedule. See [`docs/BOOKING.md`](docs/BOOKING.md).
49
50
  - **Voice profile**: an opt-in row on `/profile` ("Set up your voice"): the person reads three phrases with a live level meter, their device turns each take into a speaker embedding (WeSpeaker via transformers.js), and only the averaged vector is saved on their user, never audio. `Studio::VoiceMatch.identify(embedding, among: User.all, model:)` answers who is speaking. Turn it on with `config.voice_profile = true` after installing the migration. See [`docs/VOICE_PROFILE.md`](docs/VOICE_PROFILE.md).
50
51
  - **Voice capture**: the microphone chip (names the microphone a page records from, picks another, Chrome help dialog), one level meter, and a record button that refuses a silent take and hands a heard one to the page as a `studio:voice-recorder:take` event with the blob; it uploads nothing. Render `studio/voice/microphone_chip` and `studio/voice/recorder`. See [`docs/VOICE_CAPTURE.md`](docs/VOICE_CAPTURE.md).
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The friendly not-found page, drawn by Studio::ExceptionsApp for an HTML 404
5
+ # (docs/NOT_FOUND_PAGE.md). It has no route: only the exceptions app calls it.
6
+ #
7
+ # It inherits the host's ApplicationController so the page sits inside the
8
+ # app's own layout, navbar, footer and theme, as Studio::PublicUsersController
9
+ # does. A signed-out visitor must see it, so it skips the engine's sign-in gate.
10
+ # A host with its own wall lists it there (the hub's AdminWall names
11
+ # "studio/not_found" => %w[show]). If the host still answers anything but a
12
+ # 404 here, the exceptions app throws that answer away and serves the static
13
+ # public/404.html, so a wall can cost the page its layout but never its status.
14
+ class NotFoundController < ::ApplicationController
15
+ skip_before_action :require_authentication, raise: false
16
+ # No forgery skip: Rails' ShowExceptions calls the exceptions app as a GET
17
+ # (request_method = "GET"), which never needs the token, so the host's CSRF
18
+ # allow-lists stay as they are.
19
+
20
+ def show
21
+ @studio_not_found = {
22
+ app_name: Studio.app_name,
23
+ logo: not_found_logo,
24
+ home_path: not_found_home_path,
25
+ back_path: not_found_back_path
26
+ }
27
+ render "studio/not_found/show", status: :not_found, formats: [:html], content_type: "text/html"
28
+ end
29
+
30
+ private
31
+
32
+ def not_found_logo
33
+ Studio.navbar_brand&.dig(:logo) || Studio.logo_for("Navbar Logo")
34
+ end
35
+
36
+ def not_found_home_path
37
+ main_app.respond_to?(:root_path) ? main_app.root_path : "/"
38
+ end
39
+
40
+ # The page the visitor came from, when it is on this app: "Go back" returns
41
+ # there. A link from elsewhere, or none, draws no second link.
42
+ def not_found_back_path
43
+ referer = URI.parse(request.referer.to_s)
44
+ return nil unless referer.host == request.host && referer.path.present?
45
+ # "//other.host/x" is a same-host path a browser reads as another site.
46
+ return nil if referer.path.start_with?("//")
47
+ return nil if referer.path == request.env["action_dispatch.original_path"]
48
+
49
+ [referer.path, referer.query].compact.join("?")
50
+ rescue URI::InvalidURIError
51
+ nil
52
+ end
53
+ end
54
+ 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 image_alt].freeze
13
+ OVERRIDE_KEYS = %i[image title description image_alt url].freeze
14
14
 
15
15
  # THE PAGE OVERRIDE. Call it from any view (the layout renders later, so it
16
16
  # sees the call):
@@ -71,7 +71,7 @@ module Studio
71
71
  image_height: height,
72
72
  image_alt: (overrides[:image_alt].to_s.strip.presence || preview[:title] if preview[:image]),
73
73
  site_name: Studio.app_name.to_s,
74
- url: studio_link_preview_page_url
74
+ url: studio_link_preview_page_url(overrides[:url])
75
75
  )
76
76
  end
77
77
 
@@ -127,9 +127,22 @@ module Studio
127
127
  request_base_url: (request.base_url if studio_link_preview_request?))
128
128
  end
129
129
 
130
- def studio_link_preview_page_url
130
+ # The page's og:url: the request's own URL, or the `url:` a page passed to
131
+ # link_preview. A path is built on the same base as the request's; an
132
+ # absolute URL is used as is. The not-found page passes the app's home, so a
133
+ # 404 never advertises the broken address it was asked for (a 404 at a
134
+ # hidden route would otherwise name that route in its og:url).
135
+ def studio_link_preview_page_url(override = nil)
131
136
  return nil unless studio_link_preview_request?
132
137
 
138
+ override = override.to_s.strip
139
+ return override if override.match?(%r{\Ahttps?://}i)
140
+
141
+ if override.start_with?("/")
142
+ return Studio::LinkPreview.page_url(base_url: Studio.link_preview_base_url,
143
+ request_url: "#{request.base_url}#{override}", path: override)
144
+ end
145
+
133
146
  Studio::LinkPreview.page_url(base_url: Studio.link_preview_base_url,
134
147
  request_url: request.original_url, path: request.path)
135
148
  end
@@ -0,0 +1,30 @@
1
+ <%# locals: (app_name:, home_path:, logo: nil, back_path: nil, heading: :h1) %>
2
+ <%# THE NOT-FOUND PAGE: what a lost visitor sees on an unknown address or a
3
+ record that is not there (docs/NOT_FOUND_PAGE.md). Studio::NotFoundController
4
+ renders it inside the app's own layout, from the app's Studio config:
5
+
6
+ app_name Studio.app_name: named in the button home.
7
+ home_path the app's root: the primary button.
8
+ logo the app's brand mark (Studio.navbar_brand's logo, else the
9
+ Navbar Logo theme image). nil draws no image.
10
+ back_path a page on this app the visitor came from: a second link back.
11
+ nil draws no second link.
12
+ heading the title's tag: :h1 on the page, a lower level where the page
13
+ is shown inside another (the style guide's card).
14
+
15
+ Theme tokens only, so it reads in light and dark. The static last resort,
16
+ public/404.html, carries the same words and the same data-test hooks. %>
17
+ <section class="mx-auto w-full max-w-md px-4 py-16 sm:py-24 text-center" data-studio-not-found data-test="not-found" aria-labelledby="studio-not-found-title">
18
+ <% if logo.present? %>
19
+ <img src="<%= logo %>" alt="<%= app_name %>" width="64" height="64" class="mx-auto mb-6 h-16 w-16 object-contain" data-studio-not-found-logo>
20
+ <% end %>
21
+ <p class="type-fine font-semibold uppercase tracking-wide text-primary-ink mb-2">404 &middot; Not found</p>
22
+ <%= content_tag heading, "This page isn’t here", id: "studio-not-found-title", class: "type-h2 text-heading mb-3" %>
23
+ <p class="type-body text-secondary mb-8">The address may be mistyped, or the page may have moved. Head back home and pick up from there.</p>
24
+ <div class="flex flex-col sm:flex-row items-center justify-center gap-3">
25
+ <a href="<%= home_path %>" class="btn btn-primary w-full sm:w-auto" data-test="not-found-home">Back to <%= app_name %></a>
26
+ <% if back_path.present? %>
27
+ <a href="<%= back_path %>" class="btn btn-outline w-full sm:w-auto" data-test="not-found-back">Go back</a>
28
+ <% end %>
29
+ </div>
30
+ </section>
@@ -0,0 +1,5 @@
1
+ <%# Studio::NotFoundController#show: the not-found page in the app's layout. %>
2
+ <% content_for(:title, "Page not found · #{Studio.app_name}") %>
3
+ <%# A 404's og:url is the app's home, never the broken address it was asked for. %>
4
+ <% link_preview(url: @studio_not_found[:home_path]) %>
5
+ <%= render "studio/not_found", **@studio_not_found %>
@@ -0,0 +1,19 @@
1
+ <%# The Pages group of the Tricks board (admin/style): whole pages an app
2
+ adopts with one line. Not Found is the friendly 404 (docs/NOT_FOUND_PAGE.md),
3
+ drawn here with THIS app's name and brand mark; the Go back link shows as it
4
+ does when the visitor came from a page on this app. %>
5
+ <section id="pages" class="section-subtarget space-y-5">
6
+ <div class="space-y-1">
7
+ <h3 class="type-h4 text-heading">Pages</h3>
8
+ <p class="text-muted text-sm">Whole pages an app adopts with one line in config/application.rb.</p>
9
+ </div>
10
+
11
+ <div class="grid gap-4 sm:grid-cols-2">
12
+ <%= render layout: "style/specimen", locals: { trick: "not_found" } do %>
13
+ <div class="card w-full">
14
+ <%= render "studio/not_found", app_name: Studio.app_name, logo: Studio.navbar_brand&.dig(:logo) || Studio.logo_for("Navbar Logo"),
15
+ home_path: "/", back_path: "#pages", heading: :h4 %>
16
+ </div>
17
+ <% end %>
18
+ </div>
19
+ </section>
@@ -527,4 +527,7 @@
527
527
 
528
528
  <!-- Site footer and booking -->
529
529
  <%= render "style/site_footer" %>
530
+
531
+ <!-- Pages -->
532
+ <%= render "style/pages" %>
530
533
  </section>
@@ -462,6 +462,13 @@ booking_frame:
462
462
  note: "Cropped to the slot picker at rest; bin/booking-crop-measure prints yours."
463
463
  resolve: [config:booking_crop]
464
464
 
465
+ not_found:
466
+ name: Not Found
467
+ group: Pages
468
+ token: "config.exceptions_app = Studio::ExceptionsApp.new"
469
+ note: "A lost visitor's 404 page, with the app's brand and a button home."
470
+ resolve: [partial:studio/not_found]
471
+
465
472
  board:
466
473
  name: Board
467
474
  group: Tasks
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+
5
+ module Studio
6
+ # The friendly not-found page, as an exceptions app. An app opts in with one
7
+ # line in config/application.rb (docs/NOT_FOUND_PAGE.md):
8
+ #
9
+ # config.exceptions_app = Studio::ExceptionsApp.new
10
+ #
11
+ # Rails hands this app every exception ShowExceptions renders, with PATH_INFO
12
+ # rewritten to "/<status>" and the method set to GET. That is the path a
13
+ # RoutingError takes (an unknown address) and the path a RecordNotFound takes
14
+ # (Studio::ErrorHandling#handle_not_found re-raises on purpose so it lands here).
15
+ #
16
+ # What it answers:
17
+ #
18
+ # 404, an HTML request Studio::NotFoundController#show: the app's name and
19
+ # brand mark, a short message and a button home,
20
+ # inside the app's own layout. Status 404.
21
+ # 404, a data request ActionDispatch::PublicExceptions, unchanged: a JSON
22
+ # request gets {"status":404,"error":"Not Found"} with
23
+ # status 404, never the HTML page.
24
+ # any other status ActionDispatch::PublicExceptions, unchanged (500,
25
+ # 422 and the rest keep public/<status>.html).
26
+ #
27
+ # IT CANNOT LOOP AND IT CANNOT CHANGE THE STATUS. The page renders through the
28
+ # app's ApplicationController, so the app's before_actions, layout and database
29
+ # all run. If any of them raises, or answers anything but a 404 (a sign-in
30
+ # redirect from a wall that does not list the page, a 406 from allow_browser),
31
+ # the answer is thrown away and PublicExceptions serves public/404.html, the
32
+ # static last resort, with status 404. PublicExceptions only reads a file, so
33
+ # the fallback has nothing left that can fail into this app again.
34
+ #
35
+ # Which requests are HTML is PublicExceptions' own rule, so the split never
36
+ # drifts from Rails': a format whose body PublicExceptions can serialize (JSON,
37
+ # XML) is data, and everything else, `Accept: */*` included, is the page.
38
+ class ExceptionsApp
39
+ NOT_FOUND = 404
40
+ PAGE_PARAMS = { controller: "studio/not_found", action: "show" }.freeze
41
+ # Cached by the failed request; the page's request must not read them.
42
+ STALE_KEYS = %w[action_dispatch.request.path_parameters
43
+ action_dispatch.request.parameters
44
+ action_dispatch.request.query_parameters
45
+ action_dispatch.request.request_parameters
46
+ action_controller.instance].freeze
47
+
48
+ # public_path: where the static pages live. Defaults to Rails.public_path,
49
+ # read on each call, so the line works before the app finishes booting.
50
+ # page: the Rack endpoint that draws the page (a seam for tests).
51
+ def initialize(public_path = nil, page: nil)
52
+ @public_path = public_path
53
+ @page = page
54
+ end
55
+
56
+ def call(env)
57
+ return static(env) unless page_request?(env)
58
+
59
+ response = draw_page(env)
60
+ return response if response
61
+
62
+ static(env)
63
+ end
64
+
65
+ private
66
+
67
+ def page_request?(env)
68
+ request = ActionDispatch::Request.new(env)
69
+ return false unless request.path_info.delete_prefix("/").to_i == NOT_FOUND
70
+
71
+ html?(request)
72
+ end
73
+
74
+ # PublicExceptions renders a body in a format the error hash serializes to
75
+ # (to_json, to_xml) and the HTML file for anything else.
76
+ def html?(request)
77
+ content_type = begin
78
+ request.formats.first
79
+ rescue ActionDispatch::Http::MimeNegotiation::InvalidType
80
+ Mime[:text]
81
+ end
82
+ return true unless content_type
83
+
84
+ !{}.respond_to?("to_#{content_type.to_sym}")
85
+ end
86
+
87
+ # The page's answer, or nil when it raised or came back as anything but a
88
+ # 404 HTML page.
89
+ def draw_page(env)
90
+ page_env = env.except(*STALE_KEYS)
91
+ page_env["action_dispatch.request.path_parameters"] = PAGE_PARAMS.dup
92
+ status, headers, body = page.call(page_env)
93
+ return [status, headers, body] if status.to_i == NOT_FOUND && html_body?(headers)
94
+
95
+ body.close if body.respond_to?(:close)
96
+ log("answered #{status}, not 404")
97
+ nil
98
+ rescue StandardError, ScriptError => e
99
+ log("#{e.class}: #{e.message}")
100
+ nil
101
+ end
102
+
103
+ def html_body?(headers)
104
+ type = headers["content-type"] || headers["Content-Type"]
105
+ type.to_s.start_with?("text/html")
106
+ end
107
+
108
+ def page
109
+ @page || Studio::NotFoundController.action(:show)
110
+ end
111
+
112
+ def static(env)
113
+ ActionDispatch::PublicExceptions.new(public_path).call(env)
114
+ end
115
+
116
+ def public_path
117
+ (@public_path || Rails.public_path).to_s
118
+ end
119
+
120
+ def log(reason)
121
+ Rails.logger&.warn("[studio.not_found] the page fell back to public/404.html: #{reason}")
122
+ end
123
+ end
124
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.134.0"
2
+ VERSION = "0.135.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -80,6 +80,7 @@ require "studio/survey/breakdown"
80
80
  require "studio/survey/export"
81
81
  require "studio/crumbs"
82
82
  require "studio/page_header"
83
+ require "studio/exceptions_app"
83
84
 
84
85
  module Studio
85
86
  mattr_accessor :app_name, default: "Studio"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.134.0
4
+ version: 0.135.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -470,6 +470,7 @@ files:
470
470
  - app/controllers/studio/local_reviews_controller.rb
471
471
  - app/controllers/studio/marketing_kit_controller.rb
472
472
  - app/controllers/studio/models_controller.rb
473
+ - app/controllers/studio/not_found_controller.rb
473
474
  - app/controllers/studio/onboarding_controller.rb
474
475
  - app/controllers/studio/profiles_controller.rb
475
476
  - app/controllers/studio/public_users_controller.rb
@@ -656,6 +657,7 @@ files:
656
657
  - app/views/studio/_hold_button.html.erb
657
658
  - app/views/studio/_hold_button_guard.html.erb
658
659
  - app/views/studio/_leveling_activity_assets.html.erb
660
+ - app/views/studio/_not_found.html.erb
659
661
  - app/views/studio/_page_header.html.erb
660
662
  - app/views/studio/_section_header.html.erb
661
663
  - app/views/studio/_session_stamp.html.erb
@@ -771,6 +773,7 @@ files:
771
773
  - app/views/studio/models/show.html.erb
772
774
  - app/views/studio/newsletter_mailer/subscribed.html.erb
773
775
  - app/views/studio/newsletter_mailer/subscribed.text.erb
776
+ - app/views/studio/not_found/show.html.erb
774
777
  - app/views/studio/profile_mailer/email_change_notification.html.erb
775
778
  - app/views/studio/profile_mailer/email_change_notification.text.erb
776
779
  - app/views/studio/profiles/_birthday_fields.html.erb
@@ -817,6 +820,7 @@ files:
817
820
  - app/views/style/_loaders_feed_page.html.erb
818
821
  - app/views/style/_modal_specimen.html.erb
819
822
  - app/views/style/_modals.html.erb
823
+ - app/views/style/_pages.html.erb
820
824
  - app/views/style/_scrims.html.erb
821
825
  - app/views/style/_site_footer.html.erb
822
826
  - app/views/style/_specimen.html.erb
@@ -884,6 +888,7 @@ files:
884
888
  - lib/studio/email_smoke.rb
885
889
  - lib/studio/engine.rb
886
890
  - lib/studio/environment_banner.rb
891
+ - lib/studio/exceptions_app.rb
887
892
  - lib/studio/geo.rb
888
893
  - lib/studio/geo/countries.rb
889
894
  - lib/studio/geo/lookup.rb