moxml 0.1.26 → 0.3.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 (86) 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 +6 -1
  6. data/lib/moxml/adapter/base.rb +53 -56
  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 +137 -117
  27. data/lib/moxml/adapter/nokogiri.rb +29 -8
  28. data/lib/moxml/adapter/oga.rb +139 -55
  29. data/lib/moxml/adapter/ox.rb +93 -137
  30. data/lib/moxml/adapter/rexml.rb +64 -51
  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/builder.rb +0 -18
  35. data/lib/moxml/config.rb +29 -5
  36. data/lib/moxml/context.rb +33 -0
  37. data/lib/moxml/document.rb +1 -1
  38. data/lib/moxml/element.rb +29 -12
  39. data/lib/moxml/entity/reference.rb +28 -0
  40. data/lib/moxml/entity/restorer.rb +95 -0
  41. data/lib/moxml/entity.rb +126 -0
  42. data/lib/moxml/entity_registry.rb +1 -1
  43. data/lib/moxml/error.rb +0 -42
  44. data/lib/moxml/node.rb +135 -10
  45. data/lib/moxml/node_set.rb +1 -1
  46. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  47. data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
  48. data/lib/moxml/version.rb +1 -1
  49. data/lib/moxml/xml_emitter.rb +71 -0
  50. data/lib/moxml/xpath/compiler.rb +143 -8
  51. data/lib/moxml/xpath/parser.rb +22 -15
  52. data/lib/moxml.rb +3 -3
  53. data/spec/examples/readme_examples_spec.rb +0 -4
  54. data/spec/examples/xpath_examples_spec.rb +0 -11
  55. data/spec/integration/all_adapters_spec.rb +18 -1
  56. data/spec/integration/sax_parity_spec.rb +58 -0
  57. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  58. data/spec/integration/shared_examples/high_level/{document_builder_behavior.rb → parse_behavior.rb} +1 -2
  59. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  60. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  61. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  62. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +15 -8
  63. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  64. data/spec/moxml/adapter/libxml_internals_spec.rb +1 -1
  65. data/spec/moxml/adapter/ox_spec.rb +20 -6
  66. data/spec/moxml/adapter/platform_spec.rb +15 -2
  67. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  68. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  69. data/spec/moxml/config_spec.rb +0 -1
  70. data/spec/moxml/context_spec.rb +22 -0
  71. data/spec/moxml/doctype_spec.rb +1 -1
  72. data/spec/moxml/entity_spec.rb +76 -0
  73. data/spec/moxml/error_spec.rb +0 -50
  74. data/spec/moxml/node_spec.rb +104 -0
  75. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  76. data/spec/moxml/xml_emitter_spec.rb +64 -0
  77. data/spec/moxml/xpath/axes_spec.rb +112 -0
  78. data/spec/moxml/xpath/parser_spec.rb +6 -0
  79. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  80. data/spec/performance/benchmark_spec.rb +13 -6
  81. data/spec/performance/thread_safety_spec.rb +0 -4
  82. data/spec/spec_helper.rb +5 -1
  83. metadata +23 -6
  84. data/lib/moxml/adapter/libxml/entity_restorer.rb +0 -94
  85. data/lib/moxml/document_builder.rb +0 -167
  86. data/spec/moxml/document_builder_spec.rb +0 -30
@@ -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
@@ -30,30 +40,48 @@ module Moxml
30
40
  )
31
41
  end
32
42
 
43
+ # Oga parses multiple root elements leniently; the moxml
44
+ # contract requires a single root. to_a because the vendored
45
+ # fork's NodeSet#count ignores its block.
46
+ roots = native_doc.children.to_a.count do |child|
47
+ child.instance_of?(::Oga::XML::Element)
48
+ end
49
+ if roots > 1
50
+ raise Moxml::ParseError.new(
51
+ "multiple root elements",
52
+ source: xml.is_a?(String) ? xml[0..100] : nil,
53
+ )
54
+ end
55
+
33
56
  ctx = _context || Context.new(:oga)
34
- DocumentBuilder.new(ctx).build(native_doc)
57
+ doc = Document.new(native_doc, ctx)
58
+ Entity::Restorer.new(doc).run if ctx.config.restore_entities
59
+ doc
35
60
  end
36
61
 
37
- # SAX parsing implementation for Oga
62
+ # SAX parsing implementation for Oga.
63
+ #
64
+ # Driven off `Oga.parse_xml` plus a DOM walk because Oga 3.4's
65
+ # native SAX parser decodes `on_text` content before delivery and
66
+ # exposes no hook to override. The prepended EntityDecoderOverride
67
+ # ensures `node.text` invoked during the walk produces the correct
68
+ # single-pass decode, so no separate entity pass is needed here.
69
+ # Trade-off: peak memory scales with document size (Oga 3.4 is the
70
+ # final release of an unmaintained gem; the trade is accepted).
38
71
  #
39
72
  # @param xml [String, IO] XML to parse
40
73
  # @param handler [Moxml::SAX::Handler] Moxml SAX handler
41
74
  # @return [void]
42
75
  def sax_parse(xml, handler)
43
- bridge = OgaSAXBridge.new(handler)
44
-
45
76
  xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
77
+ native_doc = ::Oga.parse_xml(xml_string)
46
78
 
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
79
+ bridge = OgaSAXBridge.new(handler)
80
+ bridge.on_start_document
81
+ native_doc.children.each { |child| bridge.emit(child) }
82
+ bridge.on_end_document
54
83
  rescue StandardError => e
55
- error = Moxml::ParseError.new(e.message)
56
- handler.on_error(error)
84
+ handler.on_error(Moxml::ParseError.new(e.message))
57
85
  end
58
86
 
59
87
  def create_document(_native_doc = nil)
@@ -65,7 +93,14 @@ module Moxml
65
93
  end
66
94
 
67
95
  def create_native_text(content, _owner_doc = nil)
68
- ::Oga::XML::Text.new(text: preprocess_entities(content))
96
+ processed = preprocess_entities(content)
97
+ text = ::Oga::XML::Text.new(text: processed)
98
+ # Oga::XML::Text.new stores the value via ivar, bypassing the
99
+ # RawValueOverride setter. Set the sidecar explicitly so reads
100
+ # return the user-supplied value verbatim rather than running
101
+ # it through the lazy decoder.
102
+ attachments.set(text, :raw_text, processed)
103
+ text
69
104
  end
70
105
 
71
106
  def create_native_entity_reference(name)
@@ -136,6 +171,7 @@ module Moxml
136
171
 
137
172
  def set_namespace(element, ns_or_string)
138
173
  element.namespace_name = ns_or_string.to_s
174
+ element
139
175
  end
140
176
 
141
177
  def namespace(element)
@@ -218,6 +254,38 @@ module Moxml
218
254
  node.parent if node.is_a?(::Oga::XML::Node)
219
255
  end
220
256
 
257
+ # Oga's Node#dup shallow-copies the ivars, leaving attributes
258
+ # and children shared with the original; the public dup
259
+ # contract needs a full deep copy.
260
+ def duplicate_node(node)
261
+ case node
262
+ when ::Oga::XML::Element
263
+ copy = ::Oga::XML::Element.new(name: node.name)
264
+ copy.namespace_name = node.namespace_name if node.namespace_name
265
+ node.attributes.each do |attr|
266
+ copy.add_attribute(::Oga::XML::Attribute.new(
267
+ name: attr.name,
268
+ namespace_name: attr.namespace_name,
269
+ value: attr.value,
270
+ ))
271
+ end
272
+ node.children.each do |child|
273
+ copy.children << duplicate_node(child)
274
+ end
275
+ copy
276
+ when ::Oga::XML::Text
277
+ ::Oga::XML::Text.new(text: node.text)
278
+ when ::Oga::XML::Cdata
279
+ ::Oga::XML::Cdata.new(text: node.text)
280
+ when ::Oga::XML::Comment
281
+ ::Oga::XML::Comment.new(text: node.text)
282
+ when ::Oga::XML::ProcessingInstruction
283
+ ::Oga::XML::ProcessingInstruction.new(name: node.name, text: node.text)
284
+ else
285
+ node.dup
286
+ end
287
+ end
288
+
221
289
  def next_sibling(node)
222
290
  node.next
223
291
  end
@@ -257,11 +325,15 @@ module Moxml
257
325
  2)
258
326
  end
259
327
 
328
+ processed = preprocess_entities(value.to_s)
260
329
  attr = ::Oga::XML::Attribute.new(
261
330
  name: name.to_s,
262
331
  namespace_name: namespace_name,
263
- value: preprocess_entities(value.to_s),
332
+ value: processed,
264
333
  )
334
+ # See create_native_text: Attribute.new bypasses the override
335
+ # setter, so populate the sidecar here.
336
+ attachments.set(attr, :raw_value, processed)
265
337
  element.add_attribute(attr)
266
338
  end
267
339
 
@@ -270,7 +342,8 @@ module Moxml
270
342
  end
271
343
 
272
344
  def get_attribute_value(element, name)
273
- element[name.to_s]
345
+ attr = element.attribute(name.to_s)
346
+ attr&.value
274
347
  end
275
348
 
276
349
  def remove_attribute(element, name)
@@ -278,6 +351,12 @@ module Moxml
278
351
  element.attributes.delete(attr) if attr
279
352
  end
280
353
 
354
+ def remove_attribute_native(attr)
355
+ element = attr.element
356
+ element&.attributes&.delete(attr)
357
+ attr
358
+ end
359
+
281
360
  def add_child(element, child_or_text)
282
361
  child =
283
362
  if child_or_text.is_a?(String)
@@ -438,8 +517,10 @@ module Moxml
438
517
  end
439
518
 
440
519
  def xpath(node, expression, namespaces = nil)
441
- node.xpath(expression, {},
442
- namespaces: namespaces&.transform_keys(&:to_s)).to_a
520
+ result = node.xpath(expression, {},
521
+ namespaces: namespaces&.transform_keys(&:to_s))
522
+ # Adapter contract: Array<native> | scalar
523
+ result.is_a?(Enumerable) ? result.to_a : result
443
524
  rescue ::LL::ParserError => e
444
525
  raise Moxml::XPathError.new(
445
526
  e.message,
@@ -528,50 +609,53 @@ module Moxml
528
609
  ::Moxml::Adapter::CustomizedOga::XmlGenerator.new(node).to_xml
529
610
  end
530
611
  end
531
- end
532
612
 
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
613
+ # Bridge between a parsed Oga DOM and Moxml SAX events.
614
+ #
615
+ # @private
616
+ class OgaSAXBridge
617
+ include Moxml::SAX::NamespaceSplitter
545
618
 
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
619
+ def initialize(handler)
620
+ @handler = handler
621
+ end
554
622
 
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
623
+ def on_start_document
624
+ @handler.on_start_document
625
+ end
560
626
 
561
- def on_text(text)
562
- @handler.on_characters(text)
563
- end
627
+ def on_end_document
628
+ @handler.on_end_document
629
+ end
564
630
 
565
- def on_cdata(text)
566
- @handler.on_cdata(text)
567
- end
631
+ # Walk a parsed Oga node and emit Moxml SAX events.
632
+ def emit(node)
633
+ case node
634
+ when ::Oga::XML::Element
635
+ element_name = qualified_name(node.namespace_name, node.name)
636
+ pairs = node.attributes.map do |a|
637
+ [qualified_name(a.namespace_name, a.name), a.value]
638
+ end
639
+ attrs, namespaces = split_attributes_and_namespaces(pairs)
640
+ @handler.on_start_element(element_name, attrs, namespaces)
641
+ node.children.each { |c| emit(c) }
642
+ @handler.on_end_element(element_name)
643
+ when ::Oga::XML::Text
644
+ @handler.on_characters(node.text)
645
+ when ::Oga::XML::Cdata
646
+ @handler.on_cdata(node.text)
647
+ when ::Oga::XML::Comment
648
+ @handler.on_comment(node.text)
649
+ when ::Oga::XML::ProcessingInstruction
650
+ @handler.on_processing_instruction(node.name, node.text || "")
651
+ end
652
+ end
568
653
 
569
- def on_comment(text)
570
- @handler.on_comment(text)
571
- end
654
+ private
572
655
 
573
- def on_processing_instruction(name, text)
574
- @handler.on_processing_instruction(name, text || "")
656
+ def qualified_name(namespace, local)
657
+ namespace ? "#{namespace}:#{local}" : local
658
+ end
575
659
  end
576
660
  end
577
661
  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)