moxml 0.5.142 → 0.5.145

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: 2c55a66ba735b6d8f170ad9f83a95faeb16717520d2fd30f767e1dc56914914b
4
- data.tar.gz: 74f3c07cdccc077e78fbef88d9d8e8f83310f5f9cdc1175221bf719a3a864146
3
+ metadata.gz: 042f6cd21672a36e869b19c158819a3624d3a79a53de1fd083e83a377b577442
4
+ data.tar.gz: 7cacbfbd44bb9a87aa252a638b8ac9d25fb5cb67b6e3845c8fdf9c5cf943df24
5
5
  SHA512:
6
- metadata.gz: 2b8a163b9601f4da9d2c9a96b1d5ef35eb0d874c27ec5d6c608283cd7ca27c9d801fa9065724a8989406423bd0844100d914cb2ee0de07a2a8f51f0212eb4ab4
7
- data.tar.gz: 153852b30839c08744fbd29558af04ba07a1c7c27979fa5cfd0ab6fbf48185a1186a33b7989b4aaf07e477942a3c9c215aa6b25b9225dff5670def94a71aa563
6
+ metadata.gz: 507216b5acdf26ae9cd5b4d69c12d2519b949fbe2017c39a804954a04659780c5432b4fe8b11262642ca1699e09eb453af08e230a653a9b530f55404b6d6f5f1
7
+ data.tar.gz: 5d19c7d3d7e1ad18864d13b0b3eaf69f481b5f1a0c68fd652f29cd9bff4e7b9f95f4adf57698d77849371bc635f9ba3ef36c945f12a1b63e2a350e50fc886bbb
@@ -89,6 +89,15 @@ module Moxml
89
89
  # no declaration. indent:/indent_text: thread the
90
90
  # pretty-print knobs where the adapter's engine carries
91
91
  # them. Adapters without an HTML serializer raise.
92
+ # XHTML-shaped serialization (issue #390): void elements
93
+ # self-closed, non-void paired, indent knobs composing.
94
+ # Adapters with an HTML engine answer this; the default
95
+ # raises so consumers get a named error, not mangled bytes.
96
+ def serialize_xhtml(_node, _options = {})
97
+ raise Moxml::NotImplementedError,
98
+ "XHTML serialization requires an adapter with an HTML engine"
99
+ end
100
+
92
101
  def serialize_html(_node, _options = {})
93
102
  raise Moxml::AdapterError.new(
94
103
  "HTML serialization is not supported by the #{name.split('::').last} adapter",
@@ -1212,6 +1212,16 @@ module Moxml
1212
1212
  node.to_html(**kwargs)
1213
1213
  end
1214
1214
 
1215
+ # XHTML shape (issue #390): the binding's XML method on an
1216
+ # HTML document — void elements short-form (<br/>), non-void
1217
+ # paired, raw text escaped, no declaration. The indent knobs
1218
+ # thread the #354 machinery.
1219
+ def serialize_xhtml(node, options = {})
1220
+ node.to_xml(no_decl: true, **options.slice(:indent,
1221
+ :indent_text, :encoding)
1222
+ .compact)
1223
+ end
1224
+
1215
1225
  def parse_html_fragment(html, context_tag = :body, _context = nil)
1216
1226
  unless HTML_FRAGMENT
1217
1227
  raise Moxml::AdapterError.new(
@@ -189,6 +189,22 @@ module Moxml
189
189
  node.to_html(**kwargs)
190
190
  end
191
191
 
192
+ # Nokogiri's AS_XHTML save flag is the byte reference for the
193
+ # XHTML shape (issue #390): FORMAT layout, self-closed voids,
194
+ # no declaration.
195
+ def serialize_xhtml(node, options = {})
196
+ save_with = ::Nokogiri::XML::Node::SaveOptions::FORMAT |
197
+ ::Nokogiri::XML::Node::SaveOptions::AS_XHTML |
198
+ ::Nokogiri::XML::Node::SaveOptions::NO_DECLARATION
199
+ io = StringIO.new
200
+ kwargs = { save_with: save_with }
201
+ kwargs[:indent] = options[:indent] if options[:indent]
202
+ kwargs[:indent_text] = options[:indent_text] if options[:indent_text]
203
+ kwargs[:encoding] = options[:encoding] if options[:encoding]
204
+ node.write_to(io, **kwargs)
205
+ io.string
206
+ end
207
+
192
208
  # WHATWG fragment parse through Nokogiri::HTML5.fragment
193
209
  # (issue #353) — the same tree construction the leptris
194
210
  # lane composes over its WHATWG engine.
@@ -129,12 +129,30 @@ module Moxml
129
129
  # (leptris >= the #354 ride; nokogiri passes them to the
130
130
  # engine). Adapters without the knob raise Moxml::AdapterError.
131
131
  def to_html(options = {})
132
+ return to_xhtml(options) if options[:xhtml]
133
+
132
134
  result = adapter.serialize_html(@native, options)
133
135
  apply_line_ending(result,
134
136
  options.fetch(:line_ending, Config::LINE_ENDING_LF))
135
137
  result
136
138
  end
137
139
 
140
+ # XHTML-shaped serialization (issue #390): void elements
141
+ # self-closed (<br/>), non-void elements paired, raw text
142
+ # escaped (the XML method's §16.2-compatible escaping), no
143
+ # declaration unless asked. Composes with the indent knobs.
144
+ # Also selected by to_html(xhtml: true).
145
+ #
146
+ # Byte reference: Nokogiri's FORMAT | AS_XHTML | NO_DECLARATION
147
+ # save flag; canon pins engine-vs-Nokogiri parity on its display
148
+ # corpus before flipping its lane.
149
+ def to_xhtml(options = {})
150
+ result = adapter.serialize_xhtml(@native, options)
151
+ apply_line_ending(result,
152
+ options.fetch(:line_ending, Config::LINE_ENDING_LF))
153
+ result
154
+ end
155
+
138
156
  def create_element(name)
139
157
  Wrappers::Element.new(adapter.create_element(name, owner_doc: @native), context)
140
158
  end
@@ -209,7 +227,21 @@ module Moxml
209
227
  self
210
228
  end
211
229
 
230
+ # Relative name-test paths on the document resolve laxly, as
231
+ # descendant-or-self anchored at the root — Nokogiri's own
232
+ # Document#at/#search semantics (moxml#393; its #xpath/#at_xpath are
233
+ # strict document-context, where the only element child is the root,
234
+ # and the adapters disagree there anyway: leptris's engine returns []
235
+ # for every relative path — leptris-ruby#502 — while Nokogiri's
236
+ # #at/#xpath disagree with each other). Every strict match survives
237
+ # the lax rewrite (the only strict-matchable first step is the root
238
+ # itself), so this is purely additive. Non-name-test expressions
239
+ # (axes, node types, ".", "..") keep document context.
240
+ RELATIVE_NAME_TEST = /\A\s*(\*|[A-Za-z_][\w.-]*)(\s*\[|\s*\||\z|\/)/
241
+
212
242
  def xpath(expression, namespaces = nil)
243
+ expression = lax_document_path(expression) if relative_name_test?(expression)
244
+
213
245
  result = adapter.xpath(@native, expression, namespaces)
214
246
 
215
247
  # Handle different result types:
@@ -228,6 +260,8 @@ module Moxml
228
260
  end
229
261
 
230
262
  def at_xpath(expression, namespaces = nil)
263
+ expression = lax_document_path(expression) if relative_name_test?(expression)
264
+
231
265
  if (native_node = adapter.at_xpath(@native, expression, namespaces))
232
266
  Moxml::Node.wrap(native_node, context)
233
267
  end
@@ -250,5 +284,44 @@ module Moxml
250
284
  def find_all(xpath)
251
285
  xpath(xpath).to_a
252
286
  end
287
+
288
+ private
289
+
290
+ def relative_name_test?(expression)
291
+ expression.match?(RELATIVE_NAME_TEST)
292
+ end
293
+
294
+ # Anchor every top-level union branch at the document root
295
+ # ("//"= descendant-or-self): "a/b | c" -> "//a/b | //c" — one
296
+ # adapter call, engine document order preserved, and the
297
+ # attribute-terminal idiom (pinned below) stays intact, which the
298
+ # ".//" form does not (libxml2 rejects .//r/a/@id).
299
+ def lax_document_path(expression)
300
+ split_top_level_unions(expression)
301
+ .map { |branch| "//#{branch.strip}" }
302
+ .join(" | ")
303
+ end
304
+
305
+ def split_top_level_unions(expression)
306
+ branches = [+""]
307
+ depth = 0
308
+ quote = nil
309
+ expression.each_char do |ch|
310
+ if quote
311
+ quote = nil if ch == quote
312
+ elsif ["'", '"'].include?(ch)
313
+ quote = ch
314
+ elsif ["(", "["].include?(ch)
315
+ depth += 1
316
+ elsif [")", "]"].include?(ch)
317
+ depth -= 1
318
+ elsif ch == "|" && depth.zero?
319
+ branches << +""
320
+ next
321
+ end
322
+ branches.last << ch
323
+ end
324
+ branches
325
+ end
253
326
  end
254
327
  end
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.142"
4
+ VERSION = "0.5.145"
5
5
  end
@@ -53,9 +53,28 @@ RSpec.describe "XPath idioms across adapters" do
53
53
  expect(doc.xpath("//a/@id").first&.value).to eq("x")
54
54
  expect(doc.xpath("//@id").first&.value).to eq("x")
55
55
  expect(doc.xpath("r/a/@id").first&.value).to eq("x")
56
- expect(
57
- doc.xpath("//*[local-name()='a']/@id").first&.value,
58
- ).to eq("x")
56
+ expect(doc.xpath("//*[local-name()='a']/@id").first&.value).to eq("x")
57
+ end
58
+
59
+ # moxml#393: a relative name-test path on the DOCUMENT resolves
60
+ # laxly, descendant-anchored at the root — Nokogiri's own
61
+ # Document#at/#search semantics, now pinned for every adapter.
62
+ # Axes, node types and "." keep strict document context.
63
+ it "resolves document-context relative name-test paths from the root" do
64
+ doc = ctx.parse(plain_xml)
65
+ expect(doc.at("a/b")&.name).to eq("b")
66
+ expect(doc.at_xpath("a/b")&.name).to eq("b")
67
+ expect(doc.xpath("a/b").map(&:name)).to eq(["b"])
68
+ expect(doc.xpath("a | r").map(&:name).sort).to eq(%w(a r))
69
+ expect(doc.xpath("missing").map(&:name)).to eq([])
70
+ expect(doc.at_xpath("missing")).to be_nil
71
+ end
72
+
73
+ it "keeps non-name-test expressions in document context" do
74
+ doc = ctx.parse(plain_xml)
75
+ expect(doc.at_xpath(".")).to be_truthy
76
+ expect(doc.at_xpath("/*")&.name).to eq("r")
77
+ expect(doc.at_xpath("/r")&.name).to eq("r")
59
78
  end
60
79
  end
61
80
  end
@@ -13,7 +13,7 @@ SUPPORTED = %i[nokogiri leptris].select do |name|
13
13
  Moxml::Adapter::AVAILABLE_ADAPTERS.include?(name)
14
14
  end.freeze
15
15
 
16
- CORPUS = {
16
+ XSLT_CORPUS = {
17
17
  "value-of with count" => [
18
18
  <<~XSL,
19
19
  <xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
@@ -82,7 +82,7 @@ RSpec.describe "Moxml::XSLT cross-engine agreement" do
82
82
  output.sub(/\A<\?xml[^>]*\?>\s*/, "")
83
83
  end
84
84
 
85
- CORPUS.each do |label, (sheet_xml, input, expected_re)|
85
+ XSLT_CORPUS.each do |label, (sheet_xml, input, expected_re)|
86
86
  it "transforms #{label}" do
87
87
  sheet = ctx.xslt(sheet_xml)
88
88
  doc = ctx.parse(input)
@@ -97,7 +97,7 @@ RSpec.describe "Moxml::XSLT cross-engine agreement" do
97
97
  return unless SUPPORTED.size == 2
98
98
 
99
99
  first, second = SUPPORTED.map { |name| Moxml.new(name) }
100
- CORPUS.each do |label, (sheet_xml, input, _)|
100
+ XSLT_CORPUS.each do |label, (sheet_xml, input, _)|
101
101
  a = first.xslt(sheet_xml).apply_to_string(first.parse(input))
102
102
  b = second.xslt(sheet_xml).apply_to_string(second.parse(input))
103
103
  expect(Moxml::C14n.equivalent?(a, b))
@@ -3,7 +3,7 @@
3
3
  require "spec_helper"
4
4
  require "moxml/c14n"
5
5
 
6
- CORPUS = {
6
+ C14N_DELEGATION_CORPUS = {
7
7
  "cdata content becomes escaped text" => [
8
8
  "<doc><![CDATA[a < b]]>tail</doc>",
9
9
  "<doc>a &lt; btail</doc>",
@@ -45,7 +45,7 @@ RSpec.describe "Moxml::C14n cross-adapter canonicalization" do
45
45
  context "with the #{adapter_name} adapter" do
46
46
  let(:ctx) { Moxml.new(adapter_name) }
47
47
 
48
- CORPUS.each do |label, (input, expected)|
48
+ C14N_DELEGATION_CORPUS.each do |label, (input, expected)|
49
49
  it "canonicalizes #{label}" do
50
50
  root = ctx.parse(input).root
51
51
  expect(Moxml::C14n.canonicalize_inclusive10(root)).to eq(expected)
@@ -9,7 +9,7 @@ ENVELOPED_DOC = %(<env><payload id="p1">data &amp; more</payload>#{SIG}<tail/></
9
9
  ENVELOPED_EXCLUDE = '//*[not(ancestor-or-self::*[local-name()="Signature"])]'
10
10
  SIGNATURE_XPATH = '//*[local-name()="Signature"]'
11
11
 
12
- CORPUS = [
12
+ C14N_SUBSET_CORPUS = [
13
13
  ["apex attraction + text",
14
14
  %(<a xml:lang="en"><b id="1"><c>t</c></b><d/></a>), "//b",
15
15
  %(<b id="1" xml:lang="en"></b>)],
@@ -59,7 +59,7 @@ RSpec.describe "Moxml::C14n subset canonicalization" do
59
59
  context "with the #{adapter_name} adapter" do
60
60
  let(:ctx) { Moxml.new(adapter_name) }
61
61
 
62
- CORPUS.each do |label, xml, xpath, expected|
62
+ C14N_SUBSET_CORPUS.each do |label, xml, xpath, expected|
63
63
  next if label == "text selection" && adapter_name == :rexml
64
64
 
65
65
  # rexml's adapter xpath does not return text nodes for
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Issue #390: XHTML-shaped serialization — void elements self-closed
6
+ # (<br/>), non-void elements paired, raw text escaped, no XML
7
+ # declaration. Composes with the #354 indent knobs. Nokogiri's
8
+ # FORMAT | AS_XHTML | NO_DECLARATION save flag is the byte
9
+ # reference; canon's fixture_ready display lane rides the face.
10
+ RSpec.describe "XHTML output mode" do
11
+ let(:doc) do
12
+ Moxml.new(ENV.fetch("XHTML_ADAPTER", :leptris)).parse_html(
13
+ '<!DOCTYPE html><html><body><p>a<br>b</p><img src="x.png">' \
14
+ "<hr></body></html>",
15
+ )
16
+ end
17
+
18
+ describe "Document#to_xhtml" do
19
+ it "self-closes void elements with the XHTML slash" do
20
+ out = doc.to_xhtml
21
+ expect(out).not_to include("<br>")
22
+ expect(out).to include("<br/>")
23
+ expect(out).to include('<img src="x.png"/>')
24
+ expect(out).to include("<hr/>")
25
+ end
26
+
27
+ it "pairs non-void elements" do
28
+ out = doc.to_xhtml
29
+ expect(out).to include("<p>a<br/>b</p>")
30
+ expect(out).not_to include("<p/>")
31
+ end
32
+
33
+ it "omits the XML declaration" do
34
+ expect(doc.to_xhtml).not_to start_with("<?xml")
35
+ end
36
+
37
+ it "keeps the doctype as document content" do
38
+ expect(doc.to_xhtml).to include("<!DOCTYPE html>")
39
+ end
40
+
41
+ it "composes with the indent knobs" do
42
+ out = doc.to_xhtml(indent: 2)
43
+ expect(out).to include("\n <body>")
44
+ expect(out).to include("\n <p>a<br/>b</p>")
45
+ end
46
+
47
+ it "escapes raw text per the XML method" do
48
+ doc = Moxml.new(ENV.fetch("XHTML_ADAPTER", :leptris)).parse_html(
49
+ "<!DOCTYPE html><html><body><script>if (a < b) { x(); }</script>" \
50
+ "</body></html>",
51
+ )
52
+ out = doc.to_xhtml
53
+ expect(out).not_to include("a < b")
54
+ expect(out).to include("&lt;") if out.include?("script")
55
+ end
56
+ end
57
+
58
+ describe "Document#to_html(xhtml: true)" do
59
+ it "selects the same shape" do
60
+ expect(doc.to_html(xhtml: true)).to eq(doc.to_xhtml)
61
+ end
62
+
63
+ it "still serves the HTML shape without the flag" do
64
+ expect(doc.to_html).to include("<br>")
65
+ expect(doc.to_html).not_to include("<br/>")
66
+ end
67
+ end
68
+
69
+ describe "adapter coverage" do
70
+ it "serves the nokogiri lane (the byte reference)" do
71
+ doc = Moxml.new(:nokogiri).parse_html(
72
+ "<!DOCTYPE html><html><body><p>a<br>b</p></body></html>",
73
+ )
74
+ out = doc.to_xhtml
75
+ expect(out).to include("<br/>")
76
+ expect(out).to include("<p>a<br/>b</p>")
77
+ end
78
+ end
79
+ end
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.142
4
+ version: 0.5.145
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-10-18 00:00:00.000000000 Z
11
+ date: 2026-10-19 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
@@ -560,6 +560,7 @@ files:
560
560
  - spec/moxml/text_spec.rb
561
561
  - spec/moxml/to_html_spec.rb
562
562
  - spec/moxml/version_spec.rb
563
+ - spec/moxml/xhtml_output_spec.rb
563
564
  - spec/moxml/xml_emitter_spec.rb
564
565
  - spec/moxml/xml_utils/.gitkeep
565
566
  - spec/moxml/xml_utils/encoder_spec.rb