dommy 0.12.0 → 0.13.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.
@@ -241,107 +241,169 @@ module Dommy
241
241
  end
242
242
  end
243
243
 
244
- # The HTMLHyperlinkElementUtils IDL mixin, shared by <a> and <area>. `href`
245
- # reads back as the RESOLVED absolute URL (the URL-decomposition IDL
246
- # attributes all report resolved values), while the setter writes the content
247
- # attribute verbatim — `link.href = url` is how most code sets a link.
244
+ # The HTMLHyperlinkElementUtils IDL mixin, shared by <a> and <area>. The
245
+ # `href` content attribute, parsed against the document base URL, is the
246
+ # element's URL; every getter reads a component of it and every setter
247
+ # changes that component the way the URL API does, then writes the
248
+ # serialization back into the attribute. Without an href there is no URL
249
+ # and the getters return the empty string (":" for protocol); an href that
250
+ # does not parse reads back as written.
251
+ #
252
+ # Spec: https://html.spec.whatwg.org/multipage/links.html#htmlhyperlinkelementutils
248
253
  module HyperlinkUtils
254
+ URL_COMPONENTS = %w[origin protocol username password host hostname port pathname search hash].freeze
255
+
249
256
  # WebIDL stringifier: `String(link)` / `link.toString()` is its href (the
250
257
  # resolved absolute URL), not the element's serialization.
251
258
  def to_s
252
- anchor_href
259
+ href
253
260
  end
254
261
 
255
262
  def href
256
- anchor_href
263
+ raw = @__node__["href"]
264
+ return "" if raw.nil?
265
+
266
+ url = hyperlink_url
267
+ url ? url.href : raw
257
268
  end
258
269
 
259
270
  def href=(value)
260
271
  set_attribute("href", value.to_s)
261
272
  end
262
273
 
263
- # URL-decomposition helpers. The href is resolved to an absolute URL
264
- # (Element#anchor_href); break it into the standard components on demand.
265
- def hash
266
- uri_part(:fragment) ? "##{uri_part(:fragment)}" : ""
274
+ def origin
275
+ url = hyperlink_url
276
+ url ? url.origin : ""
277
+ end
278
+
279
+ def protocol
280
+ url = hyperlink_url
281
+ url ? url.protocol : ":"
282
+ end
283
+
284
+ def protocol=(value)
285
+ change_url { |url| url.protocol = value }
286
+ end
287
+
288
+ def username
289
+ hyperlink_url&.username.to_s
290
+ end
291
+
292
+ def username=(value)
293
+ change_url { |url| url.username = value }
294
+ end
295
+
296
+ def password
297
+ hyperlink_url&.password.to_s
298
+ end
299
+
300
+ def password=(value)
301
+ change_url { |url| url.password = value }
267
302
  end
268
303
 
269
304
  def host
270
- uri.host ? "#{uri.host}#{port_suffix}" : ""
305
+ hyperlink_url&.host.to_s
306
+ end
307
+
308
+ def host=(value)
309
+ change_url { |url| url.host = value }
271
310
  end
272
311
 
273
312
  def hostname
274
- uri.host || ""
313
+ hyperlink_url&.hostname.to_s
314
+ end
315
+
316
+ def hostname=(value)
317
+ change_url { |url| url.hostname = value }
318
+ end
319
+
320
+ def port
321
+ hyperlink_url&.port.to_s
322
+ end
323
+
324
+ def port=(value)
325
+ change_url { |url| url.port = value }
275
326
  end
276
327
 
277
328
  def pathname
278
- uri.path || "/"
329
+ hyperlink_url&.pathname.to_s
279
330
  end
280
331
 
281
- def protocol
282
- uri.scheme ? "#{uri.scheme}:" : ""
332
+ def pathname=(value)
333
+ change_url { |url| url.pathname = value }
283
334
  end
284
335
 
285
336
  def search
286
- uri.query ? "?#{uri.query}" : ""
337
+ hyperlink_url&.search.to_s
287
338
  end
288
339
 
289
- def port
290
- uri.port ? uri.port.to_s : ""
340
+ def search=(value)
341
+ change_url { |url| url.search = value }
291
342
  end
292
343
 
293
- def origin
294
- uri.scheme && uri.host ? "#{uri.scheme}://#{uri.host}#{port_suffix}" : ""
344
+ def hash
345
+ hyperlink_url&.hash.to_s
346
+ end
347
+
348
+ def hash=(value)
349
+ change_url { |url| url.hash = value }
295
350
  end
296
351
 
297
352
  def __js_get__(key)
298
353
  case key
299
- when "href"
300
- href
301
- when "hash"
302
- self.hash
303
- when "host"
304
- host
305
- when "hostname"
306
- hostname
307
- when "pathname"
308
- pathname
309
- when "protocol"
310
- protocol
311
- when "search"
312
- search
313
- when "port"
314
- port
315
- when "origin"
316
- origin
317
- else
318
- super
354
+ when "href" then href
355
+ when "origin" then origin
356
+ when "protocol" then protocol
357
+ when "username" then username
358
+ when "password" then password
359
+ when "host" then host
360
+ when "hostname" then hostname
361
+ when "port" then port
362
+ when "pathname" then pathname
363
+ when "search" then search
364
+ when "hash" then self.hash
365
+ else super
319
366
  end
320
367
  end
321
368
 
322
369
  def __js_set__(key, value)
323
- key == "href" ? (self.href = value) : super
370
+ case key
371
+ when "href" then self.href = value
372
+ when "protocol" then self.protocol = value
373
+ when "username" then self.username = value
374
+ when "password" then self.password = value
375
+ when "host" then self.host = value
376
+ when "hostname" then self.hostname = value
377
+ when "port" then self.port = value
378
+ when "pathname" then self.pathname = value
379
+ when "search" then self.search = value
380
+ when "hash" then self.hash = value
381
+ else super
382
+ end
324
383
  end
325
384
 
326
385
  private
327
386
 
328
- def uri
329
- require "uri"
387
+ # HTML "reinitialize url": the href attribute parsed against the
388
+ # document base URL, or nil when there is none or it does not parse.
389
+ def hyperlink_url
390
+ raw = @__node__["href"]
391
+ return nil if raw.nil?
330
392
 
331
- URI(anchor_href)
332
- rescue URI::InvalidURIError, ArgumentError
333
- URI("")
334
- end
335
-
336
- def uri_part(part)
337
- uri.send(part)
393
+ base = @document.base_uri.to_s
394
+ URL.new(raw, base.empty? ? nil : base)
395
+ rescue Bridge::TypeError
396
+ nil
338
397
  end
339
398
 
340
- def port_suffix
341
- return "" unless uri.port
399
+ # A setter: nothing without a URL; otherwise the change, then "update
400
+ # href" with the serialization.
401
+ def change_url
402
+ url = hyperlink_url
403
+ return unless url
342
404
 
343
- default = uri.scheme == "https" ? 443 : 80
344
- uri.port == default ? "" : ":#{uri.port}"
405
+ yield url
406
+ set_attribute("href", url.href)
345
407
  end
346
408
  end
347
409
 
@@ -747,12 +809,12 @@ module Dommy
747
809
  str.gsub(/[\r\n]/, "")
748
810
  end
749
811
 
812
+ # HTML's value sanitization for number and range: anything that is not a
813
+ # valid floating-point number, or is out of the finite range, is the
814
+ # empty string. Ruby's Float() is wider than the spec (" 1", "+1", "1.").
750
815
  def sanitize_number(raw)
751
816
  s = raw.to_s
752
- Float(s)
753
- s
754
- rescue ArgumentError, TypeError
755
- ""
817
+ parse_valid_float(s).finite? ? s : ""
756
818
  end
757
819
 
758
820
  # Underlying string the user supplied to `value=`, before any
@@ -1068,10 +1130,15 @@ module Dommy
1068
1130
  return if mn && mx && mn > mx
1069
1131
 
1070
1132
  before = value_as_number
1071
- base = before.nan? ? (mn || 0.0) : before
1133
+ # The arithmetic runs on the decimal values the attributes spell, not on
1134
+ # their nearest doubles: 0.1 + 0.1 + 0.1 is 0.3, not 0.30000000000000004.
1135
+ allowed = decimal(allowed)
1136
+ mn = decimal(mn) if mn
1137
+ mx = decimal(mx) if mx
1138
+ base = before.nan? ? (mn || 0r) : decimal(before)
1072
1139
  result = base + count * allowed
1073
1140
 
1074
- step_base = mn || 0.0
1141
+ step_base = mn || 0r
1075
1142
  result = mx - (mx - step_base) % allowed if mx && result > mx
1076
1143
  result = mn + (step_base - mn) % allowed if mn && result < mn
1077
1144
 
@@ -1082,10 +1149,16 @@ module Dommy
1082
1149
  return if count.negative? && result > before
1083
1150
  end
1084
1151
 
1085
- self.value_as_number = result
1152
+ self.value_as_number = result.to_f
1086
1153
  nil
1087
1154
  end
1088
1155
 
1156
+ # The decimal number a double stands for: 0.1 is 1/10, not the binary
1157
+ # fraction nearest to it.
1158
+ def decimal(number)
1159
+ number.rationalize(Rational(1, 10**12))
1160
+ end
1161
+
1089
1162
  # The scale that turns one declared step into valueAsNumber units (ms for the
1090
1163
  # date/time types, natural units for number/range/month).
1091
1164
  def step_scale_factor
@@ -1132,7 +1205,8 @@ module Dommy
1132
1205
  # WHATWG "valid floating-point number": no surrounding whitespace (unlike
1133
1206
  # Ruby's Float()), optional sign, digits with optional fraction, optional
1134
1207
  # exponent. Anything else — including " 1 " or "1e" — yields NaN.
1135
- VALID_FLOAT_RE = /\A-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?\z/
1208
+ # A fraction needs a digit after the dot, so "1." is not a number.
1209
+ VALID_FLOAT_RE = /\A-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?\z/
1136
1210
 
1137
1211
  def parse_valid_float(str)
1138
1212
  s = str.to_s
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Shared shaping for WHATWG "report an exception": turn whatever a throwing
6
+ # entry point handed us into the pair the report needs — the value to expose
7
+ # as `ErrorEvent#error` and the string to expose as `ErrorEvent#message`.
8
+ #
9
+ # Two shapes arrive. A JS callback's throw comes wrapped in a
10
+ # `Bridge::ThrowValue` carrying the thrown JS value with its identity intact
11
+ # (so `event.error === thrown` holds in the page); everything else is a plain
12
+ # Ruby exception raised by the host or the engine. Unwrapping in one place
13
+ # keeps every report site (event dispatch, script evaluation, a timer
14
+ # callback, `reportError`) agreeing on what the page sees.
15
+ module ExceptionReport
16
+ module_function
17
+
18
+ # The `error` value a report should expose: a ThrowValue's payload keeps
19
+ # JS identity, anything else reports itself.
20
+ def error_value(error)
21
+ error.is_a?(Bridge::ThrowValue) ? error.value : error
22
+ end
23
+
24
+ # The `message` a report should expose for `error`. A JS Error proxy and a
25
+ # Ruby exception both answer `message`; a thrown non-Error (a string, an
26
+ # object literal) has only its string form.
27
+ def message_for(error)
28
+ value = error_value(error)
29
+ return value.message.to_s if value.respond_to?(:message)
30
+
31
+ value.to_s
32
+ end
33
+
34
+ # Both halves at once, for a caller that needs the pair.
35
+ def describe(error)
36
+ [error_value(error), message_for(error)]
37
+ end
38
+
39
+ # One JS stack frame, in either of the two forms an engine writes:
40
+ # `at name (file:line:col)` and the anonymous `at file:line:col`.
41
+ STACK_FRAME = /\Aat\s+(?:\S+\s+\()?(?<file>.+?):(?<line>\d+):(?<column>\d+)\)?\z/
42
+
43
+ # Frames inside Dommy's own JS plumbing. The page did not write them, so
44
+ # they must not be reported as where its error happened.
45
+ INTERNAL_SOURCES = %w[host_runtime.js observable_runtime.js].freeze
46
+
47
+ # Where the error happened, as `ErrorEvent`'s [filename, lineno, colno].
48
+ # A JS engine puts its frames on the raised exception's backtrace, so the
49
+ # topmost frame the PAGE owns is the position to report. Zeroes when there
50
+ # is no usable frame, which is what the spec says to expose when the
51
+ # position is unknown.
52
+ def source_position(error)
53
+ Array(error.backtrace).each do |frame|
54
+ match = STACK_FRAME.match(frame.to_s.strip)
55
+ next unless match
56
+ next if INTERNAL_SOURCES.any? { |source| match[:file].end_with?(source) }
57
+
58
+ return [match[:file], match[:line].to_i, match[:column].to_i]
59
+ end
60
+ ["", 0, 0]
61
+ end
62
+
63
+ # WHATWG "report an exception" at `window`, shaping the thrown value, its
64
+ # message and its source position from whatever the entry point caught.
65
+ # Every entry point reports through here, so none of them can drift from
66
+ # the others in what the page gets to see. Returns whether the page
67
+ # handled it.
68
+ # `value` overrides what the page sees as `event.error`, for a caller that
69
+ # can supply a better one than the raw catch: an engine that converted the
70
+ # throw to a host exception can rebuild the JS Error the page threw, which
71
+ # is the difference between a handler reading `.message` and one crashing
72
+ # on `undefined`.
73
+ def report_at(window, error, value: nil)
74
+ value ||= error_value(error)
75
+ message = message_for(error)
76
+ file, line, column = source_position(error)
77
+ window.__internal_report_exception__(value, message,
78
+ filename: document_source(file, window), lineno: line, colno: column, host_error: error)
79
+ end
80
+
81
+ # What an engine calls source it was handed with no name of its own — an
82
+ # inline `<script>`, an `eval`. A browser reports the document's URL for
83
+ # those, so the placeholder is swapped for it rather than leaking an
84
+ # engine-internal name into `ErrorEvent#filename`.
85
+ ANONYMOUS_SOURCES = ["<code>", "<input>", "<eval>", "<anonymous>"].freeze
86
+
87
+ def document_source(file, window)
88
+ return file unless ANONYMOUS_SOURCES.include?(file)
89
+
90
+ location = window.location if window.respond_to?(:location)
91
+ location.respond_to?(:href) ? location.href.to_s : file
92
+ end
93
+
94
+ # A host-loggable exception for a value that came straight off the bridge
95
+ # with no exception attached — a rejection reason, which the engine hands
96
+ # over as the JS value itself. `Bridge::ThrowValue` is that shape already:
97
+ # it is raisable, keeps the value, and takes the JS frames as its backtrace
98
+ # so the report can still say where the page failed.
99
+ def thrown_host_error(value)
100
+ return value if value.is_a?(::Exception)
101
+
102
+ name = value.js_name if value.respond_to?(:js_name)
103
+ text = value.to_s
104
+ error = Bridge::ThrowValue.new(value, name && name != "Object" ? "#{name}: #{text}" : text)
105
+ frames = value.respond_to?(:stack_frames) ? value.stack_frames : nil
106
+ error.set_backtrace(frames) if frames && !frames.empty?
107
+ error
108
+ end
109
+
110
+ # The form the HOST logs. A report's `error` value is whatever the page
111
+ # threw, which for JS code is an opaque `Bridge::JSValue` with no `class` /
112
+ # `message` / backtrace — everything an error log wants. A caller that has
113
+ # the original Ruby exception passes it as `host_error`; otherwise the bare
114
+ # value is wrapped, so the log never has to special-case what it holds.
115
+ def host_form(error_value, host_error)
116
+ return host_error if host_error
117
+ return error_value if error_value.is_a?(::Exception)
118
+
119
+ Bridge::ThrowValue.new(error_value)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -7,7 +7,7 @@ module Dommy
7
7
  # IDNA ToASCII / ToUnicode for domain names. Built on
8
8
  # `Internal::Punycode` plus the Unicode tables in
9
9
  # `Internal::IDNAData` (generated by `script/build_idna_tables.rb`
10
- # from Unicode 16.0 source files in `vendor/unicode/`).
10
+ # from Unicode 17.0 source files in `vendor/unicode/`).
11
11
  #
12
12
  # Conforms (approximately) to UTS #46 with WHATWG URL parameters:
13
13
  #
@@ -201,12 +201,12 @@ module Dommy
201
201
  end
202
202
  end
203
203
 
204
+ # UTS #46 V6: General_Category Mark (Mn, Mc or Me), which is wider than
205
+ # the Bidi class NSM; a spacing mark such as U+1B44 is L.
204
206
  def self.validate_no_leading_combining_mark(label)
205
207
  first_cp = label.codepoints.first
206
208
  return unless first_cp
207
-
208
- bidi_class = bidi_class_of(first_cp)
209
- return unless bidi_class == :NSM
209
+ return unless IDNAData.lookup(IDNAData::MARK, first_cp)
210
210
 
211
211
  raise Error, "label starts with combining mark: #{label.inspect}"
212
212
  end