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
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `EventSource` (Server-Sent Events). Like `WebSocket`, dommy
5
+ # provides simulation seams instead of network IO:
6
+ #
7
+ # es.__test_simulate_open__
8
+ # es.__test_simulate_message__(data, event: "msg", id: "1")
9
+ # es.__test_simulate_error__
10
+ #
11
+ # Auto-opens on a microtask after construction, mirroring real
12
+ # browser behavior.
13
+ #
14
+ # Spec: https://html.spec.whatwg.org/multipage/server-sent-events.html
15
+ class EventSource
16
+ include EventTarget
17
+
18
+ CONNECTING = 0
19
+ OPEN = 1
20
+ CLOSED = 2
21
+
22
+ INLINE_HANDLERS = %w[open message error].freeze
23
+
24
+ attr_reader :url, :ready_state, :with_credentials
25
+
26
+ def initialize(window, url, options = nil)
27
+ @window = window
28
+ @url = url.to_s
29
+ @ready_state = CONNECTING
30
+ opts = options.is_a?(Hash) ? options : {}
31
+ @with_credentials = !!(opts["withCredentials"] || opts[:withCredentials])
32
+ @inline_handlers = {}
33
+
34
+ @window.scheduler.queue_microtask(proc { __test_simulate_open__ })
35
+ end
36
+
37
+ def close
38
+ @ready_state = CLOSED
39
+ nil
40
+ end
41
+
42
+ # --- Test seams ------------------------------------------------
43
+
44
+ def __test_simulate_open__
45
+ return if @ready_state != CONNECTING
46
+
47
+ @ready_state = OPEN
48
+ dispatch_event(Event.new("open"))
49
+ end
50
+
51
+ def __test_simulate_message__(data, event: "message", id: nil, retry_ms: nil)
52
+ return if @ready_state != OPEN
53
+
54
+ payload = {"data" => data.to_s}
55
+ payload["lastEventId"] = id.to_s if id
56
+ payload["retry"] = retry_ms.to_i if retry_ms
57
+ dispatch_event(MessageEvent.new(event.to_s, payload))
58
+ end
59
+
60
+ def __test_simulate_error__
61
+ dispatch_event(Event.new("error"))
62
+ end
63
+
64
+ # --- JS bridge -------------------------------------------------
65
+
66
+ def __js_get__(key)
67
+ case key
68
+ when "url"
69
+ @url
70
+ when "readyState"
71
+ @ready_state
72
+ when "withCredentials"
73
+ @with_credentials
74
+ when "CONNECTING"
75
+ CONNECTING
76
+ when "OPEN"
77
+ OPEN
78
+ when "CLOSED"
79
+ CLOSED
80
+ else
81
+ @inline_handlers[inline_event_for(key)]
82
+ end
83
+ end
84
+
85
+ def __js_set__(key, value)
86
+ event = inline_event_for(key)
87
+ set_inline_handler(event, value) if event
88
+ nil
89
+ end
90
+
91
+ def __js_call__(method, args)
92
+ case method
93
+ when "close"
94
+ close
95
+ when "addEventListener"
96
+ add_event_listener(args[0], args[1], args[2])
97
+ when "removeEventListener"
98
+ remove_event_listener(args[0], args[1])
99
+ when "dispatchEvent"
100
+ dispatch_event(args[0])
101
+ end
102
+ end
103
+
104
+ def __internal_event_parent__
105
+ nil
106
+ end
107
+
108
+ private
109
+
110
+ INLINE_EVENT_MAP = INLINE_HANDLERS
111
+ .each_with_object({}) do |name, h|
112
+ h["on#{name}"] = name
113
+ end
114
+ .freeze
115
+
116
+ def inline_event_for(key)
117
+ INLINE_EVENT_MAP[key.to_s]
118
+ end
119
+
120
+ def set_inline_handler(event, handler)
121
+ previous = @inline_handlers[event]
122
+ remove_event_listener(event, previous) if previous
123
+ if handler.nil?
124
+ @inline_handlers.delete(event)
125
+ else
126
+ add_event_listener(event, handler)
127
+ @inline_handlers[event] = handler
128
+ end
129
+ end
130
+ end
131
+ end
data/lib/dommy/fetch.rb CHANGED
@@ -118,6 +118,68 @@ module Dommy
118
118
  end
119
119
  end
120
120
 
121
+ # `Request` polyfill — minimal Fetch API Request object so callers
122
+ # constructing `new Request(url, init)` get a value with `.url`,
123
+ # `.method`, `.headers`, `.body`. The stub-based `fetch` doesn't
124
+ # consume it directly (it still takes `(url, init)`), but having
125
+ # Request available means JS code that constructs one before
126
+ # passing to fetch keeps working.
127
+ class Request
128
+ attr_reader :url, :method, :body
129
+
130
+ def initialize(url, init = nil)
131
+ opts = init.is_a?(Hash) ? init : {}
132
+ @url = url.to_s
133
+ @method = (opts["method"] || opts[:method] || "GET").to_s.upcase
134
+ @body = opts["body"] || opts[:body]
135
+ raw_headers = opts["headers"] || opts[:headers] || {}
136
+ @headers = Headers.new(raw_headers)
137
+ @credentials = (opts["credentials"] || opts[:credentials] || "same-origin").to_s
138
+ @mode = (opts["mode"] || opts[:mode] || "cors").to_s
139
+ @cache = (opts["cache"] || opts[:cache] || "default").to_s
140
+ @redirect = (opts["redirect"] || opts[:redirect] || "follow").to_s
141
+ end
142
+
143
+ attr_reader :headers, :credentials, :mode, :cache, :redirect
144
+
145
+ def __js_get__(key)
146
+ case key
147
+ when "url"
148
+ @url
149
+ when "method"
150
+ @method
151
+ when "headers"
152
+ @headers
153
+ when "body"
154
+ @body
155
+ when "credentials"
156
+ @credentials
157
+ when "mode"
158
+ @mode
159
+ when "cache"
160
+ @cache
161
+ when "redirect"
162
+ @redirect
163
+ end
164
+ end
165
+
166
+ def __js_call__(method, _args)
167
+ case method
168
+ when "clone"
169
+ Request.new(
170
+ @url,
171
+ "method" => @method,
172
+ "body" => @body,
173
+ "headers" => @headers.to_h,
174
+ "credentials" => @credentials,
175
+ "mode" => @mode,
176
+ "cache" => @cache,
177
+ "redirect" => @redirect
178
+ )
179
+ end
180
+ end
181
+ end
182
+
121
183
  # `Response` polyfill — just enough surface for Fetchy:
122
184
  # `[:status]` / `[:ok]` / `[:url]` / `[:headers]` (with
123
185
  # `.entries()` / `.get(name)`) and `.text()` / `.json()` / `.body`
@@ -165,8 +227,10 @@ module Dommy
165
227
  rejected(err)
166
228
  end
167
229
 
168
- when "arrayBuffer", "blob"
169
- immediate(@body)
230
+ when "arrayBuffer"
231
+ immediate(@body.bytes)
232
+ when "blob"
233
+ immediate(Blob.new([@body], "type" => @headers.__js_call__("get", ["content-type"]) || ""))
170
234
  when "clone"
171
235
  Response.new(
172
236
  @window,
@@ -218,15 +282,21 @@ module Dommy
218
282
  when "entries"
219
283
  @hash.to_a
220
284
  when "has"
221
- @hash.key?(args[0].to_s)
285
+ # Match `get`'s case-insensitive lookup: try the raw name
286
+ # first, then the Title-Case canonical form. WHATWG defines
287
+ # header names as case-insensitive throughout the Headers API.
288
+ name = args[0].to_s
289
+ @hash.key?(name) || @hash.key?(Headers.canonical(name))
222
290
  when "forEach"
223
- # Browser API: forEach(callback) — callback(value, key)
291
+ # WHATWG: forEach(callback) — callback(value, key, headers).
292
+ # Pass `self` as the third argument so consumers that read
293
+ # `(_, _, h) => h.get("Foo")` work the same as in a browser.
224
294
  cb = args[0]
225
295
  @hash.each do |k, v|
226
296
  if cb.respond_to?(:__js_call__)
227
- cb.__js_call__("call", [v, k])
297
+ cb.__js_call__("call", [v, k, self])
228
298
  elsif cb.respond_to?(:call)
229
- cb.call(v, k)
299
+ cb.call(v, k, self)
230
300
  end
231
301
  end
232
302
 
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `FileReader` — reads `Blob` / `File` instances into Strings, byte
5
+ # arrays, or data URLs. Asynchronous reads schedule a microtask that
6
+ # fires `load` + `loadend`; sync access (via `result` after the
7
+ # microtask drain) mirrors browser behavior.
8
+ #
9
+ # Spec: https://w3c.github.io/FileAPI/#APIASynch
10
+ class FileReader
11
+ include EventTarget
12
+
13
+ EMPTY = 0
14
+ LOADING = 1
15
+ DONE = 2
16
+
17
+ INLINE_HANDLERS = %w[loadstart progress load loadend abort error].freeze
18
+
19
+ attr_reader :ready_state, :result, :error
20
+
21
+ def initialize(window)
22
+ @window = window
23
+ @ready_state = EMPTY
24
+ @result = nil
25
+ @error = nil
26
+ @inline_handlers = {}
27
+ @aborted = false
28
+ @generation = 0
29
+ end
30
+
31
+ def read_as_text(blob, _encoding = "utf-8")
32
+ schedule_read(blob) { |raw| raw.dup.force_encoding("UTF-8") }
33
+ end
34
+
35
+ alias readAsText read_as_text
36
+
37
+ def read_as_data_url(blob)
38
+ schedule_read(blob) do |raw|
39
+ mime = blob.respond_to?(:type) ? blob.type.to_s : ""
40
+ mime = "application/octet-stream" if mime.empty?
41
+ "data:#{mime};base64,#{[raw].pack("m0")}"
42
+ end
43
+ end
44
+
45
+ alias readAsDataURL read_as_data_url
46
+
47
+ def read_as_array_buffer(blob)
48
+ schedule_read(blob) { |raw| raw.bytes }
49
+ end
50
+
51
+ alias readAsArrayBuffer read_as_array_buffer
52
+
53
+ def read_as_binary_string(blob)
54
+ schedule_read(blob) { |raw| raw.dup.force_encoding("ASCII-8BIT") }
55
+ end
56
+
57
+ alias readAsBinaryString read_as_binary_string
58
+
59
+ def abort
60
+ return if @ready_state != LOADING
61
+
62
+ @aborted = true
63
+ @generation += 1
64
+ @ready_state = DONE
65
+ @result = nil
66
+ dispatch_event(Event.new("abort"))
67
+ dispatch_event(Event.new("loadend"))
68
+ nil
69
+ end
70
+
71
+ def __js_get__(key)
72
+ case key
73
+ when "readyState"
74
+ @ready_state
75
+ when "result"
76
+ @result
77
+ when "error"
78
+ @error
79
+ when "EMPTY"
80
+ EMPTY
81
+ when "LOADING"
82
+ LOADING
83
+ when "DONE"
84
+ DONE
85
+ else
86
+ @inline_handlers[inline_event_for(key)]
87
+ end
88
+ end
89
+
90
+ def __js_set__(key, value)
91
+ event = inline_event_for(key)
92
+ set_inline_handler(event, value) if event
93
+ nil
94
+ end
95
+
96
+ def __js_call__(method, args)
97
+ case method
98
+ when "readAsText"
99
+ read_as_text(args[0], args[1])
100
+ when "readAsDataURL"
101
+ read_as_data_url(args[0])
102
+ when "readAsArrayBuffer"
103
+ read_as_array_buffer(args[0])
104
+ when "readAsBinaryString"
105
+ read_as_binary_string(args[0])
106
+ when "abort"
107
+ abort
108
+ when "addEventListener"
109
+ add_event_listener(args[0], args[1], args[2])
110
+ when "removeEventListener"
111
+ remove_event_listener(args[0], args[1])
112
+ when "dispatchEvent"
113
+ dispatch_event(args[0])
114
+ end
115
+ end
116
+
117
+ def __internal_event_parent__
118
+ nil
119
+ end
120
+
121
+ private
122
+
123
+ def schedule_read(blob, &decoder)
124
+ @ready_state = LOADING
125
+ @result = nil
126
+ @aborted = false
127
+ @generation += 1
128
+ gen = @generation
129
+ dispatch_event(Event.new("loadstart"))
130
+
131
+ @window.scheduler.queue_microtask(
132
+ proc do
133
+ next unless @generation == gen && !@aborted
134
+
135
+ raw = extract_raw(blob)
136
+ @result = decoder.call(raw)
137
+ @ready_state = DONE
138
+ dispatch_event(Event.new("load"))
139
+ dispatch_event(Event.new("loadend"))
140
+ end
141
+ )
142
+
143
+ nil
144
+ end
145
+
146
+ # Returns the blob's raw bytes as a binary String.
147
+ def extract_raw(blob)
148
+ if blob.respond_to?(:__dommy_bytes__)
149
+ blob.__dommy_bytes__.to_s
150
+ else
151
+ blob.to_s
152
+ end
153
+ end
154
+
155
+ INLINE_EVENT_MAP = INLINE_HANDLERS
156
+ .each_with_object({}) do |name, h|
157
+ h["on#{name}"] = name
158
+ end
159
+ .freeze
160
+
161
+ def inline_event_for(key)
162
+ INLINE_EVENT_MAP[key.to_s]
163
+ end
164
+
165
+ def set_inline_handler(event, handler)
166
+ previous = @inline_handlers[event]
167
+ remove_event_listener(event, previous) if previous
168
+ if handler.nil?
169
+ @inline_handlers.delete(event)
170
+ else
171
+ add_event_listener(event, handler)
172
+ @inline_handlers[event] = handler
173
+ end
174
+ end
175
+ end
176
+ end
@@ -146,7 +146,7 @@ module Dommy
146
146
  next if name.empty?
147
147
  next if disabled?(el)
148
148
 
149
- case el.__node__.name
149
+ case el.__dommy_backend_node__.name
150
150
  when "input"
151
151
  collect_input(el, name)
152
152
  when "select"
@@ -198,8 +198,6 @@ module Dommy
198
198
  # File / Blob values pass through unchanged (multipart form
199
199
  # encoding handles them); other values are stringified per spec.
200
200
  return value if value.is_a?(Blob)
201
- # Backward-compat: embedders' own file-marker objects.
202
- return value if value.respond_to?(:__file_marker__)
203
201
  return "" if value.nil?
204
202
 
205
203
  value.to_s
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `window.history` polyfill. Stack-based; back/forward move the
5
+ # cursor. pushState appends; replaceState mutates the current entry.
6
+ # Each entry is `{ state:, url: }`. Popstate fires when back /
7
+ # forward triggers a different cursor (not on pushState per spec).
8
+ class History
9
+ def initialize(window, location)
10
+ @window = window
11
+ @location = location
12
+ # Initial entry mirrors the live Location. Bookmark URL is
13
+ # resynthesized lazily from Location each time we read it.
14
+ @stack = [{state: nil, url: nil}]
15
+ @cursor = 0
16
+ @scroll_restoration = "auto"
17
+ end
18
+
19
+ def __js_get__(key)
20
+ case key
21
+ when "length"
22
+ @stack.size
23
+ when "state"
24
+ @stack[@cursor][:state]
25
+ when "scrollRestoration"
26
+ @scroll_restoration
27
+ end
28
+ end
29
+
30
+ def __js_set__(key, value)
31
+ case key
32
+ when "scrollRestoration"
33
+ # Per spec, only "auto" and "manual" are accepted. Invalid
34
+ # values silently retain the current value.
35
+ v = value.to_s
36
+ @scroll_restoration = v if %w[auto manual].include?(v)
37
+ end
38
+
39
+ nil
40
+ end
41
+
42
+ def __js_call__(method, args)
43
+ case method
44
+ when "pushState"
45
+ push(args[0], args[2])
46
+ when "replaceState"
47
+ replace(args[0], args[2])
48
+ when "back"
49
+ go(-1)
50
+ when "forward"
51
+ go(1)
52
+ when "go"
53
+ go((args[0] || 0).to_i)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def push(state, url)
60
+ @stack = @stack[0..@cursor]
61
+ @location.__internal_set_url__(url.to_s) if url
62
+ # WHATWG: pushState serializes the state via structured-clone
63
+ # so subsequent caller-side mutation of the original cannot
64
+ # affect history.state.
65
+ @stack << {state: Dommy.structured_clone(state), url: nil}
66
+ @cursor = @stack.size - 1
67
+ end
68
+
69
+ def replace(state, url)
70
+ @location.__internal_set_url__(url.to_s) if url
71
+ @stack[@cursor] = {state: Dommy.structured_clone(state), url: nil}
72
+ end
73
+
74
+ def go(delta)
75
+ target = @cursor + delta
76
+ return if target < 0 || target >= @stack.size
77
+
78
+ @cursor = target
79
+ @window.fire_popstate(@stack[@cursor][:state])
80
+ end
81
+ end
82
+ end
@@ -46,9 +46,9 @@ module Dommy
46
46
  return nil if key.empty?
47
47
 
48
48
  to_a.find do |el|
49
- next false unless el.respond_to?(:__node__)
49
+ next false unless el.respond_to?(:__dommy_backend_node__)
50
50
 
51
- el.__node__["id"].to_s == key || el.__node__["name"].to_s == key
51
+ el.__dommy_backend_node__["id"].to_s == key || el.__dommy_backend_node__["name"].to_s == key
52
52
  end
53
53
  end
54
54
 
@@ -125,7 +125,7 @@ module Dommy
125
125
  # accepts either another option (insert before that node) or an
126
126
  # integer index. Strings/`null` append.
127
127
  def add(option, before = nil)
128
- return nil unless option.respond_to?(:__node__)
128
+ return nil unless option.respond_to?(:__dommy_backend_node__)
129
129
 
130
130
  case before
131
131
  when nil
@@ -134,7 +134,7 @@ module Dommy
134
134
  anchor = item(before)
135
135
  anchor ? @owner.insert_before(option, anchor) : @owner.append_child(option)
136
136
  else
137
- if before.respond_to?(:__node__)
137
+ if before.respond_to?(:__dommy_backend_node__)
138
138
  @owner.insert_before(option, before)
139
139
  else
140
140
  @owner.append_child(option)