dommy 0.8.0 → 0.9.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.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dom_matching"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
module Internal
|
|
7
|
+
# Element-type-specific finders for links, forms, selects, and
|
|
8
|
+
# checkable fields. Lives in dommy core so that companion gems
|
|
9
|
+
# (currently dommy-rails) share a single finder implementation
|
|
10
|
+
# instead of re-implementing the selector / filter logic per gem.
|
|
11
|
+
#
|
|
12
|
+
# Attribute criteria (`href:`, `action:`, `name:`) accept a String
|
|
13
|
+
# (exact match), a Regexp, or any object responding to `matches?` —
|
|
14
|
+
# the extension point dommy-rails uses to inject URL normalization.
|
|
15
|
+
module ElementMatching
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Find <a> elements matching the given criteria.
|
|
19
|
+
def find_links(scope, text: nil, href: nil)
|
|
20
|
+
links = scope.query_selector_all("a[href]").to_a
|
|
21
|
+
links = links.select { |el| DomMatching.text_matches?(el.text_content, text) } if text
|
|
22
|
+
links = links.select { |el| attribute_matches?(el, "href", href) } if href
|
|
23
|
+
links
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Find <form> elements matching the given criteria. `method:` is
|
|
27
|
+
# matched against the effective method, honoring a hidden
|
|
28
|
+
# `_method` override field inside POST forms.
|
|
29
|
+
def find_forms(scope, action: nil, method: nil)
|
|
30
|
+
forms = scope.query_selector_all("form").to_a
|
|
31
|
+
forms = forms.select { |el| attribute_matches?(el, "action", action) } if action
|
|
32
|
+
forms = forms.select { |el| form_method_matches?(el, method) } if method
|
|
33
|
+
forms
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Find <select> elements matching the given criteria.
|
|
37
|
+
def find_selects(scope, name: nil, label: nil)
|
|
38
|
+
selects = scope.query_selector_all("select").to_a
|
|
39
|
+
selects = selects.select { |el| attribute_matches?(el, "name", name) } if name
|
|
40
|
+
selects = selects.select { |el| field_label_matches?(el, label) } if label
|
|
41
|
+
selects
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Find checkable fields (input[type=checkbox|radio]) matching the
|
|
45
|
+
# given criteria.
|
|
46
|
+
def find_checkable_fields(scope, name: nil, checked: nil)
|
|
47
|
+
fields = scope.query_selector_all("input[type='checkbox'], input[type='radio']").to_a
|
|
48
|
+
fields = fields.select { |el| attribute_matches?(el, "name", name) } if name
|
|
49
|
+
fields = fields.select { |el| el.get_attribute("checked") } if checked == true
|
|
50
|
+
fields = fields.reject { |el| el.get_attribute("checked") } if checked == false
|
|
51
|
+
fields
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def attribute_matches?(element, attr_name, expected)
|
|
55
|
+
actual = element.get_attribute(attr_name).to_s
|
|
56
|
+
case expected
|
|
57
|
+
when Regexp
|
|
58
|
+
actual.match?(expected)
|
|
59
|
+
else
|
|
60
|
+
if expected.respond_to?(:matches?)
|
|
61
|
+
expected.matches?(actual)
|
|
62
|
+
else
|
|
63
|
+
actual == expected.to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Labels associated with a field: a <label for=...> pointing at
|
|
69
|
+
# its id, and/or the nearest <label> ancestor wrapping it.
|
|
70
|
+
# Scans label elements instead of interpolating the id into a
|
|
71
|
+
# selector, so ids containing quotes cannot break the query.
|
|
72
|
+
def field_labels(field)
|
|
73
|
+
labels = []
|
|
74
|
+
|
|
75
|
+
id = field.get_attribute("id").to_s
|
|
76
|
+
unless id.empty?
|
|
77
|
+
for_label = field.owner_document.query_selector_all("label").to_a.find do |label|
|
|
78
|
+
label.get_attribute("for") == id
|
|
79
|
+
end
|
|
80
|
+
labels << for_label if for_label
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
parent = field.parent_node
|
|
84
|
+
while parent
|
|
85
|
+
if parent.respond_to?(:tag_name) && parent.tag_name == "LABEL"
|
|
86
|
+
labels << parent
|
|
87
|
+
break
|
|
88
|
+
end
|
|
89
|
+
parent = parent.respond_to?(:parent_node) ? parent.parent_node : nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
labels
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def field_label_matches?(field, expected_label)
|
|
96
|
+
field_labels(field).any? { |label| DomMatching.text_matches?(label.text_content, expected_label) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def form_method_matches?(form, expected_method)
|
|
100
|
+
actual_method = form.get_attribute("method").to_s.downcase
|
|
101
|
+
if actual_method == "post"
|
|
102
|
+
hidden_method = form.query_selector("input[type='hidden'][name='_method']")
|
|
103
|
+
actual_method = hidden_method.get_attribute("value").to_s.downcase if hidden_method
|
|
104
|
+
end
|
|
105
|
+
actual_method == expected_method.to_s.downcase
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "cgi"
|
|
4
4
|
require "erb"
|
|
5
|
+
require "base64"
|
|
5
6
|
|
|
6
7
|
module Dommy
|
|
7
8
|
module Internal
|
|
@@ -21,6 +22,38 @@ module Dommy
|
|
|
21
22
|
def decode_uri_component(value)
|
|
22
23
|
CGI.unescape(value.to_s)
|
|
23
24
|
end
|
|
25
|
+
|
|
26
|
+
# JS `btoa`: base64-encode a binary (Latin1) string. Each code unit must be
|
|
27
|
+
# 0..255; anything beyond Latin1 is an InvalidCharacterError (per spec).
|
|
28
|
+
def btoa(value)
|
|
29
|
+
codepoints = value.to_s.codepoints
|
|
30
|
+
if codepoints.any? { |c| c > 0xFF }
|
|
31
|
+
raise DOMException::InvalidCharacterError.new(
|
|
32
|
+
"Failed to execute 'btoa': characters outside the Latin1 range cannot be base64-encoded."
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Base64.strict_encode64(codepoints.pack("C*"))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# JS `atob`: decode base64 to a binary (Latin1) string. ASCII whitespace is
|
|
40
|
+
# ignored; an invalid alphabet/length is an InvalidCharacterError. The
|
|
41
|
+
# decoded bytes are returned as a string whose code units equal the bytes.
|
|
42
|
+
def atob(value)
|
|
43
|
+
data = value.to_s.gsub(/[\t\n\f\r ]/, "")
|
|
44
|
+
invalid_atob! if data.length % 4 == 1 || !data.match?(%r{\A[A-Za-z0-9+/]*={0,2}\z})
|
|
45
|
+
|
|
46
|
+
padded = data + ("=" * ((-data.length) % 4))
|
|
47
|
+
Base64.strict_decode64(padded).bytes.map { |byte| byte.chr(Encoding::UTF_8) }.join
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
invalid_atob!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def invalid_atob!
|
|
53
|
+
raise DOMException::InvalidCharacterError.new(
|
|
54
|
+
"Failed to execute 'atob': the string to be decoded is not correctly base64-encoded."
|
|
55
|
+
)
|
|
56
|
+
end
|
|
24
57
|
end
|
|
25
58
|
end
|
|
26
59
|
end
|
|
@@ -43,12 +43,96 @@ module Dommy
|
|
|
43
43
|
|
|
44
44
|
if nk.element?
|
|
45
45
|
wrapped = @document.wrap_node(nk)
|
|
46
|
-
|
|
46
|
+
if wrapped
|
|
47
|
+
notify_connected(wrapped)
|
|
48
|
+
run_connected_script(wrapped)
|
|
49
|
+
fire_blank_iframe_load(wrapped)
|
|
50
|
+
end
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
nk.children.each { |c| notify_connected_subtree(c) } if nk.respond_to?(:children)
|
|
50
54
|
end
|
|
51
55
|
|
|
56
|
+
# A srcless ("blank"/about:blank) `<iframe>` connected to the document gets
|
|
57
|
+
# an empty nested browsing context (a real, complete content document) and
|
|
58
|
+
# fires its `load` event ASYNCHRONOUSLY (a microtask), like a real browser —
|
|
59
|
+
# handlers are commonly attached after insertion (`appendChild(f); f.onload
|
|
60
|
+
# = …`). Without this, code that awaits a blank iframe's load and then reads
|
|
61
|
+
# `iframe.contentWindow.document` hangs: FingerprintJS's `withIframe` (its
|
|
62
|
+
# font sources) does exactly that, which hung note.com's tracking plugin and
|
|
63
|
+
# its whole Nuxt hydration. A `src` iframe is left to the integration layer.
|
|
64
|
+
BLANK_IFRAME_SRCS = ["", "about:blank"].freeze
|
|
65
|
+
|
|
66
|
+
def fire_blank_iframe_load(element)
|
|
67
|
+
return unless element.respond_to?(:local_name) && element.local_name == "iframe"
|
|
68
|
+
return unless element.respond_to?(:is_connected?) && element.is_connected?
|
|
69
|
+
return unless element.respond_to?(:src) && BLANK_IFRAME_SRCS.include?(element.src.to_s.strip)
|
|
70
|
+
|
|
71
|
+
ensure_blank_content_document(element)
|
|
72
|
+
fire = proc { element.dispatch_event(Event.new("load")) rescue nil }
|
|
73
|
+
scheduler = (@document.default_view&.scheduler if @document.respond_to?(:default_view))
|
|
74
|
+
scheduler ? scheduler.queue_microtask(fire) : fire.call
|
|
75
|
+
rescue StandardError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Give a blank iframe a fresh empty document (or its `srcdoc`) so
|
|
80
|
+
# `contentWindow` / `contentDocument` resolve and DOM ops + measurement
|
|
81
|
+
# inside it work (readyState defaults to "complete"). No-op if it already
|
|
82
|
+
# has one.
|
|
83
|
+
def ensure_blank_content_document(element)
|
|
84
|
+
return unless element.respond_to?(:__internal_set_content_document__)
|
|
85
|
+
return if element.respond_to?(:content_document) && element.content_document
|
|
86
|
+
|
|
87
|
+
srcdoc = (element.srcdoc.to_s if element.respond_to?(:srcdoc))
|
|
88
|
+
html = srcdoc.nil? || srcdoc.empty? ? "<html><head></head><body></body></html>" : srcdoc
|
|
89
|
+
win = Dommy::Window.new(backend_doc: Dommy::Backend.parse(html))
|
|
90
|
+
element.__internal_set_content_document__(win.document)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A classic <script> that's now genuinely connected to this document runs:
|
|
94
|
+
# an inline body through the document's script_runner (wired by the JS
|
|
95
|
+
# bridge), an external `src` through external_script_runner (wired by the
|
|
96
|
+
# integration layer, which fetches + runs it — webpack/Vite load on-demand
|
|
97
|
+
# chunks by injecting `<script src>` this way). Gated on is_connected?
|
|
98
|
+
# because this walk also fires for additions to a still-detached subtree.
|
|
99
|
+
def run_connected_script(element)
|
|
100
|
+
return unless element.respond_to?(:__internal_take_pending_script__) # a <script>
|
|
101
|
+
return unless element.respond_to?(:is_connected?) && element.is_connected?
|
|
102
|
+
|
|
103
|
+
if (runner = @document.script_runner) && (source = element.__internal_take_pending_script__)
|
|
104
|
+
# A script-inserted INLINE classic script runs synchronously on insertion.
|
|
105
|
+
runner.call(source)
|
|
106
|
+
elsif @document.external_script_runner &&
|
|
107
|
+
element.respond_to?(:__internal_take_pending_src__) &&
|
|
108
|
+
(src = element.__internal_take_pending_src__)
|
|
109
|
+
run_external_connected_script(element, src)
|
|
110
|
+
end
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A script-inserted EXTERNAL `<script src>` loads and runs ASYNCHRONOUSLY
|
|
116
|
+
# (per HTML spec), unlike an inline one. Running it synchronously inside the
|
|
117
|
+
# insertion steps would (a) execute it mid-render and, worse, (b) make the
|
|
118
|
+
# engine drain its microtask queue while JS is still on the stack — running
|
|
119
|
+
# an unrelated queued microtask (e.g. Vue's `nextTick` scheduler flush)
|
|
120
|
+
# re-entrantly and patching a half-built component tree (note.com's
|
|
121
|
+
# RecommendTemplate crashed Vue's `isPatchable` this way). Defer to a
|
|
122
|
+
# microtask so it runs at a proper checkpoint, after the current task
|
|
123
|
+
# unwinds. Re-check connectedness then (the node may have been removed).
|
|
124
|
+
def run_external_connected_script(element, src)
|
|
125
|
+
run = proc do
|
|
126
|
+
next unless element.respond_to?(:is_connected?) && element.is_connected?
|
|
127
|
+
|
|
128
|
+
@document.external_script_runner.call(element, src)
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
scheduler = (@document.default_view&.scheduler if @document.respond_to?(:default_view))
|
|
133
|
+
scheduler ? scheduler.queue_microtask(run) : run.call
|
|
134
|
+
end
|
|
135
|
+
|
|
52
136
|
def notify_disconnected_subtree(nk)
|
|
53
137
|
return unless nk.respond_to?(:element?)
|
|
54
138
|
|
|
@@ -80,6 +164,7 @@ module Dommy
|
|
|
80
164
|
previous_sibling: nil,
|
|
81
165
|
next_sibling: nil
|
|
82
166
|
)
|
|
167
|
+
@document.__internal_bump_style_generation__
|
|
83
168
|
target = @document.wrap_node(target_node)
|
|
84
169
|
return nil unless target
|
|
85
170
|
return nil if added_nodes.empty? && removed_nodes.empty?
|
|
@@ -111,12 +196,16 @@ module Dommy
|
|
|
111
196
|
)
|
|
112
197
|
# Only observers whose matching registration requested childList get the
|
|
113
198
|
# record (an `attributes`/`characterData`-only observer must not — e.g.
|
|
114
|
-
# `observe(t, {childList: false, attributes: true})`).
|
|
199
|
+
# `observe(t, {childList: false, attributes: true})`). A subtree
|
|
200
|
+
# registration that matched ALSO gains a transient registered observer
|
|
201
|
+
# for each removed node, so mutations within the just-removed subtree
|
|
202
|
+
# (before the next microtask checkpoint) are still observed.
|
|
115
203
|
@observer_manager.observers_matching(target).each do |observer|
|
|
116
204
|
entry = observer.find_matching_entry(target)
|
|
117
|
-
next unless entry
|
|
205
|
+
next unless entry
|
|
118
206
|
|
|
119
|
-
observer.enqueue(record)
|
|
207
|
+
observer.enqueue(record) if entry[:child_list]
|
|
208
|
+
wrapped_removed.each { |removed| observer.add_transient(removed, entry) } if entry[:subtree]
|
|
120
209
|
end
|
|
121
210
|
|
|
122
211
|
nil
|
|
@@ -124,6 +213,7 @@ module Dommy
|
|
|
124
213
|
|
|
125
214
|
# Fire MutationObserver attribute records
|
|
126
215
|
def notify_attribute_mutation(target_node:, attribute_name:, old_value:, namespace: nil)
|
|
216
|
+
@document.__internal_bump_style_generation__
|
|
127
217
|
target = @document.wrap_node(target_node)
|
|
128
218
|
return nil unless target
|
|
129
219
|
|
|
@@ -158,6 +248,7 @@ module Dommy
|
|
|
158
248
|
|
|
159
249
|
# Fire MutationObserver characterData records
|
|
160
250
|
def notify_character_data_mutation(target_node:, old_value:)
|
|
251
|
+
@document.__internal_bump_style_generation__
|
|
161
252
|
target = @document.wrap_node(target_node)
|
|
162
253
|
return nil unless target
|
|
163
254
|
|
|
@@ -25,31 +25,75 @@ module Dommy
|
|
|
25
25
|
|
|
26
26
|
# The full Name production (NameStartChar additionally includes ":").
|
|
27
27
|
NAME = Regexp.new("\\A[:#{NC_START}][:#{NC_START}#{NC_EXTRA}]*\\z")
|
|
28
|
+
# `createElement` / `setAttribute` name validation. Browsers (and the WPT
|
|
29
|
+
# tests that pin web reality) are far more lenient than the XML Name
|
|
30
|
+
# production: any non-empty string with no ASCII whitespace or ">", whose
|
|
31
|
+
# first character is not a digit, ".", "-", "<", ">", or "}". (Names like
|
|
32
|
+
# "f}oo", "f<oo" or a leading combining mark are valid here but not under
|
|
33
|
+
# the strict QName production the *AttributeNS family still uses.)
|
|
34
|
+
HTML_NAME = /\A(?![\s0-9.\-<>}])[^\s>]+\z/
|
|
28
35
|
# PrefixedName | UnprefixedName.
|
|
29
36
|
QNAME = Regexp.new(
|
|
30
37
|
"(?:\\A#{NCSTART}#{NCCHAR}*:#{NCSTART}#{NCCHAR}*\\z)|(?:\\A#{NCSTART}#{NCCHAR}*\\z)"
|
|
31
38
|
)
|
|
32
39
|
|
|
40
|
+
# Code points forbidden anywhere in a "valid local name" / "valid namespace
|
|
41
|
+
# prefix" per the modern WHATWG algorithm: ASCII whitespace (TAB, LF, FF,
|
|
42
|
+
# CR, SPACE), NULL, U+002F (/), U+003E (>).
|
|
43
|
+
LOCAL_FORBIDDEN = Regexp.new("[\\u0000\\u0009\\u000A\\u000C\\u000D\\u0020/>]")
|
|
44
|
+
# Valid first code point of an element local name: ASCII alpha, U+003A (:),
|
|
45
|
+
# U+005F (_), or any code point U+0080 and above.
|
|
46
|
+
ELEMENT_LOCAL_START = Regexp.new("\\A[A-Za-z:_\\u0080-\\u{10FFFF}]")
|
|
47
|
+
|
|
33
48
|
module_function
|
|
34
49
|
|
|
50
|
+
def valid_namespace_prefix?(str)
|
|
51
|
+
!str.empty? && !str.match?(LOCAL_FORBIDDEN)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def valid_element_local_name?(str)
|
|
55
|
+
return false if str.empty?
|
|
56
|
+
return false unless str.match?(ELEMENT_LOCAL_START)
|
|
57
|
+
|
|
58
|
+
rest = str[1..]
|
|
59
|
+
rest.nil? || rest.empty? || !rest.match?(LOCAL_FORBIDDEN)
|
|
60
|
+
end
|
|
61
|
+
|
|
35
62
|
# https://dom.spec.whatwg.org/#validate-and-extract
|
|
36
63
|
# Returns [namespace_or_nil, prefix_or_nil, local_name]. Raises
|
|
37
64
|
# DOMException (InvalidCharacterError / NamespaceError) on bad input.
|
|
38
|
-
|
|
65
|
+
#
|
|
66
|
+
# `context: :element` applies the modern WHATWG "validate" character rules
|
|
67
|
+
# (lenient: a restricted first code point then any non-forbidden code
|
|
68
|
+
# points, multiple colons allowed when namespaced). `context: :attribute`
|
|
69
|
+
# (the default) keeps the strict XML QName production used historically by
|
|
70
|
+
# the *AttributeNS family.
|
|
71
|
+
def validate_and_extract(namespace, qualified_name, context: :attribute)
|
|
39
72
|
ns = namespace.to_s
|
|
40
73
|
ns = nil if ns.empty?
|
|
41
74
|
qname = qualified_name.to_s
|
|
42
75
|
|
|
43
|
-
unless qname.match?(QNAME)
|
|
44
|
-
raise DOMException::InvalidCharacterError, "invalid qualified name: #{qname.inspect}"
|
|
45
|
-
end
|
|
46
|
-
|
|
47
76
|
prefix = nil
|
|
48
77
|
local = qname
|
|
49
78
|
if qname.include?(":")
|
|
79
|
+
# Split on the FIRST colon: any further colons stay in the local part
|
|
80
|
+
# (e.g. "f:o:o" → prefix "f", local "o:o").
|
|
50
81
|
prefix, local = qname.split(":", 2)
|
|
51
82
|
end
|
|
52
83
|
|
|
84
|
+
if context == :element
|
|
85
|
+
if prefix && !valid_namespace_prefix?(prefix)
|
|
86
|
+
raise DOMException::InvalidCharacterError, "invalid namespace prefix: #{prefix.inspect}"
|
|
87
|
+
end
|
|
88
|
+
unless valid_element_local_name?(local)
|
|
89
|
+
raise DOMException::InvalidCharacterError, "invalid local name: #{local.inspect}"
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
unless qname.match?(QNAME)
|
|
93
|
+
raise DOMException::InvalidCharacterError, "invalid qualified name: #{qname.inspect}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
53
97
|
if prefix && ns.nil?
|
|
54
98
|
raise DOMException::NamespaceError, "prefix #{prefix.inspect} with null namespace"
|
|
55
99
|
end
|