dommy 0.5.0 → 0.7.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -13
  3. data/lib/dommy/animation.rb +288 -0
  4. data/lib/dommy/attr.rb +23 -11
  5. data/lib/dommy/backend/nokogiri_adapter.rb +51 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +80 -0
  7. data/lib/dommy/backend.rb +129 -0
  8. data/lib/dommy/blob.rb +2 -2
  9. data/lib/dommy/compression_streams.rb +147 -0
  10. data/lib/dommy/cookie_store.rb +128 -0
  11. data/lib/dommy/crypto.rb +396 -0
  12. data/lib/dommy/css.rb +7 -7
  13. data/lib/dommy/custom_elements.rb +6 -6
  14. data/lib/dommy/document.rb +190 -32
  15. data/lib/dommy/dom_parser.rb +5 -4
  16. data/lib/dommy/element.rb +356 -53
  17. data/lib/dommy/event.rb +431 -25
  18. data/lib/dommy/event_source.rb +131 -0
  19. data/lib/dommy/fetch.rb +76 -6
  20. data/lib/dommy/file_reader.rb +176 -0
  21. data/lib/dommy/form_data.rb +1 -3
  22. data/lib/dommy/history.rb +82 -0
  23. data/lib/dommy/html_collection.rb +4 -4
  24. data/lib/dommy/html_elements.rb +130 -67
  25. data/lib/dommy/internal/cookie_jar.rb +2 -0
  26. data/lib/dommy/internal/css_pseudo_handlers.rb +28 -0
  27. data/lib/dommy/internal/dom_matching.rb +4 -4
  28. data/lib/dommy/internal/idna.rb +443 -0
  29. data/lib/dommy/internal/idna_data.rb +10379 -0
  30. data/lib/dommy/internal/ipv4_parser.rb +78 -0
  31. data/lib/dommy/internal/node_traversal.rb +1 -1
  32. data/lib/dommy/internal/node_wrapper_cache.rb +23 -12
  33. data/lib/dommy/internal/observable_callback.rb +25 -0
  34. data/lib/dommy/internal/punycode.rb +202 -0
  35. data/lib/dommy/internal/range_text_serializer.rb +72 -0
  36. data/lib/dommy/internal/reflected_attributes.rb +45 -0
  37. data/lib/dommy/internal/template_content_registry.rb +6 -6
  38. data/lib/dommy/intersection_observer.rb +82 -0
  39. data/lib/dommy/{router.rb → location.rb} +8 -142
  40. data/lib/dommy/media_query_list.rb +118 -0
  41. data/lib/dommy/message_channel.rb +249 -0
  42. data/lib/dommy/{observer.rb → mutation_observer.rb} +21 -11
  43. data/lib/dommy/navigator.rb +365 -5
  44. data/lib/dommy/node.rb +12 -0
  45. data/lib/dommy/notification.rb +89 -0
  46. data/lib/dommy/parser.rb +13 -13
  47. data/lib/dommy/performance.rb +146 -0
  48. data/lib/dommy/performance_observer.rb +55 -0
  49. data/lib/dommy/range.rb +597 -0
  50. data/lib/dommy/resize_observer.rb +53 -0
  51. data/lib/dommy/shadow_root.rb +10 -8
  52. data/lib/dommy/streams.rb +386 -0
  53. data/lib/dommy/svg_elements.rb +3863 -0
  54. data/lib/dommy/text_codec.rb +175 -0
  55. data/lib/dommy/tree_walker.rb +21 -21
  56. data/lib/dommy/url.rb +274 -29
  57. data/lib/dommy/url_pattern.rb +144 -0
  58. data/lib/dommy/version.rb +1 -1
  59. data/lib/dommy/web_socket.rb +209 -0
  60. data/lib/dommy/window.rb +369 -0
  61. data/lib/dommy/worker.rb +143 -0
  62. data/lib/dommy/xml_http_request.rb +438 -0
  63. data/lib/dommy.rb +43 -5
  64. metadata +44 -29
  65. data/lib/dommy/world.rb +0 -209
@@ -5,7 +5,7 @@ require "uri"
5
5
  module Dommy
6
6
  # `window.location` polyfill. The Window owns one Location and one
7
7
  # History instance, and they share the same underlying state. Hash
8
- # / pushState / replaceState all flow through `__set_url__`.
8
+ # / pushState / replaceState all flow through `__internal_set_url__`.
9
9
  class Location
10
10
  def initialize(window, origin: "http://localhost", pathname: "/", search: "", hash: "")
11
11
  @window = window
@@ -41,7 +41,7 @@ module Dommy
41
41
  def __js_set__(key, value)
42
42
  case key
43
43
  when "href"
44
- __set_url__(value.to_s)
44
+ __internal_set_url__(value.to_s)
45
45
  when "hash"
46
46
  new_hash = value.to_s
47
47
  new_hash = "##{new_hash}" unless new_hash.empty? || new_hash.start_with?("#")
@@ -68,7 +68,7 @@ module Dommy
68
68
  def __js_call__(method, args)
69
69
  case method
70
70
  when "assign", "replace"
71
- __set_url__(args[0].to_s)
71
+ __internal_set_url__(args[0].to_s)
72
72
  when "reload"
73
73
  nil
74
74
  when "toString"
@@ -83,12 +83,16 @@ module Dommy
83
83
  # Internal — accepts an absolute or relative URL string and
84
84
  # updates pathname / search / hash. Called by History pushState /
85
85
  # replaceState and by `location.href = ...`.
86
- def __set_url__(raw)
86
+ def __internal_set_url__(raw)
87
87
  previous_hash = @hash
88
88
  if raw.start_with?("#")
89
89
  @hash = raw
90
90
  else
91
91
  uri = URI.join(@origin + @pathname + @search + @hash, raw) rescue URI(raw)
92
+ # An absolute URL (carrying scheme + host) navigates to a new
93
+ # origin; a relative URL inherits the current origin from the
94
+ # join base, so rebuilding with the same parts is a no-op.
95
+ rebuild_origin(scheme: uri.scheme, host: uri.host, port: uri.port) if uri.scheme && uri.host
92
96
  @pathname = uri.path.to_s == "" ? "/" : uri.path
93
97
  @search = uri.query ? "?#{uri.query}" : ""
94
98
  @hash = uri.fragment ? "##{uri.fragment}" : ""
@@ -134,142 +138,4 @@ module Dommy
134
138
  rebuild_origin(scheme: scheme, host: parts[:host], port: parts[:port])
135
139
  end
136
140
  end
137
-
138
- # `window.history` polyfill. Stack-based; back/forward move the
139
- # cursor. pushState appends; replaceState mutates the current entry.
140
- # Each entry is `{ state:, url: }`. Popstate fires when back /
141
- # forward triggers a different cursor (not on pushState per spec).
142
- class History
143
- def initialize(window, location)
144
- @window = window
145
- @location = location
146
- # Initial entry mirrors the live Location. Bookmark URL is
147
- # resynthesized lazily from Location each time we read it.
148
- @stack = [{state: nil, url: nil}]
149
- @cursor = 0
150
- @scroll_restoration = "auto"
151
- end
152
-
153
- def __js_get__(key)
154
- case key
155
- when "length"
156
- @stack.size
157
- when "state"
158
- @stack[@cursor][:state]
159
- when "scrollRestoration"
160
- @scroll_restoration
161
- end
162
- end
163
-
164
- def __js_set__(key, value)
165
- case key
166
- when "scrollRestoration"
167
- # Per spec, only "auto" and "manual" are accepted. Invalid
168
- # values silently retain the current value.
169
- v = value.to_s
170
- @scroll_restoration = v if %w[auto manual].include?(v)
171
- end
172
-
173
- nil
174
- end
175
-
176
- def __js_call__(method, args)
177
- case method
178
- when "pushState"
179
- push(args[0], args[2])
180
- when "replaceState"
181
- replace(args[0], args[2])
182
- when "back"
183
- go(-1)
184
- when "forward"
185
- go(1)
186
- when "go"
187
- go((args[0] || 0).to_i)
188
- end
189
- end
190
-
191
- private
192
-
193
- def push(state, url)
194
- @stack = @stack[0..@cursor]
195
- @location.__set_url__(url.to_s) if url
196
- @stack << {state: state, url: nil}
197
- @cursor = @stack.size - 1
198
- end
199
-
200
- def replace(state, url)
201
- @location.__set_url__(url.to_s) if url
202
- @stack[@cursor] = {state: state, url: nil}
203
- end
204
-
205
- def go(delta)
206
- target = @cursor + delta
207
- return if target < 0 || target >= @stack.size
208
-
209
- @cursor = target
210
- @window.fire_popstate(@stack[@cursor][:state])
211
- end
212
- end
213
-
214
- # `URL` constructor — Ruby `URI` wrap. Browser URL API surface:
215
- # `[:origin]`, `[:pathname]`, `[:search]`, `[:hash]`, `[:href]`.
216
- # Supports the two-arg form `new URL(raw, base)`.
217
- class Url
218
- def initialize(raw, base = nil)
219
- uri = if base && !base.empty?
220
- URI.join(base, raw)
221
- else
222
- URI(raw.to_s)
223
- end
224
-
225
- @origin = origin_of(uri)
226
- @pathname = uri.path.to_s == "" ? "/" : uri.path
227
- @search = uri.query ? "?#{uri.query}" : ""
228
- @hash = uri.fragment ? "##{uri.fragment}" : ""
229
- @href = uri.to_s
230
- rescue URI::InvalidURIError, ArgumentError
231
- @origin = ""
232
- @pathname = ""
233
- @search = ""
234
- @hash = ""
235
- @href = raw.to_s
236
- end
237
-
238
- def __js_get__(key)
239
- case key
240
- when "origin"
241
- @origin
242
- when "pathname"
243
- @pathname
244
- when "search"
245
- @search
246
- when "hash"
247
- @hash
248
- when "href"
249
- @href
250
- when "toString"
251
- @href
252
- end
253
- end
254
-
255
- def __js_set__(_key, _value)
256
- nil
257
- end
258
-
259
- def __js_call__(method, _args)
260
- method == "toString" ? @href : nil
261
- end
262
-
263
- private
264
-
265
- def origin_of(uri)
266
- scheme = uri.scheme
267
- host = uri.host
268
- return "" unless scheme && host
269
-
270
- port = uri.port
271
- default = (scheme == "https" ? 443 : 80)
272
- port == default ? "#{scheme}://#{host}" : "#{scheme}://#{host}:#{port}"
273
- end
274
- end
275
141
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `MediaQueryList` — what `window.matchMedia(query)` returns.
5
+ #
6
+ # Dommy has no layout / viewport, so `matches` is `false` by default.
7
+ # Tests drive media query changes via `__test_set_matches__(bool)`, which
8
+ # flips the boolean and fires a `change` event — exactly the surface
9
+ # libraries like Material-UI / Bootstrap / @testing-library consult.
10
+ #
11
+ # Spec: https://drafts.csswg.org/cssom-view/#mediaquerylist
12
+ class MediaQueryList
13
+ include EventTarget
14
+
15
+ attr_reader :media
16
+
17
+ def initialize(window, query)
18
+ @window = window
19
+ @media = query.to_s
20
+ @matches = false
21
+ @onchange = nil
22
+ end
23
+
24
+ def matches
25
+ @matches
26
+ end
27
+
28
+ alias matches? matches
29
+
30
+ # Spec aliases for legacy support.
31
+ def add_listener(callback)
32
+ add_event_listener("change", callback)
33
+ end
34
+
35
+ alias addListener add_listener
36
+
37
+ def remove_listener(callback)
38
+ remove_event_listener("change", callback)
39
+ end
40
+
41
+ alias removeListener remove_listener
42
+
43
+ # Test seam: flip the match state and dispatch a `change` event so
44
+ # subscribers re-render.
45
+ def __test_set_matches__(value)
46
+ return if @matches == !!value
47
+
48
+ @matches = !!value
49
+ dispatch_event(MediaQueryListEvent.new("change", "matches" => @matches, "media" => @media))
50
+ nil
51
+ end
52
+
53
+ def __js_get__(key)
54
+ case key
55
+ when "media"
56
+ @media
57
+ when "matches"
58
+ @matches
59
+ when "onchange"
60
+ @onchange
61
+ end
62
+ end
63
+
64
+ def __js_set__(key, value)
65
+ case key
66
+ when "onchange"
67
+ remove_event_listener("change", @onchange) if @onchange
68
+ @onchange = value
69
+ add_event_listener("change", value) if value
70
+ end
71
+
72
+ nil
73
+ end
74
+
75
+ def __js_call__(method, args)
76
+ case method
77
+ when "matches"
78
+ @matches
79
+ when "addListener"
80
+ add_listener(args[0])
81
+ when "removeListener"
82
+ remove_listener(args[0])
83
+ when "addEventListener"
84
+ add_event_listener(args[0], args[1], args[2])
85
+ when "removeEventListener"
86
+ remove_event_listener(args[0], args[1])
87
+ when "dispatchEvent"
88
+ dispatch_event(args[0])
89
+ end
90
+ end
91
+
92
+ def __internal_event_parent__
93
+ nil
94
+ end
95
+ end
96
+
97
+ # `MediaQueryListEvent` — the `change` event payload.
98
+ class MediaQueryListEvent < Event
99
+ def initialize(type, init = nil)
100
+ super
101
+ @matches = !!read_init(init, "matches")
102
+ @media = (read_init(init, "media") || "").to_s
103
+ end
104
+
105
+ attr_reader :matches, :media
106
+
107
+ def __js_get__(key)
108
+ case key
109
+ when "matches"
110
+ @matches
111
+ when "media"
112
+ @media
113
+ else
114
+ super
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `MessageChannel` — creates a pair of `MessagePort`s connected to
5
+ # each other. `port1.postMessage(x)` queues a microtask that fires
6
+ # a `message` event on `port2`, and vice versa.
7
+ #
8
+ # Spec: https://html.spec.whatwg.org/multipage/web-messaging.html
9
+ class MessageChannel
10
+ attr_reader :port1, :port2
11
+
12
+ def initialize(window)
13
+ @port1 = MessagePort.new(window)
14
+ @port2 = MessagePort.new(window)
15
+ @port1.__internal_entangle__(@port2)
16
+ @port2.__internal_entangle__(@port1)
17
+ end
18
+
19
+ def __js_get__(key)
20
+ case key
21
+ when "port1"
22
+ @port1
23
+ when "port2"
24
+ @port2
25
+ end
26
+ end
27
+ end
28
+
29
+ # `MessagePort` — one end of a MessageChannel. `postMessage(value)`
30
+ # dispatches a `MessageEvent` on the entangled port asynchronously.
31
+ class MessagePort
32
+ include EventTarget
33
+
34
+ def initialize(window)
35
+ @window = window
36
+ @entangled = nil
37
+ @onmessage = nil
38
+ @started = false
39
+ @pending = []
40
+ end
41
+
42
+ def __internal_entangle__(other)
43
+ @entangled = other
44
+ end
45
+
46
+ def post_message(data)
47
+ port = @entangled
48
+ return unless port
49
+
50
+ @window.scheduler.queue_microtask(
51
+ proc do
52
+ evt = MessageEvent.new("message", "data" => Dommy.structured_clone(data))
53
+ if port.__internal_started?
54
+ port.dispatch_event(evt)
55
+ else
56
+ port.__internal_enqueue__(evt)
57
+ end
58
+ end
59
+ )
60
+
61
+ nil
62
+ end
63
+
64
+ alias postMessage post_message
65
+
66
+ def start
67
+ @started = true
68
+ flush = @pending
69
+ @pending = []
70
+ flush.each { |evt| dispatch_event(evt) }
71
+ nil
72
+ end
73
+
74
+ def close
75
+ @entangled = nil
76
+ nil
77
+ end
78
+
79
+ def __internal_started?
80
+ @started || !@inline_message_handler.nil?
81
+ end
82
+
83
+ def __internal_enqueue__(event)
84
+ @pending << event
85
+ end
86
+
87
+ def __js_get__(key)
88
+ case key
89
+ when "onmessage"
90
+ @onmessage
91
+ end
92
+ end
93
+
94
+ def __js_set__(key, value)
95
+ case key
96
+ when "onmessage"
97
+ # Setting onmessage implicitly starts the port per spec.
98
+ remove_event_listener("message", @onmessage) if @onmessage
99
+ @onmessage = value
100
+ @inline_message_handler = value
101
+ add_event_listener("message", value) if value
102
+ start if value
103
+ end
104
+
105
+ nil
106
+ end
107
+
108
+ def __js_call__(method, args)
109
+ case method
110
+ when "postMessage"
111
+ post_message(args[0])
112
+ when "start"
113
+ start
114
+ when "close"
115
+ close
116
+ when "addEventListener"
117
+ add_event_listener(args[0], args[1], args[2])
118
+ when "removeEventListener"
119
+ remove_event_listener(args[0], args[1])
120
+ when "dispatchEvent"
121
+ dispatch_event(args[0])
122
+ end
123
+ end
124
+
125
+ def __internal_event_parent__
126
+ nil
127
+ end
128
+ end
129
+
130
+ # `MessageEvent` — payload of `message` events on MessagePort /
131
+ # BroadcastChannel / WebSocket / EventSource.
132
+ class MessageEvent < Event
133
+ def initialize(type, init = nil)
134
+ super
135
+ @data = read_init(init, "data")
136
+ @origin = (read_init(init, "origin") || "").to_s
137
+ @last_event_id = (read_init(init, "lastEventId") || "").to_s
138
+ @source = read_init(init, "source")
139
+ @ports = read_init(init, "ports") || []
140
+ end
141
+
142
+ attr_reader :data, :origin, :last_event_id, :source, :ports
143
+
144
+ def __js_get__(key)
145
+ case key
146
+ when "data"
147
+ @data
148
+ when "origin"
149
+ @origin
150
+ when "lastEventId"
151
+ @last_event_id
152
+ when "source"
153
+ @source
154
+ when "ports"
155
+ @ports
156
+ else
157
+ super
158
+ end
159
+ end
160
+ end
161
+
162
+ # `BroadcastChannel` — same-origin pub/sub. Dommy keeps a per-window
163
+ # channel registry; sending posts to all other peers on the same
164
+ # name within the same Window.
165
+ class BroadcastChannel
166
+ include EventTarget
167
+
168
+ @@registries = Hash.new { |h, w| h[w] = Hash.new { |c, n| c[n] = [] } }
169
+
170
+ attr_reader :name
171
+
172
+ def initialize(window, name)
173
+ @window = window
174
+ @name = name.to_s
175
+ @closed = false
176
+ @onmessage = nil
177
+ @@registries[window][@name] << self
178
+ end
179
+
180
+ def post_message(data)
181
+ return if @closed
182
+
183
+ peers = @@registries[@window][@name].reject { |p| p.equal?(self) || p.closed? }
184
+ cloned = Dommy.structured_clone(data)
185
+ peers.each do |peer|
186
+ @window.scheduler.queue_microtask(
187
+ proc do
188
+ peer.dispatch_event(MessageEvent.new("message", "data" => cloned))
189
+ end
190
+ )
191
+ end
192
+
193
+ nil
194
+ end
195
+
196
+ alias postMessage post_message
197
+
198
+ def close
199
+ return if @closed
200
+
201
+ @closed = true
202
+ @@registries[@window][@name].delete(self)
203
+ nil
204
+ end
205
+
206
+ def closed?
207
+ @closed
208
+ end
209
+
210
+ def __js_get__(key)
211
+ case key
212
+ when "name"
213
+ @name
214
+ when "onmessage"
215
+ @onmessage
216
+ end
217
+ end
218
+
219
+ def __js_set__(key, value)
220
+ case key
221
+ when "onmessage"
222
+ remove_event_listener("message", @onmessage) if @onmessage
223
+ @onmessage = value
224
+ add_event_listener("message", value) if value
225
+ end
226
+
227
+ nil
228
+ end
229
+
230
+ def __js_call__(method, args)
231
+ case method
232
+ when "postMessage"
233
+ post_message(args[0])
234
+ when "close"
235
+ close
236
+ when "addEventListener"
237
+ add_event_listener(args[0], args[1], args[2])
238
+ when "removeEventListener"
239
+ remove_event_listener(args[0], args[1])
240
+ when "dispatchEvent"
241
+ dispatch_event(args[0])
242
+ end
243
+ end
244
+
245
+ def __internal_event_parent__
246
+ nil
247
+ end
248
+ end
249
+ end
@@ -148,17 +148,27 @@ module Dommy
148
148
  raise TypeError, "MutationObserver.observe: at least one of childList, attributes, characterData must be true"
149
149
  end
150
150
 
151
- @observed <<
152
- {
153
- target: target,
154
- child_list: child_list_on,
155
- subtree: truthy_option(opts, "subtree"),
156
- attributes: attributes_on,
157
- attribute_filter: attribute_filter,
158
- attribute_old_value: truthy_option(opts, "attributeOldValue"),
159
- character_data: character_data_on,
160
- character_data_old_value: truthy_option(opts, "characterDataOldValue")
161
- }
151
+ entry = {
152
+ target: target,
153
+ child_list: child_list_on,
154
+ subtree: truthy_option(opts, "subtree"),
155
+ attributes: attributes_on,
156
+ attribute_filter: attribute_filter,
157
+ attribute_old_value: truthy_option(opts, "attributeOldValue"),
158
+ character_data: character_data_on,
159
+ character_data_old_value: truthy_option(opts, "characterDataOldValue")
160
+ }
161
+
162
+ # WHATWG MutationObserver §observe: if `target` is already
163
+ # observed, replace the existing registration's options
164
+ # (don't merge or stack).
165
+ existing_index = @observed.index { |e| e[:target].equal?(target) }
166
+ if existing_index
167
+ @observed[existing_index] = entry
168
+ else
169
+ @observed << entry
170
+ end
171
+
162
172
  @document.register_observer(self)
163
173
  nil
164
174
  end