moxml 0.1.26 → 0.2.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/windows.yml +62 -0
  3. data/Gemfile +3 -1
  4. data/README.adoc +69 -6
  5. data/lib/compat/opal/moxml_boot.rb +5 -0
  6. data/lib/moxml/adapter/base.rb +53 -49
  7. data/lib/moxml/adapter/customized_leptris/declaration.rb +32 -0
  8. data/lib/moxml/adapter/customized_leptris/doctype.rb +31 -0
  9. data/lib/moxml/adapter/customized_leptris/entity_reference.rb +12 -0
  10. data/lib/moxml/adapter/customized_leptris/text_segment.rb +26 -0
  11. data/lib/moxml/adapter/customized_leptris.rb +16 -0
  12. data/lib/moxml/adapter/customized_libxml/cdata.rb +3 -12
  13. data/lib/moxml/adapter/customized_libxml/comment.rb +2 -9
  14. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -10
  15. data/lib/moxml/adapter/customized_libxml/node.rb +8 -0
  16. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +2 -10
  17. data/lib/moxml/adapter/customized_libxml/text.rb +2 -9
  18. data/lib/moxml/adapter/customized_oga/entity_decoder.rb +37 -0
  19. data/lib/moxml/adapter/customized_oga/raw_value_override.rb +52 -0
  20. data/lib/moxml/adapter/customized_oga/xml_generator.rb +4 -34
  21. data/lib/moxml/adapter/customized_oga.rb +2 -0
  22. data/lib/moxml/adapter/customized_ox/entity_reference.rb +1 -17
  23. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +1 -11
  24. data/lib/moxml/adapter/headed_ox.rb +9 -128
  25. data/lib/moxml/adapter/leptris.rb +965 -0
  26. data/lib/moxml/adapter/libxml.rb +131 -99
  27. data/lib/moxml/adapter/nokogiri.rb +29 -8
  28. data/lib/moxml/adapter/oga.rb +91 -54
  29. data/lib/moxml/adapter/ox.rb +93 -137
  30. data/lib/moxml/adapter/rexml.rb +61 -50
  31. data/lib/moxml/adapter.rb +2 -1
  32. data/lib/moxml/attribute.rb +8 -4
  33. data/lib/moxml/attribute_resolver.rb +189 -0
  34. data/lib/moxml/config.rb +29 -3
  35. data/lib/moxml/context.rb +12 -0
  36. data/lib/moxml/element.rb +29 -12
  37. data/lib/moxml/entity/reference.rb +28 -0
  38. data/lib/moxml/entity.rb +125 -0
  39. data/lib/moxml/node.rb +90 -1
  40. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  41. data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
  42. data/lib/moxml/version.rb +1 -1
  43. data/lib/moxml/xml_emitter.rb +71 -0
  44. data/lib/moxml/xpath/compiler.rb +60 -7
  45. data/lib/moxml/xpath/parser.rb +22 -15
  46. data/lib/moxml.rb +3 -0
  47. data/spec/examples/readme_examples_spec.rb +0 -4
  48. data/spec/examples/xpath_examples_spec.rb +0 -11
  49. data/spec/integration/all_adapters_spec.rb +17 -0
  50. data/spec/integration/sax_parity_spec.rb +58 -0
  51. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  52. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  53. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  54. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  55. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +0 -8
  56. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  57. data/spec/moxml/adapter/ox_spec.rb +20 -6
  58. data/spec/moxml/adapter/platform_spec.rb +15 -2
  59. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  60. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  61. data/spec/moxml/doctype_spec.rb +1 -1
  62. data/spec/moxml/entity_spec.rb +76 -0
  63. data/spec/moxml/node_spec.rb +104 -0
  64. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  65. data/spec/moxml/xml_emitter_spec.rb +64 -0
  66. data/spec/moxml/xpath/axes_spec.rb +72 -0
  67. data/spec/moxml/xpath/parser_spec.rb +6 -0
  68. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  69. data/spec/performance/benchmark_spec.rb +13 -6
  70. data/spec/performance/thread_safety_spec.rb +0 -4
  71. data/spec/spec_helper.rb +5 -1
  72. metadata +21 -2
@@ -5,6 +5,16 @@ require "oga"
5
5
  module Moxml
6
6
  module Adapter
7
7
  class Oga < Base
8
+ # Trigger autoloads that prepend override modules onto Oga's
9
+ # native classes before any parse / read runs.
10
+ # - EntityDecoderOverride: replace Oga 3.4's multi-pass decoder
11
+ # with a single-pass decoder on Oga::EntityDecoder.
12
+ # - RawValueOverride: route Attribute#value / Text#text through
13
+ # the NativeAttachment sidecar so user-authored values bypass
14
+ # the lazy decoder and are returned verbatim.
15
+ ::Moxml::Adapter::CustomizedOga::EntityDecoderOverride
16
+ ::Moxml::Adapter::CustomizedOga::RawValueOverride
17
+
8
18
  class << self
9
19
  def attachments
10
20
  @attachments ||= Moxml::NativeAttachment.new
@@ -34,26 +44,29 @@ module Moxml
34
44
  DocumentBuilder.new(ctx).build(native_doc)
35
45
  end
36
46
 
37
- # SAX parsing implementation for Oga
47
+ # SAX parsing implementation for Oga.
48
+ #
49
+ # Driven off `Oga.parse_xml` plus a DOM walk because Oga 3.4's
50
+ # native SAX parser decodes `on_text` content before delivery and
51
+ # exposes no hook to override. The prepended EntityDecoderOverride
52
+ # ensures `node.text` invoked during the walk produces the correct
53
+ # single-pass decode, so no separate entity pass is needed here.
54
+ # Trade-off: peak memory scales with document size (Oga 3.4 is the
55
+ # final release of an unmaintained gem; the trade is accepted).
38
56
  #
39
57
  # @param xml [String, IO] XML to parse
40
58
  # @param handler [Moxml::SAX::Handler] Moxml SAX handler
41
59
  # @return [void]
42
60
  def sax_parse(xml, handler)
43
- bridge = OgaSAXBridge.new(handler)
44
-
45
61
  xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
62
+ native_doc = ::Oga.parse_xml(xml_string)
46
63
 
47
- # Manually call start_document (Oga doesn't)
48
- handler.on_start_document
49
-
50
- ::Oga.sax_parse_xml(bridge, xml_string)
51
-
52
- # Manually call end_document (Oga doesn't)
53
- handler.on_end_document
64
+ bridge = OgaSAXBridge.new(handler)
65
+ bridge.on_start_document
66
+ native_doc.children.each { |child| bridge.emit(child) }
67
+ bridge.on_end_document
54
68
  rescue StandardError => e
55
- error = Moxml::ParseError.new(e.message)
56
- handler.on_error(error)
69
+ handler.on_error(Moxml::ParseError.new(e.message))
57
70
  end
58
71
 
59
72
  def create_document(_native_doc = nil)
@@ -65,7 +78,14 @@ module Moxml
65
78
  end
66
79
 
67
80
  def create_native_text(content, _owner_doc = nil)
68
- ::Oga::XML::Text.new(text: preprocess_entities(content))
81
+ processed = preprocess_entities(content)
82
+ text = ::Oga::XML::Text.new(text: processed)
83
+ # Oga::XML::Text.new stores the value via ivar, bypassing the
84
+ # RawValueOverride setter. Set the sidecar explicitly so reads
85
+ # return the user-supplied value verbatim rather than running
86
+ # it through the lazy decoder.
87
+ attachments.set(text, :raw_text, processed)
88
+ text
69
89
  end
70
90
 
71
91
  def create_native_entity_reference(name)
@@ -136,6 +156,7 @@ module Moxml
136
156
 
137
157
  def set_namespace(element, ns_or_string)
138
158
  element.namespace_name = ns_or_string.to_s
159
+ element
139
160
  end
140
161
 
141
162
  def namespace(element)
@@ -257,11 +278,15 @@ module Moxml
257
278
  2)
258
279
  end
259
280
 
281
+ processed = preprocess_entities(value.to_s)
260
282
  attr = ::Oga::XML::Attribute.new(
261
283
  name: name.to_s,
262
284
  namespace_name: namespace_name,
263
- value: preprocess_entities(value.to_s),
285
+ value: processed,
264
286
  )
287
+ # See create_native_text: Attribute.new bypasses the override
288
+ # setter, so populate the sidecar here.
289
+ attachments.set(attr, :raw_value, processed)
265
290
  element.add_attribute(attr)
266
291
  end
267
292
 
@@ -270,7 +295,8 @@ module Moxml
270
295
  end
271
296
 
272
297
  def get_attribute_value(element, name)
273
- element[name.to_s]
298
+ attr = element.attribute(name.to_s)
299
+ attr&.value
274
300
  end
275
301
 
276
302
  def remove_attribute(element, name)
@@ -278,6 +304,12 @@ module Moxml
278
304
  element.attributes.delete(attr) if attr
279
305
  end
280
306
 
307
+ def remove_attribute_native(attr)
308
+ element = attr.element
309
+ element&.attributes&.delete(attr)
310
+ attr
311
+ end
312
+
281
313
  def add_child(element, child_or_text)
282
314
  child =
283
315
  if child_or_text.is_a?(String)
@@ -438,8 +470,10 @@ module Moxml
438
470
  end
439
471
 
440
472
  def xpath(node, expression, namespaces = nil)
441
- node.xpath(expression, {},
442
- namespaces: namespaces&.transform_keys(&:to_s)).to_a
473
+ result = node.xpath(expression, {},
474
+ namespaces: namespaces&.transform_keys(&:to_s))
475
+ # Adapter contract: Array<native> | scalar
476
+ result.is_a?(Enumerable) ? result.to_a : result
443
477
  rescue ::LL::ParserError => e
444
478
  raise Moxml::XPathError.new(
445
479
  e.message,
@@ -528,50 +562,53 @@ module Moxml
528
562
  ::Moxml::Adapter::CustomizedOga::XmlGenerator.new(node).to_xml
529
563
  end
530
564
  end
531
- end
532
565
 
533
- # Bridge between Oga SAX and Moxml SAX
534
- #
535
- # Translates Oga SAX events to Moxml::SAX::Handler events.
536
- # Oga has different event naming and namespace as first param.
537
- #
538
- # @private
539
- class OgaSAXBridge
540
- include Moxml::SAX::NamespaceSplitter
541
-
542
- def initialize(handler)
543
- @handler = handler
544
- end
566
+ # Bridge between a parsed Oga DOM and Moxml SAX events.
567
+ #
568
+ # @private
569
+ class OgaSAXBridge
570
+ include Moxml::SAX::NamespaceSplitter
545
571
 
546
- # Oga: on_element(namespace, name, attributes)
547
- # namespace may be nil
548
- # attributes is an array of [name, value] pairs
549
- def on_element(namespace, name, attributes)
550
- element_name = namespace ? "#{namespace}:#{name}" : name
551
- attr_hash, ns_hash = split_attributes_and_namespaces(attributes)
552
- @handler.on_start_element(element_name, attr_hash, ns_hash)
553
- end
572
+ def initialize(handler)
573
+ @handler = handler
574
+ end
554
575
 
555
- # Oga: after_element(namespace, name)
556
- def after_element(namespace, name)
557
- element_name = namespace ? "#{namespace}:#{name}" : name
558
- @handler.on_end_element(element_name)
559
- end
576
+ def on_start_document
577
+ @handler.on_start_document
578
+ end
560
579
 
561
- def on_text(text)
562
- @handler.on_characters(text)
563
- end
580
+ def on_end_document
581
+ @handler.on_end_document
582
+ end
564
583
 
565
- def on_cdata(text)
566
- @handler.on_cdata(text)
567
- end
584
+ # Walk a parsed Oga node and emit Moxml SAX events.
585
+ def emit(node)
586
+ case node
587
+ when ::Oga::XML::Element
588
+ element_name = qualified_name(node.namespace_name, node.name)
589
+ pairs = node.attributes.map do |a|
590
+ [qualified_name(a.namespace_name, a.name), a.value]
591
+ end
592
+ attrs, namespaces = split_attributes_and_namespaces(pairs)
593
+ @handler.on_start_element(element_name, attrs, namespaces)
594
+ node.children.each { |c| emit(c) }
595
+ @handler.on_end_element(element_name)
596
+ when ::Oga::XML::Text
597
+ @handler.on_characters(node.text)
598
+ when ::Oga::XML::Cdata
599
+ @handler.on_cdata(node.text)
600
+ when ::Oga::XML::Comment
601
+ @handler.on_comment(node.text)
602
+ when ::Oga::XML::ProcessingInstruction
603
+ @handler.on_processing_instruction(node.name, node.text || "")
604
+ end
605
+ end
568
606
 
569
- def on_comment(text)
570
- @handler.on_comment(text)
571
- end
607
+ private
572
608
 
573
- def on_processing_instruction(name, text)
574
- @handler.on_processing_instruction(name, text || "")
609
+ def qualified_name(namespace, local)
610
+ namespace ? "#{namespace}:#{local}" : local
611
+ end
575
612
  end
576
613
  end
577
614
  end
@@ -51,7 +51,7 @@ module Moxml
51
51
  )
52
52
  end
53
53
 
54
- ctx = _context || Context.new(:ox)
54
+ ctx = _context || Context.new(context_adapter_name)
55
55
  Document.new(native_doc, ctx)
56
56
  end
57
57
 
@@ -147,7 +147,7 @@ module Moxml
147
147
  end
148
148
 
149
149
  def set_namespace(element, ns)
150
- return unless element.is_a?(::Ox::Element) || element.is_a?(::Ox::Node)
150
+ return element unless element.is_a?(::Ox::Element) || element.is_a?(::Ox::Node)
151
151
 
152
152
  prefix = ns.prefix
153
153
  # attributes don't have attributes but can have a namespace prefix
@@ -157,7 +157,7 @@ module Moxml
157
157
  end
158
158
  element.name = [prefix,
159
159
  element.name.delete_prefix("xmlns:")].compact.join(":")
160
- namespace(element)
160
+ element
161
161
  end
162
162
 
163
163
  def namespace(element)
@@ -167,6 +167,19 @@ module Moxml
167
167
  elsif element.name.include?(":")
168
168
  element.name.split(":").first
169
169
  end
170
+
171
+ # Unprefixed attributes are in no namespace — the default
172
+ # namespace declaration does not apply to attribute names
173
+ # (Namespaces 1.0 §5.2). Unprefixed elements DO take it.
174
+ return nil if element.is_a?(::Moxml::Adapter::CustomizedOx::Attribute) && prefix.nil?
175
+
176
+ # Namespaces 1.0 §4: xml is prebound; no declaration required.
177
+ if prefix == "xml"
178
+ return ::Moxml::Adapter::CustomizedOx::Namespace.new(
179
+ prefix, Moxml::AttributeResolver::XML_NAMESPACE_URI, element
180
+ )
181
+ end
182
+
170
183
  attr_name = ["xmlns", prefix].compact.join(":")
171
184
 
172
185
  ([element] + ancestors(element)).each do |node|
@@ -341,6 +354,7 @@ module Moxml
341
354
  element = attribute.parent
342
355
  element.attributes.delete(old_name)
343
356
  element.attributes[name] = attribute.value
357
+ attribute
344
358
  end
345
359
 
346
360
  def set_attribute_value(attribute, new_value)
@@ -348,9 +362,16 @@ module Moxml
348
362
  # Ox converts all values to strings
349
363
  remove_attribute(attribute.parent, attribute.name)
350
364
  else
365
+ element = attribute.parent
366
+ # Rekey by the expanded name (Ox hash keys carry the
367
+ # prefix) and collapse any symbol/string spelling of the
368
+ # same key, or a stale symbol key shadows the new value.
369
+ expanded = attribute.expanded_name
370
+ element.attributes.delete(expanded.to_sym)
371
+ element.attributes[expanded] = new_value
351
372
  attribute.value = new_value
352
- attribute.parent.attributes[attribute.name] = new_value
353
373
  end
374
+ attribute
354
375
  end
355
376
 
356
377
  def get_attribute(element, name)
@@ -378,6 +399,14 @@ module Moxml
378
399
  element.attributes.delete(name.to_s.to_sym)
379
400
  end
380
401
 
402
+ def remove_attribute_native(attribute)
403
+ expanded = attribute.expanded_name
404
+ element = attribute.parent
405
+ element.attributes.delete(expanded)
406
+ element.attributes.delete(expanded.to_sym)
407
+ attribute
408
+ end
409
+
381
410
  def add_child(element, child)
382
411
  if element.is_a?(::Ox::Document) && child.is_a?(::Ox::Instruct) && child.target == "xml"
383
412
  element.attributes ||= {}
@@ -504,6 +533,7 @@ module Moxml
504
533
  case node
505
534
  when String then node.to_s
506
535
  when ::Moxml::Adapter::CustomizedOx::Text then node.value
536
+ when ::Ox::CData then node.value.to_s
507
537
  when ::Moxml::Adapter::CustomizedOx::EntityReference then ""
508
538
  else
509
539
  return "" unless node.is_a?(::Ox::Element) || node.is_a?(::Ox::Document)
@@ -601,34 +631,34 @@ module Moxml
601
631
  matches.last&.first
602
632
  end
603
633
 
634
+ # XPath runs through Moxml's pure-Ruby XPath 1.0 engine (shared
635
+ # with HeadedOx and Leptris). Ox's locate() only covers a
636
+ # small XPath subset; the engine provides all axes,
637
+ # predicates, functions and variables.
604
638
  def xpath(node, expression, namespaces = {})
605
- # Translate common XPath patterns to Ox locate() syntax
606
- locate_expr = translate_xpath_to_locate(expression, namespaces)
607
-
608
- # Ox's locate() works differently on documents vs elements
609
- # For relative descendant searches on elements, we need special handling
610
- if expression.start_with?(".//") && node.is_a?(::Ox::Element)
611
- # Manually search descendants for relative paths from elements
612
- element_name = locate_expr.sub("?/", "")
613
- results = []
614
- traverse(node) do |n|
615
- next unless n.is_a?(::Ox::Element)
616
-
617
- results << n if n.name == element_name || element_name.empty?
618
- end
619
- return results.map do |n|
620
- patch_node(n, find_parent_in_tree(n, node))
621
- end
639
+ wrapped = if node.is_a?(Moxml::Node)
640
+ node
641
+ else
642
+ Moxml::Node.wrap(node, Context.new(context_adapter_name))
643
+ end
644
+
645
+ ast = XPath::Parser.parse(expression)
646
+ proc = XPath::Compiler.compile_with_cache(ast, namespaces: namespaces)
647
+ result = proc.call(wrapped)
648
+
649
+ case result
650
+ when Array
651
+ dedupe_natives(result.map do |n|
652
+ n.is_a?(Moxml::Node) ? n.native : n
653
+ end)
654
+ when NodeSet
655
+ dedupe_natives(result.to_a.map(&:native))
656
+ else
657
+ result
622
658
  end
623
-
624
- # Use Ox's locate method for other cases
625
- results = node.locate(locate_expr)
626
-
627
- # Wrap results and set their parents by finding them in the tree
628
- results.map { |n| patch_node(n, find_parent_in_tree(n, node)) }
629
659
  rescue StandardError => e
630
660
  raise Moxml::XPathError.new(
631
- "XPath translation failed: #{e.message}",
661
+ "XPath execution failed: #{e.message}",
632
662
  expression: expression,
633
663
  adapter: "Ox",
634
664
  node: node,
@@ -636,7 +666,32 @@ module Moxml
636
666
  end
637
667
 
638
668
  def at_xpath(node, expression, namespaces = {})
639
- xpath(node, expression, namespaces)&.first
669
+ result = xpath(node, expression, namespaces)
670
+ result.is_a?(Array) ? result.first : result
671
+ end
672
+
673
+ def xpath_supported?
674
+ true
675
+ end
676
+
677
+ # Identity-based dedup: uniq's value equality would collapse
678
+ # equal String text nodes.
679
+ def dedupe_natives(natives)
680
+ seen = {}
681
+ natives.select do |native|
682
+ id = native.object_id
683
+ if seen[id]
684
+ false
685
+ else
686
+ seen[id] = true
687
+ end
688
+ end
689
+ end
690
+
691
+ # Adapter name used when wrapping bare native nodes; kept
692
+ # overridable so HeadedOx wraps into its own adapter.
693
+ def context_adapter_name
694
+ :ox
640
695
  end
641
696
 
642
697
  def serialize(node, options = {})
@@ -644,7 +699,7 @@ module Moxml
644
699
  # back-reference, but that makes it unknown to Ox.dump's XML emitter,
645
700
  # which then falls back to generic object marshalling. Short-circuit
646
701
  # here with proper XML escaping.
647
- return escape_xml_text(node.value) if node.is_a?(CustomizedOx::Text)
702
+ return XmlEmitter.escape_text(node.value) if node.is_a?(CustomizedOx::Text)
648
703
 
649
704
  needs_custom = needs_custom_serialize?(node)
650
705
 
@@ -782,10 +837,7 @@ module Moxml
782
837
  version = node[:version] || "1.0"
783
838
  encoding = resolve_decl_attr(node, :encoding, options[:encoding])
784
839
  standalone = resolve_decl_attr(node, :standalone, nil)
785
- output << "<?xml version=\"#{version}\""
786
- output << " encoding=\"#{encoding}\"" if encoding
787
- output << " standalone=\"#{standalone}\"" if standalone
788
- output << "?>"
840
+ output << XmlEmitter.declaration_xml(version, encoding, standalone)
789
841
  end
790
842
  (node.nodes || []).each do |child|
791
843
  output << serialize_node_custom(child)
@@ -799,10 +851,10 @@ module Moxml
799
851
  def serialize_node_custom(node)
800
852
  case node
801
853
  when ::Ox::Element then serialize_element_custom(node)
802
- when String then escape_xml_text(node)
803
- when ::Moxml::Adapter::CustomizedOx::Text then escape_xml_text(node.value)
854
+ when String then XmlEmitter.escape_text(node)
855
+ when ::Moxml::Adapter::CustomizedOx::Text then XmlEmitter.escape_text(node.value)
804
856
  when ::Moxml::Adapter::CustomizedOx::EntityReference then "&#{node.name};"
805
- when ::Ox::CData then serialize_cdata(node.value)
857
+ when ::Ox::CData then XmlEmitter.cdata(node.value)
806
858
  when ::Ox::Comment then "<!--#{node.value}-->"
807
859
  when ::Ox::Instruct then "<?#{node.target} #{node.value || ''}?>"
808
860
  when ::Ox::DocType then "<!DOCTYPE #{node.value}>"
@@ -813,7 +865,7 @@ module Moxml
813
865
  def serialize_element_custom(elem)
814
866
  output = "<#{elem.name}"
815
867
  elem.attributes.each do |name, value|
816
- output << " #{name}=\"#{escape_xml_attribute(value.to_s)}\""
868
+ output << " #{name}=\"#{XmlEmitter.escape_attribute(value)}\""
817
869
  end
818
870
 
819
871
  if elem.nodes.nil? || elem.nodes.empty?
@@ -828,104 +880,6 @@ module Moxml
828
880
  output << "</#{elem.name}>"
829
881
  output
830
882
  end
831
-
832
- def serialize_cdata(content)
833
- escaped = content.gsub("]]>", "]]]]><![CDATA[>")
834
- "<![CDATA[#{escaped}]]>"
835
- end
836
-
837
- def escape_xml_text(text)
838
- text.to_s.gsub(/[<>&]/) do |match|
839
- case match
840
- when "<" then "&lt;"
841
- when ">" then "&gt;"
842
- when "&" then "&amp;"
843
- end
844
- end
845
- end
846
-
847
- def escape_xml_attribute(value)
848
- value.to_s.gsub(/[<>&"]/) do |match|
849
- case match
850
- when "<" then "&lt;"
851
- when ">" then "&gt;"
852
- when "&" then "&amp;"
853
- when '"' then "&quot;"
854
- end
855
- end
856
- end
857
-
858
- # Translate a subset of XPath to Ox locate() syntax
859
- # Supports: //element, /path/to/element, .//element, element[@attr]
860
- # Note: Ox locate() doesn't support namespace prefixes in the path
861
- def translate_xpath_to_locate(xpath, namespaces = {})
862
- expr = xpath.dup
863
-
864
- # Strip namespace prefixes from element names
865
- # XPath: //ns:element → locate: element
866
- if namespaces && !namespaces.empty?
867
- namespaces.each_key do |prefix|
868
- expr = expr.gsub("/#{prefix}:", "/")
869
- expr = expr.gsub("/*#{prefix}:", "/*")
870
- expr = expr.gsub("//*#{prefix}:", "//")
871
- expr = expr.gsub("//#{prefix}:", "//")
872
- expr = expr.gsub("///#{prefix}:", "///")
873
- end
874
- end
875
-
876
- # Remove any remaining namespace prefixes
877
- # Use possessive quantifier to prevent ReDoS
878
- expr = expr.gsub(/[a-zA-Z_][\w-]*+:/, "")
879
-
880
- # Remove attribute predicates for now - we'll filter manually
881
- # Save the attribute name if present
882
- expr = expr.gsub(/\[@(\w+)\]/, "")
883
-
884
- # XPath: //element → locate: ?/element (any depth)
885
- # Note: In Ox, ?/ means "any path"
886
- expr = expr.sub(%r{^//}, "?/") if expr.start_with?("//")
887
-
888
- # XPath: .//element → locate: ?/element (relative any depth)
889
- # For relative paths from an element, we still use ?/ which searches
890
- # descendants
891
- expr = expr.sub(%r{^\.//}, "?/") if expr.start_with?(".//")
892
-
893
- # XPath: /root/child → locate: root/child (absolute path)
894
- # Remove leading / for Ox
895
- expr = expr.sub(%r{^/}, "")
896
-
897
- # XPath: ./element → locate: element (direct child, just remove ./)
898
- expr.sub(%r{^\./}, "")
899
- end
900
-
901
- # Find the actual parent of a node by searching the tree
902
- def find_parent_in_tree(target_node, search_root)
903
- # Start from the document root if we have a document
904
- root = search_root.is_a?(::Ox::Document) ? search_root : document(search_root)
905
-
906
- result = nil
907
- traverse(root) do |node|
908
- next unless node.is_a?(::Ox::Element) || node.is_a?(::Ox::Document)
909
-
910
- node.nodes&.each do |child|
911
- if child.equal?(target_node)
912
- result = node
913
- break
914
- end
915
- end
916
- break if result
917
- end
918
- result
919
- end
920
-
921
- def traverse(node, &block)
922
- return unless node
923
-
924
- yield node
925
- return unless node.is_a?(::Ox::Element) || node.is_a?(::Ox::Document)
926
-
927
- node.nodes&.each { |child| traverse(child, &block) }
928
- end
929
883
  end
930
884
  end
931
885
 
@@ -948,7 +902,9 @@ module Moxml
948
902
 
949
903
  # Ox delivers attributes AFTER start_element
950
904
  def attr(name, value)
951
- @pending_attrs[name] = value
905
+ # Attributes arriving before the first start_element belong
906
+ # to the XML declaration, not to any element
907
+ @pending_attrs[name.to_s] = value if @pending_element_name
952
908
  end
953
909
 
954
910
  # Called when element starts (but attributes come AFTER this)