moxml 0.5.16 → 0.5.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39cfbe08ea2a0e92f2ba89e83beacf564401765e964807dc4d73ca23d867af38
4
- data.tar.gz: cdd5d499fd4b3233a6c4d2774b188aef2339f341f7c7a7112a82ac392ec4e53b
3
+ metadata.gz: c793d84315c4005a5141698aeb716c001d23b7157b768c77cbdb3539a4b08f53
4
+ data.tar.gz: 35c1d60e6d3a8e17e9768c733d028962bcb6e5798e3c8528b18deaebd855ae5f
5
5
  SHA512:
6
- metadata.gz: 894d6bacdd4f26dbb2a59b0684b29e8ca68b39b66f841a5cd11005351eff38404e745b47d58bb860c898d3da92e1e2a769210938f26cac2f88cac34cc887de76
7
- data.tar.gz: c5b7b7c9de4f2362e910b740ce4cf6185a72773f39f31a3dfc17fbd7436f65f542e66ff099900bf9073e87bd5975568cb3833ac658115ea5ee21a4218719ce90
6
+ metadata.gz: '092b193420e9117adbd6dade8718474c9a72de401eb0b7a741d36639bbd7e9285c7e53d55f96b482b17bea3382d1da3adb1cae6bbc7b787c552859d80377db39'
7
+ data.tar.gz: 18a342a60a7dda872730dd2a9158aa852948596e21fc4613961d0336ece2f12977d02fe434e7fa64429f3ce73015a4434285f3f052425c4eb6d88c20a9571db7
@@ -18,7 +18,9 @@ code disagree.
18
18
  == Method groups
19
19
 
20
20
  Parse and document lifecycle::
21
- `parse(xml, options, context)`, `create_document`, `create` factories
21
+ `parse(xml, options, context)` `noblanks: true` drops
22
+ whitespace-only text nodes where the engine has the flag
23
+ (Nokogiri, leptris), `create_document`, `create` factories
22
24
  (`create_native_element`, `create_native_text`, `create_native_cdata`,
23
25
  `create_native_comment`, `create_native_processing_instruction`,
24
26
  `create_native_declaration`, `create_native_doctype`,
@@ -55,7 +57,10 @@ the tracked native; wrappers re-key through `refresh_native!`.
55
57
 
56
58
  Serialization::
57
59
  `serialize(node, options)`. Options: `indent`, `encoding`,
58
- `declaration`, `no_declaration`, `expand_empty`, `line_ending`.
60
+ `declaration`, `no_declaration`, `expand_empty`, `line_ending`,
61
+ `indent_text` (the indent-unit string — Nokogiri and
62
+ leptris >= 1.9.45; leptris text-bearing leaves wait on
63
+ leptris/leptris#658).
59
64
  Entity-marker restoration is owned by the wrapper layer
60
65
  (`Node#to_xml`), guarded by `entity_bearing?`.
61
66
 
@@ -30,6 +30,11 @@ ctx.config.adapter_name # => :leptris / :nokogiri / :rexml (Opal)
30
30
 
31
31
  == Parse options
32
32
 
33
+ `noblanks: true`::
34
+ Drop whitespace-only text nodes (libxml2 `XML_PARSE_NOBLANKS`
35
+ parity, issue #153) — the parse shape canon's pretty-printer
36
+ compares against `Nokogiri::XML(xml, &:noblanks)`.
37
+
33
38
  `readonly: true`::
34
39
  The binding memoizes every read and refuses mutations — the
35
40
  parse-and-read lifecycle for multi-pass consumers (comparison, diff,
@@ -62,16 +67,18 @@ implementation over one `leptris_node_traverse` call.
62
67
 
63
68
  == Known limitations (tracked upstream)
64
69
 
65
- * Tab indentation (`indent_text`) needs a serialize-time parameter in
66
- libleptris; post-hoc rewriting would corrupt mixed content.
67
- * Child PIs serialize inline (engine emits `<r>\n<?pi x?> <e/>`
68
- rather than libxml2's one-PI-per-indented-line layout); safe
69
- post-hoc rewriting is impossible in mixed content.
70
- * DOCTYPE internal subsets serialize inline; libxml2 separates the
71
- declarations with newlines.
72
- * Document-level PIs are read-only natives (`target=`/`data=`/
73
- `unlink` rejected); write-through and removal are pending C
74
- surface (leptris/leptris#612).
70
+ * Tab indentation honors the indent-unit string on leptris >= 1.9.45
71
+ (`indent_text:`), byte-identical to Nokogiri on element-only
72
+ trees; text-bearing leaves still emit spaces until the engine fix
73
+ (leptris/leptris#658).
75
74
  * A PI preceding the DOCTYPE reorders on serialization — libleptris
76
75
  normalizes DOCTYPE-first and the document node does not record its
77
76
  relative position.
77
+
78
+ Resolved with leptris-ruby 1.9.42 (leptris/leptris#636/#612): child
79
+ PIs serialize on their own indented lines, DOCTYPE internal subsets
80
+ newline-separate their declarations (moxml re-formats the subset to
81
+ the libxml2 layout), the stray trailing newline after non-ASCII
82
+ ending elements is gone engine-side, and document-level PIs accept
83
+ `target=`/`content=`/removal with full round-trip. The moxml-side
84
+ compat guards remain as no-ops for older floor bindings.
@@ -100,4 +100,9 @@ context.parse(src).to_xml(
100
100
  ) == Nokogiri::XML(src).to_xml(indent: 2, encoding: "UTF-8") # => true
101
101
  ----
102
102
 
103
- Known residual divergences (engine-level, tracked upstream): tab indentation needs a serialize-time parameter in libleptris, and a PI preceding the DOCTYPE reorders (the engine normalizes DOCTYPE-first).
103
+ Known residual divergences (engine-level, tracked upstream): a PI
104
+ preceding the DOCTYPE reorders (the engine normalizes DOCTYPE-first),
105
+ and tab indentation waits on the binding exposing an indent-unit
106
+ parameter (leptris-ruby#109) — child-PI lines, DOCTYPE subset
107
+ layout, and the non-ASCII trailing-newline quirk were resolved with
108
+ leptris-ruby 1.9.42.
@@ -113,9 +113,75 @@ module Moxml
113
113
  return nil unless dt
114
114
 
115
115
  subset = dt.internal_subset if dt.class.method_defined?(:internal_subset)
116
+ subset = format_internal_subset(subset) if LIBXML2_LAYOUT_PARITY
116
117
  XmlEmitter.doctype_xml(dt.root_name, dt.public_id, dt.system_id, subset)
117
118
  end
118
119
 
120
+ # libxml2's DTD dump layout (leptris/leptris#636): newline
121
+ # after "[", one after every markup declaration, none after
122
+ # comments (they glue to both neighbors); an empty subset
123
+ # drops the brackets. Returns the INNER text for
124
+ # XmlEmitter.doctype_xml, nil when there is nothing to emit.
125
+ # The engine reports internal_subset as raw source text, so
126
+ # the declarations are re-tokenized — quote-aware, since an
127
+ # attribute default can contain ">".
128
+ def format_internal_subset(subset)
129
+ return nil if subset.nil? || subset.empty?
130
+
131
+ out = +"\n"
132
+ pos = 0
133
+ length = subset.length
134
+ while pos < length
135
+ start = subset.index("<", pos)
136
+ break if start.nil?
137
+
138
+ terminator, skip = if subset[start, 4] == "<!--"
139
+ ["-->", 4]
140
+ elsif subset[start, 2] == "<?"
141
+ ["?>", 2]
142
+ else
143
+ [nil, 0]
144
+ end
145
+ if terminator
146
+ stop = subset.index(terminator, start + skip)
147
+ break if stop.nil?
148
+
149
+ item_end = stop + terminator.length
150
+ else
151
+ item_end = markup_decl_end(subset, start)
152
+ break if item_end.nil?
153
+ end
154
+ out << subset[start...item_end]
155
+ # Comments carry no trailing newline; declarations do.
156
+ out << "\n" unless subset[start, 4] == "<!--"
157
+ pos = item_end
158
+ end
159
+ out == "\n" ? nil : out
160
+ end
161
+
162
+ # End index of a markup declaration starting at `start`:
163
+ # the first ">" outside quotes.
164
+ QUOTE_CHARS = ['"', "'"].freeze
165
+ private_constant :QUOTE_CHARS
166
+
167
+ def markup_decl_end(subset, start)
168
+ quote = nil
169
+ i = start
170
+ length = subset.length
171
+ while i < length
172
+ ch = subset[i]
173
+ if quote
174
+ quote = nil if ch == quote
175
+ elsif QUOTE_CHARS.include?(ch)
176
+ quote = ch
177
+ elsif ch == ">"
178
+ return i + 1
179
+ end
180
+ i += 1
181
+ end
182
+ nil
183
+ end
184
+
119
185
  def default_declaration_xml(doc, options)
120
186
  encoding = options[:encoding] || doc.encoding
121
187
  encoding = "UTF-8" if encoding.to_s.empty?
@@ -63,14 +63,21 @@ module Moxml
63
63
  include_decl = options.fetch(:declaration) do
64
64
  options[:no_declaration] ? false : document_has_declaration?(node)
65
65
  end
66
- xml = node.to_xml(
66
+ kwargs = {
67
67
  indent: options.fetch(:indent, 0),
68
68
  no_decl: !include_decl,
69
69
  encoding: options[:encoding],
70
- )
70
+ }
71
+ if INDENT_UNIT_SUPPORTED && options[:indent_text].is_a?(String)
72
+ kwargs[:indent_text] = options[:indent_text]
73
+ end
74
+ xml = node.to_xml(**kwargs)
71
75
  # Element output always ends with the close tag — but the
72
76
  # engine's serializer appends a stray trailing newline when
73
- # the element's last text child is non-ASCII.
77
+ # the element's last text child is non-ASCII (fixed engine
78
+ # side in 1.9.42; kept for older floor bindings). Note: the
79
+ # binding's indent_text kwarg is the display-form boolean,
80
+ # not Nokogiri's indent-unit string — do not forward it.
74
81
  xml.sub(/\n+\z/, "")
75
82
  end
76
83
 
@@ -31,6 +31,24 @@ module Moxml
31
31
  PREFIXED_ATTR_PREDICATES_NATIVE =
32
32
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.40")
33
33
 
34
+ # leptris/leptris#636 (leptris-ruby 1.9.42): libxml2-compatible
35
+ # pretty-print — child-PI lines, DOCTYPE internal-subset layout,
36
+ # no stray trailing newline after non-ASCII text, and the indent
37
+ # unit (`to_xml`'s indent_text takes the unit string). Older
38
+ # floor bindings treat indent_text as a display-form boolean, so
39
+ # the passthrough and the parity pins gate on this.
40
+ LIBXML2_LAYOUT_PARITY =
41
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.42")
42
+
43
+ # leptris-ruby#109 (1.9.45): Element#to_xml takes the
44
+ # indent-unit string, so moxml's per-child document composition
45
+ # can carry `indent_text:` through. Text-bearing leaves still
46
+ # emit spaces until the engine fix (leptris/leptris#658); the
47
+ # boolean display form stays document-level and is never
48
+ # forwarded (the element face raises on it).
49
+ INDENT_UNIT_SUPPORTED =
50
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.45")
51
+
34
52
  NO_PARSE_ERRORS = [].freeze
35
53
  private_constant :NO_PARSE_ERRORS
36
54
 
@@ -72,7 +90,7 @@ module Moxml
72
90
  ::Leptris::XML::Document.parse(
73
91
  processed,
74
92
  readonly: options[:readonly] == true,
75
- options: dtdattr_parse_options(options),
93
+ options: parse_flags(options),
76
94
  )
77
95
  rescue ::Leptris::XML::ParseError => e
78
96
  # libleptris has no recovery mode that survives unclosed
@@ -95,11 +113,16 @@ module Moxml
95
113
  doc
96
114
  end
97
115
 
98
- # nil unless DTDATTR is requested — the binding treats a nil
99
- # options hash as plain defaults (defaults exclude ATTLIST
100
- # defaults, matching libxml2/Nokogiri semantics).
101
- def dtdattr_parse_options(options)
102
- options[:dtdattr] == true ? ::Leptris::XML::ParseOptions.dtdattr : nil
116
+ # nil when no parse flag is requested — the binding treats a
117
+ # nil options hash as plain defaults (matching libxml2/
118
+ # Nokogiri semantics: blanks kept, no ATTLIST defaults).
119
+ # noblanks (issue #153) drops whitespace-only text nodes,
120
+ # libxml2 XML_PARSE_NOBLANKS parity.
121
+ def parse_flags(options)
122
+ flags = 0
123
+ flags |= ::Leptris::XML::ParseOptions::DTDATTR if options[:dtdattr] == true
124
+ flags |= ::Leptris::XML::ParseOptions::NOBLANKS if options[:noblanks] == true
125
+ flags.zero? ? nil : ::Leptris::XML::ParseOptions.new(flags)
103
126
  end
104
127
 
105
128
  def parse_errors(native_doc)
@@ -26,11 +26,13 @@ module Moxml
26
26
  ::Nokogiri::XML::DocumentFragment.parse(processed_xml) do |config|
27
27
  config.strict.nonet
28
28
  config.recover unless options[:strict]
29
+ config.noblanks if options[:noblanks]
29
30
  end
30
31
  else
31
32
  ::Nokogiri::XML(processed_xml, nil, "UTF-8") do |config|
32
33
  config.strict.nonet
33
34
  config.recover unless options[:strict]
35
+ config.noblanks if options[:noblanks]
34
36
  end
35
37
  end
36
38
  rescue ::Nokogiri::XML::SyntaxError => e
@@ -429,11 +431,13 @@ module Moxml
429
431
  custom_decl = "<?xml #{attrs.join(' ')}?>"
430
432
  end
431
433
 
432
- result = node.to_xml(
434
+ serialize_kwargs = {
433
435
  indent: options[:indent],
434
436
  encoding: options[:encoding],
435
437
  save_with: save_options,
436
- )
438
+ }
439
+ serialize_kwargs[:indent_text] = options[:indent_text] if options[:indent_text]
440
+ result = node.to_xml(**serialize_kwargs)
437
441
 
438
442
  if custom_decl
439
443
  result = "#{custom_decl}\n#{result}"
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.16"
4
+ VERSION = "0.5.18"
5
5
  end
@@ -157,6 +157,15 @@ RSpec.describe Moxml::Adapter::Leptris do
157
157
  selfclosing: %(<r><a/><b/><c>t</c><d/></r>),
158
158
  }
159
159
 
160
+ # leptris/leptris#636 (binding 1.9.42): child-PI lines and
161
+ # DOCTYPE internal-subset layout match libxml2.
162
+ if defined?(described_class::LIBXML2_LAYOUT_PARITY) && described_class::LIBXML2_LAYOUT_PARITY
163
+ cases[:pi_child] = %(<r><?pi data?><e/></r>)
164
+ cases[:pi_child_mixed] = %(<r>t<?pi d?><e>x</e>u</r>)
165
+ cases[:doctype_subset] = %(<?xml version="1.0"?><!DOCTYPE r [<!ELEMENT r (#PCDATA)>]><r>t</r>)
166
+ cases[:doctype_subset_multi] = %(<?xml version="1.0"?><!DOCTYPE r [<!ELEMENT r (#PCDATA)><!ATTLIST e a CDATA "d">]><r><e/></r>)
167
+ end
168
+
160
169
  cases.each do |name, source|
161
170
  target = Nokogiri::XML(source).to_xml(indent: 2, encoding: "UTF-8")
162
171
  output = ctx.parse(source).to_xml(
@@ -166,6 +175,43 @@ RSpec.describe Moxml::Adapter::Leptris do
166
175
  end
167
176
  end
168
177
 
178
+ it "round-trips document-level PI mutations through serialization" do
179
+ # leptris/leptris#612: parse-created document PIs carry doc
180
+ # linkage — the setters work and the tree round-trips.
181
+ doc = ctx.parse("<?pi-prolog before?><root/>")
182
+ pi = doc.children.to_a.first
183
+ pi.target = "renamed"
184
+ pi.content = "changed"
185
+ expect(doc.to_xml).to include("<?renamed changed?>")
186
+ end
187
+
188
+ it "matches raw Nokogiri with tab units on element-only trees" do
189
+ skip "requires the indent unit (leptris 1.9.45+)" unless described_class::INDENT_UNIT_SUPPORTED
190
+
191
+ source = %(<r><a><b/></a><c><d/><e/></c></r>)
192
+ target = Nokogiri::XML(source).to_xml(indent: 2, indent_text: "\t", encoding: "UTF-8")
193
+ output = ctx.parse(source).to_xml(
194
+ indent: 2, declaration: true, expand_empty: false,
195
+ encoding: "UTF-8", indent_text: "\t"
196
+ )
197
+ expect(output).to eq(target)
198
+ end
199
+
200
+ it "drops space-only text nodes with noblanks and matches Nokogiri (issue #153)" do
201
+ source = %(<a><t>1</t> <n/></a>)
202
+ recipe = {
203
+ indent: 2, declaration: true, expand_empty: false, encoding: "UTF-8"
204
+ }
205
+ target = Nokogiri::XML(source, &:noblanks).to_xml(indent: 2, encoding: "UTF-8")
206
+
207
+ doc = ctx.parse(source, noblanks: true)
208
+ expect(doc.root.children.to_a.select(&:text?)).to be_empty
209
+ expect(doc.to_xml(recipe)).to eq(target)
210
+
211
+ via_nokogiri = Moxml.new(:nokogiri).parse(source, noblanks: true).to_xml(recipe)
212
+ expect(via_nokogiri).to eq(target)
213
+ end
214
+
169
215
  it "reports the tracked native for a re-added document PI" do
170
216
  doc = ctx.parse("<?pi-src orig?><root/>")
171
217
  moved = doc.children.to_a[0]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.16
4
+ version: 0.5.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-30 00:00:00.000000000 Z
11
+ date: 2026-08-31 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Moxml is a unified XML manipulation library that provides a common API