studio-engine 0.73.0 → 0.74.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +205 -2
- data/Gemfile +3 -2
- data/app/assets/tailwind/studio_engine/engine.css +1 -1
- data/app/views/components/_sidebar_panel.html.erb +13 -3
- data/app/views/layouts/_navbar.html.erb +1 -1
- data/app/views/studio/board/_card_shell.html.erb +14 -0
- data/app/views/studio/emails/show.html.erb +14 -3
- data/app/views/studio/modals/_crop_photo.html.erb +7 -1
- data/app/views/studio/modals/blocks/_entry_confirmed.html.erb +7 -1
- data/app/views/studio/modals/blocks/_error_card.html.erb +11 -2
- data/app/views/studio/modals/blocks/_leveling_activity.html.erb +23 -5
- data/app/views/studio/modals/blocks/_processing_card.html.erb +20 -1
- data/app/views/studio/modals/blocks/_solana_tx_link.html.erb +6 -1
- data/app/views/studio/modals/blocks/_success_card.html.erb +35 -5
- data/app/views/studio/modals/onboarding/_first_name.html.erb +19 -19
- data/app/views/studio/profiles/_birthday_fields.html.erb +8 -1
- data/app/views/style/_modals.html.erb +21 -4
- data/lib/studio/js_literal.rb +93 -0
- data/lib/studio/mail_transport.rb +2 -2
- data/lib/studio/s3.rb +18 -1
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +2 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d056d9eddc4bd13c863b5ec0f3e239e7591fc16ddd2170ac5192537f5a190326
|
|
4
|
+
data.tar.gz: 289b7050571718689aa567db5f0434be21c41ee7074b4b49d26969f16b07a815
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15c2e97be72e8fe2e4e67719a569f6d456f82e961fd36b11d0c5062d8ace97836316398eef5058bf9436cdec7ec4ed33aca31ca7decf70bcfe3569dd8665e693
|
|
7
|
+
data.tar.gz: 6dd53cf3747fdb36271afaea5a76c0dc2ee615e25f394083b3219476a5222991999edf97474e1022e9f2f584350ba05b04cbfaa01d5fe0f28ce2995f3a06d6fa
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,156 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **A quote in a host local can no longer close an Alpine attribute and turn the
|
|
10
|
+
rest of the element into markup.** Four partials built an attribute — its OWN
|
|
11
|
+
QUOTES included — as a Ruby String and marked it `html_safe`, which is the one
|
|
12
|
+
shape ERB's attribute escaping can never reach: the marking tells ERB to stand
|
|
13
|
+
down, so a double quote in the local ended the attribute and the HTML parser read
|
|
14
|
+
the remainder as new attributes. Measured, not reasoned: one hostile
|
|
15
|
+
`auto_redirect_seconds` turned the success card's root into an element carrying
|
|
16
|
+
attributes named `quote"`, `\`, `<` and `script`, with its `class` swallowed
|
|
17
|
+
whole. This is strictly worse than the JS-string-literal class fixed in
|
|
18
|
+
*Host-supplied locals can no longer brick an Alpine component* below, which at
|
|
19
|
+
least stayed inside its attribute.
|
|
20
|
+
|
|
21
|
+
**THE REPAIR IS NOT ESCAPING.** These locals are Alpine EXPRESSIONS by contract —
|
|
22
|
+
the caller is handing the engine JS on purpose — so `escape_javascript` would
|
|
23
|
+
break the feature it was protecting. The partials stop writing the quotes and let
|
|
24
|
+
ActionView write them: `tag.attributes("x-init": expr.html_safe)`. `tag_option`
|
|
25
|
+
runs `gsub('"', """)` on the finished value UNCONDITIONALLY, on the line
|
|
26
|
+
AFTER the escape branch it skips for a marked String, so the expression keeps its
|
|
27
|
+
apostrophes, angle brackets and backslashes byte-for-byte and still cannot end the
|
|
28
|
+
attribute. `blocks/_rail_row:112` already did exactly this and was the worked
|
|
29
|
+
example.
|
|
30
|
+
|
|
31
|
+
**WHAT MOVED — twelve splices in four files.** `blocks/_success_card` (the
|
|
32
|
+
Ruby-built `x-data` countdown object, and the `x-init` call list that splices
|
|
33
|
+
`auto_redirect_url_key`), `blocks/_processing_card` (one assembled `x-data`/`x-init`
|
|
34
|
+
pair carrying TWO locals — `resolve_expr` and `min_duration`, which only LOOKS
|
|
35
|
+
numeric because just its guard calls `.to_i`), `blocks/_leveling_activity` (the
|
|
36
|
+
Next Quest `@click`, plus `minlength` / `maxlength` / `pattern` / `title` on the
|
|
37
|
+
form input), and `components/_sidebar_panel` (`@click.outside`,
|
|
38
|
+
`@keydown.escape.window` and `@turbo:before-cache.window` on the root of the shell
|
|
39
|
+
every link menu hangs from). No default or in-repo value contains a character tag
|
|
40
|
+
encoding touches, so every shipped page renders byte-for-byte what it did — a
|
|
41
|
+
render-level test asserts that directly.
|
|
42
|
+
|
|
43
|
+
**THE MARKING NOW FOLLOWS WHAT THE VALUE IS.** Code stays `html_safe` and reaches
|
|
44
|
+
the browser unchanged; CONTENT does not. The four input constraint attributes are
|
|
45
|
+
a length, a regex and a tooltip, so they take the full escaping a content
|
|
46
|
+
attribute is owed — which also closes a second latent bug the marking carried, a
|
|
47
|
+
bare `&` in a `title` or `pattern` reaching the browser as the start of an entity.
|
|
48
|
+
|
|
49
|
+
**TWO OF THE SIX SITES ON THE ORIGINAL LIST WERE ALREADY SAFE and were left
|
|
50
|
+
alone.** `blocks/_rail_row:112` routes its handler through `content_tag`, and
|
|
51
|
+
`board/_card_shell:54` escapes both halves of its arbitrary-attribute passthrough
|
|
52
|
+
through ERB. Both were re-verified by rendering them with a hostile value, not by
|
|
53
|
+
reading them. Filing a fix for either would have bought a guard that can never
|
|
54
|
+
bite.
|
|
55
|
+
|
|
56
|
+
**HOW THE GUARDS ASSERT.** `test/views/assembled_attribute_locals_test.rb` reads
|
|
57
|
+
every seam through Nokogiri's HTML5 parser — the algorithm a browser runs — and
|
|
58
|
+
asserts on the DECODED attribute value, never on the template's output bytes,
|
|
59
|
+
because ERB entity-escapes an unmarked value too and a raw-markup structural
|
|
60
|
+
assertion therefore cannot fail. (HTML5 rather than HTML4 on purpose: HTML4
|
|
61
|
+
silently drops an `@click`, so every Alpine assertion made through it reads nil on
|
|
62
|
+
a correct page.) Each seam makes two claims — the element carries exactly the
|
|
63
|
+
attributes a benign value gives it, and the browser recovers the whole expression
|
|
64
|
+
— plus a non-vacuity control that fails if the partial ever stops splicing the
|
|
65
|
+
local at all. All twelve were mutated back to their assembled form ONE AT A TIME
|
|
66
|
+
and each was killed by its own test; the shared `_processing_card` call was also
|
|
67
|
+
mutated per-local. `test/lib/studio/attribute_encoding_contract_test.rb` pins the
|
|
68
|
+
ActionView property the whole repair stands on, so a future Rails moving that
|
|
69
|
+
`gsub` reports itself as one dependency change rather than twelve partial bugs.
|
|
70
|
+
|
|
71
|
+
**STILL OPEN, and named rather than quietly folded in:**
|
|
72
|
+
`studio/mailers/_layered_banner:69,100` assemble `background=` and `bgcolor=` the
|
|
73
|
+
same way. Same shape, different family — email attributes, no Alpine, no JS — so
|
|
74
|
+
they are left for their own ticket rather than widened into this one.
|
|
75
|
+
|
|
76
|
+
- **The Rails guard sweep really is finished now, and a test says so instead of a
|
|
77
|
+
comment.** Fixing `Studio::S3` left THREE sites still guarding a Rails method
|
|
78
|
+
call on a bare `defined?(Rails)`: both keyword defaults in
|
|
79
|
+
`Studio::MailTransport.configure!` (`rails_env:` reading `Rails.env`, `logger:`
|
|
80
|
+
reading `Rails.logger`) and the developer-desk route guard in
|
|
81
|
+
`Studio.routes`. All three now ask `Rails.respond_to?` first.
|
|
82
|
+
|
|
83
|
+
**THE MAIL TRANSPORT ONE WAS ARMED, not theoretical.** `lib/studio.rb` requires
|
|
84
|
+
`studio/js_literal` (line 11) — whose first line is `require "action_view"`,
|
|
85
|
+
which is what defines the namespace-only `module Rails` — BEFORE it requires
|
|
86
|
+
`studio/mail_transport` (line 25). Measured at this branch's head, a bare
|
|
87
|
+
`Studio::MailTransport.configure!(env: {}, action_mailer: mailer)` died with
|
|
88
|
+
`NoMethodError: undefined method 'env' for module Rails`. No shipped app can
|
|
89
|
+
reach it (every host configures mail from an initializer, where `Rails.env`
|
|
90
|
+
exists, and all four in-repo callers pass `rails_env:` explicitly) — the cost
|
|
91
|
+
lands on the next gem unit test that omits the kwarg, which is the same
|
|
92
|
+
half-hour the `Studio::S3` fix already paid once.
|
|
93
|
+
|
|
94
|
+
**THE TWO DEFAULTS ARE SEPARATE STRAGGLERS**, and the first hid the second: Ruby
|
|
95
|
+
evaluates only the defaults a caller omitted, and `rails_env:` is declared above
|
|
96
|
+
`logger:`, so a bare call raised out of `Rails.env` and never reached
|
|
97
|
+
`Rails.logger`. `test/lib/studio/mail_transport_namespace_only_rails_test.rb`
|
|
98
|
+
supplies one argument and omits the other in each test, so each guard is pinned
|
|
99
|
+
independently rather than behind its neighbour.
|
|
100
|
+
|
|
101
|
+
**THE ROUTE GUARD IS PINNED BY A SCAN, deliberately.** `Studio.routes` only
|
|
102
|
+
loads inside a real Rails application, where `Rails.env` exists — so the
|
|
103
|
+
condition under test cannot be constructed where the file loads, and no
|
|
104
|
+
behavioural test can reach it. `test/lib/studio/rails_guard_sweep_test.rb` is
|
|
105
|
+
the tree-wide net instead, and it asserts both that it really read the files and
|
|
106
|
+
that its predicate still flags the original shape, so it cannot pass for free.
|
|
107
|
+
|
|
108
|
+
**The completeness claim itself was the other half of the bug.** `lib/studio/s3.rb`
|
|
109
|
+
and this changelog both said that fix "was the straggler" — while one of the
|
|
110
|
+
remaining three sat in `lib/studio.rb`, the very file the comment cited as
|
|
111
|
+
already clean. Both claims are corrected, and the comment now points at the test
|
|
112
|
+
rather than restating that the sweep is done.
|
|
113
|
+
|
|
114
|
+
- **The app census in these comments was short by the most important app.** Ten
|
|
115
|
+
sites said this engine is mounted by SIX apps and that THREE of them bundle no
|
|
116
|
+
`solana-studio`. Measured 2026-09-07 by the criterion that reproduces it — a
|
|
117
|
+
checkout whose own `config/routes.rb` calls `Studio.routes(self)`. A Gemfile
|
|
118
|
+
grep returns seven instead, catching `solana-studio`'s dev-group pin and the
|
|
119
|
+
`mcritchie-studio-ai-builder-cache` clone of the hub. **Five** apps mount it —
|
|
120
|
+
`mcritchie-studio`, `turf-monster`, `acquisition-studio`,
|
|
121
|
+
`mcritchie-industries`, `moms-app` — and **four** of those bundle no
|
|
122
|
+
`solana-studio`. `turf-monster` is the only one that does.
|
|
123
|
+
|
|
124
|
+
**The omission was the HUB.** Every roster of BASE consumers named
|
|
125
|
+
`acquisition-studio`, `mcritchie-industries` and `moms-app` and left out
|
|
126
|
+
`mcritchie-studio` — the app a developer reading those comments is most likely
|
|
127
|
+
to be sitting in, and one whose `/admin/style` is exactly what the
|
|
128
|
+
missing-template gate protects. The error ran in the direction that
|
|
129
|
+
UNDERSTATES the risk the comments exist to explain.
|
|
130
|
+
|
|
131
|
+
**Two near misses are what make a naive count wrong.** `chain-ops` bundles
|
|
132
|
+
`solana-studio` and NOT this engine, so it is not in this set at all;
|
|
133
|
+
`acquisition-studio` is a retired prototype whose `Gemfile` still pins the
|
|
134
|
+
engine, so it is. `rolio` bundles neither and is not a consumer.
|
|
135
|
+
|
|
136
|
+
**Corrected at every site, because a partial correction is a contradiction:**
|
|
137
|
+
`app/views/style/_modals.html.erb`, `test/dummy/config/application.rb`,
|
|
138
|
+
`Gemfile`, `test/views/style_web3_specimens_test.rb`,
|
|
139
|
+
`test/views/style_host_section_test.rb` (both sites),
|
|
140
|
+
`app/views/layouts/_navbar.html.erb`,
|
|
141
|
+
`app/assets/tailwind/studio_engine/engine.css`,
|
|
142
|
+
`test/views/nav_collapse_contract_test.rb`, `test/lib/vendored_alpine_test.rb`,
|
|
143
|
+
`test/lib/vendored_montserrat_test.rb`, and two earlier Unreleased entries
|
|
144
|
+
below. The navbar-fork claim rides the same denominator — three of FIVE fork
|
|
145
|
+
it, not three of six, and the named three are unchanged — and the
|
|
146
|
+
Sprockets/propshaft split is two of five rather than "exactly half the fleet",
|
|
147
|
+
a phrase that encoded an even total no census supports.
|
|
148
|
+
|
|
149
|
+
**What was already right, and stays.** The five-consumer roster in
|
|
150
|
+
`lib/studio.rb`'s `draw_profile_routes` note (each checked 2026-08-14) named
|
|
151
|
+
the correct five all along; this change brings the rest of the repo to it. The
|
|
152
|
+
"all six consumer DATABASES" figures elsewhere in this file are three apps
|
|
153
|
+
times two environments, not an app count.
|
|
154
|
+
|
|
155
|
+
Comment and prose only. No behaviour changes.
|
|
156
|
+
|
|
7
157
|
### Added
|
|
8
158
|
|
|
9
159
|
- **The first-name card now says WHICH path finished it, and can type its
|
|
@@ -80,7 +230,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
80
230
|
that ships no such partial gets **nothing** — no pill, no heading, no
|
|
81
231
|
container — and that is asserted as a whole-document comparison rather than a
|
|
82
232
|
handful of refutes: the page an app WITH a section gets, minus that section
|
|
83
|
-
and its pill, must equal the page a base app gets.
|
|
233
|
+
and its pill, must equal the page a base app gets. Four of the five apps
|
|
84
234
|
mounting this engine will ship no host section, and their page is unchanged.
|
|
85
235
|
|
|
86
236
|
**A host specimen drives `$store.modals`, not `dsModals`, and the distinction
|
|
@@ -272,6 +422,59 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
272
422
|
|
|
273
423
|
### Fixed
|
|
274
424
|
|
|
425
|
+
- **A host's apostrophe no longer kills the card it was passed to — now across the
|
|
426
|
+
blocks, not just the first-name step.** The same defect
|
|
427
|
+
`onboarding/_first_name` fixed one release ago turns out to be an engine-wide
|
|
428
|
+
idiom: a host-supplied local interpolated into a JS string literal inside a
|
|
429
|
+
JS-evaluating attribute. Repaired at eleven more splices.
|
|
430
|
+
|
|
431
|
+
**WHY IT IS WORTH A CHANGELOG LINE WHEN NOTHING VISIBLE CHANGES.** The failure
|
|
432
|
+
mode is silent. A bare apostrophe closes the JS literal, the whole expression
|
|
433
|
+
becomes a SyntaxError, and Alpine mounts the component as a NO-OP that still
|
|
434
|
+
renders every element — a card that looks perfect and whose buttons do nothing.
|
|
435
|
+
There is no error on screen, nothing in the server log, and no markup assertion
|
|
436
|
+
that can see it. Every value in every consumer resolves to a source literal or a
|
|
437
|
+
frozen constant today, so this is latent cover rather than a live fix; it is
|
|
438
|
+
worth doing because the next local to carry prose will look like an ordinary
|
|
439
|
+
change to whoever writes it.
|
|
440
|
+
|
|
441
|
+
**WHAT MOVED.** `blocks/_success_card` (`cta_event` at both CTA branches,
|
|
442
|
+
`secondary_event`), `blocks/_error_card` (`cta_event`, `secondary_event`),
|
|
443
|
+
`blocks/_entry_confirmed` and `blocks/_solana_tx_link` (`cluster_param`),
|
|
444
|
+
`modals/_crop_photo` (`store`), `studio/emails/show` (the two upload filenames
|
|
445
|
+
and the success sentence), and `profiles/_birthday_fields` (the date value).
|
|
446
|
+
No default or in-repo value contains a character either escaper touches, so
|
|
447
|
+
every shipped card renders byte-for-byte what it did.
|
|
448
|
+
|
|
449
|
+
**THE MECHANISM NOW HAS ONE HOME AND ONE GUARD.** `Studio::JsLiteral.in_attribute`
|
|
450
|
+
replaces the four inline copies in `_first_name`. TWO escapers have to run — one
|
|
451
|
+
for the JS literal, one for the HTML attribute — and the second only runs on a
|
|
452
|
+
value ERB still believes is unsafe, which is why the value is interpolated before
|
|
453
|
+
it is escaped. That subtlety was re-derived at every call site and had no test
|
|
454
|
+
anywhere; deleting it used to leave the suite green.
|
|
455
|
+
|
|
456
|
+
**NOT A FIX FOR IDENTIFIER POSITION, deliberately.** A local spliced in as a bare
|
|
457
|
+
NAME — `$store.<name>.close()` — must be VALIDATED, never escaped, because
|
|
458
|
+
`escape_javascript` also escapes `$` and mangles a legal store name. That fleet
|
|
459
|
+
(about 36 splices across 19 partials) and a third class found alongside it —
|
|
460
|
+
Ruby-ASSEMBLED JS emitted into an attribute, some of it already `html_safe` — are
|
|
461
|
+
scoped OUT of this change and carry their own tickets. The SHAPE of the splice
|
|
462
|
+
decides the repair, never the name of the local.
|
|
463
|
+
|
|
464
|
+
- **`Studio::S3` no longer mistakes a Rails NAMESPACE for a Rails application.**
|
|
465
|
+
`environment` guarded on `defined?(Rails)` and then called `Rails.env`.
|
|
466
|
+
rails-html-sanitizer — a transitive dependency of `action_view`, which arrives
|
|
467
|
+
long before any application does — ships a namespace-only `module Rails` with no
|
|
468
|
+
singleton methods, so that guard reads true and the next call raises
|
|
469
|
+
`NoMethodError: undefined method 'env' for module Rails`. It now asks
|
|
470
|
+
`Rails.respond_to?(:env)`, which is the form `lib/studio.rb` already uses in three
|
|
471
|
+
places. **This entry originally called it "the straggler"; that was wrong** —
|
|
472
|
+
three more sites carried the bare form, swept in *The Rails guard sweep really is
|
|
473
|
+
finished now* above. No shipped app can reach it — every host boots a
|
|
474
|
+
real application — but the engine's own pure-Ruby unit lane can, and it did:
|
|
475
|
+
adding one `require` for a file that needs `action_view` turned an untouched
|
|
476
|
+
`email_catalog_test` red with two errors about email uploads.
|
|
477
|
+
|
|
275
478
|
- **The style guide's two "Sign Wallet" thumbnails no longer crown themselves
|
|
276
479
|
with a padlock the card stopped drawing, and the guide's lock no longer trails
|
|
277
480
|
the release that removed it.** solana-studio 0.6.1 replaced the step-up card's
|
|
@@ -606,7 +809,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
606
809
|
|
|
607
810
|
**The band table shipped from the wrong file.** The `.nav-shell` `--nav-*`
|
|
608
811
|
sizes were inline in `layouts/_navbar.html.erb`, so only an app rendering that
|
|
609
|
-
partial got them — and **three of
|
|
812
|
+
partial got them — and **three of five apps fork the navbar**
|
|
610
813
|
(`turf-monster`, `mcritchie-studio`, `moms-app`), each left to hand-write its
|
|
611
814
|
own. That is precisely how four independent copies of this collapse came to
|
|
612
815
|
exist. They now ship from `engine.css`, which every engine-consuming app
|
data/Gemfile
CHANGED
|
@@ -40,8 +40,9 @@ end
|
|
|
40
40
|
#
|
|
41
41
|
# DEVELOPMENT AND TEST ONLY, and deliberately NOT a gemspec runtime dependency.
|
|
42
42
|
# Declaring it there would push a Solana stack onto every BASE consumer
|
|
43
|
-
# (acquisition-studio, mcritchie-industries
|
|
44
|
-
# bundle no solana-studio
|
|
43
|
+
# (mcritchie-studio, acquisition-studio, mcritchie-industries and moms-app all
|
|
44
|
+
# mount this engine and bundle no solana-studio — four of the five consumers,
|
|
45
|
+
# the hub included), which is precisely the coupling the split removed.
|
|
45
46
|
# It is here so the DUMMY app can resolve the gem's partials and the style guide
|
|
46
47
|
# renders the real shipped cards rather than a fork of them.
|
|
47
48
|
#
|
|
@@ -415,7 +415,7 @@ body.modal-open .studio-app-banner {
|
|
|
415
415
|
/* === THE NAVBAR COLLAPSE BAND TABLE =====================================
|
|
416
416
|
LAYER 2 OF THE PRIMITIVE, and it lives here rather than in
|
|
417
417
|
layouts/_navbar.html.erb for one reason: every engine-consuming app imports
|
|
418
|
-
this stylesheet, but only some render that partial. Three of
|
|
418
|
+
this stylesheet, but only some render that partial. Three of five FORK the
|
|
419
419
|
navbar (turf-monster, mcritchie-studio, moms-app), and while the table sat
|
|
420
420
|
inline each fork had to hand-write its own — which is exactly how four
|
|
421
421
|
independent copies of this collapse came to exist.
|
|
@@ -34,9 +34,19 @@
|
|
|
34
34
|
<% if id.present? %>id="<%= id %>"<% end %>
|
|
35
35
|
x-cloak
|
|
36
36
|
x-show="<%= open %>"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
<%# THE THREE DISMISSAL HANDLERS, EMITTED BY tag.attributes. Each was built as
|
|
38
|
+
%(@name="#{action}").html_safe — the attribute's OWN quotes assembled in Ruby
|
|
39
|
+
and then marked safe, which is the one shape ERB's escaping can never reach. A
|
|
40
|
+
double quote in any of the three actions closed the attribute and everything
|
|
41
|
+
after it became markup on the panel's root element.
|
|
42
|
+
|
|
43
|
+
The actions stay html_safe: they are Alpine EXPRESSIONS the host wrote (see the
|
|
44
|
+
@click at the close button below, and `open` above). tag_option's
|
|
45
|
+
gsub('"', """) runs on the finished value whether or not it is marked, so
|
|
46
|
+
the expression reaches Alpine unchanged and cannot end the attribute. %>
|
|
47
|
+
<%= tag.attributes("@click.outside": outside_action.html_safe) if outside_action.present? %>
|
|
48
|
+
<%= tag.attributes("@keydown.escape.window": escape_action.html_safe) if escape_action.present? %>
|
|
49
|
+
<%= tag.attributes("@turbo:before-cache.window": close_action.html_safe) if close_action.present? %>
|
|
40
50
|
x-transition:enter="transition ease-out duration-300"
|
|
41
51
|
x-transition:enter-start="translate-x-full"
|
|
42
52
|
x-transition:enter-end="translate-x-0"
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
@media (min-width: 400px) { .user-nav-col { width: 15rem; } .user-nav-fit { max-width: 15rem; } }
|
|
72
72
|
@media (min-width: 768px) { .user-nav-col { width: 20rem; } .user-nav-fit { max-width: 20rem; } }
|
|
73
73
|
/* The COLLAPSE band table used to live here, which meant only an app
|
|
74
|
-
rendering THIS partial got it. Three of
|
|
74
|
+
rendering THIS partial got it. Three of five apps fork the navbar, so each
|
|
75
75
|
was left to hand-write its own — the way four independent copies happened
|
|
76
76
|
in the first place. It ships from engine.css now (every consuming app
|
|
77
77
|
imports it), so a forking app OPTS IN with `nav-shell` +
|
|
@@ -32,6 +32,20 @@
|
|
|
32
32
|
attrs: Hash of extra root attributes ({ "x-data" => "…", "data-foo" => 1 }),
|
|
33
33
|
values are HTML-escaped — the rebase seam for a card that carries
|
|
34
34
|
its own Alpine/data-* on the root.
|
|
35
|
+
|
|
36
|
+
NOT A CANDIDATE FOR THE ASSEMBLED-ATTRIBUTE SWEEP: nothing here is
|
|
37
|
+
marked html_safe, so ERB escapes both halves. A double quote in a
|
|
38
|
+
VALUE becomes " and the parser decodes it back — the value half
|
|
39
|
+
cannot break out. Verified 2026-09-08 by rendering, not reading.
|
|
40
|
+
|
|
41
|
+
THE NAME HALF IS A DIFFERENT QUESTION, and entity decoding is not
|
|
42
|
+
what answers it. html_escape does not touch a SPACE, and a space
|
|
43
|
+
ENDS an attribute name: a name of `data-y=1 x-init` renders as
|
|
44
|
+
`data-y=1 x-init="…"` and the parser builds a real SECOND attribute
|
|
45
|
+
— measured, an injected x-init Alpine will run. What makes this seam
|
|
46
|
+
safe today is that nothing passes attrs: at all — zero call sites in
|
|
47
|
+
studio-engine, turf-monster and mcritchie-studio. Its first caller
|
|
48
|
+
owes an allow-list on the NAME before this emits it.
|
|
35
49
|
%>
|
|
36
50
|
<%
|
|
37
51
|
id = local_assigns.fetch(:id)
|
|
@@ -197,6 +197,17 @@
|
|
|
197
197
|
|
|
198
198
|
<%# ROW TWO — the pictures, and the words that ride on them. %>
|
|
199
199
|
<div class="grid gap-6 md:grid-cols-2 mb-6">
|
|
200
|
+
<%# THE FILENAMES AND THE LABEL ARE ESCAPED ON THE WAY INTO THIS x-data. All
|
|
201
|
+
three come off the email REGISTRY entry rather than a form, so nothing
|
|
202
|
+
reaches them with a quote today — but they are the only values in the
|
|
203
|
+
attribute that are not authored right here, and bare interpolation of one
|
|
204
|
+
would close its JS literal and mount the whole upload host as a silent
|
|
205
|
+
no-op: an image picker that opens, crops, and never saves.
|
|
206
|
+
|
|
207
|
+
successMessage previously used the BARE `j` form. That is correct only
|
|
208
|
+
while the label is a plain String — `j` preserves an html_safe marking, and
|
|
209
|
+
a safe value would skip ERB's attribute-escaping half and let a raw double
|
|
210
|
+
quote out of the attribute. Studio::JsLiteral removes that condition. %>
|
|
200
211
|
<section class="card p-4"
|
|
201
212
|
<% if @uploads_available %>
|
|
202
213
|
x-data="imageUploadHost({
|
|
@@ -205,10 +216,10 @@
|
|
|
205
216
|
maxWidth: <%= max_width %>,
|
|
206
217
|
transparent: false,
|
|
207
218
|
allowGifs: true,
|
|
208
|
-
filename: '<%= @entry.key %>.png',
|
|
219
|
+
filename: '<%= Studio::JsLiteral.in_attribute(@entry.key) %>.png',
|
|
209
220
|
saving: 'Saving banner…',
|
|
210
221
|
success: 'Banner updated',
|
|
211
|
-
successMessage: '<%=
|
|
222
|
+
successMessage: '<%= Studio::JsLiteral.in_attribute(@entry.label) %> now uses this app\'s own image.',
|
|
212
223
|
failure: 'Couldn\'t save the banner'
|
|
213
224
|
})"
|
|
214
225
|
@crop-photo-confirmed.window="onCropConfirmed($event.detail)"
|
|
@@ -513,7 +524,7 @@
|
|
|
513
524
|
maxWidth: 600,
|
|
514
525
|
transparent: true,
|
|
515
526
|
allowGifs: false,
|
|
516
|
-
filename: '<%= @entry.key %>-logo.png',
|
|
527
|
+
filename: '<%= Studio::JsLiteral.in_attribute(@entry.key) %>-logo.png',
|
|
517
528
|
saving: 'Saving logo…',
|
|
518
529
|
success: 'Logo updated',
|
|
519
530
|
successMessage: 'This email now uses its own logo.',
|
|
@@ -26,7 +26,13 @@
|
|
|
26
26
|
The living style guide passes store: "dsModals" for its page-scoped host.
|
|
27
27
|
%>
|
|
28
28
|
<% crop_store = local_assigns.fetch(:store, "modals") %>
|
|
29
|
-
|
|
29
|
+
<%# STRING position, so it is ESCAPED rather than validated: the store name is
|
|
30
|
+
handed to cropPhotoModal as a JS string argument, not spliced in as a bare
|
|
31
|
+
identifier. An apostrophe would close that literal and mount the modal as a
|
|
32
|
+
silent no-op that still renders every element below. Studio::JsLiteral carries
|
|
33
|
+
why both escapers have to run. %>
|
|
34
|
+
<% crop_store_js = Studio::JsLiteral.in_attribute(crop_store) %>
|
|
35
|
+
<div x-data="cropPhotoModal({ store: '<%= crop_store_js %>' })">
|
|
30
36
|
<h3 class="text-heading font-bold text-lg text-center mb-4">Crop Photo</h3>
|
|
31
37
|
|
|
32
38
|
<template x-if="error">
|
|
@@ -71,6 +71,12 @@
|
|
|
71
71
|
subtitle_key = local_assigns[:subtitle_key]
|
|
72
72
|
tx_signature_key = local_assigns.fetch(:tx_signature_key, "props.txSignature")
|
|
73
73
|
cluster_param = local_assigns.fetch(:cluster_param, "")
|
|
74
|
+
# STRING position — clusterParam is a JS string on the root scope that
|
|
75
|
+
# _success_card's tx-link concatenates into a URL. An apostrophe closes the
|
|
76
|
+
# literal and the whole x-data becomes a SyntaxError, which mounts this card and
|
|
77
|
+
# every nested block as a silent no-op. props_expr one line up is the OPPOSITE
|
|
78
|
+
# shape (an Alpine expression by contract) and is deliberately left alone.
|
|
79
|
+
cluster_param_js = Studio::JsLiteral.in_attribute(cluster_param)
|
|
74
80
|
lobby_url_key = local_assigns.fetch(:lobby_url_key, "props.lobbyUrl")
|
|
75
81
|
cta_label = local_assigns.fetch(:cta_label, "Continue")
|
|
76
82
|
seeds_earned_key = local_assigns.fetch(:seeds_earned_key, "props.seedsEarned")
|
|
@@ -113,7 +119,7 @@
|
|
|
113
119
|
<%# props getter + clusterParam live on the root scope so _success_card's nested
|
|
114
120
|
Alpine expressions (props.txSignature, props.lobbyUrl, clusterParam) resolve up
|
|
115
121
|
the scope chain. clusterParam feeds _success_card's branded tx-link URL. %>
|
|
116
|
-
<div x-data="{ get props() { return <%= props_expr %>; }, clusterParam: '<%=
|
|
122
|
+
<div x-data="{ get props() { return <%= props_expr %>; }, clusterParam: '<%= cluster_param_js %>' }">
|
|
117
123
|
<%= render "studio/modals/blocks/success_card", sc_locals do %>
|
|
118
124
|
<%# The app's own enrichment, above the seeds bar. turf puts its kickoff
|
|
119
125
|
countdown here — "when does it start" reads before the celebratory seeds
|
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
<%
|
|
25
25
|
icon_emoji = local_assigns[:icon_emoji] || "⏳" # ⏳
|
|
26
26
|
cta_label = local_assigns[:cta_label] || 'Refresh'
|
|
27
|
+
# THE TWO EVENT NAMES ARE THE ONLY LOCALS HERE THAT LAND INSIDE JS. Both sit in a
|
|
28
|
+
# single-quoted literal inside a double-quoted @click, so an apostrophe in either
|
|
29
|
+
# closes the literal, the handler becomes a SyntaxError, and the button renders
|
|
30
|
+
# perfectly and does NOTHING when clicked — no error, no log, nothing a markup
|
|
31
|
+
# assertion can see. Escaped, not validated: an event name is a STRING to
|
|
32
|
+
# $dispatch, and any character is legal in one. Studio::JsLiteral says why the
|
|
33
|
+
# interpolated form is load-bearing.
|
|
34
|
+
cta_event_js = Studio::JsLiteral.in_attribute(local_assigns[:cta_event])
|
|
35
|
+
secondary_event_js = Studio::JsLiteral.in_attribute(local_assigns[:secondary_event])
|
|
27
36
|
%>
|
|
28
37
|
<%# role="alert" on the CARD, not on its message: the message is server-rendered
|
|
29
38
|
static text, so it never "changes" for a live region to notice. The modal host
|
|
@@ -42,11 +51,11 @@
|
|
|
42
51
|
<% if local_assigns[:cta_reload] %>
|
|
43
52
|
<button @click="window.location.reload()" class="btn btn-outline btn-sm"><%= cta_label %></button>
|
|
44
53
|
<% elsif local_assigns[:cta_event] %>
|
|
45
|
-
<button @click="$dispatch('<%=
|
|
54
|
+
<button @click="$dispatch('<%= cta_event_js %>')" class="btn btn-outline btn-sm"><%= cta_label %></button>
|
|
46
55
|
<% end %>
|
|
47
56
|
|
|
48
57
|
<% if local_assigns[:secondary_label] && local_assigns[:secondary_event] %>
|
|
49
|
-
<button @click="$dispatch('<%=
|
|
58
|
+
<button @click="$dispatch('<%= secondary_event_js %>')"
|
|
50
59
|
class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
|
|
51
60
|
<%= secondary_label %>
|
|
52
61
|
</button>
|
|
@@ -138,10 +138,23 @@
|
|
|
138
138
|
<% if input %>
|
|
139
139
|
<input type="<%= input_type %>" x-model="value" class="input-field w-full"
|
|
140
140
|
placeholder="<%= placeholder %>"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
<%# FOUR CONSTRAINT ATTRIBUTES, ENCODED AND — unlike the Alpine
|
|
142
|
+
handlers in this file — DELIBERATELY NOT html_safe. Each was
|
|
143
|
+
assembled as `attr="#{value}"`.html_safe, which handed ERB a
|
|
144
|
+
String it must not touch, so a double quote in any of the four
|
|
145
|
+
closed the attribute and the rest became markup. minlength
|
|
146
|
+
looks numeric and is not: only the GUARD calls .to_i, the
|
|
147
|
+
interpolation splices the local raw.
|
|
148
|
+
|
|
149
|
+
These are CONTENT, not code — a length, a regex, a tooltip
|
|
150
|
+
sentence — so they take the full escaping a content attribute
|
|
151
|
+
is owed and the parser decodes it back. That also repairs a
|
|
152
|
+
second latent bug the marking carried: a bare & in a title or
|
|
153
|
+
pattern used to reach the browser as the start of an entity. %>
|
|
154
|
+
<%= tag.attributes(minlength: min_length) if min_length.to_i.positive? %>
|
|
155
|
+
<%= tag.attributes(maxlength: max_length) if max_length.present? %>
|
|
156
|
+
<%= tag.attributes(pattern: pattern) if pattern.present? %>
|
|
157
|
+
<%= tag.attributes(title: pattern_title) if pattern_title.present? %>
|
|
145
158
|
autofocus>
|
|
146
159
|
<% end %>
|
|
147
160
|
<% if consent_label.present? %>
|
|
@@ -180,7 +193,12 @@
|
|
|
180
193
|
</div>
|
|
181
194
|
|
|
182
195
|
<% if next_label.present? && next_open.present? %>
|
|
183
|
-
|
|
196
|
+
<%# next_open is html_safe (:102) because it is a developer-authored Alpine
|
|
197
|
+
EXPRESSION — so ERB steps aside here and a double quote in it used to
|
|
198
|
+
close the @click attribute outright. tag.attributes writes the quotes and
|
|
199
|
+
entity-encodes any of its own that appear in the value, leaving the
|
|
200
|
+
expression otherwise byte-identical. %>
|
|
201
|
+
<button type="button" <%= tag.attributes("@click": next_open) %> class="btn btn-primary btn-lg w-full">
|
|
184
202
|
<%= next_label %>
|
|
185
203
|
</button>
|
|
186
204
|
<% end %>
|
|
@@ -41,8 +41,27 @@
|
|
|
41
41
|
# JS sides of the convention agree on the same floor.
|
|
42
42
|
min_duration = local_assigns[:min_duration] || 1400
|
|
43
43
|
resolve_expr = local_assigns[:resolve_expr]
|
|
44
|
+
|
|
45
|
+
# THE AUTO-RESOLVE PAIR, ENCODED RATHER THAN ASSEMBLED. This line used to build
|
|
46
|
+
# ` x-data="{}" x-init="…"` as one html_safe String, quotes and all, so ERB had
|
|
47
|
+
# nothing left to escape: a double quote in EITHER local — min_duration as much as
|
|
48
|
+
# resolve_expr, since min_duration is interpolated raw and only its GUARD calls
|
|
49
|
+
# .to_i — closed the attribute and turned the remainder into markup.
|
|
50
|
+
#
|
|
51
|
+
# tag.attributes writes the quotes instead. The x-init value stays html_safe
|
|
52
|
+
# because it is CODE the caller handed us, and tag_option's unconditional
|
|
53
|
+
# gsub('"', """) still fires on an html_safe value, so the JS arrives intact
|
|
54
|
+
# and the attribute cannot end early. safe_join supplies the separating space
|
|
55
|
+
# without a second hand-marked String.
|
|
56
|
+
resolve_attrs =
|
|
57
|
+
if resolve_expr
|
|
58
|
+
safe_join([" ", tag.attributes(
|
|
59
|
+
"x-data": "{}",
|
|
60
|
+
"x-init": "window.StudioModals.holdAtLeast(#{min_duration}).then(() => { #{resolve_expr} })".html_safe
|
|
61
|
+
)])
|
|
62
|
+
end
|
|
44
63
|
%>
|
|
45
|
-
<div class="text-center py-6"<%=
|
|
64
|
+
<div class="text-center py-6"<%= resolve_attrs %>>
|
|
46
65
|
<div class="mx-auto <%= spinner_class %> rounded-full border-<%= color %>/30 border-t-<%= color %> animate-spin mb-5"></div>
|
|
47
66
|
<% if local_assigns[:title_key] %>
|
|
48
67
|
<p class="text-base font-bold text-heading mb-1" x-text="<%= title_key %>"></p>
|
|
@@ -17,10 +17,15 @@
|
|
|
17
17
|
<%
|
|
18
18
|
tx_signature_key = local_assigns.fetch(:tx_signature_key)
|
|
19
19
|
cluster_param = local_assigns.fetch(:cluster_param, "")
|
|
20
|
+
# STRING position, inside the :href expression below. tx_signature_key beside it
|
|
21
|
+
# is an Alpine EXPRESSION by contract and is spliced in bare; this one is a
|
|
22
|
+
# literal suffix, so an apostrophe would close it and turn the whole :href
|
|
23
|
+
# binding into a SyntaxError — the link then renders with no href at all.
|
|
24
|
+
cluster_param_js = Studio::JsLiteral.in_attribute(cluster_param)
|
|
20
25
|
%>
|
|
21
26
|
<template x-if="(<%= tx_signature_key %>)">
|
|
22
27
|
<div class="text-center mb-3 -mt-1">
|
|
23
|
-
<a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + '<%=
|
|
28
|
+
<a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + '<%= cluster_param_js %>'"
|
|
24
29
|
target="_blank" rel="noopener"
|
|
25
30
|
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-subtle hover:border-primary/50 transition group no-underline"
|
|
26
31
|
style="background: rgb(var(--color-primary-500-rgb) / 0.06);">
|
|
@@ -106,12 +106,42 @@
|
|
|
106
106
|
}
|
|
107
107
|
}".gsub(/\s+/, ' ').html_safe
|
|
108
108
|
|
|
109
|
+
# THE TWO EVENT NAMES, ESCAPED FOR THE @click THEY ARE SPLICED INTO. Each sits in
|
|
110
|
+
# a JS single-quoted literal inside a double-quoted attribute, so an apostrophe in
|
|
111
|
+
# either closes the literal and the handler becomes a SyntaxError — a CTA that
|
|
112
|
+
# renders exactly right and does nothing when clicked. This is the engine's
|
|
113
|
+
# most-rendered block, so it is also the widest reach that failure has.
|
|
114
|
+
#
|
|
115
|
+
# ESCAPED, NOT VALIDATED: an event name is a STRING argument to $dispatch and any
|
|
116
|
+
# character is legal in one. Contrast cta_href_key / props_expr below, which are
|
|
117
|
+
# Alpine EXPRESSIONS by contract and must not be touched. Studio::JsLiteral
|
|
118
|
+
# carries why the interpolated form is load-bearing.
|
|
119
|
+
cta_event_js = Studio::JsLiteral.in_attribute(local_assigns[:cta_event])
|
|
120
|
+
secondary_event_js = Studio::JsLiteral.in_attribute(local_assigns[:secondary_event])
|
|
121
|
+
|
|
109
122
|
init_calls = []
|
|
110
123
|
init_calls << "fireConfetti()" if fire_confetti
|
|
111
124
|
init_calls << "startCountdown(#{local_assigns[:auto_redirect_url_key]})" if has_redirect
|
|
125
|
+
init_expr = init_calls.join("; ").html_safe
|
|
112
126
|
%>
|
|
113
|
-
|
|
114
|
-
|
|
127
|
+
<%# BOTH ALPINE ATTRIBUTES ARE EMITTED BY tag.attributes, NOT ASSEMBLED BY HAND.
|
|
128
|
+
Each carries Ruby-built JS that splices a host local — redirect_secs into the
|
|
129
|
+
x-data object, auto_redirect_url_key into startCountdown() — and both blobs are
|
|
130
|
+
html_safe, because they are CODE and the caller is owed the JS it wrote.
|
|
131
|
+
|
|
132
|
+
THAT MARKING USED TO BE THE HOLE. html_safe tells ERB to step aside, so a double
|
|
133
|
+
quote in either local closed the ATTRIBUTE and everything after it became markup:
|
|
134
|
+
x-data="{ _remaining: it's a " quote" … — measured, not reasoned.
|
|
135
|
+
|
|
136
|
+
tag.attributes closes it without touching the JS. ActionView's tag_option runs
|
|
137
|
+
gsub('"', """) on the finished value UNCONDITIONALLY, outside the escape
|
|
138
|
+
branch it skips for an html_safe string (actionview tag_helper.rb, the line after
|
|
139
|
+
the escape ternary), so the attribute cannot be closed early and the HTML parser
|
|
140
|
+
decodes the entity back before Alpine ever sees it. Every other character reaches
|
|
141
|
+
the attribute byte-for-byte as it does today. blocks/_rail_row:112 is the same
|
|
142
|
+
move on a whole tag and its comment carries the rest of the reasoning. %>
|
|
143
|
+
<div <%= tag.attributes("x-data": data_attr) %>
|
|
144
|
+
<%= tag.attributes("x-init": init_expr) if init_calls.any? %>
|
|
115
145
|
class="text-center py-6">
|
|
116
146
|
|
|
117
147
|
<%# Icon — emoji takes priority over the default circular green check %>
|
|
@@ -229,7 +259,7 @@
|
|
|
229
259
|
<span class="relative z-10"><%= cta_label %></span>
|
|
230
260
|
</a>
|
|
231
261
|
<% elsif use_drain && local_assigns[:cta_event] %>
|
|
232
|
-
<button @click="$dispatch('<%=
|
|
262
|
+
<button @click="$dispatch('<%= cta_event_js %>')"
|
|
233
263
|
class="btn btn-primary btn-lg w-full relative overflow-hidden no-underline">
|
|
234
264
|
<div class="absolute inset-0 pointer-events-none origin-left"
|
|
235
265
|
style="background: rgba(255,255,255,0.18);"
|
|
@@ -246,12 +276,12 @@
|
|
|
246
276
|
<% elsif local_assigns[:cta_href_key] %>
|
|
247
277
|
<a :href="<%= cta_href_key %>" class="btn btn-primary btn-lg w-full"><%= cta_label %></a>
|
|
248
278
|
<% elsif local_assigns[:cta_event] %>
|
|
249
|
-
<button @click="$dispatch('<%=
|
|
279
|
+
<button @click="$dispatch('<%= cta_event_js %>')" class="btn btn-primary btn-lg w-full"><%= cta_label %></button>
|
|
250
280
|
<% end %>
|
|
251
281
|
<% end %>
|
|
252
282
|
|
|
253
283
|
<% if local_assigns[:secondary_label] && local_assigns[:secondary_event] %>
|
|
254
|
-
<button @click="$dispatch('<%=
|
|
284
|
+
<button @click="$dispatch('<%= secondary_event_js %>')"
|
|
255
285
|
class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
|
|
256
286
|
<%= secondary_label %>
|
|
257
287
|
</button>
|
|
@@ -94,11 +94,11 @@
|
|
|
94
94
|
STRING position — empty_error, submit_path, skip_path, done_event — each sits
|
|
95
95
|
inside a JS SINGLE-quoted literal. A bare apostrophe closes the literal, the
|
|
96
96
|
expression becomes a SyntaxError, and the card is that silent no-op. Repair:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
Studio::JsLiteral.in_attribute, which is where the mechanism now lives and is
|
|
98
|
+
tested. Two escapers have to run — one for the JS literal, one for the
|
|
99
|
+
attribute — and the second only runs on a value ERB still believes is unsafe.
|
|
100
|
+
This card used to spell that out inline at four sites; the engine has eleven
|
|
101
|
+
more of them, so it is one method with one guard now.
|
|
102
102
|
|
|
103
103
|
IDENTIFIER position — modal_store — is spliced in as a bare NAME, at three
|
|
104
104
|
sites: the props getter, finish's close, and the Ruby-built dismiss_action
|
|
@@ -145,15 +145,15 @@
|
|
|
145
145
|
# apostrophes. Interpolated raw, an error reading "We'll need a name" would
|
|
146
146
|
# close the JS single-quoted string, make the whole expression a SyntaxError,
|
|
147
147
|
# and mount the component as a SILENT NO-OP that still renders every element
|
|
148
|
-
# below.
|
|
149
|
-
# backslash and the newline
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
148
|
+
# below. Studio::JsLiteral.in_attribute covers the apostrophe, the double quote,
|
|
149
|
+
# the backslash and the newline, AND hands ERB a value it still believes is
|
|
150
|
+
# unsafe so the attribute's own escaping runs too — a safe string would skip that
|
|
151
|
+
# half and could smuggle a raw double quote in, the exact failure the CRITICAL
|
|
152
|
+
# note above describes.
|
|
153
153
|
#
|
|
154
154
|
# INERT ON BOTH DEFAULTS. Neither default string contains a character either
|
|
155
155
|
# escaper touches, so the shipped card is byte-for-byte what it was.
|
|
156
|
-
empty_error_js =
|
|
156
|
+
empty_error_js = Studio::JsLiteral.in_attribute(empty_error)
|
|
157
157
|
placeholder = local_assigns.fetch(:placeholder, "Alex")
|
|
158
158
|
# OFF unless a host passes a non-empty array. Normalised to nil so that an
|
|
159
159
|
# empty list behaves exactly like an absent local rather than emitting the
|
|
@@ -201,11 +201,11 @@
|
|
|
201
201
|
# Alpine mounts the component as a SILENT NO-OP that still renders every element
|
|
202
202
|
# — perfect-looking markup, dead card.
|
|
203
203
|
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
204
|
+
# WHY A METHOD RATHER THAN escape_javascript AT EACH SITE: the second escaper is
|
|
205
|
+
# conditional. ERB skips a value that answers true to html_safe, and
|
|
206
|
+
# escape_javascript preserves its argument's marking, so the naive call silently
|
|
207
|
+
# loses the attribute half. Studio::JsLiteral.in_attribute owns that, and owns the
|
|
208
|
+
# single test that fails when it is removed.
|
|
209
209
|
#
|
|
210
210
|
# INERT ON EVERY DEFAULT. Two paths, an event name and a store name carry no
|
|
211
211
|
# character either escaper touches, so the shipped card is byte-for-byte what it
|
|
@@ -213,9 +213,9 @@
|
|
|
213
213
|
# host supplies these as prose today, but empty_error established that prose does
|
|
214
214
|
# belong in this attribute, and the next local to carry an apostrophe will look
|
|
215
215
|
# like an ordinary change to whoever writes it.
|
|
216
|
-
submit_path_js =
|
|
217
|
-
skip_path_js =
|
|
218
|
-
done_event_js =
|
|
216
|
+
submit_path_js = Studio::JsLiteral.in_attribute(submit_path)
|
|
217
|
+
skip_path_js = Studio::JsLiteral.in_attribute(skip_path)
|
|
218
|
+
done_event_js = Studio::JsLiteral.in_attribute(done_event)
|
|
219
219
|
field_id = local_assigns.fetch(:id, "onboarding-first-name")
|
|
220
220
|
# The × mirrors the skip affordance it sits beside: it SKIPS while the step is
|
|
221
221
|
# skippable, and merely CLOSES once it is required. Resolved here rather than in
|
|
@@ -37,9 +37,16 @@
|
|
|
37
37
|
m = user.birth_month
|
|
38
38
|
d = user.birth_day
|
|
39
39
|
value = (y.present? && m.present? && d.present?) ? format("%04d-%02d-%02d", y, m, d) : nil
|
|
40
|
+
# ESCAPED FOR THE x-data BELOW. format() cannot produce a quote today, so this is
|
|
41
|
+
# latent cover rather than a live fix — but the local is read off a USER record,
|
|
42
|
+
# and the guard has to sit on the splice rather than on today's formatter. Bare,
|
|
43
|
+
# an apostrophe would close the JS literal and studioBirthdayFields would never
|
|
44
|
+
# evaluate, leaving three selects that render and never save. The <input value="">
|
|
45
|
+
# copy below needs no such treatment: ERB already escapes it, and it is not JS.
|
|
46
|
+
value_js = Studio::JsLiteral.in_attribute(value)
|
|
40
47
|
%>
|
|
41
48
|
|
|
42
|
-
<div x-data="studioBirthdayFields('<%=
|
|
49
|
+
<div x-data="studioBirthdayFields('<%= value_js %>')">
|
|
43
50
|
<%# --- the JS-less path ------------------------------------------------- %>
|
|
44
51
|
<template x-if="!alpine">
|
|
45
52
|
<input type="date" name="profile[birthday]" id="profile_birthday"
|
|
@@ -43,13 +43,30 @@
|
|
|
43
43
|
# /auth/solana routes, Solana::SessionAuth, the phantom callback) and ships no
|
|
44
44
|
# wallet UI at all.
|
|
45
45
|
#
|
|
46
|
-
# So resolution is a RUNTIME question here, not a build-time one.
|
|
47
|
-
#
|
|
48
|
-
# moms-app) bundle no solana-studio
|
|
49
|
-
#
|
|
46
|
+
# So resolution is a RUNTIME question here, not a build-time one. FOUR of the
|
|
47
|
+
# five apps mounting this engine (mcritchie-studio, acquisition-studio,
|
|
48
|
+
# mcritchie-industries, moms-app) bundle no solana-studio — turf-monster is
|
|
49
|
+
# the only one that does — and an unconditional render would turn their
|
|
50
|
+
# /admin/style into a missing-template 500. The gem is a development and
|
|
50
51
|
# test dependency of this engine precisely so the guide can show the REAL
|
|
51
52
|
# cards; where it is absent the specimens stay listed but unopenable.
|
|
52
53
|
#
|
|
54
|
+
# THE EXCEPTION IS THE DURABLE HALF; the count rots the day an app is born.
|
|
55
|
+
# Derived 2026-09-07 — and the CRITERION is recorded, not just the count,
|
|
56
|
+
# because the obvious grep does not reproduce it. The five are the checkouts
|
|
57
|
+
# whose own config/routes.rb calls `Studio.routes(self)`, the same five
|
|
58
|
+
# lib/studio.rb's draw_profile_routes note names. Grepping Gemfiles for
|
|
59
|
+
# `gem "studio-engine"` returns SEVEN: it also catches solana-studio, which
|
|
60
|
+
# pins the engine in `group :development, :test` for its dummy and draws no
|
|
61
|
+
# routes, and mcritchie-studio-ai-builder-cache, which is the HUB checked out
|
|
62
|
+
# twice (same Heroku remote; HEAD c7d5d326 is a commit inside
|
|
63
|
+
# mcritchie-studio) and would double-count it. This line used to read "three
|
|
64
|
+
# of the six" and to omit the HUB — the app a reader is likeliest to be
|
|
65
|
+
# sitting in, and one that bundles no solana-studio. Two further near misses:
|
|
66
|
+
# chain-ops bundles solana-studio WITHOUT this engine, so it is not in this
|
|
67
|
+
# set at all; acquisition-studio is a retired prototype whose Gemfile still
|
|
68
|
+
# pins the engine, so it is.
|
|
69
|
+
#
|
|
53
70
|
# Same three-term lookup_context.exists? the modal host uses for host_extras
|
|
54
71
|
# (studio/modals/_host.html.erb): name, prefixes, partial.
|
|
55
72
|
web3_gem = lookup_context.exists?("wallet_connect", ["solana_studio/modals"], true)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require "action_view"
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# ONE home for the repair that keeps a host-supplied value from bricking an
|
|
5
|
+
# Alpine component when it is spliced into a JS-evaluating HTML attribute.
|
|
6
|
+
#
|
|
7
|
+
# THE FAILURE THIS EXISTS FOR is silent, which is the whole reason it is worth a
|
|
8
|
+
# module instead of a convention. A local sits inside a JS single-quoted literal
|
|
9
|
+
# in a double-quoted attribute:
|
|
10
|
+
#
|
|
11
|
+
# <button @click="$dispatch('<%= cta_event %>')">
|
|
12
|
+
#
|
|
13
|
+
# A bare apostrophe in `cta_event` closes that literal, the expression becomes a
|
|
14
|
+
# SyntaxError, and Alpine mounts the component as a NO-OP that still renders
|
|
15
|
+
# every element. Perfect-looking markup, dead card. Nothing raises, nothing logs
|
|
16
|
+
# a server-side warning, and every string assertion about the response bytes
|
|
17
|
+
# still passes — which is how this class of defect survives a review.
|
|
18
|
+
#
|
|
19
|
+
# BOTH ESCAPERS HAVE TO RUN, and that is the part a hand-written call gets wrong.
|
|
20
|
+
# There are two nested contexts, so there are two ways out of the attribute:
|
|
21
|
+
#
|
|
22
|
+
# * out of the JS STRING, with `'` — repaired by escape_javascript
|
|
23
|
+
# * out of the HTML ATTRIBUTE, with `"` — repaired by ERB's own escaping
|
|
24
|
+
#
|
|
25
|
+
# escape_javascript covers the first. ERB covers the second, but ONLY for a value
|
|
26
|
+
# it believes is unsafe, and `escape_javascript(SafeBuffer)` answers true to
|
|
27
|
+
# html_safe?. Hand an html_safe string straight to escape_javascript and ERB
|
|
28
|
+
# steps aside, a raw double quote reaches the attribute, and the attribute closes
|
|
29
|
+
# early — the same dead card by a longer route.
|
|
30
|
+
#
|
|
31
|
+
# So the interpolation wrapper below is LOAD-BEARING, not a style tic: `"#{value}"`
|
|
32
|
+
# produces a plain String, escape_javascript therefore returns a plain String, and
|
|
33
|
+
# ERB does its half on the way into the attribute. That subtlety was re-derived at
|
|
34
|
+
# every call site before this module existed, and it had no test cover anywhere;
|
|
35
|
+
# it now has exactly one implementation and one guard
|
|
36
|
+
# (test/lib/studio/js_literal_test.rb).
|
|
37
|
+
#
|
|
38
|
+
# DELIBERATELY NARROW — there is no script-body variant here, and adding one is
|
|
39
|
+
# not a copy of this method. Inside <script>…</script> the HTML parser does no
|
|
40
|
+
# entity decoding, so ERB's escaping is not a second layer of safety there, it is
|
|
41
|
+
# CORRUPTION: `\"` would arrive as `\"` and break the JS this method exists
|
|
42
|
+
# to protect. A script body wants JS escaping ONLY (escape_javascript, marked
|
|
43
|
+
# safe). The name says `in_attribute` so that the next person reaching for this in
|
|
44
|
+
# a <script> has to stop and notice the difference.
|
|
45
|
+
#
|
|
46
|
+
# AND IT IS THE WRONG REPAIR FOR AN ATTRIBUTE THE PARTIAL ASSEMBLES ITSELF —
|
|
47
|
+
# `<%= "x-init=\"#{expr}\"".html_safe %>`, where the attribute's OWN QUOTES are
|
|
48
|
+
# built in Ruby. Nothing here helps: escaping the value would break the Alpine
|
|
49
|
+
# EXPRESSION the caller deliberately handed the engine, and the marking has already
|
|
50
|
+
# told ERB to stand down, so a double quote closes the ATTRIBUTE and the remainder
|
|
51
|
+
# is parsed as MARKUP. The repair is to stop writing the quotes and let ActionView
|
|
52
|
+
# write them — `tag.attributes("x-init": expr.html_safe)` — because tag_option
|
|
53
|
+
# quote-escapes the finished value UNCONDITIONALLY, outside the escape branch it
|
|
54
|
+
# skips for a marked String. The expression survives byte-for-byte and cannot end
|
|
55
|
+
# the attribute. modals/blocks/_rail_row:112 and components/_sidebar_panel carry
|
|
56
|
+
# the worked examples; test/lib/studio/attribute_encoding_contract_test.rb pins the
|
|
57
|
+
# ActionView behaviour the whole thing rests on.
|
|
58
|
+
#
|
|
59
|
+
# AND IT IS THE WRONG REPAIR FOR IDENTIFIER POSITION. A local spliced in as a bare
|
|
60
|
+
# NAME — `$store.<%= modal_store %>.close()` — must be VALIDATED, never escaped:
|
|
61
|
+
# escape_javascript also escapes `$`, so a legitimate store name like `dsModals$2`
|
|
62
|
+
# comes back mangled and the card dies anyway. The SHAPE of the splice decides the
|
|
63
|
+
# repair, never the name of the local. studio/modals/onboarding/_first_name
|
|
64
|
+
# carries the worked example of both.
|
|
65
|
+
module JsLiteral
|
|
66
|
+
extend ActionView::Helpers::JavaScriptHelper
|
|
67
|
+
|
|
68
|
+
module_function
|
|
69
|
+
|
|
70
|
+
# Escape +value+ for a JS string literal that lives inside an HTML attribute.
|
|
71
|
+
#
|
|
72
|
+
# Returns a plain (NOT html_safe) String on purpose — see the note above. The
|
|
73
|
+
# caller interpolates it inside the JS quotes, exactly as it would have
|
|
74
|
+
# interpolated the raw local:
|
|
75
|
+
#
|
|
76
|
+
# cta_event_js = Studio::JsLiteral.in_attribute(cta_event)
|
|
77
|
+
# # …
|
|
78
|
+
# <button @click="$dispatch('<%= cta_event_js %>')">
|
|
79
|
+
#
|
|
80
|
+
# nil becomes "" rather than "nil", because every current caller reaches this
|
|
81
|
+
# with an optional local and an empty JS string is the honest rendering of an
|
|
82
|
+
# absent value.
|
|
83
|
+
# ONE MECHANISM, DELIBERATELY. An earlier draft also re-stripped the marking on
|
|
84
|
+
# the way out, as belt-and-braces. That made both halves redundant, and a
|
|
85
|
+
# redundant guard is one that survives being deleted: removing the interpolation
|
|
86
|
+
# below left every test green, because the second stripper covered for it. There
|
|
87
|
+
# is one way this works now, and test/lib/studio/js_literal_test.rb fails when it
|
|
88
|
+
# is removed.
|
|
89
|
+
def in_attribute(value)
|
|
90
|
+
JsLiteral.escape_javascript("#{value}")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -6,9 +6,9 @@ module Studio
|
|
|
6
6
|
|
|
7
7
|
class << self
|
|
8
8
|
def configure!(env: ENV,
|
|
9
|
-
rails_env: defined?(Rails) ? Rails.env : "development",
|
|
9
|
+
rails_env: defined?(Rails) && Rails.respond_to?(:env) ? Rails.env : "development",
|
|
10
10
|
action_mailer: defined?(ActionMailer) ? ActionMailer::Base : nil,
|
|
11
|
-
logger: defined?(Rails) ? Rails.logger : nil,
|
|
11
|
+
logger: defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil,
|
|
12
12
|
mailer_from: defined?(Studio) && Studio.respond_to?(:mailer_from) ? Studio.mailer_from : nil,
|
|
13
13
|
resend_loader: method(:load_resend!),
|
|
14
14
|
resend_configurer: method(:configure_resend!))
|
data/lib/studio/s3.rb
CHANGED
|
@@ -123,7 +123,24 @@ module Studio
|
|
|
123
123
|
def environment
|
|
124
124
|
return "dev" if EnvironmentBanner.qa_environment?
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
# ASKS FOR THE METHOD, NOT THE CONSTANT, and the difference is not
|
|
127
|
+
# theoretical. rails-html-sanitizer — which arrives with action_view, long
|
|
128
|
+
# before any Rails APPLICATION does — defines a namespace-only `module
|
|
129
|
+
# Rails` with no singleton methods on it. Against that, a bare
|
|
130
|
+
# `defined?(Rails)` reads TRUE and the very next call dies with
|
|
131
|
+
# NoMethodError: undefined method `env` for module Rails. It turned an
|
|
132
|
+
# unrelated green suite red the first time a unit test pulled action_view
|
|
133
|
+
# in.
|
|
134
|
+
#
|
|
135
|
+
# THIS WAS NOT THE LAST ONE, though it was described that way when it
|
|
136
|
+
# landed. Three sites still carried the bare form afterwards — both
|
|
137
|
+
# keyword defaults in lib/studio/mail_transport.rb and the developer-desk
|
|
138
|
+
# route guard in lib/studio.rb — and the claim that the sweep was finished
|
|
139
|
+
# is how they survived a second review. The whole tree is swept now, and
|
|
140
|
+
# test/lib/studio/rails_guard_sweep_test.rb is what keeps it that way; do
|
|
141
|
+
# not restate completeness here, because a comment cannot notice the next
|
|
142
|
+
# straggler and that test can.
|
|
143
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env&.production? ? "production" : "dev"
|
|
127
144
|
end
|
|
128
145
|
end
|
|
129
146
|
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "studio/color_scale"
|
|
|
8
8
|
require "studio/environment_banner"
|
|
9
9
|
require "studio/theme_resolver"
|
|
10
10
|
require "studio/ui_primitives"
|
|
11
|
+
require "studio/js_literal"
|
|
11
12
|
require "studio/sidebar_sections"
|
|
12
13
|
require "studio/profile_sections"
|
|
13
14
|
require "studio/profile_image"
|
|
@@ -855,7 +856,7 @@ module Studio
|
|
|
855
856
|
# Developer-desk tools. Drawn outside production, and each controller
|
|
856
857
|
# re-checks Studio.local_tool_enabled? per request (loopback only) — the
|
|
857
858
|
# route being absent is the outer gate, not the only one.
|
|
858
|
-
unless defined?(Rails) && Rails.env.production?
|
|
859
|
+
unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
859
860
|
get "_studio/local_emails", to: "studio/local_emails#index", as: :studio_local_emails
|
|
860
861
|
get "_studio/local_review", to: "studio/local_reviews#show", as: :studio_local_review
|
|
861
862
|
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.74.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -573,6 +573,7 @@ files:
|
|
|
573
573
|
- lib/studio/geo/lookup.rb
|
|
574
574
|
- lib/studio/image_cache.rb
|
|
575
575
|
- lib/studio/ip_locations.rb
|
|
576
|
+
- lib/studio/js_literal.rb
|
|
576
577
|
- lib/studio/link_resolution.rb
|
|
577
578
|
- lib/studio/link_token.rb
|
|
578
579
|
- lib/studio/log_rotation.rb
|