dommy 0.11.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/backend/makiri_adapter.rb +2 -14
  3. data/lib/dommy/backend.rb +0 -5
  4. data/lib/dommy/bridge.rb +16 -3
  5. data/lib/dommy/browser.rb +43 -47
  6. data/lib/dommy/callable_invoker.rb +16 -0
  7. data/lib/dommy/compression_streams.rb +12 -62
  8. data/lib/dommy/custom_elements.rb +22 -16
  9. data/lib/dommy/document.rb +241 -47
  10. data/lib/dommy/element.rb +71 -23
  11. data/lib/dommy/encodings/tables.rb +751 -0
  12. data/lib/dommy/encodings.rb +299 -0
  13. data/lib/dommy/event.rb +10 -8
  14. data/lib/dommy/fetch.rb +60 -49
  15. data/lib/dommy/form_data.rb +10 -0
  16. data/lib/dommy/html_elements.rb +136 -62
  17. data/lib/dommy/internal/child_node.rb +1 -5
  18. data/lib/dommy/internal/exception_report.rb +123 -0
  19. data/lib/dommy/internal/idna.rb +4 -4
  20. data/lib/dommy/internal/idna_data.rb +513 -100
  21. data/lib/dommy/internal/leaf_node.rb +2 -8
  22. data/lib/dommy/internal/mutation_coordinator.rb +46 -19
  23. data/lib/dommy/internal/node_wrapper_cache.rb +23 -1
  24. data/lib/dommy/internal/parent_node.rb +14 -74
  25. data/lib/dommy/internal/range_text_serializer.rb +2 -1
  26. data/lib/dommy/internal/selector_ast.rb +17 -0
  27. data/lib/dommy/internal/selector_matcher.rb +20 -21
  28. data/lib/dommy/internal/selector_parser.rb +104 -32
  29. data/lib/dommy/internal/shadow_root_registry.rb +12 -1
  30. data/lib/dommy/internal/url_parser.rb +70 -18
  31. data/lib/dommy/internal/web_idl.rb +57 -0
  32. data/lib/dommy/js/custom_element_bridge.rb +10 -0
  33. data/lib/dommy/js/dom_interfaces.rb +15 -4
  34. data/lib/dommy/js/error_log.rb +137 -0
  35. data/lib/dommy/js/handle_table.rb +15 -0
  36. data/lib/dommy/js/host_bridge.rb +27 -0
  37. data/lib/dommy/js/host_runtime.js +186 -30
  38. data/lib/dommy/js/marshaller.rb +22 -2
  39. data/lib/dommy/js/runtime.rb +8 -1
  40. data/lib/dommy/js/script_boot.rb +58 -14
  41. data/lib/dommy/js/wire_tags.rb +8 -0
  42. data/lib/dommy/location.rb +14 -4
  43. data/lib/dommy/navigator.rb +3 -1
  44. data/lib/dommy/node.rb +19 -3
  45. data/lib/dommy/promise.rb +4 -4
  46. data/lib/dommy/range.rb +116 -183
  47. data/lib/dommy/scheduler.rb +35 -6
  48. data/lib/dommy/selection.rb +422 -0
  49. data/lib/dommy/shadow_root.rb +8 -3
  50. data/lib/dommy/static_range.rb +65 -0
  51. data/lib/dommy/streams.rb +1200 -187
  52. data/lib/dommy/svg_elements.rb +45 -0
  53. data/lib/dommy/text_codec.rb +110 -213
  54. data/lib/dommy/tree_walker.rb +9 -17
  55. data/lib/dommy/url.rb +34 -32
  56. data/lib/dommy/url_pattern/canonicalizer.rb +183 -0
  57. data/lib/dommy/url_pattern/component.rb +64 -0
  58. data/lib/dommy/url_pattern/constructor_string_parser.rb +281 -0
  59. data/lib/dommy/url_pattern/init_processor.rb +163 -0
  60. data/lib/dommy/url_pattern/pattern_parser.rb +343 -0
  61. data/lib/dommy/url_pattern/regexp_translator.rb +694 -0
  62. data/lib/dommy/url_pattern/tokenizer.rb +199 -0
  63. data/lib/dommy/url_pattern.rb +310 -103
  64. data/lib/dommy/version.rb +1 -1
  65. data/lib/dommy/window.rb +164 -35
  66. data/lib/dommy/xml_http_request.rb +18 -4
  67. data/lib/dommy.rb +5 -0
  68. metadata +17 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47c59ce209a6e63313db2b6685126910463abfc26020ec5f24a7b7494b67b10f
4
- data.tar.gz: 43980f4f5eb7b2c5bfe76e41bd2786e9c8cad571df1eb882e0e6d5107fbe04c6
3
+ metadata.gz: 64a22d9ba17102a03881baedbda66b5236b8c4499140d779e4c4acba86583241
4
+ data.tar.gz: e111a114c5a7ae9485a3e99af339e70a59dce6b9a83a837639ac6564873a8d4e
5
5
  SHA512:
6
- metadata.gz: b2627fa8969554f7ecb7a04ed16c331a77e604b44a9ca9e95412a89e48412744a3dd606892255e93eb56988cfda5214000a15821bbc5eb6ed7d18701740b3262
7
- data.tar.gz: 632bddbea02093111566d46ba9fc44af35ed17879bf9921c4ad477a138d44b83e2c2761337d34f5bdfa31cc150353a4b7afe47b0cca8d817e79eda95d7b556ad
6
+ metadata.gz: 67c651bd499d2cd0d6cf1fb862b11a9f0f6b68ba1814abe40732b990e5b66b9b5f0494fd33cb989e4b7a5f64faa3a8a152e8979231f4c082f32187999188030a
7
+ data.tar.gz: e7e8ee9194687b9fe6be28646e9f80d32631f3cf78e5b9fefbab5fd913b9a1d6116ab819bae3a54030f2fefc6e4fa8e478c8ab2667cc086e009c5ebb1a7c9481
@@ -43,18 +43,6 @@ module Dommy
43
43
  node.clone_node(deep)
44
44
  end
45
45
 
46
- # Makiri documents have no node-level clone; re-parsing the serialized
47
- # document reproduces the full tree. Dispatch on the document kind so an XML
48
- # document round-trips through the XML parser (case/namespaces/CDATA) and an
49
- # HTML document through the HTML parser.
50
- def clone_document(doc)
51
- if doc.is_a?(::Makiri::XML::Document)
52
- ::Makiri::XML::Document.parse(doc.to_xml)
53
- else
54
- ::Makiri::HTML::Document.parse(doc.to_html)
55
- end
56
- end
57
-
58
46
  # A fresh, empty HTML-backed document (children dropped so it starts with no
59
47
  # documentElement). The backing for a shallow clone of an HTML document.
60
48
  def empty_document
@@ -233,8 +221,8 @@ module Dommy
233
221
  end
234
222
 
235
223
  # Both Makiri document families (HTML and XML) mint a real PI node and
236
- # serialize it (HTML as `<?target data>`, XML as `<?target data?>`), so PIs
237
- # — unlike CDATA — need no HTML-document fallback.
224
+ # serialize it as `<?target data?>`, so PIs — unlike CDATA — need no
225
+ # HTML-document fallback.
238
226
  def create_processing_instruction(target, data, doc)
239
227
  doc.create_processing_instruction(target, data)
240
228
  end
data/lib/dommy/backend.rb CHANGED
@@ -53,11 +53,6 @@ module Dommy
53
53
  current.clone_node(node, deep: deep)
54
54
  end
55
55
 
56
- # Deep copy of a whole document (DOM cloneNode on the document).
57
- def clone_document(doc)
58
- current.clone_document(doc)
59
- end
60
-
61
56
  # A fresh, empty HTML-backed document.
62
57
  def empty_document
63
58
  current.empty_document
data/lib/dommy/bridge.rb CHANGED
@@ -57,9 +57,20 @@ module Dommy
57
57
  class JSValue
58
58
  attr_reader :ref
59
59
 
60
- def initialize(ref, label = nil)
60
+ # `stack_frames` are the value's JS stack, when the tag that crossed
61
+ # carried one. Ruby cannot reach through a ref to read `.stack`, so an
62
+ # error-shaped value brings its frames with it (see host_runtime.js
63
+ # tagValue) and reporting uses them for the source position.
64
+ attr_reader :stack_frames
65
+
66
+ # The value's JS constructor name, when the tag carried one.
67
+ attr_reader :js_name
68
+
69
+ def initialize(ref, label = nil, stack_frames = nil, js_name = nil)
61
70
  @ref = ref
62
71
  @label = label
72
+ @stack_frames = stack_frames
73
+ @js_name = js_name
63
74
  end
64
75
 
65
76
  def to_s = (@label || "[object]").to_s
@@ -75,9 +86,11 @@ module Dommy
75
86
  class ThrowValue < RuntimeError
76
87
  attr_reader :value
77
88
 
78
- def initialize(value)
89
+ # `message` overrides the value's string form, for a caller that can say
90
+ # more about it than `to_s` can (a JS error's kind, say).
91
+ def initialize(value, message = nil)
79
92
  @value = value
80
- super(value.to_s)
93
+ super(message || value.to_s)
81
94
  end
82
95
  end
83
96
 
data/lib/dommy/browser.rb CHANGED
@@ -23,24 +23,14 @@ module Dommy
23
23
  # settle before the next line.
24
24
  include Dommy::Interaction::Driver
25
25
 
26
- # Raised in strict mode when JS errors were collected and not acknowledged.
27
- class JsError < StandardError
28
- attr_reader :causes
26
+ attr_reader :window, :runtime, :console
29
27
 
30
- def initialize(causes)
31
- @causes = causes
32
- super(build_message(causes))
33
- end
34
-
35
- private
36
-
37
- def build_message(causes)
38
- lines = causes.map { |e| " #{e.class}: #{e.message}" }
39
- "#{causes.length} uncaught JS error(s):\n#{lines.join("\n")}"
40
- end
41
- end
28
+ # The ledger of errors the page left unhandled (`Dommy::Js::ErrorLog`);
29
+ # shared with the other hosts so strict mode behaves the same everywhere.
30
+ attr_reader :error_log
42
31
 
43
- attr_reader :window, :runtime, :js_errors, :console
32
+ # The errors themselves, as a browser console's scrollback.
33
+ def js_errors = @error_log.errors
44
34
 
45
35
  # Build a browser and (unless `execute_scripts: false`) boot its scripts. In
46
36
  # block form the browser is yielded and disposed afterward, returning the
@@ -72,16 +62,13 @@ module Dommy
72
62
  wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
73
63
  @resources = resources
74
64
  @same_origin = same_origin
75
- @strict = strict
76
65
  @backend = backend
77
66
  @execute_scripts = execute_scripts
78
67
  @settle_after_boot = settle
79
68
  @wasm_memory_shim = wasm_memory_shim
80
69
  @navigable = navigable
81
- @js_errors = []
70
+ @error_log = Js::ErrorLog.new(strict: strict)
82
71
  @console = []
83
- @acknowledged = 0
84
- @allow_errors = false
85
72
  @disposed = false
86
73
  @pending_navigation = nil
87
74
  @runtime = nil
@@ -238,22 +225,19 @@ module Dommy
238
225
 
239
226
  # Suppress strict-mode failure for JS errors raised inside the block (they
240
227
  # stay collected in #js_errors for inspection). For tests that expect errors.
241
- def allow_js_errors
242
- prev = @allow_errors
243
- @allow_errors = true
244
- yield
245
- ensure
246
- @allow_errors = prev
247
- @acknowledged = @js_errors.length
228
+ def allow_js_errors(&block)
229
+ @error_log.allow(&block)
248
230
  end
249
231
 
232
+ # Tear the realm down, then fail on anything the page left unhandled and
233
+ # nobody has reported. Disposing first means the failure cannot leave a live
234
+ # VM behind.
250
235
  def dispose
251
236
  return if @disposed
252
237
 
253
238
  @disposed = true
254
- pending = unacknowledged
255
239
  @runtime&.dispose
256
- raise JsError, pending if @strict && !pending.empty?
240
+ @error_log.check!(context: current_url)
257
241
  end
258
242
 
259
243
  private
@@ -268,8 +252,19 @@ module Dommy
268
252
  # The JS engine is pluggable: `@backend` selects a registered runtime
269
253
  # (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
270
254
  runtime = Js.build_runtime(@backend)
271
- runtime.on_unhandled_rejection { |err| @js_errors << err }
272
- runtime.on_callback_error { |err| @js_errors << err } if runtime.respond_to?(:on_callback_error)
255
+ # Every uncaught error the page produces goes through the window's
256
+ # "report an exception" / "notify about rejected promises" funnel, and only
257
+ # what the page left unhandled lands here. A page that installs its own
258
+ # `window.onerror` / `unhandledrejection` handler and cancels the event
259
+ # suppresses the failure, exactly as it would in a browser.
260
+ window.__internal_on_unhandled_error__ { |err| @error_log.record(err) }
261
+ # A rejection the page handled after we reported it is retracted, so it
262
+ # stops failing anything (WHATWG `rejectionhandled`).
263
+ window.__internal_on_rejection_handled__ { |record| @error_log.retract(record) }
264
+ runtime.on_unhandled_rejection { |err| report_rejection(window, err) }
265
+ if runtime.respond_to?(:on_callback_error)
266
+ runtime.on_callback_error { |err| Internal::ExceptionReport.report_at(window, err) }
267
+ end
273
268
  runtime.on_log { |log| @console << log }
274
269
  runtime.define_host_object("document", window.document)
275
270
  runtime.install_window(window)
@@ -285,24 +280,35 @@ module Dommy
285
280
  # Installed whenever a runtime is attached — an embedder that drives script
286
281
  # boot itself (`execute_scripts: false`) still needs inline handlers wired.
287
282
  doc.inline_handler_wirer = lambda do
288
- Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @js_errors << e })
283
+ Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @error_log.record(e) })
289
284
  end
290
285
  return unless @execute_scripts
291
286
 
287
+ # `on_error:` here is only the windowless fallback — a booted document has
288
+ # a window, so a throwing script reports through it (see ScriptBoot).
289
+
292
290
  # Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
293
291
  # fetch + run through the same resources adapter, after boot.
294
292
  doc.external_script_runner = lambda do |element, src|
295
293
  Js::ScriptBoot.run_external_script(runtime, doc, element, src,
296
- resources: @resources, on_error: ->(e) { @js_errors << e })
294
+ resources: @resources, on_error: ->(e) { @error_log.record(e) })
297
295
  end
298
296
  Js::ScriptBoot.run_document_scripts(
299
- runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
297
+ runtime, doc, resources: @resources, on_error: ->(e) { @error_log.record(e) }
300
298
  )
301
299
  # Leave the page in a ready state: run on-load promises, due-now timers,
302
300
  # and rAF (not future timers). `settle: false` observes it mid-flight.
303
301
  runtime.settle if @settle_after_boot
304
302
  end
305
303
 
304
+ # Route an unhandled promise rejection through the page's own
305
+ # `unhandledrejection` handling first. The engine decides WHEN a rejection
306
+ # counts as unhandled (see Window#__internal_report_rejection__).
307
+ def report_rejection(window, error)
308
+ value = Internal::ExceptionReport.error_value(error)
309
+ window.__internal_report_rejection__(value, host_error: error)
310
+ end
311
+
306
312
  # Perform a recorded navigation: fetch the target (following redirects),
307
313
  # fire the old document's unload, then replace the Window + JS realm with the
308
314
  # freshly parsed document and update the joint history. A network miss or a
@@ -400,8 +406,6 @@ module Dommy
400
406
  content_type.empty? || content_type.include?("html") || content_type.include?("xml")
401
407
  end
402
408
 
403
- def unacknowledged = @js_errors[@acknowledged..] || []
404
-
405
409
  def submit_button?(button)
406
410
  if button.tag_name == "BUTTON"
407
411
  button.type == "submit"
@@ -410,18 +414,10 @@ module Dommy
410
414
  end
411
415
  end
412
416
 
413
- # In strict mode, fail on any JS error collected since the last
414
- # acknowledgement. Marks all current errors acknowledged so each is reported
415
- # at most once.
417
+ # In strict mode, fail on anything the page left unhandled since the last
418
+ # checkpoint. The ledger drains itself, so each error is reported once.
416
419
  def check_js_errors!
417
- return if @allow_errors
418
- return unless @strict
419
-
420
- pending = unacknowledged
421
- return if pending.empty?
422
-
423
- @acknowledged = @js_errors.length
424
- raise JsError, pending
420
+ @error_log.check!(context: current_url)
425
421
  end
426
422
  end
427
423
  end
@@ -20,6 +20,22 @@ module Dommy
20
20
  end
21
21
  end
22
22
 
23
+ # Invoke `callback` so a JS throw SURFACES as a Ruby exception instead of
24
+ # being swallowed by the bridge. WHATWG requires a task's exception to be
25
+ # reported at the global ("report an exception") rather than silently
26
+ # dropped, so the entry points that own a task — a timer / rAF callback —
27
+ # invoke this form and report whatever comes out. The plain `invoke` keeps
28
+ # the swallowing behavior for callers that handle their own errors.
29
+ def invoke_raising(callback, *args)
30
+ return if callback.nil?
31
+
32
+ if callback.respond_to?(:__js_invoke__)
33
+ callback.__js_invoke__(args, raising: true)
34
+ else
35
+ invoke(callback, *args)
36
+ end
37
+ end
38
+
23
39
  # Invoke a DOM event listener per the EventTarget rule: an object with
24
40
  # `handle_event`, else a Ruby callable, else a JS-bridged callable (tried in
25
41
  # that order). A JS function listener's `this` must be the event's
@@ -10,43 +10,18 @@ module Dommy
10
10
  # finalizes and emits the compressed/decompressed bytes downstream.
11
11
  #
12
12
  # Spec: https://wicg.github.io/compression/
13
- class CompressionStream
13
+ class CompressionStream < TransformStream
14
14
  SUPPORTED = %w[gzip deflate deflate-raw].freeze
15
15
 
16
- attr_reader :readable, :writable
17
-
18
16
  def initialize(window, format)
19
17
  raise ArgumentError, "unsupported format #{format.inspect}" unless SUPPORTED.include?(format.to_s)
20
18
 
21
- @buffer = +""
19
+ buffer = +""
22
20
  compressor = build_compressor(format.to_s)
23
-
24
- @readable = ReadableStream.new(window)
25
- controller = TransformStreamDefaultController.new(@readable)
26
-
27
- @writable = WritableStream.new(
28
- window,
29
- {
30
- "write" => proc { |chunk| @buffer << coerce(chunk) },
31
- "close" => proc do
32
- compressed = compressor.call(@buffer)
33
- controller.enqueue(compressed)
34
- @readable.__internal_close__
35
- end,
36
- "abort" => proc { |r| @readable.__internal_error__(r) }
37
- }
38
- )
39
- end
40
-
41
- def __js_get__(key)
42
- case key
43
- when "readable"
44
- @readable
45
- when "writable"
46
- @writable
47
- else
48
- Bridge::ABSENT
49
- end
21
+ super(window, {
22
+ "transform" => proc { |chunk, _controller| buffer << coerce(chunk); nil },
23
+ "flush" => proc { |controller| controller.enqueue(compressor.call(buffer)); nil }
24
+ })
50
25
  end
51
26
 
52
27
  private
@@ -79,43 +54,18 @@ module Dommy
79
54
  end
80
55
  end
81
56
 
82
- class DecompressionStream
57
+ class DecompressionStream < TransformStream
83
58
  SUPPORTED = %w[gzip deflate deflate-raw].freeze
84
59
 
85
- attr_reader :readable, :writable
86
-
87
60
  def initialize(window, format)
88
61
  raise ArgumentError, "unsupported format #{format.inspect}" unless SUPPORTED.include?(format.to_s)
89
62
 
90
- @buffer = +""
63
+ buffer = +""
91
64
  decompressor = build_decompressor(format.to_s)
92
-
93
- @readable = ReadableStream.new(window)
94
- controller = TransformStreamDefaultController.new(@readable)
95
-
96
- @writable = WritableStream.new(
97
- window,
98
- {
99
- "write" => proc { |chunk| @buffer << coerce(chunk) },
100
- "close" => proc do
101
- plain = decompressor.call(@buffer)
102
- controller.enqueue(plain)
103
- @readable.__internal_close__
104
- end,
105
- "abort" => proc { |r| @readable.__internal_error__(r) }
106
- }
107
- )
108
- end
109
-
110
- def __js_get__(key)
111
- case key
112
- when "readable"
113
- @readable
114
- when "writable"
115
- @writable
116
- else
117
- Bridge::ABSENT
118
- end
65
+ super(window, {
66
+ "transform" => proc { |chunk, _controller| buffer << coerce(chunk); nil },
67
+ "flush" => proc { |controller| controller.enqueue(decompressor.call(buffer)); nil }
68
+ })
119
69
  end
120
70
 
121
71
  private
@@ -86,19 +86,16 @@ module Dommy
86
86
  promise
87
87
  end
88
88
 
89
- # Walk `root`'s subtree and re-wrap any nodes whose tag is now
89
+ # Walk `root`'s shadow-including subtree and re-wrap any nodes whose tag is now
90
90
  # registered; fires `connectedCallback` for each upgraded node
91
91
  # that's currently attached to a document tree.
92
92
  def upgrade(root)
93
93
  return nil unless root.respond_to?(:__dommy_backend_node__)
94
94
 
95
- walk_descendants(root.__dommy_backend_node__) do |nk|
96
- next unless nk.element?
95
+ @window.document.__internal_each_shadow_including_element__(root.__dommy_backend_node__) do |nk|
97
96
  next unless @definitions.key?(nk.name)
98
97
 
99
- # Force re-wrap by clearing the document's cached wrapper.
100
- @window.document.__internal_reset_wrapper__(nk)
101
- wrapped = @window.document.wrap_node(nk)
98
+ wrapped = rewrap_for_upgrade(@window.document, nk)
102
99
  next unless wrapped
103
100
 
104
101
  replay_observed_attributes(wrapped)
@@ -137,6 +134,22 @@ module Dommy
137
134
  list&.each { |p| p.fulfill(klass) }
138
135
  end
139
136
 
137
+ # Re-wrap `nk` as its definition's class, or nil when there is nothing to
138
+ # upgrade. An element that is already an instance of the class is left alone:
139
+ # upgrading an element that is already custom does nothing. The new wrapper
140
+ # is told which one it replaces, because a script may hold the element — a
141
+ # JS-defined element upgrades that reference in place.
142
+ def rewrap_for_upgrade(doc, nk)
143
+ previous = doc.__internal_peek_wrapper__(nk)
144
+ klass = @definitions[nk.name]
145
+ return nil if klass.is_a?(Module) && previous.is_a?(klass)
146
+
147
+ doc.__internal_reset_wrapper__(nk)
148
+ wrapped = doc.wrap_node(nk)
149
+ wrapped.__internal_upgraded_from__(previous) if previous && wrapped.respond_to?(:__internal_upgraded_from__)
150
+ wrapped
151
+ end
152
+
140
153
  # When define() lands after the matching element is already in
141
154
  # the document, those nodes need upgrading: re-wrap them with the
142
155
  # new class and fire connectedCallback.
@@ -145,11 +158,11 @@ module Dommy
145
158
  # Match by tag name rather than interpolating `name` into a CSS selector:
146
159
  # a spec-valid custom element name may contain "." (a CSS class selector
147
160
  # char) or other metacharacters, which would corrupt the query.
148
- doc.backend_doc.css("*").each do |nk|
161
+ # Shadow trees included, in shadow-including tree order (define step 18).
162
+ doc.__internal_each_shadow_including_element__(doc.backend_doc) do |nk|
149
163
  next unless nk.name == name
150
164
 
151
- doc.__internal_reset_wrapper__(nk)
152
- wrapped = doc.wrap_node(nk)
165
+ wrapped = rewrap_for_upgrade(doc, nk)
153
166
  next unless wrapped
154
167
 
155
168
  replay_observed_attributes(wrapped)
@@ -178,12 +191,5 @@ module Dommy
178
191
  )
179
192
  end
180
193
  end
181
-
182
- def walk_descendants(node, &blk)
183
- yield node
184
- return unless node.respond_to?(:children)
185
-
186
- node.children.each { |c| walk_descendants(c, &blk) }
187
- end
188
194
  end
189
195
  end