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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60486d6f2711eb8d61d258fa6305f05cfd2d85910858aacc1506dee587f3c114
4
- data.tar.gz: beb3d3a69e3c01fca35a1196e37065b56b9b563696e2d7d3c6d15d5718440fd1
3
+ metadata.gz: d056d9eddc4bd13c863b5ec0f3e239e7591fc16ddd2170ac5192537f5a190326
4
+ data.tar.gz: 289b7050571718689aa567db5f0434be21c41ee7074b4b49d26969f16b07a815
5
5
  SHA512:
6
- metadata.gz: c668f68df4d51e460a58a3bfb6b8608acb943b9ab75558b7676c9ed79489219e686249e45c20593ec97c70401ed34c9b8ce78fafb927201912c1d0e87012fd1e
7
- data.tar.gz: f9f13b69c48bd71d7ad127e1229245a7079dd0155dcfd731fe3044b25fcd439217e6dd61ca3f33b4c73b065cd8d5629dacd500218ac1b27cd02270f528f78e3a
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('"', "&quot;")` 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. Five of the six apps
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 six apps fork the navbar**
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, moms-app mount this engine and
44
- # bundle no solana-studio), which is precisely the coupling the split removed.
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 six FORK the
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
- <%= %(#{outside_action.present? ? "@click.outside=\"#{outside_action}\"" : ""}).html_safe %>
38
- <%= %(#{escape_action.present? ? "@keydown.escape.window=\"#{escape_action}\"" : ""}).html_safe %>
39
- <%= %(#{close_action.present? ? "@turbo:before-cache.window=\"#{close_action}\"" : ""}).html_safe %>
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('"', "&quot;") 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 six apps fork the navbar, so each
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 &quot; 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: '<%= j @entry.label %> now uses this app\'s own image.',
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
- <div x-data="cropPhotoModal({ store: '<%= crop_store %>' })">
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: '<%= cluster_param %>' }">
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('<%= cta_event %>')" class="btn btn-outline btn-sm"><%= cta_label %></button>
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('<%= secondary_event %>')"
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
- <%= "minlength=\"#{min_length}\"".html_safe if min_length.to_i.positive? %>
142
- <%= "maxlength=\"#{max_length}\"".html_safe if max_length.present? %>
143
- <%= "pattern=\"#{pattern}\"".html_safe if pattern.present? %>
144
- <%= "title=\"#{pattern_title}\"".html_safe if pattern_title.present? %>
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
- <button type="button" @click="<%= next_open %>" class="btn btn-primary btn-lg w-full">
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('"', "&quot;") 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"<%= " x-data=\"{}\" x-init=\"window.StudioModals.holdAtLeast(#{min_duration}).then(() => { #{resolve_expr} })\"".html_safe if resolve_expr %>>
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 %>) + '<%= cluster_param %>'"
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
- <div x-data="<%= data_attr %>"
114
- <%= "x-init=\"#{init_calls.join('; ')}\"".html_safe if init_calls.any? %>
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('"', "&quot;") 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('<%= cta_event %>')"
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('<%= cta_event %>')" class="btn btn-primary btn-lg w-full"><%= cta_label %></button>
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('<%= secondary_event %>')"
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
- escape_javascript, in the INTERPOLATED form. The wrapper is load-bearing:
98
- escape_javascript(SafeBuffer) answers true to html_safe, so ERB would skip
99
- its own attribute-escaping half and a raw double quote could still close the
100
- attribute. Wrapping the value in a plain interpolation first strips the safe
101
- marking, so both escapers run.
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. escape_javascript covers the apostrophe, the double quote, the
149
- # backslash and the newline; the interpolation around it first strips any
150
- # html_safe marking, because a safe string would skip ERB's own attribute
151
- # escaping and could smuggle a raw double quote in the exact failure the
152
- # CRITICAL note above describes.
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 = escape_javascript("#{empty_error}")
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
- # THE INTERPOLATED FORM IS LOAD-BEARING, not a style choice:
205
- # escape_javascript(SafeBuffer).html_safe? is TRUE, so ERB would skip its own
206
- # attribute-escaping half and a raw double quote could still close the
207
- # double-quoted x-data. Wrapping in "#{}" first strips the html_safe marking, so
208
- # both escapers run: escape_javascript for the JS literal, ERB for the attribute.
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 = escape_javascript("#{submit_path}")
217
- skip_path_js = escape_javascript("#{skip_path}")
218
- done_event_js = escape_javascript("#{done_event}")
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('<%= value %>')">
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. Three of the
47
- # six apps mounting this engine (acquisition-studio, mcritchie-industries,
48
- # moms-app) bundle no solana-studio, and an unconditional render would turn
49
- # their /admin/style into a missing-template 500. The gem is a development and
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 `\&quot;` 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
- defined?(Rails) && Rails.env.production? ? "production" : "dev"
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.73.0"
2
+ VERSION = "0.74.1"
3
3
  end
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.73.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