studio-engine 0.91.1 → 0.92.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 +4 -4
- data/CHANGELOG.md +54 -0
- data/Gemfile +8 -0
- data/app/assets/tailwind/studio_engine/engine.css +8 -0
- data/app/components/previews/studio/badge_component_preview/schemes.html.erb +5 -0
- data/app/components/previews/studio/badge_component_preview/tones.html.erb +5 -0
- data/app/components/previews/studio/badge_component_preview.rb +29 -0
- data/app/components/studio/badge_component.html.erb +1 -0
- data/app/components/studio/badge_component.rb +85 -0
- data/app/controllers/concerns/studio/link_consumption.rb +2 -2
- data/app/controllers/studio/component_previews_controller.rb +23 -0
- data/app/javascript/studio/local_path.js +19 -0
- data/app/models/studio/site_identity.rb +2 -2
- data/app/views/components/_badge.html.erb +6 -37
- data/app/views/layouts/studio/component_preview.html.erb +16 -0
- data/app/views/style/_components.html.erb +37 -0
- data/app/views/style/index.html.erb +8 -4
- data/config/importmap.rb +13 -0
- data/lib/studio/booking.rb +5 -3
- data/lib/studio/component_gallery.rb +121 -0
- data/lib/studio/engine.rb +94 -0
- data/lib/studio/link_token.rb +5 -3
- data/lib/studio/local_path.rb +28 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +31 -0
- data/studio-engine.gemspec +17 -3
- metadata +53 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 823f5e3df6cb271556188c72d4322a968b5cf63f8635c37b70fc71a56ad93fe3
|
|
4
|
+
data.tar.gz: ab908a4f8a0c9beae55f1844bbc2957b43c7bc33f1643c3ae083473ae68473d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f66ecc8ebf3c1b6318e88e4b069cb2bc4b19452db48035558eea01cb5cf163fcf126435dfdc8b74d5e837bcd65535a10b99157e9cec1fa286f2452739f420ba
|
|
7
|
+
data.tar.gz: a3ae0c907619c2ba686bdb3a35a82883bd1a15e010191969e52c102414e37b57982a19de4cba20ccf20866638d6814983ad06e6fe0f7027ad8bad925caba6c06
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,60 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.92.0 — 2026-10-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **ViewComponent comes with the engine.** It is a runtime dependency, so an
|
|
12
|
+
app renders the engine's components with no Gemfile line of its own. Lookbook
|
|
13
|
+
is not: an app that wants the component gallery adds
|
|
14
|
+
`gem "lookbook", group: [:development, :test]` after studio-engine (ungrouped,
|
|
15
|
+
with `Studio.lookbook_in_production`, for a live gallery). Without it the app
|
|
16
|
+
loads no Lookbook code, which keeps tens of megabytes out of every production
|
|
17
|
+
process. The front-end standard (`docs/FRONT_END_STANDARD.md`) now names a
|
|
18
|
+
primitive's public surface (its inputs, its `data-*` hooks and its named CSS
|
|
19
|
+
classes) and what a change to it takes: a Breaking line here and a
|
|
20
|
+
consumer-CI run against every consumer before publish.
|
|
21
|
+
- **`Studio::BadgeComponent`, the first component.** It takes `text:`,
|
|
22
|
+
`data_board_count:`, and either `tone:` (one of the five status roles,
|
|
23
|
+
`success`, `warning`, `danger`, `primary`, `muted`, with the same chip classes
|
|
24
|
+
as the hub's `status_tone`) or `scheme:` (the palette the badge partial took).
|
|
25
|
+
The `components/badge` partial now renders the component and its output is
|
|
26
|
+
unchanged, so no caller changes this release. `engine.css` adds the engine's
|
|
27
|
+
`app/components` to every host's Tailwind scan.
|
|
28
|
+
- **The component gallery at `/admin/style/components`.** Lookbook over the
|
|
29
|
+
engine's previews, with a Components section on `/admin/style` linking each
|
|
30
|
+
state. A signed-in admin gets it; a visitor or non-admin gets 404. It shows
|
|
31
|
+
the rendered output only (no Source or Params panel). It is drawn only where
|
|
32
|
+
the app's bundle loads lookbook: in development and test, and in production
|
|
33
|
+
only with `Studio.lookbook_in_production = true`. No app draws ViewComponent's
|
|
34
|
+
preview routes in production. ViewComponent's own preview pages move to
|
|
35
|
+
`/admin/style/previews`, behind the same wall.
|
|
36
|
+
- **The engine's own importmap pins.** The engine's `config/importmap.rb` pins
|
|
37
|
+
each module under `app/javascript/studio` as `studio/<name>` (not preloaded),
|
|
38
|
+
and the `studio.importmap` initializer draws it into every host that has
|
|
39
|
+
importmap-rails, before the host's own map, so a host pin of the same name
|
|
40
|
+
wins. No host edit. The first module is `studio/local_path`, the browser twin
|
|
41
|
+
of `Studio::LocalPath.local?`.
|
|
42
|
+
- **Consumer CI runs Cyvasse's suite** against each engine change, beside the
|
|
43
|
+
hub, Turf Monster and McRitchie Industries.
|
|
44
|
+
|
|
45
|
+
## 0.91.2 — 2026-10-06
|
|
46
|
+
|
|
47
|
+
### Security
|
|
48
|
+
|
|
49
|
+
- **A return path beginning `/\` no longer redirects off-site.** Browsers read
|
|
50
|
+
`/\evil.com` as `//evil.com`, and the engine's path sanitizers checked only for
|
|
51
|
+
one leading `/` and not two, so a magic link minted with `return_to=/\evil.com`
|
|
52
|
+
sent the visitor to another host after sign-in (or, under Rails'
|
|
53
|
+
`raise_on_open_redirects`, answered 500). One predicate, `Studio::LocalPath.local?`,
|
|
54
|
+
now holds the rule (one leading `/`, no backslash, no control character) and
|
|
55
|
+
every engine site uses it: `Studio::LinkToken.sanitize_path`, the link-consume
|
|
56
|
+
redirect, `Studio::Booking.local_path?`, and the link-preview fallback image.
|
|
57
|
+
Each site keeps its fallback: a refused return path lands on the default page.
|
|
58
|
+
Consumers that sanitize a return path themselves should call
|
|
59
|
+
`Studio::LocalPath.local?` instead of a bare `start_with?("/")` check.
|
|
60
|
+
|
|
7
61
|
## 0.91.1 — 2026-10-06
|
|
8
62
|
|
|
9
63
|
### Fixed
|
data/Gemfile
CHANGED
|
@@ -102,3 +102,11 @@ end
|
|
|
102
102
|
group :development, :test do
|
|
103
103
|
gem "solana-studio", ">= 0.5.3"
|
|
104
104
|
end
|
|
105
|
+
|
|
106
|
+
# importmap-rails is the dummy host's importmap, so the engine's pin contract
|
|
107
|
+
# (config/importmap.rb, drawn in by the studio.importmap initializer) is tested
|
|
108
|
+
# against the real gem every consumer runs. Not a runtime dependency: the
|
|
109
|
+
# initializer skips a host without importmap-rails (a footer-only consumer).
|
|
110
|
+
group :development, :test do
|
|
111
|
+
gem "importmap-rails", ">= 2.0"
|
|
112
|
+
end
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
their local copies for this import.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
/* -- Components: in every host's class scan --------------------------------
|
|
32
|
+
The engine's ViewComponent classes (app/components) carry their colour
|
|
33
|
+
classes in Ruby, and a host's Tailwind config scans only the engine's
|
|
34
|
+
app/views. This adds app/components to the scan of every host that imports
|
|
35
|
+
this file, with no host edit. The path is relative to this file.
|
|
36
|
+
test/integration/component_tailwind_source_test.rb proves it compiles. */
|
|
37
|
+
@source "../../../components";
|
|
38
|
+
|
|
31
39
|
/* -- Layer scale ------------------------------------------------------------
|
|
32
40
|
The ONE stacking order every Studio app shares. Read the ORDER, not the
|
|
33
41
|
number: the tier is the contract, the integer is an implementation detail.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# Every state Studio::BadgeComponent can show: the five status tones, every
|
|
5
|
+
# legacy scheme including the stage ladder, the neutral default, and the board
|
|
6
|
+
# count hook. No @param tags: the gallery takes no input.
|
|
7
|
+
class BadgeComponentPreview < ViewComponent::Preview
|
|
8
|
+
# The five status roles, as a hub view passes them from status_tone_role.
|
|
9
|
+
def tones
|
|
10
|
+
render_with_template(locals: { tones: Studio::BadgeComponent::TONES.keys })
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The palette the components/badge partial takes, stage ladder included.
|
|
14
|
+
def schemes
|
|
15
|
+
render_with_template(locals: { schemes: Studio::BadgeComponent::SCHEMES.keys })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# No tone and no scheme: the neutral scheme.
|
|
19
|
+
def default
|
|
20
|
+
render Studio::BadgeComponent.new(text: "neutral")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A board column's count, which studio/board updates through
|
|
24
|
+
# data-board-count.
|
|
25
|
+
def board_count
|
|
26
|
+
render Studio::BadgeComponent.new(text: "12", scheme: "stage-fresh", data_board_count: "fresh")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= content_tag :span, text, class: "badge #{colour_classes}", data: (data_board_count ? { board_count: data_board_count } : nil) %>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# A short label in a pill: a count, a repo name, a stage, a status.
|
|
5
|
+
#
|
|
6
|
+
# render Studio::BadgeComponent.new(text: "3")
|
|
7
|
+
# render Studio::BadgeComponent.new(text: "passed", tone: :success)
|
|
8
|
+
# render Studio::BadgeComponent.new(text: "news", scheme: "stage-fresh")
|
|
9
|
+
#
|
|
10
|
+
# Its public surface, which a release changes only with a Breaking line in the
|
|
11
|
+
# changelog (docs/FRONT_END_STANDARD.md, "A primitive's public surface"):
|
|
12
|
+
# inputs text:, tone: or scheme:, data_board_count:
|
|
13
|
+
# classes .badge plus the tone's or scheme's colour classes
|
|
14
|
+
# data-* data-board-count, the studio/board count target (its updateCounts
|
|
15
|
+
# sets the badge's text)
|
|
16
|
+
#
|
|
17
|
+
# tone: is one of the five status roles every app shares (TONES). Its classes
|
|
18
|
+
# are the hub's status_tone chip, minus the `border` width .badge already sets,
|
|
19
|
+
# so a hub view passes `tone: status_tone_role(status)` and gets the colour the
|
|
20
|
+
# rest of the page uses for that status. Tokens only, so it reads in both themes.
|
|
21
|
+
#
|
|
22
|
+
# scheme: is the palette the components/badge partial took (SCHEMES), including
|
|
23
|
+
# the stage-* pipeline ladder, and renders exactly what that partial rendered.
|
|
24
|
+
# An unknown scheme renders FALLBACK, as the partial did.
|
|
25
|
+
#
|
|
26
|
+
# Pass tone: or scheme:, not both. Neither renders the neutral scheme.
|
|
27
|
+
#
|
|
28
|
+
# Every class string is written out whole: Tailwind finds utilities by scanning
|
|
29
|
+
# source text, and engine.css adds app/components to every host's scan.
|
|
30
|
+
class BadgeComponent < ViewComponent::Base
|
|
31
|
+
strip_trailing_whitespace
|
|
32
|
+
|
|
33
|
+
TONES = {
|
|
34
|
+
success: "bg-success/10 text-success-ink border-success/40",
|
|
35
|
+
warning: "bg-warning/10 text-warning-ink border-warning/40",
|
|
36
|
+
danger: "bg-danger/10 text-danger-ink border-danger/40",
|
|
37
|
+
primary: "bg-primary/10 text-heading border-primary/40",
|
|
38
|
+
muted: "bg-surface-alt text-muted border-subtle"
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
SCHEMES = {
|
|
42
|
+
"success" => "bg-mint/10 text-mint border-mint/30",
|
|
43
|
+
"danger" => "bg-red-600/10 text-red-400 border-red-600/30",
|
|
44
|
+
"warning" => "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
|
45
|
+
"info" => "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
|
46
|
+
"violet" => "bg-violet/10 text-violet border-violet/30",
|
|
47
|
+
"primary" => "bg-primary/10 text-primary border-primary/30",
|
|
48
|
+
"orange" => "bg-orange-500/10 text-orange-500 border-orange-500/30",
|
|
49
|
+
"emerald" => "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
|
50
|
+
"gray" => "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
|
51
|
+
# The pipeline ladder News and Content share, first stage to archived.
|
|
52
|
+
"stage-fresh" => "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
|
53
|
+
"stage-shaping" => "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
|
54
|
+
"stage-structured" => "bg-mint/10 text-mint border-mint/30",
|
|
55
|
+
"stage-refined" => "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
|
56
|
+
"stage-cohered" => "bg-violet/10 text-violet border-violet/30",
|
|
57
|
+
"stage-shipped" => "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
|
58
|
+
"stage-closed" => "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
|
59
|
+
"neutral" => "bg-surface-alt text-secondary border-subtle"
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
FALLBACK = "bg-surface-alt text-body border-subtle"
|
|
63
|
+
|
|
64
|
+
attr_reader :text, :data_board_count
|
|
65
|
+
|
|
66
|
+
def initialize(text:, tone: nil, scheme: nil, data_board_count: nil)
|
|
67
|
+
super()
|
|
68
|
+
raise ArgumentError, "Studio::BadgeComponent takes tone: or scheme:, not both" if tone && scheme
|
|
69
|
+
if tone && !TONES.key?(tone.to_s.to_sym)
|
|
70
|
+
raise ArgumentError, "unknown tone #{tone.inspect}; expected one of #{TONES.keys.join(', ')}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@text = text
|
|
74
|
+
@tone = tone&.to_s&.to_sym
|
|
75
|
+
@scheme = scheme
|
|
76
|
+
@data_board_count = data_board_count
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def colour_classes
|
|
80
|
+
return TONES.fetch(@tone) if @tone
|
|
81
|
+
|
|
82
|
+
SCHEMES.fetch((@scheme || "neutral").to_s, FALLBACK)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -163,10 +163,10 @@ module Studio
|
|
|
163
163
|
user.update!(email_verified_at: Time.current)
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
-
# Only
|
|
166
|
+
# Only local paths survive (Studio::LocalPath); everything else collapses to nil.
|
|
167
167
|
def safe_path(path)
|
|
168
168
|
p = path.to_s
|
|
169
|
-
|
|
169
|
+
Studio::LocalPath.local?(p) ? p : nil
|
|
170
170
|
end
|
|
171
171
|
end
|
|
172
172
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# ViewComponent's preview controller, behind the admin wall. ViewComponent
|
|
5
|
+
# draws its own preview pages at Studio::ComponentGallery::PREVIEWS_ROUTE
|
|
6
|
+
# wherever previews are on (development and test), and Lookbook renders every
|
|
7
|
+
# preview in the gallery through this same controller. Anyone but a signed-in
|
|
8
|
+
# admin gets 404, so neither path shows a component to a visitor even if a
|
|
9
|
+
# route is drawn that the gallery's router constraint does not cover.
|
|
10
|
+
#
|
|
11
|
+
# It inherits ViewComponent's controller (Rails::ApplicationController), not
|
|
12
|
+
# the host's ApplicationController, so the host's own before_actions do not
|
|
13
|
+
# run inside a preview render.
|
|
14
|
+
class ComponentPreviewsController < ::ViewComponentsController
|
|
15
|
+
prepend_before_action :require_gallery_admin
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def require_gallery_admin
|
|
20
|
+
head :not_found unless Studio::ComponentGallery.admin_request?(request)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The one rule for "is this a path on THIS site?", in the browser. It is the
|
|
2
|
+
// twin of Studio::LocalPath.local? (lib/studio/local_path.rb), and
|
|
3
|
+
// test/lib/studio/local_path_js_parity_test.rb holds the two to the same rows.
|
|
4
|
+
//
|
|
5
|
+
// A local path begins with exactly one "/" and carries no control character and
|
|
6
|
+
// no backslash. Each clause closes a way a browser leaves the site: no leading
|
|
7
|
+
// "/" is a scheme or a relative path, "//x" is another host, a backslash is read
|
|
8
|
+
// as "/", and the URL parser strips tab and newline, so a control character can
|
|
9
|
+
// hide a second "/".
|
|
10
|
+
//
|
|
11
|
+
// Pinned for every host as "studio/local_path" (config/importmap.rb).
|
|
12
|
+
|
|
13
|
+
// A control character (C0 or DEL) or a backslash anywhere in the path.
|
|
14
|
+
const UNSAFE_CHARACTER = /[\u0000-\u001f\u007f\\]/
|
|
15
|
+
|
|
16
|
+
export function isLocalPath(path) {
|
|
17
|
+
const string = path == null ? "" : String(path)
|
|
18
|
+
return string.startsWith("/") && !string.startsWith("//") && !UNSAFE_CHARACTER.test(string)
|
|
19
|
+
}
|
|
@@ -183,7 +183,7 @@ module Studio
|
|
|
183
183
|
def static_image
|
|
184
184
|
path = Studio.link_preview_fallback_image.to_s
|
|
185
185
|
return nil if path.empty?
|
|
186
|
-
return path unless
|
|
186
|
+
return path unless Studio::LocalPath.local?(path)
|
|
187
187
|
|
|
188
188
|
@static_files ||= Concurrent::Map.new
|
|
189
189
|
present = @static_files.compute_if_absent(path) do
|
|
@@ -203,7 +203,7 @@ module Studio
|
|
|
203
203
|
# per path like static_image.
|
|
204
204
|
def static_image_dimensions
|
|
205
205
|
path = static_image
|
|
206
|
-
return nil
|
|
206
|
+
return nil unless Studio::LocalPath.local?(path)
|
|
207
207
|
|
|
208
208
|
@static_dimensions ||= Concurrent::Map.new
|
|
209
209
|
dims = @static_dimensions.compute_if_absent(path) do
|
|
@@ -1,37 +1,6 @@
|
|
|
1
|
-
<%# locals: (text:, scheme: "neutral", data_board_count: nil)
|
|
2
|
-
<%#
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
stage-structured → 3rd stage (mint)
|
|
8
|
-
stage-refined → 4th stage (emerald)
|
|
9
|
-
stage-cohered → 5th stage (violet)
|
|
10
|
-
stage-shipped → published (emerald)
|
|
11
|
-
stage-closed → archived/done (gray)
|
|
12
|
-
%>
|
|
13
|
-
<%
|
|
14
|
-
scheme_classes = case scheme.to_s
|
|
15
|
-
when "success" then "bg-mint/10 text-mint border-mint/30"
|
|
16
|
-
when "danger" then "bg-red-600/10 text-red-400 border-red-600/30"
|
|
17
|
-
when "warning" then "bg-yellow-500/10 text-yellow-400 border-yellow-500/30"
|
|
18
|
-
when "info" then "bg-blue-500/10 text-blue-500 border-blue-500/30"
|
|
19
|
-
when "violet" then "bg-violet/10 text-violet border-violet/30"
|
|
20
|
-
when "primary" then "bg-primary/10 text-primary border-primary/30"
|
|
21
|
-
when "orange" then "bg-orange-500/10 text-orange-500 border-orange-500/30"
|
|
22
|
-
when "emerald" then "bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
|
|
23
|
-
when "gray" then "bg-gray-500/10 text-gray-400 border-gray-500/30"
|
|
24
|
-
when "stage-fresh" then "bg-blue-500/10 text-blue-500 border-blue-500/30"
|
|
25
|
-
when "stage-shaping" then "bg-yellow-500/10 text-yellow-400 border-yellow-500/30"
|
|
26
|
-
when "stage-structured" then "bg-mint/10 text-mint border-mint/30"
|
|
27
|
-
when "stage-refined" then "bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
|
|
28
|
-
when "stage-cohered" then "bg-violet/10 text-violet border-violet/30"
|
|
29
|
-
when "stage-shipped" then "bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
|
|
30
|
-
when "stage-closed" then "bg-gray-500/10 text-gray-400 border-gray-500/30"
|
|
31
|
-
when "neutral" then "bg-surface-alt text-secondary border-subtle"
|
|
32
|
-
else "bg-surface-alt text-body border-subtle"
|
|
33
|
-
end
|
|
34
|
-
%>
|
|
35
|
-
<%# data_board_count wires the badge as a studio/board count target (updateCounts sets
|
|
36
|
-
its textContent) — additive + opt-in, so every existing caller renders identically. %>
|
|
37
|
-
<span class="badge <%= scheme_classes %>"<% if local_assigns[:data_board_count] %> data-board-count="<%= data_board_count %>"<% end %>><%= text %></span>
|
|
1
|
+
<%# locals: (text:, scheme: "neutral", data_board_count: nil) -%>
|
|
2
|
+
<%# The badge is Studio::BadgeComponent; this partial keeps every existing
|
|
3
|
+
`render "components/badge"` call working for one release and renders exactly
|
|
4
|
+
what it rendered before. New code renders the component, which also takes
|
|
5
|
+
tone: (the shared status roles). -%>
|
|
6
|
+
<%= render Studio::BadgeComponent.new(text: text, scheme: scheme, data_board_count: data_board_count) %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# The page a component preview renders in, in the gallery and on
|
|
2
|
+
ViewComponent's own preview pages: the host's theme tokens and stylesheets,
|
|
3
|
+
so a preview looks as it does in the app, and nothing else: no navbar, no
|
|
4
|
+
session, no script. -%>
|
|
5
|
+
<!DOCTYPE html>
|
|
6
|
+
<html>
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="utf-8">
|
|
9
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
10
|
+
<%= studio_theme_css_tag if respond_to?(:studio_theme_css_tag) %>
|
|
11
|
+
<%= stylesheet_link_tag "tailwind", "application" %>
|
|
12
|
+
</head>
|
|
13
|
+
<body class="bg-page text-body p-6">
|
|
14
|
+
<%= yield %>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<%# locals: () -%>
|
|
2
|
+
<%# Components: every ViewComponent preview the gallery serves, each state a link
|
|
3
|
+
to its page in the gallery (Lookbook at Studio::ComponentGallery::MOUNT_PATH).
|
|
4
|
+
The previews ARE the specimens, so this list cannot drift from the components.
|
|
5
|
+
An app that does not draw the gallery here (lookbook not in its bundle, or
|
|
6
|
+
production without Studio.lookbook_in_production) says so instead of linking
|
|
7
|
+
to a 404. -%>
|
|
8
|
+
<section id="components" class="space-y-6" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
9
|
+
<div class="space-y-1">
|
|
10
|
+
<h2 class="text-2xl font-bold text-heading">Components</h2>
|
|
11
|
+
<p class="text-muted text-sm max-w-2xl">
|
|
12
|
+
The engine's ViewComponent classes, one preview per state, in the component gallery.
|
|
13
|
+
</p>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<% if Studio.lookbook_mounted? %>
|
|
17
|
+
<ul class="grid gap-4 sm:grid-cols-2" data-component-previews>
|
|
18
|
+
<% Lookbook.previews.each do |preview| %>
|
|
19
|
+
<li class="card p-5 space-y-3" data-component-preview="<%= preview.lookup_path %>">
|
|
20
|
+
<p class="font-semibold text-heading"><%= preview.label %></p>
|
|
21
|
+
<div class="flex flex-wrap gap-2">
|
|
22
|
+
<% preview.scenarios.each do |scenario| %>
|
|
23
|
+
<%= link_to scenario.label, scenario.url_path, class: "btn btn-neutral btn-sm" %>
|
|
24
|
+
<% end %>
|
|
25
|
+
</div>
|
|
26
|
+
</li>
|
|
27
|
+
<% end %>
|
|
28
|
+
</ul>
|
|
29
|
+
<%= link_to "Open the component gallery", Studio::ComponentGallery::MOUNT_PATH, class: "btn btn-primary btn-sm" %>
|
|
30
|
+
<% else %>
|
|
31
|
+
<p class="empty-state">
|
|
32
|
+
This app does not serve the component gallery here. It needs lookbook in its bundle
|
|
33
|
+
(<code class="font-mono text-2xs">gem "lookbook"</code> after studio-engine), and in
|
|
34
|
+
production <code class="font-mono text-2xs">Studio.lookbook_in_production</code> too.
|
|
35
|
+
</p>
|
|
36
|
+
<% end %>
|
|
37
|
+
</section>
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
application.html.erb, inheriting that app's navbar and theme, so every
|
|
4
4
|
specimen restyles per app and in dark or light automatically.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Five ENGINE sections, reached by the sticky section nav below and rendered
|
|
7
7
|
as sibling partials: Theme is the landing section (the color foundation - it
|
|
8
8
|
owns the role tokens/swatches, the folded-in /admin/theme editor, and the
|
|
9
9
|
live preview), then Modals (the shared host + engine card blocks), Tricks
|
|
10
10
|
(the button/surface/motion/effect/leveling primitives, framed as a board of
|
|
11
|
-
copy-paste-for-an-agent snippets),
|
|
11
|
+
copy-paste-for-an-agent snippets), Tasks (the shared board primitive), and
|
|
12
|
+
Components (the ViewComponent previews, linking into the gallery at
|
|
13
|
+
/admin/style/components).
|
|
12
14
|
|
|
13
|
-
A
|
|
15
|
+
A SIXTH section is the host app's own, and appears only on an app that asks
|
|
14
16
|
for it: define app/views/style/host/_modals.html.erb in the consuming app
|
|
15
17
|
and the guide grows a section (and a nav pill) for that app's own modals,
|
|
16
18
|
between Modals and Tricks. The gem still renders the section chrome; the app
|
|
@@ -46,7 +48,8 @@
|
|
|
46
48
|
sections << ["host-modals", Studio.app_name] if host_modals
|
|
47
49
|
sections += [
|
|
48
50
|
["tricks", "Tricks"],
|
|
49
|
-
["tasks", "Tasks"]
|
|
51
|
+
["tasks", "Tasks"],
|
|
52
|
+
["components", "Components"]
|
|
50
53
|
]
|
|
51
54
|
%>
|
|
52
55
|
<div class="max-w-6xl mx-auto px-4 pb-16">
|
|
@@ -87,6 +90,7 @@
|
|
|
87
90
|
<% end %>
|
|
88
91
|
<%= render "style/tricks" %>
|
|
89
92
|
<%= render "style/tasks" %>
|
|
93
|
+
<%= render "style/components" %>
|
|
90
94
|
</div>
|
|
91
95
|
</div>
|
|
92
96
|
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# The engine's importmap pins, drawn into every host by the studio.importmap
|
|
2
|
+
# initializer (lib/studio/engine.rb) before the host's own config/importmap.rb.
|
|
3
|
+
#
|
|
4
|
+
# One `pin` per module under app/javascript/studio, named "studio/<name>", and
|
|
5
|
+
# none preloaded: a page fetches a module only when something imports it.
|
|
6
|
+
#
|
|
7
|
+
# `pin`, not `pin_all_from`, so a host can override one: importmap-rails expands
|
|
8
|
+
# a pin_all_from directory AFTER every plain pin, so a directory pin would beat
|
|
9
|
+
# the host's own `pin` of the same name. Plain pins are last-drawn-wins, and the
|
|
10
|
+
# host's map is drawn last.
|
|
11
|
+
Studio::Engine.javascript_module_logical_paths.each do |logical_path|
|
|
12
|
+
pin logical_path.delete_suffix(".js"), to: logical_path, preload: false
|
|
13
|
+
end
|
data/lib/studio/booking.rb
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
#
|
|
12
12
|
# `gv=true` is what makes Google serve the embeddable page, so the frame and the
|
|
13
13
|
# popup add it; the "open the booking page" link does not.
|
|
14
|
+
require_relative "local_path"
|
|
15
|
+
|
|
14
16
|
module Studio
|
|
15
17
|
module Booking
|
|
16
18
|
EMBED_PARAM = "gv=true"
|
|
@@ -116,10 +118,10 @@ module Studio
|
|
|
116
118
|
# hrefs (the footer's booking links, `studio_booking_link`). It begins with
|
|
117
119
|
# one "/" and carries no control character. That refuses a scheme
|
|
118
120
|
# ("javascript:", "https:") and a protocol-relative "//host", which a browser
|
|
119
|
-
# reads as another site.
|
|
121
|
+
# reads as another site. The rule is Studio::LocalPath's, shared with every
|
|
122
|
+
# other engine path sanitizer.
|
|
120
123
|
def local_path?(path)
|
|
121
|
-
|
|
122
|
-
string.start_with?("/") && !string.start_with?("//") && !string.match?(/[\x00-\x1f\x7f\\]/)
|
|
124
|
+
Studio::LocalPath.local?(path)
|
|
123
125
|
end
|
|
124
126
|
|
|
125
127
|
# Studio.booking_path: nil, a local path, or a callable receiving the view.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/security_utils"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
require "active_support/core_ext/module/attribute_accessors"
|
|
6
|
+
|
|
7
|
+
module Studio
|
|
8
|
+
# The component gallery: Lookbook, mounted by Studio.routes at
|
|
9
|
+
# /admin/style/components, rendering the ViewComponent previews under the
|
|
10
|
+
# engine's app/components/previews (docs/FRONT_END_STANDARD.md, "Components").
|
|
11
|
+
#
|
|
12
|
+
# Three rules hold it, each here so the routes, the engine initializers and
|
|
13
|
+
# the preview controller read one answer:
|
|
14
|
+
#
|
|
15
|
+
# 1. WHERE IT IS DRAWN. Only in an app whose bundle loads lookbook (the
|
|
16
|
+
# engine never requires it; a host adds `gem "lookbook"` after
|
|
17
|
+
# studio-engine). Such an app draws it in development and test, and in
|
|
18
|
+
# production only if it also sets Studio.lookbook_in_production = true.
|
|
19
|
+
# Everywhere else there is no route, no ViewComponent preview route, no
|
|
20
|
+
# /lookbook-assets middleware, and no Lookbook in memory.
|
|
21
|
+
# 2. WHO SEES IT. An admin with a live session. Anyone else gets 404, not a
|
|
22
|
+
# redirect, so the gallery's existence is not advertised. The check runs
|
|
23
|
+
# in the router (AdminConstraint), because Lookbook's controllers inherit
|
|
24
|
+
# ActionController::Base and never run the host's before_actions.
|
|
25
|
+
# 3. WHAT IT SHOWS. The rendered preview and its HTML. The Source and Params
|
|
26
|
+
# panels are switched off, embeds are off, pages are off, and no preview
|
|
27
|
+
# declares a @param tag, so the gallery shows no Ruby and accepts no input.
|
|
28
|
+
module ComponentGallery
|
|
29
|
+
MOUNT_PATH = "/admin/style/components"
|
|
30
|
+
# ViewComponent's own preview pages, moved from /rails/view_components to
|
|
31
|
+
# sit behind the same wall (Studio::ComponentPreviewsController).
|
|
32
|
+
PREVIEWS_ROUTE = "/admin/style/previews"
|
|
33
|
+
PREVIEWS_CONTROLLER = "Studio::ComponentPreviewsController"
|
|
34
|
+
# Lookbook's UI references its scripts and styles at this absolute path.
|
|
35
|
+
ASSETS_PATH = "/lookbook-assets"
|
|
36
|
+
# The layout each preview renders in: the host's stylesheets and theme, no
|
|
37
|
+
# navbar, no script.
|
|
38
|
+
PREVIEW_LAYOUT = "studio/component_preview"
|
|
39
|
+
# Only the panels that show output. Lookbook's defaults add :source (the
|
|
40
|
+
# preview's Ruby and template) and :params (live inputs from the URL).
|
|
41
|
+
MAIN_PANELS = %i[preview output].freeze
|
|
42
|
+
DRAWER_PANELS = %i[notes].freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# Whether the engine saw Lookbook already loaded when it was required, which
|
|
47
|
+
# means the host listed lookbook BEFORE studio-engine (set by
|
|
48
|
+
# lib/studio/engine.rb).
|
|
49
|
+
mattr_accessor :lookbook_loaded_before_engine, default: false
|
|
50
|
+
|
|
51
|
+
# Whether this app draws the gallery. Pure: the env, the flag and whether
|
|
52
|
+
# lookbook is loaded are passed in, so the rule unit-tests without Rails.
|
|
53
|
+
def mounted?(env:, in_production:, lookbook_loaded:)
|
|
54
|
+
return false unless lookbook_loaded
|
|
55
|
+
|
|
56
|
+
!env.to_s.casecmp?("production") || in_production == true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether the host's bundle loaded Lookbook. The engine never requires it.
|
|
60
|
+
def lookbook_loaded?
|
|
61
|
+
defined?(::Lookbook::Engine) ? true : false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Lookbook required after view_component, so ViewComponent's after_initialize
|
|
65
|
+
# draws (or does not draw) its preview routes before Lookbook turns previews on.
|
|
66
|
+
def load_order_ok?
|
|
67
|
+
!lookbook_loaded_before_engine
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Whether this request comes from an admin with a live session. It reads the
|
|
71
|
+
# same two facts the engine's own gate reads: the user id under
|
|
72
|
+
# Studio.session_key (Studio::ErrorHandling#current_user), and, for a User
|
|
73
|
+
# with a session_token column, a cookie token that matches it
|
|
74
|
+
# (#verify_session_token). It fails closed: no session, no user, a non-admin,
|
|
75
|
+
# or a stale token all answer false.
|
|
76
|
+
def admin_request?(request)
|
|
77
|
+
session = request.session
|
|
78
|
+
key = Studio.session_key
|
|
79
|
+
user_id = session[key.to_s] || session[key.to_sym]
|
|
80
|
+
return false if user_id.blank?
|
|
81
|
+
return false unless defined?(::User)
|
|
82
|
+
|
|
83
|
+
user = ::User.find_by(id: user_id)
|
|
84
|
+
return false unless user.respond_to?(:admin?) && user.admin?
|
|
85
|
+
return true unless user.respond_to?(:session_token)
|
|
86
|
+
|
|
87
|
+
token = user.session_token.to_s
|
|
88
|
+
token.present? && ActiveSupport::SecurityUtils.secure_compare(token, session[:session_token].to_s)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The directory Lookbook's own Rack::Static served ASSETS_PATH from.
|
|
92
|
+
def lookbook_assets_root
|
|
93
|
+
::Lookbook::Engine.root.join("public/lookbook-assets").to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The routing constraint on the mount. A request it refuses matches no
|
|
97
|
+
# route, which Rails answers 404.
|
|
98
|
+
class AdminConstraint
|
|
99
|
+
def matches?(request)
|
|
100
|
+
ComponentGallery.admin_request?(request)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The settings that keep the gallery to output only (rule 3). Applied by
|
|
105
|
+
# the studio.component_gallery initializer, before the host's own
|
|
106
|
+
# initializers, so a host can still change one deliberately.
|
|
107
|
+
def configure_lookbook!(config)
|
|
108
|
+
config.project_name = "Components"
|
|
109
|
+
config.preview_inspector.main_panels = MAIN_PANELS
|
|
110
|
+
config.preview_inspector.drawer_panels = DRAWER_PANELS
|
|
111
|
+
config.preview_embeds.enabled = false
|
|
112
|
+
config.page_paths = []
|
|
113
|
+
config.live_updates = false
|
|
114
|
+
config.debug_menu = false
|
|
115
|
+
# Parse previews on the first gallery request, not at boot: an app that
|
|
116
|
+
# never opens the gallery pays nothing for it.
|
|
117
|
+
config.lazy_load_previews_and_pages = true
|
|
118
|
+
config
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/studio/engine.rb
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
require_relative "log_rotation"
|
|
2
|
+
require_relative "component_gallery"
|
|
3
|
+
# Components (docs/FRONT_END_STANDARD.md). Required here, not left to the host's
|
|
4
|
+
# Gemfile, because every app takes ViewComponent through this engine.
|
|
5
|
+
#
|
|
6
|
+
# Lookbook, the gallery, is NOT required here: an app that wants it bundles it
|
|
7
|
+
# (see the gemspec), and Bundler.require loads it. Listed AFTER studio-engine, so
|
|
8
|
+
# view_component is required first: its after_initialize, which decides whether
|
|
9
|
+
# to draw its own preview routes, must run before Lookbook's, which turns
|
|
10
|
+
# previews on for its own use. Studio::ComponentGallery.load_order_ok? records
|
|
11
|
+
# the order this file saw.
|
|
12
|
+
Studio::ComponentGallery.lookbook_loaded_before_engine = defined?(::Lookbook::Engine) ? true : false
|
|
13
|
+
require "view_component"
|
|
2
14
|
|
|
3
15
|
module Studio
|
|
4
16
|
class Engine < ::Rails::Engine
|
|
@@ -71,6 +83,77 @@ module Studio
|
|
|
71
83
|
# US set ships — every other country's badge renders an emoji flag, which
|
|
72
84
|
# costs no bytes at all.
|
|
73
85
|
app.config.assets.precompile += Studio::Engine.subdivision_flag_logical_paths
|
|
86
|
+
|
|
87
|
+
# The engine's ES modules (app/javascript), which config/importmap.rb pins.
|
|
88
|
+
# On the asset paths so an importmap pin resolves on sprockets and propshaft
|
|
89
|
+
# alike, and enumerated for sprockets' precompile list like the banners.
|
|
90
|
+
javascript_root = Studio::Engine.root.join("app/javascript").to_s
|
|
91
|
+
if app.config.assets.respond_to?(:paths) && app.config.assets.paths.is_a?(Array)
|
|
92
|
+
app.config.assets.paths << javascript_root unless app.config.assets.paths.map(&:to_s).include?(javascript_root)
|
|
93
|
+
end
|
|
94
|
+
app.config.assets.precompile += Studio::Engine.javascript_module_logical_paths
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ---- THE ENGINE'S IMPORTMAP PINS ------------------------------------------
|
|
98
|
+
#
|
|
99
|
+
# A host draws the engine's pins with no edit of its own: this adds the
|
|
100
|
+
# engine's config/importmap.rb to importmap-rails' list of maps BEFORE
|
|
101
|
+
# importmap-rails draws them (its "importmap" initializer appends the host's
|
|
102
|
+
# config/importmap.rb last). The host's map is drawn after the engine's, so a
|
|
103
|
+
# host that pins the same name wins. This is the mechanism importmap-rails
|
|
104
|
+
# documents for engines ("Composing import maps").
|
|
105
|
+
#
|
|
106
|
+
# The contract a module joins by being here:
|
|
107
|
+
# - it lives under app/javascript/studio/ and is pinned as "studio/<name>";
|
|
108
|
+
# - pins are NOT preloaded, so a page fetches only the modules it imports;
|
|
109
|
+
# - its logical asset path shares the studio/ prefix with the classic
|
|
110
|
+
# scripts in app/assets/javascripts/studio, so a module may not reuse one
|
|
111
|
+
# of those names (test/integration/engine_importmap_pins_test.rb).
|
|
112
|
+
# A host without importmap-rails (a footer-only consumer) skips this.
|
|
113
|
+
initializer "studio.importmap", before: "importmap" do |app|
|
|
114
|
+
next unless app.config.respond_to?(:importmap)
|
|
115
|
+
|
|
116
|
+
app.config.importmap.paths << Studio::Engine.root.join("config/importmap.rb")
|
|
117
|
+
app.config.importmap.cache_sweepers << Studio::Engine.root.join("app/javascript")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ---- COMPONENTS AND THE GALLERY -------------------------------------------
|
|
121
|
+
#
|
|
122
|
+
# ViewComponent reads its preview settings in "view_component.set_configs",
|
|
123
|
+
# so these land first. The previews live in app/components/previews, which
|
|
124
|
+
# is its own autoload root (below): Studio::BadgeComponentPreview, not
|
|
125
|
+
# Previews::Studio::BadgeComponentPreview. ViewComponent's own preview pages
|
|
126
|
+
# move behind the admin wall (PREVIEWS_ROUTE, PREVIEWS_CONTROLLER), and each
|
|
127
|
+
# preview renders in a layout carrying the host's stylesheets.
|
|
128
|
+
paths.add "app/components/previews", autoload: true
|
|
129
|
+
|
|
130
|
+
initializer "studio.view_component", before: "view_component.set_configs" do |app|
|
|
131
|
+
previews = app.config.view_component.previews
|
|
132
|
+
preview_path = Studio::Engine.root.join("app/components/previews").to_s
|
|
133
|
+
previews.paths << preview_path unless previews.paths.include?(preview_path)
|
|
134
|
+
previews.route = Studio::ComponentGallery::PREVIEWS_ROUTE
|
|
135
|
+
previews.controller = Studio::ComponentGallery::PREVIEWS_CONTROLLER
|
|
136
|
+
previews.default_layout = Studio::ComponentGallery::PREVIEW_LAYOUT
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Lookbook's settings, before Lookbook's after_initialize reads them and
|
|
140
|
+
# before the host's config/initializers, so a host can still change one.
|
|
141
|
+
#
|
|
142
|
+
# It also takes Lookbook's UI assets off the public middleware stack.
|
|
143
|
+
# Lookbook serves /lookbook-assets through a Rack::Static it adds for every
|
|
144
|
+
# request; Studio.routes serves the same files from inside the admin
|
|
145
|
+
# constraint instead, so an app that does not draw the gallery serves none
|
|
146
|
+
# of Lookbook and a visitor never learns it is there. The delete matches by
|
|
147
|
+
# class, so a host cannot add a Rack::Static of its own (none does).
|
|
148
|
+
initializer "studio.component_gallery", after: "lookbook.assets.serve" do
|
|
149
|
+
next unless Studio::ComponentGallery.lookbook_loaded?
|
|
150
|
+
|
|
151
|
+
unless Studio::ComponentGallery.load_order_ok?
|
|
152
|
+
warn "studio-engine: lookbook was required before studio-engine; list it after " \
|
|
153
|
+
"studio-engine in the Gemfile so ViewComponent decides its preview routes first"
|
|
154
|
+
end
|
|
155
|
+
Studio::ComponentGallery.configure_lookbook!(::Lookbook.config)
|
|
156
|
+
config.app_middleware.delete(::Rack::Static)
|
|
74
157
|
end
|
|
75
158
|
|
|
76
159
|
# Logical asset paths ("emails/magic-link.png") for every default banner the
|
|
@@ -82,6 +165,16 @@ module Studio
|
|
|
82
165
|
.sort
|
|
83
166
|
end
|
|
84
167
|
|
|
168
|
+
# Logical asset paths ("studio/local_path.js") for every ES module the
|
|
169
|
+
# engine pins (config/importmap.rb).
|
|
170
|
+
def self.javascript_module_logical_paths
|
|
171
|
+
base = File.expand_path("../../app/javascript", __dir__)
|
|
172
|
+
Dir[File.join(base, "**/*.js")]
|
|
173
|
+
.select { |path| File.file?(path) }
|
|
174
|
+
.map { |path| path.delete_prefix("#{base}/") }
|
|
175
|
+
.sort
|
|
176
|
+
end
|
|
177
|
+
|
|
85
178
|
# Logical asset paths ("state-flags/wa.svg") for every subdivision flag the
|
|
86
179
|
# gem ships.
|
|
87
180
|
# Both the vector art and the small rasters behind it: the badge renders one
|
|
@@ -206,5 +299,6 @@ module Studio
|
|
|
206
299
|
Studio.validate_user_contract!(::User)
|
|
207
300
|
end
|
|
208
301
|
end
|
|
302
|
+
|
|
209
303
|
end
|
|
210
304
|
end
|
data/lib/studio/link_token.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "local_path"
|
|
4
5
|
|
|
5
6
|
module Studio
|
|
6
7
|
# Pure-Ruby helpers behind the Studio::Link model — token minting, the kind
|
|
@@ -53,12 +54,13 @@ module Studio
|
|
|
53
54
|
email.to_s.strip.downcase
|
|
54
55
|
end
|
|
55
56
|
|
|
56
|
-
# Only
|
|
57
|
+
# Only local paths survive (Studio::LocalPath: one leading "/", no
|
|
58
|
+
# backslash, no control character); protocol-relative ("//evil", "/\evil"),
|
|
57
59
|
# absolute URLs, and blanks collapse to nil so callers fall back to a safe
|
|
58
|
-
# default redirect.
|
|
60
|
+
# default redirect.
|
|
59
61
|
def sanitize_path(path)
|
|
60
62
|
p = path.to_s
|
|
61
|
-
|
|
63
|
+
Studio::LocalPath.local?(p) ? p : nil
|
|
62
64
|
end
|
|
63
65
|
end
|
|
64
66
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The one rule for "is this a path on THIS site?", used wherever the engine
|
|
5
|
+
# writes a caller-supplied path into a redirect or an href: a sign-in's
|
|
6
|
+
# return_to, a link's target, the booking page, the link-preview fallback image.
|
|
7
|
+
#
|
|
8
|
+
# A local path begins with exactly one "/" and carries no control character
|
|
9
|
+
# and no backslash. Each clause closes a way a browser leaves the site:
|
|
10
|
+
# "https://x", "javascript:x" - no leading "/": a scheme or a relative path
|
|
11
|
+
# "//x" - protocol-relative: another host
|
|
12
|
+
# "/\x", "/\/x" - browsers (the WHATWG URL parser) read "\" as
|
|
13
|
+
# "/", so these are "//x" in disguise
|
|
14
|
+
# "/\tx", "/\nx" - the URL parser strips tab and newline, so a
|
|
15
|
+
# control character can hide a second "/"
|
|
16
|
+
# Pure Ruby, so it loads and unit-tests without Rails.
|
|
17
|
+
module LocalPath
|
|
18
|
+
# A control character (C0 or DEL) or a backslash anywhere in the path.
|
|
19
|
+
UNSAFE_CHARACTER = /[\x00-\x1f\x7f\\]/
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def local?(path)
|
|
24
|
+
string = path.to_s
|
|
25
|
+
string.start_with?("/") && !string.start_with?("//") && !string.match?(UNSAFE_CHARACTER)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -12,6 +12,8 @@ require "active_support/core_ext/module/attribute_accessors"
|
|
|
12
12
|
require "active_support/core_ext/numeric/time"
|
|
13
13
|
require "active_support/core_ext/integer/time"
|
|
14
14
|
require "studio/version"
|
|
15
|
+
require "studio/local_path"
|
|
16
|
+
require "studio/component_gallery"
|
|
15
17
|
require "studio/log_rotation"
|
|
16
18
|
require "studio/ip_locations"
|
|
17
19
|
require "studio/geo"
|
|
@@ -689,6 +691,22 @@ module Studio
|
|
|
689
691
|
# config.draw_booking_routes = true
|
|
690
692
|
mattr_accessor :draw_booking_routes, default: false
|
|
691
693
|
|
|
694
|
+
# Draw the component gallery (Lookbook at /admin/style/components) in
|
|
695
|
+
# PRODUCTION. An app whose bundle loads lookbook draws it in development and
|
|
696
|
+
# test; in production only an app that also sets this does. No app sets it
|
|
697
|
+
# yet; the hub is meant to, with lookbook in its production bundle. Admins
|
|
698
|
+
# only, 404 to anyone else. See Studio::ComponentGallery.
|
|
699
|
+
#
|
|
700
|
+
# config.lookbook_in_production = true
|
|
701
|
+
mattr_accessor :lookbook_in_production, default: false
|
|
702
|
+
|
|
703
|
+
# Whether this app draws the component gallery: its bundle loaded lookbook,
|
|
704
|
+
# and this is development or test, or production with the flag.
|
|
705
|
+
def self.lookbook_mounted?
|
|
706
|
+
ComponentGallery.mounted?(env: Rails.env, in_production: lookbook_in_production,
|
|
707
|
+
lookbook_loaded: ComponentGallery.lookbook_loaded?)
|
|
708
|
+
end
|
|
709
|
+
|
|
692
710
|
# WHERE THE APP'S OWN BOOKING PAGE LIVES, for an app that renders
|
|
693
711
|
# `studio_booking_frame` in a view of its own instead of drawing the engine's
|
|
694
712
|
# /schedule: a path ("/schedule"), or a callable receiving the view
|
|
@@ -1530,6 +1548,19 @@ module Studio
|
|
|
1530
1548
|
get "admin/style", to: "style#index", as: :admin_style
|
|
1531
1549
|
get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
|
|
1532
1550
|
|
|
1551
|
+
# The component gallery: Lookbook over the engine's ViewComponent previews.
|
|
1552
|
+
# The router refuses anyone but a signed-in admin, so a visitor and a
|
|
1553
|
+
# non-admin get 404 (Studio::ComponentGallery::AdminConstraint). Drawn
|
|
1554
|
+
# only where the host's bundle loaded lookbook: in development and test,
|
|
1555
|
+
# and in production only where Studio.lookbook_in_production is set.
|
|
1556
|
+
if Studio.lookbook_mounted?
|
|
1557
|
+
constraints(Studio::ComponentGallery::AdminConstraint.new) do
|
|
1558
|
+
mount Lookbook::Engine, at: Studio::ComponentGallery::MOUNT_PATH, as: :studio_component_gallery
|
|
1559
|
+
mount Rack::Files.new(Studio::ComponentGallery.lookbook_assets_root),
|
|
1560
|
+
at: Studio::ComponentGallery::ASSETS_PATH, as: :studio_component_gallery_assets
|
|
1561
|
+
end
|
|
1562
|
+
end
|
|
1563
|
+
|
|
1533
1564
|
# The standard transactional-email page. Canonical at /admin/emails
|
|
1534
1565
|
# (Studio::EmailsController): index lists every registered email with its
|
|
1535
1566
|
# live banner and whether that banner is inherited or app-owned; update
|
data/studio-engine.gemspec
CHANGED
|
@@ -19,9 +19,11 @@ Gem::Specification.new do |spec|
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
# config/e2e_lane.yml is the BROWSER LANE's contract — a fact about this repo's CI,
|
|
22
|
-
# read by bin/e2e-executed-set-check and test/lib/e2e_lane_contract_test.rb. It
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# read by bin/e2e-executed-set-check and test/lib/e2e_lane_contract_test.rb. It
|
|
23
|
+
# has no meaning in a consuming app, so it is excluded rather than shipped.
|
|
24
|
+
# config/importmap.rb ships: it is the engine's pins, which every host draws
|
|
25
|
+
# (lib/studio/engine.rb, studio.importmap). (e2e/, bin/ and test/ were never in
|
|
26
|
+
# this list.)
|
|
25
27
|
#
|
|
26
28
|
# docs/SITE_FOOTER.md and docs/BOOKING.md ship because consumers cite them from
|
|
27
29
|
# their own initializers ("docs/SITE_FOOTER.md is the contract"), and a
|
|
@@ -56,4 +58,16 @@ Gem::Specification.new do |spec|
|
|
|
56
58
|
# because the point of the geo primitive is that EVERY app has the capacity:
|
|
57
59
|
# an app that has to add a gem before it can place a visitor does not.
|
|
58
60
|
spec.add_dependency "geocoder", ">= 1.8", "< 2.0"
|
|
61
|
+
# Components (docs/FRONT_END_STANDARD.md). Every consumer renders the engine's
|
|
62
|
+
# ViewComponent classes, so it is a runtime dependency, and an app takes it
|
|
63
|
+
# through the engine rather than its own Gemfile.
|
|
64
|
+
spec.add_dependency "view_component", ">= 4.0", "< 5"
|
|
65
|
+
# The component gallery at /admin/style/components is Lookbook, and Lookbook is
|
|
66
|
+
# NOT a runtime dependency: eager-loaded in production it costs every process
|
|
67
|
+
# tens of megabytes, and most consumers run on 512 MB dynos. An app opts in by
|
|
68
|
+
# bundling it after studio-engine, `gem "lookbook", group: [:development, :test]`
|
|
69
|
+
# for a development gallery, ungrouped plus Studio.lookbook_in_production for a
|
|
70
|
+
# live one. The engine never requires it; it mounts the gallery only when the
|
|
71
|
+
# host's bundle has loaded it. See Studio::ComponentGallery.
|
|
72
|
+
spec.add_development_dependency "lookbook", ">= 2.3", "< 3"
|
|
59
73
|
end
|
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.
|
|
4
|
+
version: 0.92.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -180,6 +180,46 @@ dependencies:
|
|
|
180
180
|
- - "<"
|
|
181
181
|
- !ruby/object:Gem::Version
|
|
182
182
|
version: '2.0'
|
|
183
|
+
- !ruby/object:Gem::Dependency
|
|
184
|
+
name: view_component
|
|
185
|
+
requirement: !ruby/object:Gem::Requirement
|
|
186
|
+
requirements:
|
|
187
|
+
- - ">="
|
|
188
|
+
- !ruby/object:Gem::Version
|
|
189
|
+
version: '4.0'
|
|
190
|
+
- - "<"
|
|
191
|
+
- !ruby/object:Gem::Version
|
|
192
|
+
version: '5'
|
|
193
|
+
type: :runtime
|
|
194
|
+
prerelease: false
|
|
195
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
196
|
+
requirements:
|
|
197
|
+
- - ">="
|
|
198
|
+
- !ruby/object:Gem::Version
|
|
199
|
+
version: '4.0'
|
|
200
|
+
- - "<"
|
|
201
|
+
- !ruby/object:Gem::Version
|
|
202
|
+
version: '5'
|
|
203
|
+
- !ruby/object:Gem::Dependency
|
|
204
|
+
name: lookbook
|
|
205
|
+
requirement: !ruby/object:Gem::Requirement
|
|
206
|
+
requirements:
|
|
207
|
+
- - ">="
|
|
208
|
+
- !ruby/object:Gem::Version
|
|
209
|
+
version: '2.3'
|
|
210
|
+
- - "<"
|
|
211
|
+
- !ruby/object:Gem::Version
|
|
212
|
+
version: '3'
|
|
213
|
+
type: :development
|
|
214
|
+
prerelease: false
|
|
215
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
216
|
+
requirements:
|
|
217
|
+
- - ">="
|
|
218
|
+
- !ruby/object:Gem::Version
|
|
219
|
+
version: '2.3'
|
|
220
|
+
- - "<"
|
|
221
|
+
- !ruby/object:Gem::Version
|
|
222
|
+
version: '3'
|
|
183
223
|
description: Studio Engine is a non-isolated Rails engine that ships an opinionated
|
|
184
224
|
authentication + SSO contract, a polymorphic ErrorLog model, a Sluggable concern,
|
|
185
225
|
a 7-role dynamic theme system with CSS-custom-property generation, and an S3-backed
|
|
@@ -318,6 +358,11 @@ files:
|
|
|
318
358
|
- app/assets/stylesheets/studio/sticky_table_header.css
|
|
319
359
|
- app/assets/tailwind/studio_engine/engine-motion.css
|
|
320
360
|
- app/assets/tailwind/studio_engine/engine.css
|
|
361
|
+
- app/components/previews/studio/badge_component_preview.rb
|
|
362
|
+
- app/components/previews/studio/badge_component_preview/schemes.html.erb
|
|
363
|
+
- app/components/previews/studio/badge_component_preview/tones.html.erb
|
|
364
|
+
- app/components/studio/badge_component.html.erb
|
|
365
|
+
- app/components/studio/badge_component.rb
|
|
321
366
|
- app/controllers/concerns/solana/session_auth.rb
|
|
322
367
|
- app/controllers/concerns/studio/admin_models.rb
|
|
323
368
|
- app/controllers/concerns/studio/board/reorderable.rb
|
|
@@ -337,6 +382,7 @@ files:
|
|
|
337
382
|
- app/controllers/solana_sessions_controller.rb
|
|
338
383
|
- app/controllers/studio/admin_surveys_controller.rb
|
|
339
384
|
- app/controllers/studio/bookings_controller.rb
|
|
385
|
+
- app/controllers/studio/component_previews_controller.rb
|
|
340
386
|
- app/controllers/studio/emails_controller.rb
|
|
341
387
|
- app/controllers/studio/geo_settings_controller.rb
|
|
342
388
|
- app/controllers/studio/knowledge_docs_controller.rb
|
|
@@ -363,6 +409,7 @@ files:
|
|
|
363
409
|
- app/helpers/studio_navbar_helper.rb
|
|
364
410
|
- app/helpers/studio_sidebar_helper.rb
|
|
365
411
|
- app/helpers/studio_theme_helper.rb
|
|
412
|
+
- app/javascript/studio/local_path.js
|
|
366
413
|
- app/jobs/error_log_cleanup_job.rb
|
|
367
414
|
- app/jobs/studio/email_delivery_job.rb
|
|
368
415
|
- app/mailers/application_mailer.rb
|
|
@@ -416,6 +463,7 @@ files:
|
|
|
416
463
|
- app/views/layouts/studio/_head.html.erb
|
|
417
464
|
- app/views/layouts/studio/_link_preview_tags.html.erb
|
|
418
465
|
- app/views/layouts/studio/_smooth_load.html.erb
|
|
466
|
+
- app/views/layouts/studio/component_preview.html.erb
|
|
419
467
|
- app/views/navbar/show.html.erb
|
|
420
468
|
- app/views/registrations/new.html.erb
|
|
421
469
|
- app/views/schema/index.html.erb
|
|
@@ -548,6 +596,7 @@ files:
|
|
|
548
596
|
- app/views/studio/surveys/show.html.erb
|
|
549
597
|
- app/views/studio/surveys/sign_in_required.html.erb
|
|
550
598
|
- app/views/studio/surveys/thanks.html.erb
|
|
599
|
+
- app/views/style/_components.html.erb
|
|
551
600
|
- app/views/style/_host.html.erb
|
|
552
601
|
- app/views/style/_modal_specimen.html.erb
|
|
553
602
|
- app/views/style/_modals.html.erb
|
|
@@ -575,6 +624,7 @@ files:
|
|
|
575
624
|
- app/views/theme_settings/edit.html.erb
|
|
576
625
|
- app/views/user_mailer/magic_link.html.erb
|
|
577
626
|
- app/views/user_mailer/magic_link.text.erb
|
|
627
|
+
- config/importmap.rb
|
|
578
628
|
- db/migrate/20260614000000_create_studio_email_deliveries.rb
|
|
579
629
|
- db/migrate/20260620000001_create_studio_links.rb
|
|
580
630
|
- db/migrate/20260620000002_allow_null_image_cache_owner.rb
|
|
@@ -600,6 +650,7 @@ files:
|
|
|
600
650
|
- lib/studio/booking.rb
|
|
601
651
|
- lib/studio/cable.rb
|
|
602
652
|
- lib/studio/color_scale.rb
|
|
653
|
+
- lib/studio/component_gallery.rb
|
|
603
654
|
- lib/studio/email.rb
|
|
604
655
|
- lib/studio/email_smoke.rb
|
|
605
656
|
- lib/studio/engine.rb
|
|
@@ -614,6 +665,7 @@ files:
|
|
|
614
665
|
- lib/studio/link_preview.rb
|
|
615
666
|
- lib/studio/link_resolution.rb
|
|
616
667
|
- lib/studio/link_token.rb
|
|
668
|
+
- lib/studio/local_path.rb
|
|
617
669
|
- lib/studio/log_rotation.rb
|
|
618
670
|
- lib/studio/mail_transport.rb
|
|
619
671
|
- lib/studio/name_parts.rb
|