dommy 0.6.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -38
  3. data/lib/dommy/animation.rb +10 -2
  4. data/lib/dommy/attr.rb +197 -32
  5. data/lib/dommy/backend/nokogiri_adapter.rb +127 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +117 -0
  7. data/lib/dommy/backend.rb +175 -0
  8. data/lib/dommy/blob.rb +30 -11
  9. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  10. data/lib/dommy/bridge/methods.rb +57 -0
  11. data/lib/dommy/bridge.rb +97 -0
  12. data/lib/dommy/callable_invoker.rb +36 -0
  13. data/lib/dommy/compression_streams.rb +4 -4
  14. data/lib/dommy/cookie_store.rb +4 -2
  15. data/lib/dommy/crypto.rb +16 -9
  16. data/lib/dommy/css.rb +53 -7
  17. data/lib/dommy/custom_elements.rb +33 -9
  18. data/lib/dommy/data_transfer.rb +4 -0
  19. data/lib/dommy/document.rb +693 -60
  20. data/lib/dommy/dom_parser.rb +29 -15
  21. data/lib/dommy/element.rb +1147 -438
  22. data/lib/dommy/event.rb +279 -79
  23. data/lib/dommy/event_source.rb +14 -10
  24. data/lib/dommy/fetch.rb +509 -39
  25. data/lib/dommy/file_reader.rb +14 -6
  26. data/lib/dommy/form_data.rb +3 -3
  27. data/lib/dommy/history.rb +46 -8
  28. data/lib/dommy/html_collection.rb +59 -6
  29. data/lib/dommy/html_elements.rb +153 -1502
  30. data/lib/dommy/internal/css_pseudo_handlers.rb +137 -0
  31. data/lib/dommy/internal/dom_matching.rb +3 -3
  32. data/lib/dommy/internal/global_functions.rb +26 -0
  33. data/lib/dommy/internal/idna.rb +16 -7
  34. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  35. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  36. data/lib/dommy/internal/namespaces.rb +70 -0
  37. data/lib/dommy/internal/node_equality.rb +86 -0
  38. data/lib/dommy/internal/node_traversal.rb +1 -1
  39. data/lib/dommy/internal/node_wrapper_cache.rb +77 -31
  40. data/lib/dommy/internal/observable_callback.rb +1 -5
  41. data/lib/dommy/internal/parent_node.rb +126 -0
  42. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  43. data/lib/dommy/internal/selector_parser.rb +664 -0
  44. data/lib/dommy/internal/template_content_registry.rb +6 -6
  45. data/lib/dommy/internal/url_parser.rb +677 -0
  46. data/lib/dommy/intersection_observer.rb +4 -2
  47. data/lib/dommy/location.rb +10 -4
  48. data/lib/dommy/media_query_list.rb +10 -4
  49. data/lib/dommy/message_channel.rb +41 -11
  50. data/lib/dommy/mutation_observer.rb +76 -23
  51. data/lib/dommy/navigator.rb +38 -24
  52. data/lib/dommy/node.rb +158 -16
  53. data/lib/dommy/notification.rb +6 -4
  54. data/lib/dommy/parser.rb +13 -13
  55. data/lib/dommy/performance.rb +4 -0
  56. data/lib/dommy/performance_observer.rb +4 -2
  57. data/lib/dommy/promise.rb +14 -14
  58. data/lib/dommy/range.rb +74 -5
  59. data/lib/dommy/resize_observer.rb +4 -2
  60. data/lib/dommy/scheduler.rb +34 -13
  61. data/lib/dommy/shadow_root.rb +31 -60
  62. data/lib/dommy/storage.rb +2 -0
  63. data/lib/dommy/streams.rb +40 -49
  64. data/lib/dommy/svg_elements.rb +204 -3606
  65. data/lib/dommy/text_codec.rb +178 -25
  66. data/lib/dommy/tree_walker.rb +270 -81
  67. data/lib/dommy/url.rb +305 -450
  68. data/lib/dommy/url_pattern.rb +2 -0
  69. data/lib/dommy/version.rb +1 -1
  70. data/lib/dommy/web_socket.rb +49 -19
  71. data/lib/dommy/window.rb +205 -203
  72. data/lib/dommy/worker.rb +12 -12
  73. data/lib/dommy/xml_http_request.rb +32 -7
  74. data/lib/dommy.rb +19 -2
  75. metadata +22 -27
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
 
@@ -182,7 +186,7 @@ module Dommy
182
186
  raise ArgumentError, "HMAC key required" unless key.is_a?(CryptoKey) && key.algorithm_name == "HMAC"
183
187
  raise ArgumentError, "key.usages must include 'sign'" unless key.usages.include?("sign")
184
188
 
185
- OpenSSL::HMAC.digest(openssl_digest_name(key.hash_name), key.__bytes__, coerce_bytes(data)).bytes
189
+ OpenSSL::HMAC.digest(openssl_digest_name(key.hash_name), key.__dommy_bytes__, coerce_bytes(data)).bytes
186
190
  end
187
191
  end
188
192
 
@@ -192,7 +196,7 @@ module Dommy
192
196
  raise ArgumentError, "HMAC key required" unless key.is_a?(CryptoKey) && key.algorithm_name == "HMAC"
193
197
  raise ArgumentError, "key.usages must include 'verify'" unless key.usages.include?("verify")
194
198
 
195
- expected = OpenSSL::HMAC.digest(openssl_digest_name(key.hash_name), key.__bytes__, coerce_bytes(data))
199
+ expected = OpenSSL::HMAC.digest(openssl_digest_name(key.hash_name), key.__dommy_bytes__, coerce_bytes(data))
196
200
  sig_bytes = coerce_bytes(signature)
197
201
  if expected.bytesize == sig_bytes.bytesize
198
202
  OpenSSL.fixed_length_secure_compare(expected, sig_bytes)
@@ -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"
@@ -327,7 +333,7 @@ module Dommy
327
333
  end
328
334
 
329
335
  def build_gcm_cipher(direction, algorithm, key)
330
- raw_key = key.is_a?(CryptoKey) ? key.__bytes__ : coerce_bytes(key)
336
+ raw_key = key.is_a?(CryptoKey) ? key.__dommy_bytes__ : coerce_bytes(key)
331
337
  raise ArgumentError, "AES-GCM key must be 16/24/32 bytes" unless [16, 24, 32].include?(raw_key.bytesize)
332
338
 
333
339
  iv = algorithm.is_a?(Hash) ? (algorithm["iv"] || algorithm[:iv]) : nil
@@ -359,9 +365,9 @@ module Dommy
359
365
  end
360
366
 
361
367
  # `CryptoKey` — opaque key handle returned by SubtleCrypto.
362
- # `extractable: false` keys reject export attempts; the
363
- # `__bytes__` accessor stays internal-only (`__double_underscore__`
364
- # convention) so production code paths can't read the raw bytes.
368
+ # `extractable: false` keys reject export attempts; the raw bytes are
369
+ # reachable only through the `__dommy_bytes__` ecosystem accessor, never
370
+ # the public (Web-mirroring) API.
365
371
  class CryptoKey
366
372
  attr_reader :type, :algorithm_name, :hash_name, :usages, :extractable
367
373
 
@@ -374,8 +380,9 @@ module Dommy
374
380
  @usages = usages.map(&:to_s).freeze
375
381
  end
376
382
 
377
- # Test / internal seam production callers should not reach in.
378
- def __bytes__
383
+ # Low-level ecosystem accessor (see __dommy_ convention) the public
384
+ # Web API never exposes raw key bytes.
385
+ def __dommy_bytes__
379
386
  @bytes
380
387
  end
381
388
 
data/lib/dommy/css.rb CHANGED
@@ -63,7 +63,7 @@ module Dommy
63
63
  idx = index.nil? ? @css_rules.length : index.to_i
64
64
  raise DOMException::IndexSizeError, "out of range" if idx < 0 || idx > @css_rules.length
65
65
 
66
- @css_rules.__insert__(idx, CSSRule.new(rule_text.to_s, self))
66
+ @css_rules.__internal_insert__(idx, CSSRule.new(rule_text.to_s, self))
67
67
  idx
68
68
  end
69
69
 
@@ -71,17 +71,17 @@ module Dommy
71
71
  idx = index.to_i
72
72
  raise DOMException::IndexSizeError, "out of range" if idx < 0 || idx >= @css_rules.length
73
73
 
74
- @css_rules.__delete_at__(idx)
74
+ @css_rules.__internal_delete_at__(idx)
75
75
  nil
76
76
  end
77
77
 
78
78
  # `replaceSync(text)` — replace all rules with a single rule blob
79
79
  # (no parsing — we keep it as one opaque entry).
80
80
  def replace_sync(text)
81
- @css_rules.__clear__
81
+ @css_rules.__internal_clear__
82
82
  return nil if text.to_s.empty?
83
83
 
84
- @css_rules.__insert__(0, CSSRule.new(text.to_s, self))
84
+ @css_rules.__internal_insert__(0, CSSRule.new(text.to_s, self))
85
85
  nil
86
86
  end
87
87
 
@@ -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"
@@ -173,15 +175,15 @@ module Dommy
173
175
  @rules.dup
174
176
  end
175
177
 
176
- def __insert__(index, rule)
178
+ def __internal_insert__(index, rule)
177
179
  @rules.insert(index, rule)
178
180
  end
179
181
 
180
- def __delete_at__(index)
182
+ def __internal_delete_at__(index)
181
183
  @rules.delete_at(index)
182
184
  end
183
185
 
184
- def __clear__
186
+ def __internal_clear__
185
187
  @rules.clear
186
188
  end
187
189
 
@@ -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)
@@ -64,16 +81,16 @@ module Dommy
64
81
  # registered; fires `connectedCallback` for each upgraded node
65
82
  # that's currently attached to a document tree.
66
83
  def upgrade(root)
67
- return nil unless root.respond_to?(:__node__)
84
+ return nil unless root.respond_to?(:__dommy_backend_node__)
68
85
 
69
- walk_descendants(root.__node__) do |nk|
86
+ walk_descendants(root.__dommy_backend_node__) do |nk|
70
87
  next unless nk.element?
71
88
  next unless @definitions.key?(nk.name)
72
89
 
73
90
  # Force re-wrap by clearing the document's cached wrapper.
74
- @window.document.__reset_wrapper__(nk)
91
+ @window.document.__internal_reset_wrapper__(nk)
75
92
  wrapped = @window.document.wrap_node(nk)
76
- @window.document.__notify_connected__(wrapped) if wrapped
93
+ @window.document.__internal_notify_connected__(wrapped) if wrapped
77
94
  end
78
95
 
79
96
  nil
@@ -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,10 +127,15 @@ 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|
112
- doc.__reset_wrapper__(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
+
136
+ doc.__internal_reset_wrapper__(nk)
113
137
  wrapped = doc.wrap_node(nk)
114
- doc.__notify_connected__(wrapped) if wrapped
138
+ doc.__internal_notify_connected__(wrapped) if wrapped
115
139
  end
116
140
  end
117
141
 
@@ -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"