dommy 0.12.0 → 0.14.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 (165) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/backend/makiri_adapter.rb +63 -29
  3. data/lib/dommy/backend.rb +32 -9
  4. data/lib/dommy/bridge/constructor.rb +72 -0
  5. data/lib/dommy/bridge/errors.rb +42 -0
  6. data/lib/dommy/bridge/methods.rb +21 -14
  7. data/lib/dommy/bridge/sentinels.rb +39 -0
  8. data/lib/dommy/bridge/values.rb +78 -0
  9. data/lib/dommy/{js → bridge}/wire_tags.rb +22 -11
  10. data/lib/dommy/bridge.rb +22 -237
  11. data/lib/dommy/browser.rb +155 -58
  12. data/lib/dommy/callable_invoker.rb +16 -0
  13. data/lib/dommy/character_data.rb +539 -0
  14. data/lib/dommy/class_list.rb +311 -0
  15. data/lib/dommy/compression_streams.rb +12 -62
  16. data/lib/dommy/crypto.rb +12 -1
  17. data/lib/dommy/css.rb +12 -0
  18. data/lib/dommy/data_transfer.rb +106 -0
  19. data/lib/dommy/document.rb +312 -374
  20. data/lib/dommy/dom_parser.rb +3 -0
  21. data/lib/dommy/element.rb +201 -1820
  22. data/lib/dommy/encodings/tables.rb +751 -0
  23. data/lib/dommy/encodings.rb +330 -0
  24. data/lib/dommy/event.rb +231 -6
  25. data/lib/dommy/event_source.rb +41 -0
  26. data/lib/dommy/fetch.rb +60 -49
  27. data/lib/dommy/form_data.rb +16 -64
  28. data/lib/dommy/form_entry_list.rb +227 -0
  29. data/lib/dommy/fragment.rb +320 -0
  30. data/lib/dommy/history.rb +3 -0
  31. data/lib/dommy/html_collection.rb +18 -8
  32. data/lib/dommy/html_elements/base.rb +338 -0
  33. data/lib/dommy/html_elements/embedded.rb +237 -0
  34. data/lib/dommy/html_elements/forms/form.rb +1088 -0
  35. data/lib/dommy/html_elements/forms/input.rb +642 -0
  36. data/lib/dommy/html_elements/forms/select.rb +536 -0
  37. data/lib/dommy/html_elements/forms.rb +8 -0
  38. data/lib/dommy/html_elements/interactive.rb +382 -0
  39. data/lib/dommy/html_elements/media.rb +329 -0
  40. data/lib/dommy/html_elements/metadata.rb +353 -0
  41. data/lib/dommy/html_elements/miscellaneous.rb +77 -0
  42. data/lib/dommy/html_elements/tables.rb +468 -0
  43. data/lib/dommy/html_elements.rb +11 -5248
  44. data/lib/dommy/hyperlink_utils.rb +171 -0
  45. data/lib/dommy/interaction/debug.rb +7 -6
  46. data/lib/dommy/interaction/dom_summary.rb +65 -40
  47. data/lib/dommy/interaction/driver.rb +29 -115
  48. data/lib/dommy/interaction/event_synthesis.rb +97 -4
  49. data/lib/dommy/interaction/field_interactor.rb +1 -13
  50. data/lib/dommy/interaction/form_submission.rb +89 -167
  51. data/lib/dommy/interaction/key_sender.rb +121 -0
  52. data/lib/dommy/interaction/mime_types.rb +25 -0
  53. data/lib/dommy/interaction.rb +2 -0
  54. data/lib/dommy/internal/accessibility_tree.rb +32 -9
  55. data/lib/dommy/internal/accessibility_visibility.rb +44 -0
  56. data/lib/dommy/internal/accessible_name.rb +85 -65
  57. data/lib/dommy/internal/aria_role.rb +21 -6
  58. data/lib/dommy/internal/aria_snapshot.rb +5 -0
  59. data/lib/dommy/internal/aria_state.rb +24 -5
  60. data/lib/dommy/internal/autofill.rb +141 -0
  61. data/lib/dommy/internal/backend_prefilter.rb +170 -0
  62. data/lib/dommy/internal/bounded_cache.rb +50 -0
  63. data/lib/dommy/internal/child_node.rb +9 -34
  64. data/lib/dommy/internal/css/calc.rb +143 -145
  65. data/lib/dommy/internal/css/cascade.rb +38 -393
  66. data/lib/dommy/internal/css/cascaded_declarations.rb +245 -0
  67. data/lib/dommy/internal/css/color.rb +61 -34
  68. data/lib/dommy/internal/css/computed_style_builder.rb +205 -0
  69. data/lib/dommy/internal/css/computed_style_declaration.rb +20 -4
  70. data/lib/dommy/internal/css/counters.rb +60 -37
  71. data/lib/dommy/internal/css/custom_properties.rb +87 -39
  72. data/lib/dommy/internal/css/media_query.rb +228 -203
  73. data/lib/dommy/internal/css/parser.rb +29 -12
  74. data/lib/dommy/internal/css/property_registry.rb +32 -56
  75. data/lib/dommy/internal/css/renderability.rb +61 -0
  76. data/lib/dommy/internal/css/rule_index.rb +17 -140
  77. data/lib/dommy/internal/css/selector_dependencies.rb +162 -0
  78. data/lib/dommy/internal/css/style_cache.rb +61 -0
  79. data/lib/dommy/internal/css/ua_stylesheet.rb +40 -1
  80. data/lib/dommy/internal/css/value_tokens.rb +60 -0
  81. data/lib/dommy/internal/directionality.rb +188 -0
  82. data/lib/dommy/internal/document_generations.rb +169 -0
  83. data/lib/dommy/internal/document_interaction_state.rb +47 -0
  84. data/lib/dommy/internal/document_live_ranges.rb +97 -0
  85. data/lib/dommy/internal/dom_matching.rb +28 -12
  86. data/lib/dommy/internal/element_aria.rb +163 -0
  87. data/lib/dommy/internal/element_geometry.rb +69 -0
  88. data/lib/dommy/internal/element_matching.rb +5 -12
  89. data/lib/dommy/internal/element_shadow.rb +117 -0
  90. data/lib/dommy/internal/element_state.rb +232 -0
  91. data/lib/dommy/internal/element_tasks.rb +19 -0
  92. data/lib/dommy/internal/element_top_layer.rb +63 -0
  93. data/lib/dommy/internal/enumerated_keyword_sets.rb +53 -0
  94. data/lib/dommy/internal/exception_report.rb +123 -0
  95. data/lib/dommy/internal/idna.rb +4 -4
  96. data/lib/dommy/internal/idna_data.rb +513 -100
  97. data/lib/dommy/internal/input_type.rb +280 -0
  98. data/lib/dommy/internal/insertion_point.rb +62 -0
  99. data/lib/dommy/internal/mime_type.rb +74 -0
  100. data/lib/dommy/internal/mutation_coordinator.rb +57 -198
  101. data/lib/dommy/internal/namespaces.rb +41 -43
  102. data/lib/dommy/internal/node_adopter.rb +84 -77
  103. data/lib/dommy/internal/node_factory.rb +157 -0
  104. data/lib/dommy/internal/node_identity.rb +31 -0
  105. data/lib/dommy/internal/node_wrapper_cache.rb +61 -207
  106. data/lib/dommy/internal/observer_options.rb +93 -0
  107. data/lib/dommy/internal/parent_node.rb +27 -27
  108. data/lib/dommy/internal/post_insertion_steps.rb +213 -0
  109. data/lib/dommy/internal/reflected_attributes.rb +425 -13
  110. data/lib/dommy/internal/rendered_text/collector.rb +221 -0
  111. data/lib/dommy/internal/rendered_text/computed_box.rb +39 -0
  112. data/lib/dommy/internal/rendered_text/fragment.rb +55 -0
  113. data/lib/dommy/internal/rendered_text/items.rb +57 -0
  114. data/lib/dommy/internal/rendered_text/whitespace_collapser.rb +132 -0
  115. data/lib/dommy/internal/selector_arguments.rb +94 -0
  116. data/lib/dommy/internal/selector_index.rb +3 -1
  117. data/lib/dommy/internal/selector_matcher.rb +23 -383
  118. data/lib/dommy/internal/selector_parser.rb +2 -37
  119. data/lib/dommy/internal/template_content_registry.rb +112 -66
  120. data/lib/dommy/internal/text_flattening.rb +20 -0
  121. data/lib/dommy/internal/text_selection.rb +104 -0
  122. data/lib/dommy/internal/toggle_events.rb +56 -0
  123. data/lib/dommy/internal/toggle_task_tracker.rb +57 -0
  124. data/lib/dommy/internal/url_parser.rb +732 -527
  125. data/lib/dommy/internal/url_record_accessors.rb +90 -0
  126. data/lib/dommy/internal/window_constructors.rb +190 -0
  127. data/lib/dommy/internal/xml_serialization.rb +168 -80
  128. data/lib/dommy/js/crossing_profile.rb +75 -0
  129. data/lib/dommy/js/dom_interfaces.rb +23 -2
  130. data/lib/dommy/js/error_log.rb +137 -0
  131. data/lib/dommy/js/host_bridge.rb +56 -187
  132. data/lib/dommy/js/host_callbacks.rb +96 -0
  133. data/lib/dommy/js/host_runtime.js +896 -729
  134. data/lib/dommy/js/import_map.rb +10 -1
  135. data/lib/dommy/js/marshaller.rb +111 -63
  136. data/lib/dommy/js/observable_runtime.js +125 -146
  137. data/lib/dommy/js/runtime.rb +17 -43
  138. data/lib/dommy/js/runtime_registry.rb +46 -0
  139. data/lib/dommy/js/script_boot.rb +94 -85
  140. data/lib/dommy/js/webidl_tables.js +619 -0
  141. data/lib/dommy/js/window_wiring.rb +69 -0
  142. data/lib/dommy/location.rb +161 -102
  143. data/lib/dommy/mutation_observer.rb +3 -57
  144. data/lib/dommy/navigation.rb +65 -12
  145. data/lib/dommy/navigator.rb +3 -1
  146. data/lib/dommy/node.rb +46 -20
  147. data/lib/dommy/promise.rb +62 -8
  148. data/lib/dommy/resources.rb +19 -1
  149. data/lib/dommy/scheduler.rb +35 -6
  150. data/lib/dommy/shadow_root.rb +11 -5
  151. data/lib/dommy/streams.rb +1200 -187
  152. data/lib/dommy/style_declaration.rb +284 -0
  153. data/lib/dommy/svg_elements.rb +1 -45
  154. data/lib/dommy/text_codec.rb +110 -213
  155. data/lib/dommy/url.rb +32 -79
  156. data/lib/dommy/url_pattern/regexp_translator.rb +1 -6
  157. data/lib/dommy/url_pattern/tokenizer.rb +4 -2
  158. data/lib/dommy/validity_state.rb +383 -0
  159. data/lib/dommy/version.rb +1 -1
  160. data/lib/dommy/window.rb +184 -192
  161. data/lib/dommy/xml_http_request.rb +52 -7
  162. data/lib/dommy.rb +25 -3
  163. metadata +78 -7
  164. data/lib/dommy/bridge/constructor_registry.rb +0 -28
  165. data/lib/dommy/internal/css_pseudo_handlers.rb +0 -378
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../css/cascade"
4
+ require_relative "../css/renderability"
5
+ require_relative "../css/ua_stylesheet"
6
+ require_relative "computed_box"
7
+ require_relative "items"
8
+ require_relative "whitespace_collapser"
9
+
10
+ module Dommy
11
+ module Internal
12
+ module RenderedText
13
+ # The `innerText` / `outerText` getter: HTML's "rendered text collection
14
+ # steps", from
15
+ # https://html.spec.whatwg.org/multipage/dom.html#the-innertext-and-outertext-properties
16
+ #
17
+ # Dommy lays nothing out, so the layout-derived parts — soft line
18
+ # wrapping, `::first-line` / `::first-letter`, multicol — are out of
19
+ # reach. Everything that follows from the computed style and the tree is
20
+ # implemented: display:none / contents, visibility, white-space,
21
+ # text-transform, <br>, block and <p> line breaks, table cells and rows,
22
+ # and the select / optgroup / option box rules.
23
+ #
24
+ # Whether the element itself is rendered is asked once, up the tree.
25
+ # Below it the walk goes top-down and never looks up again: a subtree
26
+ # without a box is skipped where it starts (see #collect_element and
27
+ # #rendered_children).
28
+ class Collector
29
+ # Elements that render as a replaced box, whose children are not
30
+ # rendered at all.
31
+ REPLACED = %w[area audio canvas embed iframe img input object textarea video].freeze
32
+
33
+ # The elements the spec gives a special box for inside <select>.
34
+ OPTION_LIKE = %w[optgroup option].freeze
35
+
36
+ # Displays that make an atomic inline-level box: it establishes its own
37
+ # inline formatting context, so its leading/trailing whitespace is
38
+ # trimmed and the whitespace around it does not merge with its content.
39
+ ATOMIC_INLINE_DISPLAYS = %w[inline-block inline-flex inline-grid inline-table].freeze
40
+
41
+ def initialize(element)
42
+ @element = element
43
+ @boxes = {}.compare_by_identity
44
+ end
45
+
46
+ # HTML's "get the text steps": textContent for an element that is not
47
+ # being rendered, the rendered text otherwise.
48
+ def text
49
+ return @element.text_content.to_s unless being_rendered?(@element)
50
+
51
+ WhitespaceCollapser.collapse(collect_children(@element))
52
+ end
53
+
54
+ private
55
+
56
+ # Rendered by tree position (connected, in the flat tree, in a rendered
57
+ # frame), and reached from the root only through boxes that render it:
58
+ # no display:none on the way, and each step down one that
59
+ # #rendered_children keeps — which is what rules out a replaced
60
+ # ancestor and the hidden content of a closed <details>.
61
+ def being_rendered?(element)
62
+ return false if CSS::Renderability.not_rendered?(element, method(:style))
63
+
64
+ node = element
65
+ while node
66
+ return false if box(node).none?
67
+
68
+ parent = node.parent_element
69
+ return false if parent && rendered_children(parent).none? { |child| child.equal?(node) }
70
+
71
+ node = parent
72
+ end
73
+ true
74
+ end
75
+
76
+ # --- the rendered text collection steps -----------------------------
77
+
78
+ def collect(node)
79
+ case node
80
+ when TextNode then collect_text(node)
81
+ when Element then collect_element(node)
82
+ else [] # a Comment / ProcessingInstruction carries no rendered text
83
+ end
84
+ end
85
+
86
+ def collect_text(node)
87
+ box = box(node.parent_element)
88
+ return [] unless box.visible?
89
+
90
+ [TextRun.new(node.data.to_s, box.white_space, box.text_transform)]
91
+ end
92
+
93
+ def collect_element(element)
94
+ box = box(element)
95
+ # Nothing inside a display:none box is rendered.
96
+ return [] if box.none?
97
+
98
+ items = collect_children(element)
99
+ # Invisible, or no box of its own (display:contents): the children's
100
+ # items alone.
101
+ return items if !box.visible? || box.contents?
102
+ # An atomic inline renders as one unbreakable box: a replaced element
103
+ # contributes nothing, an inline-block contributes its own (already
104
+ # collapsed) text, and whitespace on either side does not merge with
105
+ # what is inside. Block-level replaced elements still get line breaks
106
+ # below.
107
+ return [Atomic.new("")] if replaced?(element) && box.inline_level?
108
+ return [Atomic.new(WhitespaceCollapser.collapse(items))] if atomic_inline?(element, box)
109
+
110
+ items << Literal.new("\n") if tag(element) == "br"
111
+ items << Literal.new("\t") if table_cell_not_last?(element, box)
112
+ items << Literal.new("\n") if table_row_not_last?(element, box)
113
+ breaks = required_breaks(element, box)
114
+ breaks ? [RequiredBreak.new(breaks), *items, RequiredBreak.new(breaks)] : items
115
+ end
116
+
117
+ def collect_children(element)
118
+ rendered_children(element).flat_map { |child| collect(child) }
119
+ end
120
+
121
+ # The child nodes that get a box, where the element restricts them: a
122
+ # replaced element renders none, a <select> only its optgroup / option
123
+ # children, a closed <details> only its first <summary>, and a shadow
124
+ # host only the children assigned to a slot.
125
+ def rendered_children(element)
126
+ children = element.child_nodes.to_a
127
+ if replaced?(element)
128
+ []
129
+ elsif tag(element) == "select"
130
+ children.select { |child| option_like?(child) }
131
+ elsif closed_details?(element)
132
+ summary = children.grep(Element).find { |child| tag(child) == "summary" }
133
+ summary ? [summary] : []
134
+ elsif element.shadow_root
135
+ children.select { |child| child.respond_to?(:assigned_slot) && child.assigned_slot }
136
+ else
137
+ children
138
+ end
139
+ end
140
+
141
+ # A <p> gets two required line breaks whatever its display, any other
142
+ # block-level box one.
143
+ def required_breaks(element, box)
144
+ if tag(element) == "p" then 2
145
+ elsif block_level?(element, box) then 1
146
+ end
147
+ end
148
+
149
+ # optgroup / option act as block-level boxes whatever their computed
150
+ # display; table cells and rows are separated by tabs and newlines
151
+ # instead. With no computed display, the UA-default box decides.
152
+ def block_level?(element, box)
153
+ return true if option_like?(element)
154
+ return CSS::UAStylesheet::BLOCK_LEVEL_TAGS.include?(tag(element)) if box.display.nil?
155
+ return false if box.display == "table-cell" || box.display == "table-row"
156
+
157
+ !box.inline_level?
158
+ end
159
+
160
+ # A <p> keeps its two line breaks whatever its display.
161
+ def atomic_inline?(element, box)
162
+ tag(element) != "p" && ATOMIC_INLINE_DISPLAYS.include?(box.display)
163
+ end
164
+
165
+ def table_cell_not_last?(element, box)
166
+ return false unless box.display == "table-cell"
167
+
168
+ row = element.parent_element
169
+ return false unless row
170
+
171
+ cells = element_children(row).select { |child| box(child).display == "table-cell" }
172
+ !cells.last.equal?(element)
173
+ end
174
+
175
+ def table_row_not_last?(element, box)
176
+ return false unless box.display == "table-row"
177
+
178
+ table = element.parent_element
179
+ table = table.parent_element while table && box(table).display == "table-row-group"
180
+ return false unless table
181
+
182
+ !table_rows(table).last.equal?(element)
183
+ end
184
+
185
+ # Every table-row box of a table, in tree order, descending through the
186
+ # row groups (tbody / thead / tfoot) but not into a nested table.
187
+ def table_rows(parent)
188
+ element_children(parent).flat_map do |child|
189
+ case box(child).display
190
+ when "table-row" then [child]
191
+ when "table-row-group" then table_rows(child)
192
+ else []
193
+ end
194
+ end
195
+ end
196
+
197
+ # --- element facts --------------------------------------------------
198
+
199
+ def tag(element) = element.local_name.to_s.downcase
200
+
201
+ def replaced?(element) = REPLACED.include?(tag(element))
202
+
203
+ def option_like?(node) = node.is_a?(Element) && OPTION_LIKE.include?(tag(node))
204
+
205
+ def closed_details?(element) = tag(element) == "details" && !element.has_attribute?("open")
206
+
207
+ def element_children(element) = element.child_nodes.to_a.grep(Element)
208
+
209
+ def box(element)
210
+ @boxes[element] ||= ComputedBox.from(style(element))
211
+ end
212
+
213
+ def style(element)
214
+ CSS::Cascade.computed_style(element)
215
+ rescue CSS::Parser::Unavailable
216
+ {}
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ module RenderedText
6
+ # The computed properties the collection steps read from one element.
7
+ #
8
+ # An empty computed style (no CSS layer to ask) falls back to each
9
+ # property's initial value. `display` alone has no usable one — `inline`
10
+ # would make every <div> inline — so it is nil there, and the caller
11
+ # falls back to the element's UA-default box instead.
12
+ ComputedBox = Data.define(:display, :visibility, :white_space, :text_transform) do
13
+ def self.from(style)
14
+ new(
15
+ display: value_of(style, "display"),
16
+ visibility: value_of(style, "visibility") || "visible",
17
+ white_space: value_of(style, "white-space") || "normal",
18
+ text_transform: value_of(style, "text-transform") || "none"
19
+ )
20
+ end
21
+
22
+ def self.value_of(style, property)
23
+ value = style[property].to_s
24
+ value unless value.empty?
25
+ end
26
+ private_class_method :value_of
27
+
28
+ def visible? = visibility == "visible"
29
+
30
+ def none? = display == "none"
31
+
32
+ def contents? = display == "contents"
33
+
34
+ # Inline-level, counting an unknown display as the inline default.
35
+ def inline_level? = display.nil? || display.start_with?("inline")
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ module RenderedText
6
+ # The `innerText` / `outerText` setters, both built on HTML's "rendered
7
+ # text fragment": the value split on line breaks, a Text node for each
8
+ # run of text between them and a <br> for each break (CRLF counts once).
9
+ module Fragment
10
+ module_function
11
+
12
+ # The innerText setter: "set the inner text steps".
13
+ def set_inner(element, value)
14
+ element.__internal_replace_all__(fragment(element.document, value.to_s).map(&:__dommy_backend_node__))
15
+ nil
16
+ end
17
+
18
+ # The outerText setter: the element is replaced by the fragment (an
19
+ # empty Text node when there is none), and the Text nodes it lands
20
+ # between merge with it.
21
+ def set_outer(element, value)
22
+ raise DOMException::NoModificationAllowedError, "outerText requires a parent" if element.parent_node.nil?
23
+
24
+ following = element.next_sibling
25
+ preceding = element.previous_sibling
26
+ nodes = fragment(element.document, value.to_s)
27
+ nodes = [element.document.create_text_node("")] if nodes.empty?
28
+ element.replace_with(*nodes)
29
+ merge_next_text_node(following)
30
+ merge_next_text_node(preceding) if preceding.is_a?(TextNode)
31
+ nil
32
+ end
33
+
34
+ def fragment(document, value)
35
+ value.scan(/[^\r\n]+|\r\n|\r|\n/).map do |piece|
36
+ piece.start_with?("\r", "\n") ? document.create_element("br") : document.create_text_node(piece)
37
+ end
38
+ end
39
+
40
+ def merge_next_text_node(node)
41
+ return unless node.is_a?(TextNode)
42
+
43
+ following = node.next_sibling
44
+ return unless following.is_a?(TextNode)
45
+
46
+ node.data = node.data + following.data
47
+ following.remove
48
+ end
49
+
50
+ # The two setters are the module; the rest is how.
51
+ private_class_method :fragment, :merge_next_text_node
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ module RenderedText
6
+ # The white-space values under which a run's spaces, tabs and newlines
7
+ # collapse into one space.
8
+ COLLAPSIBLE_WHITE_SPACE = %w[normal nowrap pre-line].freeze
9
+ # The values under which a newline in the source stays a newline.
10
+ NEWLINE_PRESERVING_WHITE_SPACE = %w[pre pre-wrap pre-line break-spaces].freeze
11
+ # The values under which spaces and tabs stay as written.
12
+ SPACE_PRESERVING_WHITE_SPACE = %w[pre pre-wrap break-spaces].freeze
13
+
14
+ # The item list the collection steps produce holds four kinds of item.
15
+
16
+ # A run of text as it appears in the item list, before the block-wide
17
+ # white-space pass: the raw data plus the two computed properties that
18
+ # decide how it renders.
19
+ TextRun = Data.define(:text, :white_space, :text_transform) do
20
+ def empty? = text.empty?
21
+
22
+ def collapsible? = COLLAPSIBLE_WHITE_SPACE.include?(white_space)
23
+
24
+ def preserves_newlines? = NEWLINE_PRESERVING_WHITE_SPACE.include?(white_space)
25
+
26
+ def preserves_spaces? = SPACE_PRESERVING_WHITE_SPACE.include?(white_space)
27
+
28
+ # Collapsible whitespace and nothing else: beside a block break it
29
+ # neither shows nor separates anything.
30
+ def collapsible_whitespace_only? = collapsible? && text.match?(/\A[ \t\n\r\f]*\z/)
31
+
32
+ # The text with `text-transform` applied.
33
+ def transformed_text
34
+ case text_transform
35
+ when "uppercase" then text.upcase
36
+ when "lowercase" then text.downcase
37
+ when "capitalize" then text.gsub(/\b\p{L}/) { |c| c.upcase }
38
+ else text
39
+ end
40
+ end
41
+ end
42
+
43
+ # An atomic inline box (an inline-block or a replaced element): its own
44
+ # rendered text, already collapsed and trimmed, which the surrounding
45
+ # block treats as a single unbreakable unit.
46
+ Atomic = Data.define(:text)
47
+
48
+ # A string the collection steps append as is: "\n" for a <br>, "\t"
49
+ # between table cells, "\n" between table rows.
50
+ Literal = Data.define(:text)
51
+
52
+ # A required line break count: the breaks around a block-level box (1)
53
+ # or a <p> (2). Adjacent counts collapse to the largest.
54
+ RequiredBreak = Data.define(:count)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "items"
4
+
5
+ module Dommy
6
+ module Internal
7
+ module RenderedText
8
+ # The block-wide white-space pass: turns the item list into the final
9
+ # string. The list is first normalized (see #normalize), then each item
10
+ # is appended in turn. Its three pieces of state are the whole algorithm:
11
+ # * `pending_space` — a collapsible space seen but not yet emitted (it is
12
+ # dropped if nothing follows on the line);
13
+ # * `line_start` — suppresses that space at the start of a line;
14
+ # * `after_atomic` — makes the space following an atomic inline literal,
15
+ # so whitespace on either side of an inline-block does not merge.
16
+ class WhitespaceCollapser
17
+ def self.collapse(items) = new.collapse(items)
18
+
19
+ def initialize
20
+ @out = +""
21
+ @pending_space = false
22
+ @line_start = true
23
+ @after_atomic = false
24
+ end
25
+
26
+ def collapse(items)
27
+ normalize(items).each { |item| append(item) }
28
+ @out
29
+ end
30
+
31
+ private
32
+
33
+ # Drop empty runs, drop a collapsible whitespace-only run beside a
34
+ # required break (the whitespace around a block box neither shows nor
35
+ # separates the two breaks), trim the leading/trailing required breaks,
36
+ # and collapse each run of them to the largest count.
37
+ def normalize(items)
38
+ items = items.reject { |item| item.is_a?(TextRun) && item.empty? }
39
+ items = items.reject.with_index do |item, i|
40
+ item.is_a?(TextRun) && item.collapsible_whitespace_only? &&
41
+ (required_break_at?(items, i - 1) || required_break_at?(items, i + 1))
42
+ end
43
+ items.shift while items.first.is_a?(RequiredBreak)
44
+ items.pop while items.last.is_a?(RequiredBreak)
45
+
46
+ items.chunk_while { |a, b| a.is_a?(RequiredBreak) && b.is_a?(RequiredBreak) }.map do |chunk|
47
+ chunk.first.is_a?(RequiredBreak) ? RequiredBreak.new(chunk.map(&:count).max) : chunk.first
48
+ end
49
+ end
50
+
51
+ def required_break_at?(items, index)
52
+ index >= 0 && items[index].is_a?(RequiredBreak)
53
+ end
54
+
55
+ def append(item)
56
+ case item
57
+ when RequiredBreak then append_breaks(item.count)
58
+ when Literal then append_literal(item.text)
59
+ when Atomic then append_atomic(item.text)
60
+ when TextRun then append_run(item)
61
+ end
62
+ end
63
+
64
+ # Emit that many newlines and start a line.
65
+ def append_breaks(count)
66
+ @out << ("\n" * count)
67
+ @pending_space = false
68
+ @line_start = true
69
+ @after_atomic = false
70
+ end
71
+
72
+ def append_literal(text)
73
+ @out << text
74
+ @pending_space = false
75
+ @line_start = text == "\n"
76
+ @after_atomic = false
77
+ end
78
+
79
+ def append_atomic(text)
80
+ @out << " " if @pending_space
81
+ @out << text
82
+ @pending_space = false
83
+ @line_start = false
84
+ @after_atomic = true
85
+ end
86
+
87
+ def append_run(run)
88
+ run.transformed_text.each_char do |char|
89
+ case char
90
+ when "\n", "\r" then append_newline(run)
91
+ when " ", "\t", "\f" then append_space(char, run)
92
+ else append_char(char)
93
+ end
94
+ end
95
+ end
96
+
97
+ def append_newline(run)
98
+ if run.preserves_newlines?
99
+ @out << "\n"
100
+ @pending_space = false
101
+ @line_start = true
102
+ elsif run.collapsible?
103
+ @pending_space = true
104
+ end
105
+ end
106
+
107
+ def append_space(char, run)
108
+ if run.preserves_spaces?
109
+ @out << char
110
+ @pending_space = false
111
+ @line_start = false
112
+ elsif @after_atomic
113
+ @out << " "
114
+ @after_atomic = false
115
+ else
116
+ @pending_space = true
117
+ end
118
+ end
119
+
120
+ def append_char(char)
121
+ if @pending_space && !@line_start && !@out.end_with?(" ", "\n", "\t")
122
+ @out << " "
123
+ end
124
+ @pending_space = false
125
+ @after_atomic = false
126
+ @out << char
127
+ @line_start = false
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # What every query method does with its selector argument before anything
6
+ # matches: coerce it per WebIDL, validate it against the grammar, and map
7
+ # the errors a backend raises onto the DOM ones.
8
+ #
9
+ # Matching itself is SelectorMatcher's, and the state pseudo-classes it
10
+ # cannot answer from the tree alone (`:checked`, `:target`) are here because
11
+ # they read live DOM state rather than the selector.
12
+
13
+ # The complete set of CSS pseudo-classes (+ the four legacy single-colon
14
+ # pseudo-elements). A `:identifier` outside this set is an unknown selector
15
+ # token → SyntaxError, whereas a known-but-unimplemented one (`:hover`) is a
16
+ # valid selector that simply matches nothing.
17
+ KNOWN_PSEUDOS = %w[
18
+ active any-link autofill blank checked current default defined disabled empty
19
+ enabled first first-child first-of-type focus focus-visible focus-within
20
+ fullscreen future has host hover in-range indeterminate invalid is lang
21
+ last-child last-of-type left link local-link modal not nth-child nth-col
22
+ nth-last-child nth-last-col nth-last-of-type nth-of-type only-child
23
+ only-of-type optional out-of-range past placeholder-shown playing paused
24
+ read-only read-write required right root scope target target-within
25
+ user-invalid user-valid valid visited where dir
26
+ before after first-line first-letter
27
+ ].to_set.freeze
28
+
29
+ # Validate a non-null CSS selector for `querySelector`/`matches`/`closest`,
30
+ # raising SyntaxError for syntactically invalid selectors. Delegates to the
31
+ # full grammar parser (SelectorParser), which catches the whole Selectors
32
+ # grammar — `[*=v]`, `..x`, `div % p`, unknown pseudo-elements, undeclared
33
+ # namespaces — not just the obvious cases.
34
+ def self.validate_selector!(selector)
35
+ SelectorParser.validate!(selector.to_s)
36
+ end
37
+
38
+ # Coerce the JS argument of a query method (querySelector/All) per WebIDL: the
39
+ # selector is a *non-nullable* DOMString, so JS `null` → "null" and
40
+ # `undefined` → "undefined" (which then match `<null>` / `<undefined>` typed
41
+ # elements rather than returning nothing), while a missing argument is a
42
+ # TypeError. Used at every JS dispatch site so the behaviour is uniform.
43
+ def self.css_query_arg!(args)
44
+ raise ::Dommy::Bridge::TypeError, "1 argument required, but only 0 present" if args.empty?
45
+
46
+ value = args[0]
47
+ return "null" if value.nil?
48
+ return "undefined" if defined?(::Dommy::Bridge::UNDEFINED) && value.equal?(::Dommy::Bridge::UNDEFINED)
49
+
50
+ value
51
+ end
52
+
53
+ # Map a backend's selector complaints onto the DOM's:
54
+ # - an "Unregistered function" means a valid pseudo the backend compiled
55
+ # but can't evaluate (`:active`, `:invalid`, …) → degrade to no match
56
+ # (returns []),
57
+ # - a backend syntax complaint becomes a DOMException::SyntaxError,
58
+ # - anything else propagates.
59
+ def self.with_selector_errors(selector)
60
+ yield
61
+ rescue ::StandardError => e
62
+ return [] if e.message.include?("Unregistered function")
63
+ raise DOMException::SyntaxError, "'#{selector}' is not a valid selector." if e.message.include?("unexpected")
64
+
65
+ raise
66
+ end
67
+
68
+ # `:checked`'s checkedness/selectedness is live state, not the attribute:
69
+ # checkbox/radio inputs match on the checked property (which defaults to
70
+ # the attribute), <option> on selectedness.
71
+ def self.checked_state?(element)
72
+ return false unless element
73
+
74
+ case element.tag_name
75
+ when "INPUT"
76
+ %w[checkbox radio].include?(element.respond_to?(:type) ? element.type.to_s : "") &&
77
+ element.respond_to?(:checked) && !!element.checked
78
+ when "OPTION"
79
+ element.respond_to?(:selected) && !!element.selected
80
+ else
81
+ false
82
+ end
83
+ end
84
+
85
+ # The id referenced by the document's URL fragment (`:target`), or nil when
86
+ # there is no fragment.
87
+ def self.target_id(document)
88
+ view = document.respond_to?(:default_view) ? document.default_view : nil
89
+ loc = view.respond_to?(:location) ? view.location : nil if view
90
+ hash = loc&.__js_get__("hash").to_s
91
+ hash.start_with?("#") && hash.length > 1 ? hash[1..] : nil
92
+ end
93
+ end
94
+ end
@@ -141,7 +141,9 @@ module Dommy
141
141
  end
142
142
 
143
143
  def record(bnode, enter)
144
- name = bnode.name
144
+ # By local name, as a type selector compares: a prefixed element parsed
145
+ # from XML (`cp:coreProperties`) is found by `coreProperties`.
146
+ name = bnode.local_name
145
147
  (@by_tag[name.downcase] ||= []) << [enter, bnode] if name && !name.empty?
146
148
 
147
149
  id = bnode["id"]