dommy 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. metadata +12 -3
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Bridge
5
+ # Maps JS global constructor names (e.g. "Event", "URL", "XMLHttpRequest")
6
+ # to their `Bridge::Constructor` instances. Window builds one and routes
7
+ # `__js_get__` name lookups through it, instead of carrying one ivar plus
8
+ # one `when` arm per constructor.
9
+ class ConstructorRegistry
10
+ def initialize(map)
11
+ @map = map.freeze
12
+ end
13
+
14
+ def [](name)
15
+ @map[name]
16
+ end
17
+
18
+ def key?(name)
19
+ @map.key?(name)
20
+ end
21
+
22
+ # The set of constructor names, e.g. for host-side enumeration / tests.
23
+ def names
24
+ @map.keys
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Bridge
5
+ # Declares, in one line, the set of JS-callable method names a bridge class
6
+ # routes through `__js_call__` (as opposed to data properties read via
7
+ # `__js_get__`). The QuickJS host reads `__js_method_names__` once per
8
+ # interface to decide which property names to expose as callable functions.
9
+ #
10
+ # class Blob
11
+ # include Bridge::Methods
12
+ # js_methods %w[slice text arrayBuffer]
13
+ # def __js_call__(method, args) = ...
14
+ # end
15
+ #
16
+ # Subclasses compose automatically: a subclass's own `js_methods` are merged
17
+ # with its ancestors' (ancestors first), so `__js_call__ ... else super`
18
+ # chains stay in sync with the exposed names without a manual `super + own`.
19
+ #
20
+ # The per-class `JS_METHOD_NAMES` constant holds the class's OWN names; the
21
+ # suite asserts it matches the class's own `__js_call__` `when` arms — see
22
+ # test/test_js_call_dispatch_invariant.rb.
23
+ module Methods
24
+ def self.included(base)
25
+ base.extend(ClassMethods)
26
+ end
27
+
28
+ module ClassMethods
29
+ # `extend` is per-singleton, so a subclass of an includer would not
30
+ # inherit `js_methods`. Re-extend each subclass as it is defined.
31
+ def inherited(subclass)
32
+ super
33
+ subclass.extend(ClassMethods)
34
+ end
35
+
36
+ def js_methods(names)
37
+ own = names.map(&:to_s).freeze
38
+ const_set(:JS_METHOD_NAMES, own) unless const_defined?(:JS_METHOD_NAMES, false)
39
+
40
+ # Capture the ancestor's __js_method_names__ as a real method (if any)
41
+ # at definition time. We can't use `super` here: classes like
42
+ # StyleDeclaration define `method_missing`, so `super` would fall
43
+ # through to it and return a CSS-property String instead of raising.
44
+ parent =
45
+ if superclass.method_defined?(:__js_method_names__)
46
+ superclass.instance_method(:__js_method_names__)
47
+ end
48
+
49
+ define_method(:__js_method_names__) do
50
+ base = parent ? parent.bind(self).call : []
51
+ (base + own).uniq.freeze
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
data/lib/dommy/bridge.rb CHANGED
@@ -18,6 +18,97 @@ module Dommy
18
18
  # args (Array)
19
19
  # - `__js_new__(args)` invokes the value as a JS constructor
20
20
  module Bridge
21
+ # Sentinel returned by `__js_set__` when a key is not a known DOM property
22
+ # (so the JS host can keep it as a JS-side expando, preserving identity)
23
+ # rather than silently dropping it.
24
+ UNHANDLED = :__js_unhandled__
25
+
26
+ # Sentinel for the JS `undefined` value, used in both directions:
27
+ # - a `__js_call__` returns it for a void (undefined-returning) op, so the
28
+ # host marshals JS `undefined` rather than the `null` a bare Ruby `nil`
29
+ # would (e.g. DOMTokenList add/remove return undefined);
30
+ # - a top-level JS `undefined` *argument* arrives as it (whereas JS `null`
31
+ # arrives as `nil`), so WebIDL-style dispatch can tell an omitted optional
32
+ # argument from an explicit null.
33
+ # Its `to_s` is "undefined" so a DOMString coercion of a stray undefined is
34
+ # still spec-faithful.
35
+ UNDEFINED = Object.new
36
+ def UNDEFINED.to_s = "undefined"
37
+ def UNDEFINED.inspect = "#<Dommy::Bridge::UNDEFINED>"
38
+ UNDEFINED.freeze
39
+
40
+ # An opaque handle to a JS-side value that Ruby only stores and hands back
41
+ # (an AbortSignal's reason, a CustomEvent's detail). A non-plain JS object
42
+ # (Error, class instance, …) crosses as one of these instead of being
43
+ # flattened to a Hash, so it round-trips with IDENTITY preserved. `to_s`
44
+ # exposes the captured JS string form for the rare Ruby consumer that needs
45
+ # text (e.g. building a message).
46
+ class JSValue
47
+ attr_reader :ref
48
+
49
+ def initialize(ref, label = nil)
50
+ @ref = ref
51
+ @label = label
52
+ end
53
+
54
+ def to_s = (@label || "[object]").to_s
55
+ def inspect = "#<Dommy::Bridge::JSValue #{to_s}>"
56
+ end
57
+
58
+ # Raised by a host method that must throw an ARBITRARY value back to JS —
59
+ # not a DOMException/Error, but e.g. `signal.throwIfAborted()` throwing the
60
+ # exact abort reason (a string, number, or opaque JSValue). The bridge
61
+ # re-throws the wrapped value verbatim (identity preserved). Subclasses
62
+ # RuntimeError (with the value's string form as the message) so standalone
63
+ # CRuby callers still see a normal `raise`-able error.
64
+ class ThrowValue < RuntimeError
65
+ attr_reader :value
66
+
67
+ def initialize(value)
68
+ @value = value
69
+ super(value.to_s)
70
+ end
71
+ end
72
+
73
+ # A byte buffer that crosses the JS boundary as a `Uint8Array` (rather than a
74
+ # plain Array). Wrap a host method's byte-array result in this so JS sees a
75
+ # real typed array — e.g. `TextEncoder#encode`, `Blob#arrayBuffer`. The
76
+ # reverse direction (a JS ArrayBuffer/TypedArray argument) arrives as a
77
+ # `Bytes` too. It subclasses Array so plain-Array callers (and `== [..]`
78
+ # comparisons) keep working; only the bridge treats it specially.
79
+ class Bytes < ::Array
80
+ def initialize(bytes = [])
81
+ super()
82
+ concat(Array(bytes).map { |b| b.to_i & 0xFF })
83
+ end
84
+
85
+ alias bytes to_a
86
+ def pack_bytes = pack("C*")
87
+ end
88
+
89
+ # Like `Bytes`, but crosses the JS boundary as a bare `ArrayBuffer` rather
90
+ # than a `Uint8Array` view. Use this for the spec methods whose return type
91
+ # is `ArrayBuffer` — `Response`/`Blob`/`FileReader`/`XMLHttpRequest`'s
92
+ # `arrayBuffer`. Subclasses `Bytes` so Ruby-level `== [..]` comparisons still
93
+ # hold; only the bridge distinguishes it (and must check it before `Bytes`).
94
+ class ArrayBuffer < Bytes
95
+ end
96
+
97
+ # A Ruby-side signal that the JS boundary should surface a JS `TypeError`
98
+ # (not a `DOMException`). Some WebIDL operations — notably the `URL`
99
+ # constructor and its `href` setter — throw `TypeError` on failure rather
100
+ # than a DOMException; raising this lets a host bridge rethrow the correct
101
+ # JS error type, while Ruby callers can still rescue it like any other
102
+ # error. Kept distinct from Ruby's built-in `::TypeError` so a host can map
103
+ # only deliberate, spec-mandated TypeErrors (and not mask genuine Ruby type
104
+ # bugs) across the boundary.
105
+ class TypeError < ::StandardError; end
106
+
107
+ # Like `Bridge::TypeError`, but for spec-mandated `RangeError`s (e.g. the
108
+ # `Response` constructor rejecting a status outside 200–599). A host bridge
109
+ # rethrows a real JS `RangeError`; kept distinct from Ruby's `::RangeError`.
110
+ class RangeError < ::StandardError; end
111
+
21
112
  # Wraps an external callback handle (registered in a host-side
22
113
  # callback table) so the JS bridge can resolve / invoke it. The
23
114
  # external host that creates these is responsible for honoring
@@ -83,6 +174,12 @@ module Dommy
83
174
  handler = @class_methods[method.to_s]
84
175
  handler&.call(args)
85
176
  end
177
+
178
+ # Names of the registered class-level (static) methods, so a JS host can
179
+ # expose them on the constructor function (e.g. `URL.createObjectURL`).
180
+ def __js_class_method_names__
181
+ @class_methods.keys
182
+ end
86
183
  end
87
184
 
88
185
  # `JS.global[:Promise]` view. Implements the `resolve` / `reject`
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # Invokes a callback that may be a JS-bridged object (responds to `__js_call__`)
5
+ # or a plain Ruby callable (responds to `call`). Centralizes the dispatch used
6
+ # by promises, the scheduler, and streams so the JS/Ruby fork lives in one place.
7
+ module CallableInvoker
8
+ module_function
9
+
10
+ # Invoke `callback` with `args`. A JS-bridged callable goes through
11
+ # `__js_call__("call", args)`; a Ruby callable through `call(*args)`. A nil
12
+ # or non-callable callback is a no-op (returns nil).
13
+ def invoke(callback, *args)
14
+ return if callback.nil?
15
+
16
+ if callback.respond_to?(:__js_call__)
17
+ callback.__js_call__("call", args)
18
+ elsif callback.respond_to?(:call)
19
+ callback.call(*args)
20
+ end
21
+ end
22
+
23
+ # Invoke a DOM event listener per the EventTarget rule: an object with
24
+ # `handle_event`, else a Ruby callable, else a JS-bridged callable (tried in
25
+ # that order).
26
+ def invoke_listener(listener, event)
27
+ if listener.respond_to?(:handle_event)
28
+ listener.handle_event(event)
29
+ elsif listener.respond_to?(:call) && !listener.is_a?(Module)
30
+ listener.call(event)
31
+ elsif listener.respond_to?(:__js_call__)
32
+ listener.__js_call__("call", [event])
33
+ end
34
+ end
35
+ end
36
+ end
@@ -60,6 +60,8 @@ module Dommy
60
60
  PromiseValue.resolve(@window, nil)
61
61
  end
62
62
 
63
+ include Bridge::Methods
64
+ js_methods %w[get getAll set delete addEventListener removeEventListener dispatchEvent]
63
65
  def __js_call__(method, args)
64
66
  case method
65
67
  when "get"
@@ -73,7 +75,7 @@ module Dommy
73
75
  when "addEventListener"
74
76
  add_event_listener(args[0], args[1], args[2])
75
77
  when "removeEventListener"
76
- remove_event_listener(args[0], args[1])
78
+ remove_event_listener(args[0], args[1], args[2])
77
79
  when "dispatchEvent"
78
80
  dispatch_event(args[0])
79
81
  end
data/lib/dommy/crypto.rb CHANGED
@@ -62,6 +62,8 @@ module Dommy
62
62
  end
63
63
  end
64
64
 
65
+ include Bridge::Methods
66
+ js_methods %w[randomUUID getRandomValues]
65
67
  def __js_call__(method, args)
66
68
  case method
67
69
  when "randomUUID"
@@ -99,7 +101,9 @@ module Dommy
99
101
  hasher = ALGORITHMS[name]
100
102
  raise ArgumentError, "unsupported algorithm: #{name}" unless hasher
101
103
 
102
- hasher.call(coerce_bytes(data)).bytes
104
+ # WHATWG: digest() resolves to an ArrayBuffer — wrap so it crosses the
105
+ # JS boundary as a bare ArrayBuffer (not a plain Array).
106
+ Bridge::ArrayBuffer.new(hasher.call(coerce_bytes(data)).bytes)
103
107
  end
104
108
  end
105
109
 
@@ -230,6 +234,8 @@ module Dommy
230
234
  end
231
235
  end
232
236
 
237
+ include Bridge::Methods
238
+ js_methods %w[digest generateKey importKey sign verify encrypt decrypt]
233
239
  def __js_call__(method, args)
234
240
  case method
235
241
  when "digest"
data/lib/dommy/css.rb CHANGED
@@ -125,6 +125,8 @@ module Dommy
125
125
  nil
126
126
  end
127
127
 
128
+ include Bridge::Methods
129
+ js_methods %w[insertRule deleteRule replaceSync replace]
128
130
  def __js_call__(method, args)
129
131
  case method
130
132
  when "insertRule"
@@ -196,6 +198,8 @@ module Dommy
196
198
  end
197
199
  end
198
200
 
201
+ include Bridge::Methods
202
+ js_methods %w[item]
199
203
  def __js_call__(method, args)
200
204
  case method
201
205
  when "item"
@@ -280,4 +284,46 @@ module Dommy
280
284
  nil
281
285
  end
282
286
  end
287
+
288
+ # `window.CSS` namespace object — `escape()` for safe selector building
289
+ # (used by Turbo and friends) and a `supports()` stub (no CSS engine).
290
+ class CSSNamespace
291
+ def __js_get__(_key) = nil
292
+ def __js_set__(_key, _value) = Bridge::UNHANDLED
293
+
294
+ include Bridge::Methods
295
+ js_methods %w[escape supports]
296
+ def __js_call__(method, args)
297
+ case method
298
+ when "escape"
299
+ self.class.escape(args[0])
300
+ when "supports"
301
+ false
302
+ end
303
+ end
304
+
305
+ # CSSOM `CSS.escape` — escape a string for use as an identifier in a
306
+ # selector. Follows the spec's char rules closely enough for selectors.
307
+ def self.escape(value)
308
+ str = value.to_s
309
+ out = +""
310
+ str.each_char.with_index do |ch, i|
311
+ code = ch.ord
312
+ if code.zero?
313
+ out << "\uFFFD"
314
+ elsif (code >= 0x01 && code <= 0x1F) || code == 0x7F ||
315
+ (i.zero? && code >= 0x30 && code <= 0x39) ||
316
+ (i == 1 && code >= 0x30 && code <= 0x39 && str[0] == "-")
317
+ out << "\\#{code.to_s(16)} "
318
+ elsif code >= 0x80 || code == 0x2D || code == 0x5F ||
319
+ (code >= 0x30 && code <= 0x39) ||
320
+ (code >= 0x41 && code <= 0x5A) || (code >= 0x61 && code <= 0x7A)
321
+ out << ch
322
+ else
323
+ out << "\\#{ch}"
324
+ end
325
+ end
326
+ out
327
+ end
328
+ end
283
329
  end
@@ -10,7 +10,21 @@ module Dommy
10
10
  #
11
11
  # Names must contain a hyphen per the HTML spec (e.g., `my-button`).
12
12
  class CustomElementRegistry
13
- NAME_RE = /\A[a-z][a-z0-9-]*-[a-z0-9-]*\z/
13
+ # https://html.spec.whatwg.org/#valid-custom-element-name
14
+ # PCENChar — the characters allowed after the first (ASCII-lower) char: a
15
+ # superset of [-._0-9a-z] plus wide Unicode ranges. A valid name is
16
+ # `[a-z] PCENChar* - PCENChar*` (i.e. lower-alpha start + at least one "-").
17
+ PCEN = "\\-._0-9a-z\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D" \
18
+ "\\u037F-\\u1FFF\\u200C-\\u200D\\u203F-\\u2040\\u2070-\\u218F" \
19
+ "\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\u{10000}-\\u{EFFFF}"
20
+ NAME_RE = Regexp.new("\\A[a-z][#{PCEN}]*-[#{PCEN}]*\\z")
21
+
22
+ # Hyphenated names that the HTML spec reserves (SVG / MathML elements), so
23
+ # they are NOT valid custom element names even though they match NAME_RE.
24
+ RESERVED_NAMES = %w[
25
+ annotation-xml color-profile font-face font-face-src font-face-uri
26
+ font-face-format font-face-name missing-glyph
27
+ ].to_set.freeze
14
28
 
15
29
  def initialize(window)
16
30
  @window = window
@@ -23,7 +37,10 @@ module Dommy
23
37
  def define(name, klass, _options = nil)
24
38
  key = name.to_s
25
39
  unless key.match?(NAME_RE)
26
- raise DOMException::SyntaxError, "name must be a hyphenated string, got #{name.inspect}"
40
+ raise DOMException::SyntaxError, "#{name.inspect} is not a valid custom element name"
41
+ end
42
+ if RESERVED_NAMES.include?(key)
43
+ raise DOMException::SyntaxError, "#{name.inspect} is a reserved element name"
27
44
  end
28
45
 
29
46
  raise DOMException::NotSupportedError, "#{key} already defined" if @definitions.key?(key)
@@ -83,6 +100,8 @@ module Dommy
83
100
  nil
84
101
  end
85
102
 
103
+ include Bridge::Methods
104
+ js_methods %w[define get whenDefined upgrade]
86
105
  def __js_call__(method, args)
87
106
  case method
88
107
  when "define"
@@ -108,7 +127,12 @@ module Dommy
108
127
  # new class and fire connectedCallback.
109
128
  def upgrade_existing(name)
110
129
  doc = @window.document
111
- doc.nokogiri_doc.css(name).each do |nk|
130
+ # Match by tag name rather than interpolating `name` into a CSS selector:
131
+ # a spec-valid custom element name may contain "." (a CSS class selector
132
+ # char) or other metacharacters, which would corrupt the query.
133
+ doc.nokogiri_doc.css("*").each do |nk|
134
+ next unless nk.name == name
135
+
112
136
  doc.__internal_reset_wrapper__(nk)
113
137
  wrapped = doc.wrap_node(nk)
114
138
  doc.__internal_notify_connected__(wrapped) if wrapped
@@ -64,11 +64,15 @@ module Dommy
64
64
  @drop_effect = value.to_s
65
65
  when "effectAllowed"
66
66
  @effect_allowed = value.to_s
67
+ else
68
+ return Bridge::UNHANDLED
67
69
  end
68
70
 
69
71
  nil
70
72
  end
71
73
 
74
+ include Bridge::Methods
75
+ js_methods %w[getData setData clearData]
72
76
  def __js_call__(method, args)
73
77
  case method
74
78
  when "getData"