studio-engine 0.74.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: 6a6961114226763cef0af1b1500c982609412516d95c78c0985f63012882e92e
4
- data.tar.gz: 4694c467547769140156eac7824a72fd0d510d84f0c30637cba40a87c3344187
3
+ metadata.gz: d056d9eddc4bd13c863b5ec0f3e239e7591fc16ddd2170ac5192537f5a190326
4
+ data.tar.gz: 289b7050571718689aa567db5f0434be21c41ee7074b4b49d26969f16b07a815
5
5
  SHA512:
6
- metadata.gz: 509affb42c8bf58a11289e0e0c16cfb2b298625456284b0d56775373fba4ac225c9bc6ba6bd695acb028a44a517836989204dfbb97bd96b1ed69ca2f1e9e7023
7
- data.tar.gz: 36ed3258453f8025f591335348f989e333a6d805de78979e325c2a005a197d74c17f04995c6fcf6776bbebb2dae9d4404c483f0effe1e095bf29f2e02947ca83
6
+ metadata.gz: 15c2e97be72e8fe2e4e67719a569f6d456f82e961fd36b11d0c5062d8ace97836316398eef5058bf9436cdec7ec4ed33aca31ca7decf70bcfe3569dd8665e693
7
+ data.tar.gz: 6dd53cf3747fdb36271afaea5a76c0dc2ee615e25f394083b3219476a5222991999edf97474e1022e9f2f584350ba05b04cbfaa01d5fe0f28ce2995f3a06d6fa
data/CHANGELOG.md CHANGED
@@ -6,6 +6,111 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  ### Fixed
8
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
+
9
114
  - **The app census in these comments was short by the most important app.** Ten
10
115
  sites said this engine is mounted by SIX apps and that THREE of them bundle no
11
116
  `solana-studio`. Measured 2026-09-07 by the criterion that reproduces it — a
@@ -363,7 +468,9 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
363
468
  singleton methods, so that guard reads true and the next call raises
364
469
  `NoMethodError: undefined method 'env' for module Rails`. It now asks
365
470
  `Rails.respond_to?(:env)`, which is the form `lib/studio.rb` already uses in three
366
- places; this was the straggler. No shipped app can reach it every host boots a
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
367
474
  real application — but the engine's own pure-Ruby unit lane can, and it did:
368
475
  adding one `require` for a file that needs `action_view` turned an untouched
369
476
  `email_catalog_test` red with two errors about email uploads.
@@ -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"
@@ -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)
@@ -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>
@@ -122,9 +122,26 @@
122
122
  init_calls = []
123
123
  init_calls << "fireConfetti()" if fire_confetti
124
124
  init_calls << "startCountdown(#{local_assigns[:auto_redirect_url_key]})" if has_redirect
125
+ init_expr = init_calls.join("; ").html_safe
125
126
  %>
126
- <div x-data="<%= data_attr %>"
127
- <%= "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? %>
128
145
  class="text-center py-6">
129
146
 
130
147
  <%# Icon — emoji takes priority over the default circular green check %>
@@ -43,6 +43,19 @@ module Studio
43
43
  # safe). The name says `in_attribute` so that the next person reaching for this in
44
44
  # a <script> has to stop and notice the difference.
45
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
+ #
46
59
  # AND IT IS THE WRONG REPAIR FOR IDENTIFIER POSITION. A local spliced in as a bare
47
60
  # NAME — `$store.<%= modal_store %>.close()` — must be VALIDATED, never escaped:
48
61
  # escape_javascript also escapes `$`, so a legitimate store name like `dsModals$2`
@@ -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
@@ -128,10 +128,18 @@ module Studio
128
128
  # before any Rails APPLICATION does — defines a namespace-only `module
129
129
  # Rails` with no singleton methods on it. Against that, a bare
130
130
  # `defined?(Rails)` reads TRUE and the very next call dies with
131
- # NoMethodError: undefined method `env` for module Rails. lib/studio.rb
132
- # already spells the guard this way in three places; this was the
133
- # straggler, and it turned an unrelated green suite red the first time a
134
- # unit test pulled action_view in.
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.
135
143
  defined?(Rails) && Rails.respond_to?(:env) && Rails.env&.production? ? "production" : "dev"
136
144
  end
137
145
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.0"
2
+ VERSION = "0.74.1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -856,7 +856,7 @@ module Studio
856
856
  # Developer-desk tools. Drawn outside production, and each controller
857
857
  # re-checks Studio.local_tool_enabled? per request (loopback only) — the
858
858
  # route being absent is the outer gate, not the only one.
859
- unless defined?(Rails) && Rails.env.production?
859
+ unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
860
860
  get "_studio/local_emails", to: "studio/local_emails#index", as: :studio_local_emails
861
861
  get "_studio/local_review", to: "studio/local_reviews#show", as: :studio_local_review
862
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.74.0
4
+ version: 0.74.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie