phlex-reactive 0.4.8 → 0.9.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +924 -4
  3. data/README.md +986 -32
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +52 -7
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +30 -442
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +27 -9
  25. data/lib/phlex/reactive/js.rb +249 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +160 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +333 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. metadata +19 -1
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # Validates a phlex-reactive install and reports ✓/✗/? per check with a fix
6
+ # for each failure (issue #106). Five closed issues (#3 boot/eager-load, #26
7
+ # route shadowing, #42 lost request, #48 unregistered controller, #57
8
+ # importmap 404) were pure integration papercuts that only surfaced AFTER
9
+ # something already broke. The doctor turns "nothing happens, why?" into an
10
+ # actionable checklist you run before/after setup:
11
+ #
12
+ # bin/rails phlex_reactive:doctor
13
+ #
14
+ # Every check is a small object answering [status, message, fix]. It is
15
+ # READ-ONLY — it never mounts a component, mutates state, or touches the
16
+ # default-deny boundary; the worst it does is a throwaway sign→verify round
17
+ # trip and (for the component checks) iterate the loaded Streamable registry.
18
+ class Doctor
19
+ # The result of one check: a status (:ok/:fail/:unknown), a human message,
20
+ # and (on anything but :ok) a fix line telling the adopter what to do. A
21
+ # plain value object (not Data) so it takes positional status/message plus
22
+ # keyword name:/fix: — the shape the check builders and specs construct.
23
+ class Check
24
+ attr_reader :name, :status, :message, :fix
25
+
26
+ def initialize(status, message, name: nil, fix: nil)
27
+ @name = name
28
+ @status = status
29
+ @message = message
30
+ @fix = fix
31
+ end
32
+
33
+ def ok? = status == :ok
34
+ def fail? = status == :fail
35
+ def unknown? = status == :unknown
36
+ end
37
+
38
+ # Glyphs are plain ASCII-safe Unicode with NO ANSI color, so CI/log capture
39
+ # reads cleanly (issue #106 acceptance: stable plain-text output).
40
+ GLYPHS = { ok: "✓", fail: "✗", unknown: "?" }.freeze
41
+
42
+ # The entrypoint the rake task calls: eager-load so the component registry
43
+ # is populated, print the report, and return TRUE when nothing failed (an
44
+ # advisory `?` doesn't count) so a caller can gate its exit code on it.
45
+ # (Not a predicate name — this is the imperative "run + report" action that
46
+ # happens to return a success boolean; `io` is the conventional stream name.)
47
+ def self.run(io: $stdout) # rubocop:disable Naming/PredicateMethod,Naming/MethodParameterName
48
+ ::Rails.application.eager_load! if defined?(::Rails) && ::Rails.application
49
+ doctor = new
50
+ io.puts(doctor.report)
51
+ !doctor.failures?
52
+ end
53
+
54
+ # Run every check and return the ordered list of Check objects, memoized so
55
+ # report + failures? share one pass. The caller (or Doctor.run) is
56
+ # responsible for eager_load! so component classes are in the registry —
57
+ # the component checks are empty otherwise.
58
+ def checks
59
+ @checks ||= build_checks
60
+ end
61
+
62
+ # True when any check FAILED (a hard ✗). Advisory `?` lines are not
63
+ # failures — a Phlex-layout app legitimately can't verify csrf that way.
64
+ def failures?
65
+ checks.any?(&:fail?)
66
+ end
67
+
68
+ def build_checks
69
+ components = registered_components
70
+ [
71
+ route_check,
72
+ stimulus_check,
73
+ csrf_check,
74
+ verifier_check,
75
+ base_controller_check,
76
+ action_check(components),
77
+ id_check(components)
78
+ ]
79
+ end
80
+
81
+ # --- individual checks ------------------------------------------------
82
+
83
+ # Does POST <action_path> resolve to the gem's ActionsController? A host
84
+ # catch-all route shadows it otherwise (issue #26). Reuses the shipped
85
+ # guard verbatim — it already handles routes-not-yet-drawn.
86
+ def route_check
87
+ path = Phlex::Reactive.action_path
88
+ if Phlex::Reactive.action_route_ok?(path)
89
+ Check.new(:ok, "POST #{path} routes to #{Doctor.actions_controller}", name: :route)
90
+ else
91
+ Check.new(:fail, "POST #{path} does not resolve to #{Doctor.actions_controller}", name: :route,
92
+ fix: "A host catch-all route (match \"*path\", ...) likely shadows it. Exempt " \
93
+ "#{path.delete_prefix("/")} from the catch-all, or set Phlex::Reactive.action_path " \
94
+ "to an unshadowed path.")
95
+ end
96
+ end
97
+
98
+ # Is the generic `reactive` controller registered in a Stimulus entrypoint
99
+ # (issue #48)? Grep the candidate entrypoints for the register line; when
100
+ # importmap is present, additionally verify the pin resolves.
101
+ def stimulus_check
102
+ entrypoint = stimulus_registration_files.find { registers_reactive?(it) }
103
+ return stimulus_missing_check unless entrypoint
104
+
105
+ if importmap_pin_broken?
106
+ return Check.new(:fail, "reactive registered in #{relative(entrypoint)}, but the importmap " \
107
+ "pin for phlex/reactive/reactive_controller is missing", name: :stimulus,
108
+ fix: "The engine auto-pins it; if you overrode config/importmap.rb, add:\n " \
109
+ "pin \"phlex/reactive/reactive_controller\"")
110
+ end
111
+
112
+ Check.new(:ok, "reactive controller registered in #{relative(entrypoint)}", name: :stimulus)
113
+ end
114
+
115
+ # ADVISORY only (issue #106): grep ERB layouts AND Phlex layout files for a
116
+ # csrf_meta_tags reference. A hard fail would false-flag Phlex-layout apps
117
+ # (this gem's core audience), so a miss is :unknown, never :fail.
118
+ def csrf_check
119
+ if csrf_meta_referenced?
120
+ Check.new(:ok, "csrf_meta_tags found in a layout", name: :csrf)
121
+ else
122
+ Check.new(:unknown, "could not verify csrf_meta_tags in a layout", name: :csrf,
123
+ fix: "Confirm your layout renders csrf_meta_tags (ERB: <%= csrf_meta_tags %>; " \
124
+ "Phlex: render Phlex::Rails::Helpers::CSRFMetaTags or emit the meta tags) — " \
125
+ "the client reads the CSRF token from <meta name=\"csrf-token\">.")
126
+ end
127
+ end
128
+
129
+ # A throwaway sign→verify round trip proves the verifier is configured and
130
+ # the key round-trips (a bad secret_key_base or a purpose mismatch fails).
131
+ # verify legitimately stamps the version key "v" (issue #111), so we check
132
+ # the original entries survived rather than exact equality.
133
+ def verifier_check
134
+ payload = { "c" => "Phlex::Reactive::Doctor", "probe" => true }
135
+ roundtripped = Phlex::Reactive.verify(Phlex::Reactive.sign(payload))
136
+ if roundtripped && payload.all? { |k, v| roundtripped[k] == v }
137
+ Check.new(:ok, "identity verifier signs and verifies", name: :verifier)
138
+ else
139
+ Check.new(:fail, "identity verifier did not round-trip a probe payload", name: :verifier,
140
+ fix: "Check secret_key_base is set, or configure a dedicated " \
141
+ "Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(key).")
142
+ end
143
+ rescue => e # rubocop:disable Style/RescueStandardError
144
+ Check.new(:fail, "identity verifier raised: #{e.message}", name: :verifier,
145
+ fix: "Set secret_key_base, or configure Phlex::Reactive.verifier explicitly.")
146
+ end
147
+
148
+ # Does Phlex::Reactive.base_controller_name constantize (issue #48-adjacent)?
149
+ def base_controller_check
150
+ name = Phlex::Reactive.base_controller_name
151
+ klass = Phlex::Reactive.base_controller
152
+ Check.new(:ok, "base_controller_name #{name} constantizes to #{klass}", name: :base_controller)
153
+ rescue => e # rubocop:disable Style/RescueStandardError
154
+ Check.new(:fail, "base_controller_name #{Phlex::Reactive.base_controller_name.inspect} " \
155
+ "does not constantize (#{e.class})", name: :base_controller,
156
+ fix: "Set Phlex::Reactive.base_controller_name to a controller that exists " \
157
+ "(e.g. \"ApplicationController\").")
158
+ end
159
+
160
+ # Every declared `action :name` must have a public instance method (mirrors
161
+ # the endpoint's public_send dispatch — a missing one 500s at click).
162
+ def action_check(components)
163
+ missing = components.flat_map { missing_action_methods(it) }
164
+ return Check.new(:ok, "every declared action has a public method", name: :actions) if missing.empty?
165
+
166
+ Check.new(:fail, "declared actions with no matching public method: #{missing.join(", ")}", name: :actions,
167
+ fix: "Define a public method for each, or remove the `action :name` declaration.")
168
+ end
169
+
170
+ # Flag a class that would raise NotImplementedError in #id at render: it
171
+ # inherits Streamable's default #id AND is NOT record-backed. A record-backed
172
+ # class on the default is FINE — that default shipped in #81.
173
+ def id_check(components)
174
+ offenders = components.select { default_id_without_record?(it) }.map(&:name)
175
+ return Check.new(:ok, "every component resolves a stable #id", name: :ids) if offenders.empty?
176
+
177
+ Check.new(:fail, "state-backed components with no #id (render raises NotImplementedError): " \
178
+ "#{offenders.join(", ")}", name: :ids,
179
+ fix: "Add `def id = \"my-thing\"` to each — a state-backed component has no record to " \
180
+ "derive a default id from.")
181
+ end
182
+
183
+ # --- rendering --------------------------------------------------------
184
+
185
+ # The full plain-text report: a line per check, plus an indented fix under
186
+ # each non-passing one. No ANSI color (clean CI/log capture).
187
+ def report
188
+ lines = ["phlex-reactive doctor", ""]
189
+ results = checks
190
+ results.each { lines << render_check(it) }
191
+ lines << ""
192
+ lines << summary_line(results)
193
+ lines.join("\n")
194
+ end
195
+
196
+ # One check as "✓/✗/? message" plus an indented "→ fix" when it isn't ok.
197
+ def render_check(check)
198
+ line = "#{GLYPHS.fetch(check.status)} #{check.message}"
199
+ line += "\n → #{check.fix}" if check.fix && !check.ok?
200
+ line
201
+ end
202
+
203
+ def self.actions_controller
204
+ "phlex/reactive/actions"
205
+ end
206
+
207
+ private
208
+
209
+ # A one-line tally: how many passed, failed, and are advisory/unknown.
210
+ def summary_line(results)
211
+ passed = results.count(&:ok?)
212
+ failed = results.count(&:fail?)
213
+ advisory = results.count(&:unknown?)
214
+ parts = ["#{passed} passed"]
215
+ parts << "#{failed} failed" if failed.positive?
216
+ parts << "#{advisory} advisory" if advisory.positive?
217
+ parts.join(", ")
218
+ end
219
+
220
+ # The registry filtered to real, CONSTANT-RESOLVABLE reactive components.
221
+ # A class is only invokable by the endpoint if its own name round-trips
222
+ # through safe_constantize (that's exactly how ActionsController#resolve_component
223
+ # rebuilds it from the token). So we validate only classes where
224
+ # name.safe_constantize is the class itself — which also excludes anonymous
225
+ # classes (name nil) and test fixtures that fake `def self.name` without a
226
+ # matching constant, keeping the whole-app scan honest.
227
+ def registered_components
228
+ Phlex::Reactive::Streamable.registered_classes.select { constant_backed_component?(it) }
229
+ end
230
+
231
+ def constant_backed_component?(klass)
232
+ reactive_component?(klass) && klass.name && klass.name.safe_constantize.equal?(klass)
233
+ rescue StandardError
234
+ false
235
+ end
236
+
237
+ def reactive_component?(klass)
238
+ klass.respond_to?(:reactive_actions) && klass.include?(Phlex::Reactive::Component)
239
+ rescue StandardError
240
+ false
241
+ end
242
+
243
+ # "Klass#action" for every declared action on `klass` that has no public
244
+ # method to dispatch to. Kept as its own method (not a nested block) so the
245
+ # class is a named method arg, not a shadowed `it`.
246
+ def missing_action_methods(klass)
247
+ klass.reactive_actions.keys
248
+ .reject { klass.public_method_defined?(it) }
249
+ .map { "#{klass}##{it}" }
250
+ end
251
+
252
+ # True when the class still uses Streamable's default #id AND has no record
253
+ # to back it (so the default raises). owner == Streamable means no override.
254
+ def default_id_without_record?(klass)
255
+ klass.instance_method(:id).owner == Phlex::Reactive::Streamable &&
256
+ !(klass.respond_to?(:reactive_record_key) && klass.reactive_record_key)
257
+ rescue StandardError
258
+ false
259
+ end
260
+
261
+ def stimulus_missing_check
262
+ Check.new(:fail, "the reactive controller is not registered in any Stimulus entrypoint", name: :stimulus,
263
+ fix: "Add to your entrypoint (e.g. app/javascript/controllers/index.js):\n " \
264
+ "import ReactiveController from \"phlex/reactive/reactive_controller\"\n " \
265
+ "application.register(\"reactive\", ReactiveController)\n" \
266
+ "or re-run: bin/rails generate phlex:reactive:install")
267
+ end
268
+
269
+ # Files that may hold the register line: the JS entrypoint candidates,
270
+ # importmap-style AND esbuild/bun (issue #106) — controllers/index.js,
271
+ # controllers/application.js, application.js — PLUS ERB layouts, since a
272
+ # small/importmap app often registers inline in <head> rather than in a
273
+ # dedicated entrypoint file. Only existing files are returned.
274
+ def stimulus_registration_files
275
+ candidates = %w[
276
+ app/javascript/controllers/index.js
277
+ app/javascript/controllers/application.js
278
+ app/javascript/application.js
279
+ ].map { app_path(it) }
280
+ candidates += ::Dir.glob(app_path("app/views/layouts/**/*.erb"))
281
+ candidates.select { File.exist?(it) }
282
+ end
283
+
284
+ def registers_reactive?(path)
285
+ File.read(path).include?('application.register("reactive", ReactiveController)')
286
+ rescue StandardError
287
+ false
288
+ end
289
+
290
+ # Only meaningful when importmap is in use. True when importmap is present
291
+ # but the reactive_controller pin is absent (the engine pins it, so this is
292
+ # really "someone overrode importmap.rb and dropped the pin").
293
+ def importmap_pin_broken?
294
+ return false unless defined?(::Importmap) && ::Rails.application.respond_to?(:importmap)
295
+
296
+ map = ::Rails.application.importmap
297
+ return false unless map
298
+
299
+ !map.packages.key?("phlex/reactive/reactive_controller")
300
+ rescue StandardError
301
+ false
302
+ end
303
+
304
+ # Grep ERB layouts AND Phlex layout files for a csrf_meta_tags reference.
305
+ def csrf_meta_referenced?
306
+ globs = %w[
307
+ app/views/**/*.erb
308
+ app/views/**/*.rb
309
+ app/components/**/*.rb
310
+ app/views/**/layout*.html*
311
+ ].map { app_path(it) }
312
+
313
+ ::Dir.glob(globs).any? do
314
+ File.read(it).include?("csrf_meta_tags")
315
+ rescue StandardError
316
+ false
317
+ end
318
+ rescue StandardError
319
+ false
320
+ end
321
+
322
+ def app_path(relative)
323
+ ::Rails.root.join(relative).to_s
324
+ end
325
+
326
+ def relative(path)
327
+ return path unless defined?(::Rails) && ::Rails.root
328
+
329
+ path.delete_prefix("#{::Rails.root}/")
330
+ end
331
+ end
332
+ end
333
+ end
@@ -19,15 +19,21 @@ module Phlex
19
19
  end
20
20
  end
21
21
 
22
- # Make reactive_controller.js available to Propshaft/Sprockets so it can
23
- # be included via the asset pipeline or pinned in importmap.
22
+ # Make the MINIFIED client build available to Propshaft/Sprockets so it can
23
+ # be fingerprinted, served, and pinned in importmap. The browser ships the
24
+ # minified twin (108 KB -> 22 KB for the controller; `rake build:js`), not
25
+ # the comment-dense source. The .min.js.map is precompiled too so devtools
26
+ # resolves the linked sourcemap back to the readable source on demand.
24
27
  initializer "phlex_reactive.assets" do
25
28
  if it.config.respond_to?(:assets)
26
29
  it.config.assets.paths << root.join("app/javascript").to_s
27
30
  it.config.assets.precompile += %w[
28
- phlex/reactive/reactive_controller.js
29
- phlex/reactive/confirm.js
30
- phlex/reactive/compute.js
31
+ phlex/reactive/reactive_controller.min.js
32
+ phlex/reactive/reactive_controller.min.js.map
33
+ phlex/reactive/confirm.min.js
34
+ phlex/reactive/confirm.min.js.map
35
+ phlex/reactive/compute.min.js
36
+ phlex/reactive/compute.min.js.map
31
37
  ]
32
38
  end
33
39
  end
@@ -39,7 +45,7 @@ module Phlex
39
45
  if defined?(::Importmap::Map) && it.respond_to?(:importmap)
40
46
  it.importmap.pin(
41
47
  "phlex/reactive/reactive_controller",
42
- to: "phlex/reactive/reactive_controller.js",
48
+ to: "phlex/reactive/reactive_controller.min.js",
43
49
  preload: true
44
50
  )
45
51
  # The overridable confirm resolver (issue #55). reactive_controller.js
@@ -52,7 +58,7 @@ module Phlex
52
58
  # "phlex/reactive/confirm"` both resolve through the import map.
53
59
  it.importmap.pin(
54
60
  "phlex/reactive/confirm",
55
- to: "phlex/reactive/confirm.js",
61
+ to: "phlex/reactive/confirm.min.js",
56
62
  preload: true
57
63
  )
58
64
  # The client-side compute (data-binding) registry behind
@@ -62,7 +68,7 @@ module Phlex
62
68
  # "phlex/reactive/compute"` — both resolve through this pin.
63
69
  it.importmap.pin(
64
70
  "phlex/reactive/compute",
65
- to: "phlex/reactive/compute.js",
71
+ to: "phlex/reactive/compute.min.js",
66
72
  preload: true
67
73
  )
68
74
  end
@@ -74,7 +80,7 @@ module Phlex
74
80
  # fresh after boot. See Streamable.reset_all_view_contexts!.
75
81
  config.to_prepare do
76
82
  Phlex::Reactive::Streamable.reset_all_view_contexts!
77
- Phlex::Reactive.reset_flash_builder!
83
+ Phlex::Reactive.reset_stream_builder!
78
84
  end
79
85
 
80
86
  # Boot-time guard (issue #26): warn if the action path doesn't resolve to
@@ -84,6 +90,18 @@ module Phlex
84
90
  # a one-line log pointing at the cause. No-op when the route is fine.
85
91
  config.after_initialize do
86
92
  Phlex::Reactive.warn_unless_action_route_mounted!
93
+
94
+ # Attach the opt-in LogSubscriber (issue #107) exactly once, only when
95
+ # the app enabled it. attach_to is idempotent-safe here because this runs
96
+ # once per boot; the events fire for APMs regardless of this flag.
97
+ Phlex::Reactive::LogSubscriber.attach_to(:phlex_reactive) if Phlex::Reactive.log_events
98
+
99
+ # Freeze the param-type registry (issue #109): custom types register in
100
+ # an initializer, which has run by now, so no further registration is
101
+ # accepted. A schema referencing a type is validated at declaration; the
102
+ # frozen registry makes runtime registration a loud error rather than a
103
+ # never-validated type. Idempotent.
104
+ Phlex::Reactive.freeze_param_types!
87
105
  end
88
106
  end
89
107
  end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # An immutable, chainable builder of client-side DOM commands (issue #95) —
6
+ # the ops behind Component#on_client. Each verb returns a NEW frozen
7
+ # instance; #to_json emits the wire format the generic controller's runOps
8
+ # action interprets, entirely in the browser:
9
+ #
10
+ # button(**on_client(:click, js.toggle("#menu"))) { "Menu" }
11
+ # # -> data-reactive-ops-param='[["toggle",{"to":"#menu"}]]'
12
+ #
13
+ # These are declarative DOM OPERATIONS, not state: nothing is shipped back
14
+ # to the server, nothing is trusted from the client, and any server
15
+ # re-render of the component resets whatever they toggled (the LiveView
16
+ # JS-commands caveat — by design; use a signed action for state that must
17
+ # survive re-renders).
18
+ #
19
+ # Targets: a CSS selector string is resolved WITHIN the component's root by
20
+ # default (nested reactive roots excluded, issue #15 semantics); `:root`
21
+ # targets the root element itself; `global: true` opts a single op out of
22
+ # root scoping (document escape hatch, e.g. a page-level overlay).
23
+ #
24
+ # The op vocabulary is a fixed whitelist mirrored by the client interpreter
25
+ # — an op name the client doesn't know is warn-and-skipped there
26
+ # (client-side default-deny). Validation here is deliberately LOUD: a bad
27
+ # target or an empty class list raises at render time rather than silently
28
+ # doing nothing in the browser.
29
+ class JS
30
+ # Serialized stand-in for "the component's own root element".
31
+ ROOT_SENTINEL = "@root"
32
+
33
+ # The attribute-name allowlist (issue #96) — the security-critical part of
34
+ # the attr ops, enforced HERE at build time AND again in the client
35
+ # interpreter (two-sided default-deny: a hand-built ops attr must not
36
+ # bypass it either). Rejected:
37
+ # * /\Aon/i — event-handler attributes (onclick, onmouseover) → XSS.
38
+ # * the URL set — href/src/srcdoc/action/formaction/xlink:href can carry
39
+ # a `javascript:` payload; setting them from client ops
40
+ # is a navigation/injection surface, not a UI toggle.
41
+ # * style — inline CSS injection; use classes (add_class/...).
42
+ # The INTENDED surface is class ops plus boolean/state attributes:
43
+ # hidden, disabled, open, selected, aria-*, data-*.
44
+ URL_BEARING_ATTRS = %w[href src srcdoc action formaction xlink:href].freeze
45
+ EVENT_HANDLER_ATTR = /\Aon/i
46
+
47
+ # The attr ops whose args carry a "name" the allowlist must gate. Used to
48
+ # re-validate a RAW [op, args] list (the js([...]) / broadcast_js_to([...])
49
+ # escape hatch) that skips the builder's build-time attr_args check.
50
+ ATTR_NAME_OPS = %w[set_attr remove_attr toggle_attr].freeze
51
+
52
+ # Validate a raw ops list ([[op, args], ...] as passed to js/broadcast_js_to
53
+ # without the builder) against the attribute allowlist, so the escape hatch
54
+ # gets the SAME server-side default-deny as the JS chain (defense in depth;
55
+ # the client also enforces it). Non-attr ops and malformed entries pass
56
+ # through untouched — the client interpreter default-denies unknown ops.
57
+ def self.assert_ops_allowed!(list)
58
+ Array(list).each do |op, args|
59
+ next unless ATTR_NAME_OPS.include?(op.to_s) && args.is_a?(::Hash)
60
+
61
+ name = args["name"] || args[:name]
62
+ assert_allowed_attr(name.to_s) if name
63
+ end
64
+ end
65
+
66
+ # The attribute-name allowlist (issue #96), case-insensitive. Refuses
67
+ # event-handler (on*), URL-bearing, and style attributes. Enforced at build
68
+ # time by the instance builder AND on the raw-list escape hatch — two-sided
69
+ # default-deny with the client interpreter.
70
+ def self.assert_allowed_attr(name)
71
+ lower = name.downcase
72
+ if name.match?(EVENT_HANDLER_ATTR)
73
+ raise ArgumentError,
74
+ "#{self}: attribute #{name.inspect} is an event handler (on*) — refused (XSS). " \
75
+ "Client attr ops target hidden/disabled/open/selected/aria-*/data-* and classes."
76
+ end
77
+ return unless URL_BEARING_ATTRS.include?(lower) || lower == "style"
78
+
79
+ raise ArgumentError,
80
+ "#{self}: attribute #{name.inspect} is refused — URL-bearing attributes " \
81
+ "(#{URL_BEARING_ATTRS.join(", ")}) and `style` can't be set from client ops " \
82
+ "(injection surface). Use classes for styling; target aria-*/data-*/boolean attrs."
83
+ end
84
+
85
+ # The accumulated [name, args] op pairs, oldest first. Frozen.
86
+ attr_reader :ops
87
+
88
+ def initialize(ops = [].freeze)
89
+ @ops = ops
90
+ freeze
91
+ end
92
+
93
+ # --- Visibility (the `hidden` attribute) ---
94
+ #
95
+ # `transition:` (issue #96) — an optional [during, from, to] class triple
96
+ # animated around the visibility flip: `during`+`from` are applied, then on
97
+ # the next frame `from`→`to` swaps, and the whole set is awaited via
98
+ # `animationend` (with a setTimeout fallback so a non-animated element never
99
+ # hangs the op chain). Omit it for the instant flip.
100
+
101
+ def show(to, global: false, transition: nil)
102
+ append("show", target_args(to, global:, transition:))
103
+ end
104
+
105
+ def hide(to, global: false, transition: nil)
106
+ append("hide", target_args(to, global:, transition:))
107
+ end
108
+
109
+ def toggle(to, global: false, transition: nil)
110
+ append("toggle", target_args(to, global:, transition:))
111
+ end
112
+
113
+ # --- Classes ---
114
+
115
+ def add_class(to, *classes, global: false)
116
+ append("add_class", class_args(to, classes, global:))
117
+ end
118
+
119
+ def remove_class(to, *classes, global: false)
120
+ append("remove_class", class_args(to, classes, global:))
121
+ end
122
+
123
+ def toggle_class(to, *classes, global: false)
124
+ append("toggle_class", class_args(to, classes, global:))
125
+ end
126
+
127
+ # --- Attributes (issue #96) — allowlisted names only ---
128
+ #
129
+ # set_attr(to, name, value) / remove_attr(to, name) / toggle_attr(to, name).
130
+ # The value is stringified (a Phlex-style flag rides as the string "true",
131
+ # never a valueless attribute). The name is checked against the allowlist at
132
+ # build time — an event-handler, URL-bearing, or style name raises here.
133
+
134
+ def set_attr(to, name, value, global: false)
135
+ append("set_attr", attr_args(to, name, global:, value:))
136
+ end
137
+
138
+ def remove_attr(to, name, global: false)
139
+ append("remove_attr", attr_args(to, name, global:))
140
+ end
141
+
142
+ def toggle_attr(to, name, global: false)
143
+ append("toggle_attr", attr_args(to, name, global:))
144
+ end
145
+
146
+ # --- Focus (issue #96) ---
147
+ #
148
+ # focus(to) — focus the FIRST match of the selector.
149
+ # focus_first(to) — focus the first FOCUSABLE DESCENDANT of the match
150
+ # (e.g. focus the first menuitem inside an opened menu).
151
+
152
+ def focus(to, global: false)
153
+ append("focus", target_args(to, global:))
154
+ end
155
+
156
+ def focus_first(to, global: false)
157
+ append("focus_first", target_args(to, global:))
158
+ end
159
+
160
+ # --- Dispatch a bubbling CustomEvent (issue #96) ---
161
+ #
162
+ # dispatch(name, to: nil, detail: {}) — emit a bubbling CustomEvent so other
163
+ # components/controllers can react to a client-only interaction without a
164
+ # round trip. `to:` picks the element to dispatch ON (nil → the component
165
+ # root, serialized as the @root sentinel so the client resolves it
166
+ # uniformly); `detail:` is the event's `detail` payload. The client uses raw
167
+ # element.dispatchEvent — the shared controller SHADOWS Stimulus's
168
+ # this.dispatch helper, so the interpreter must not use it.
169
+ def dispatch(name, to: nil, detail: {}, global: false)
170
+ args = { "name" => name.to_s, "to" => normalize_target(to.nil? ? :root : to), "detail" => detail }
171
+ args["global"] = true if global
172
+ append("dispatch", args.freeze)
173
+ end
174
+
175
+ # The wire format: a JSON array of [op, args] pairs, applied in order.
176
+ def to_json(*)
177
+ @ops.to_json
178
+ end
179
+
180
+ def empty?
181
+ @ops.empty?
182
+ end
183
+
184
+ private
185
+
186
+ # Immutability: every verb funnels here and returns a NEW frozen chain —
187
+ # a builder held in a constant or memo can never be mutated by later use.
188
+ def append(name, args)
189
+ self.class.new([*@ops, [name, args].freeze].freeze)
190
+ end
191
+
192
+ def target_args(to, global:, transition: nil)
193
+ args = { "to" => normalize_target(to) }
194
+ args["global"] = true if global
195
+ args["transition"] = normalize_transition(transition) if transition
196
+ args.freeze
197
+ end
198
+
199
+ # An attr op's args: the target, the allowlisted name, an optional
200
+ # (stringified) value. The name is validated LOUDLY at build time.
201
+ def attr_args(to, name, global:, value: :__none)
202
+ name = name.to_s
203
+ assert_allowed_attr(name)
204
+ args = { "to" => normalize_target(to), "name" => name }
205
+ args["value"] = value.to_s unless value == :__none
206
+ args["global"] = true if global
207
+ args.freeze
208
+ end
209
+
210
+ # Build-time attr-name allowlist for the instance builder — delegates to the
211
+ # shared class-method check (also used by the raw-list escape hatch).
212
+ def assert_allowed_attr(name)
213
+ self.class.assert_allowed_attr(name)
214
+ end
215
+
216
+ # A transition must be exactly [during, from, to] class lists (strings).
217
+ def normalize_transition(transition)
218
+ list = Array(transition)
219
+ unless list.size == 3
220
+ raise ArgumentError,
221
+ "#{self.class}: transition: must be [during, from, to] class lists, got #{transition.inspect}"
222
+ end
223
+
224
+ list.map(&:to_s).freeze
225
+ end
226
+
227
+ def class_args(to, classes, global:)
228
+ if classes.empty?
229
+ raise ArgumentError, "#{self.class}: a class op needs at least one class (got none for #{to.inspect})"
230
+ end
231
+
232
+ args = { "to" => normalize_target(to), "classes" => classes.map(&:to_s).freeze }
233
+ args["global"] = true if global
234
+ args.freeze
235
+ end
236
+
237
+ # :root -> the sentinel; a String passes through as a CSS selector.
238
+ # Anything else (a stray symbol, nil) is a bug at the call site — raise at
239
+ # render time instead of silently matching nothing in the browser.
240
+ def normalize_target(to)
241
+ return ROOT_SENTINEL if to == :root
242
+ return to if to.is_a?(String)
243
+
244
+ raise ArgumentError,
245
+ "#{self.class}: target must be :root or a CSS selector string, got #{to.inspect}"
246
+ end
247
+ end
248
+ end
249
+ end