canon 0.3.100 → 0.3.101

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: 47ebab684f05c7445139ea4b0ed5133ab734c1d16918f3dadc055b3ddbc5692f
4
- data.tar.gz: d80c1ae4a66f43ffb92c3eb8a14ebca8e2bcd2eb373130797a71e7f79a748d9a
3
+ metadata.gz: dc66e03ef02db4a7cbb21e496076e2794027a7a52996eb797b90b697c77013ee
4
+ data.tar.gz: 6ab70c76adfd5fe466b0fa8a54f542097344f4726c34192b1f85f59a542b5752
5
5
  SHA512:
6
- metadata.gz: 3555261e4b8280e6967bd63900dec75bb19d2225ff2da16748136ee7ddcb076fcee4f04b6502801afd92d344ef67b578c692868e63c3c181dff8008efc472986
7
- data.tar.gz: 590b8d12f49526bd3965d78f2000b4d7e64452e0b026547285d0762f6fb0565311ffb00c595aaac85813c90658656842aca1a8970e25f88f20e4f6d09e6ddeb8
6
+ metadata.gz: efc3813ef9fa91be57ddd8fa537506cb938361e5c582f4bdc06ac272c7ffd299b92172c9bf41cf194f1a5d0c50237b82c8a01de801f652545dc51693ed96f08b
7
+ data.tar.gz: a867f50610cb84caa68f469561f9bd5cc15ec49cb4732543fd76efbca201787520245f013edca9454349e7a164be73f8f884bc503458f007f7ee7206bd5fedc9
data/CLAUDE.md CHANGED
@@ -136,7 +136,7 @@ Canon is engine-agnostic across three seams (MECE — one concern per module):
136
136
 
137
137
  - `Canon::XmlBackend` — XML engine selection. The default follows moxml's resolved adapter: **leptris whenever it is installed** (parse ~1.6x, serialize ~3.8x vs Nokogiri), raw Nokogiri otherwise (wrapping Nokogiri in moxml buys nothing — the wrapper adds 2-3x overhead). `:moxml` under Opal. `CANON_XML_BACKEND=nokogiri|moxml` forces either engine.
138
138
  - `Canon::XmlParsing` — the only place that talks to engines for XML parse/serialize (moxml parses pass `readonly: true` — canon never mutates engine documents); node type queries answer for ANY recognized node (Nokogiri or moxml) by type, never by active backend — user-supplied Nokogiri nodes keep working under the moxml engine.
139
- - `Canon::Html::NokogiriSupport` — HTML engine seam. **HTML comparison parses through the engine** since 0.3.86: `HtmlParser.parse` routes through moxml `Context#parse_html_fragment` (WHATWG fragment parsing, moxml#353 riding leptris) and builds a Canon tree via the materialize-records fast path — whitespace text nodes survive the parse unconditionally (HTML whitespace significance is the comparison profile's decision, #112/#137), and `parse_node` walks Canon trees directly. The Nokogiri HTML4/HTML5 fragment path remains for runtimes without an engine HTML adapter and for user-supplied Nokogiri nodes. Nokogiri still serves the other HTML lanes: the pretty-printer's HTML5/fixture-ready display modes, the canonical HTML formatters, and the semantic lane's `DataModel.from_html`. The fragment-records lane once delivered nil URIs for prefixed attributes (moxml#367, fixed in moxml 0.5.130 — the bulk read now falls back to the reserved-prefix table); the TreeBuilder keeps the one legal `xml`-prefix binding as standing defense.
139
+ - `Canon::Html::NokogiriSupport` — HTML engine seam. **HTML is engine-served end-to-end** since 0.3.86-0.3.101: `HtmlParser.parse` routes through moxml `Context#parse_html_fragment` (WHATWG fragment parsing, moxml#353 riding leptris) and builds a Canon tree via the materialize-records fast path — whitespace text nodes survive the parse unconditionally (HTML whitespace significance is the comparison profile's decision, #112/#137), and `parse_node` walks Canon trees directly. The semantic lane's `DataModel.from_html`, the `:format` string normalizer, the canonical HTML formatters, `format_html`'s default/XHTML display modes, and `validate_xhtml!` are all engine-served (0.3.99/0.3.100/0.3.101) — **HTML comparison and formatting run nokogiri-less** (pinned by the optionality contract spec). The Nokogiri HTML fragment path remains only for runtimes without an engine HTML adapter and for user-supplied Nokogiri nodes. Nokogiri's residual roles: the `fixture_ready` display mode (libxml FORMAT|AS_XHTML — moxml#390 filed for an engine XHTML mode), the HTML5 validation lint layer (gumbo error-level classification — leptris#1771 filed), and the fragment-records lane once delivered nil URIs for prefixed attributes (moxml#367, fixed in moxml 0.5.130 — the bulk read now falls back to the reserved-prefix table); the TreeBuilder keeps the one legal `xml`-prefix binding as standing defense.
140
140
  - `Canon::Xml::Sax` — SAX driver selection follows the resolved adapter: `MoxmlDriver` (leptris SAX — the comparison hot path, CI-gated in the split flip) under the leptris default, `NokogiriDriver` when the resolved adapter is nokogiri, `MoxmlDriver` under Opal. The builder (`SaxBuilder`) is engine-neutral.
141
141
 
142
142
  Engine parity is complete through libleptris 1.9.8 / leptris-ruby 1.9.33 / moxml 0.5.12: attribute-value normalization, prolog/epilog PIs and comments, DTD ATTLIST defaults off by default (opt-in via `ParseOptions::DTDATTR`), PI-data whitespace matching libxml2, materialize with namespace declarations and a root-subtree-only record contract (moxml#140), readonly parse, deterministic `Document#free` (wired into `from_moxml_xml` — moxml#134). Canon's moxml conversion is record-based (`build_from_moxml` consumes `materialize` records — no per-node wrapper allocation; ~0.55x the Nokogiri conversion). `spec/canon/xml/engine_parity_spec.rb` is fully green. Pretty-printing runs the leptris serializer through `PrettyPrinter::Xml#moxml_format` (byte parity via moxml#153/#155/#156; the #167 escaping race fixed in 0.5.28), with Nokogiri serving the fallback engine and Opal — pretty-printed bytes are canon's product, gated by the fixture-integrity suite and the CI performance gate.
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Canon
4
4
  module Formatters
5
- # HTML4 formatter using Nokogiri::HTML parser
5
+ # HTML4 formatter — parsed through the engine WHATWG lane (the
6
+ # #118 html4/html5 unification: one parse serves both flavors).
6
7
  class Html4Formatter < HtmlFormatterBase
7
8
  # Parse HTML4 document
8
9
  # @param html [String] HTML document to parse
9
- # @return [Nokogiri::HTML::Document] Parsed HTML4 document
10
+ # @return [Moxml::Document] Parsed document
10
11
  def self.parse(html)
11
- Nokogiri::HTML(html)
12
+ Canon::Html::DataModel.engine_html_parse(html)
12
13
  end
13
14
  end
14
15
  end
@@ -2,13 +2,13 @@
2
2
 
3
3
  module Canon
4
4
  module Formatters
5
- # HTML5 formatter using Nokogiri::HTML5 parser
5
+ # HTML5 formatter — parsed through the engine WHATWG lane.
6
6
  class Html5Formatter < HtmlFormatterBase
7
7
  # Parse HTML5 document
8
8
  # @param html [String] HTML document to parse
9
- # @return [Nokogiri::HTML5::Document] Parsed HTML5 document
9
+ # @return [Moxml::Document] Parsed document
10
10
  def self.parse(html)
11
- Nokogiri::HTML5(html)
11
+ Canon::Html::DataModel.engine_html_parse(html)
12
12
  end
13
13
  end
14
14
  end
@@ -1,23 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Canon::NokogiriLoader.require!("HTML formatting")
4
-
5
3
  module Canon
6
4
  module Formatters
7
- # HTML formatter for HTML 4/5 and XHTML
5
+ # HTML formatter for HTML 4/5 and XHTML — engine-parsed.
8
6
  class HtmlFormatter < HtmlFormatterBase
9
- # Parse HTML into a Nokogiri document
7
+ # Parse HTML into an engine document
10
8
  # @param html [String] HTML document to parse
11
- # @return [Nokogiri::HTML::Document, Nokogiri::XML::Document]
12
- # Parsed HTML or XML document
9
+ # @return [Moxml::Document] Parsed document
13
10
  def self.parse(html)
14
11
  # Validate before parsing
15
12
  Canon::Validators::HtmlValidator.validate!(html)
16
13
 
17
14
  if xhtml?(html)
18
- Nokogiri::XML(html)
15
+ # XHTML is XML — the recover-and-record parse matches the old
16
+ # non-strict Nokogiri::XML behaviour.
17
+ Canon::XmlParsing.moxml_context.parse(html)
18
+ else
19
+ Canon::Html::DataModel.engine_html_parse(html)
20
+ end
21
+ end
22
+
23
+ # XHTML input must serialize XML-shaped (self-closed voids) so
24
+ # the canonical output stays well-formed and re-validates.
25
+ def self.format(html)
26
+ if xhtml?(html)
27
+ doc = parse(html)
28
+ canonicalize(doc, serializer: :xml)
19
29
  else
20
- Nokogiri::HTML5(html)
30
+ doc = Canon::Html::DataModel.engine_html_parse(html)
31
+ canonicalize(doc, serializer: :html)
21
32
  end
22
33
  end
23
34
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Canon::NokogiriLoader.require!("HTML formatting")
4
3
  require "set"
5
4
 
6
5
  module Canon
@@ -63,7 +62,7 @@ module Canon
63
62
  # Compiled once — building the alternation and compiling the
64
63
  # regex per format call cost more than the gsub it drives.
65
64
  BLOCK_SPACING_PATTERN =
66
- Regexp.new("(</(?:#{BLOCK_ELEMENTS.join('|')})>)(<(?:#{BLOCK_ELEMENTS.join('|')})[\s>])").freeze
65
+ Regexp.new("(</(?:#{BLOCK_ELEMENTS.join('|')})>)(?:\n*)(<(?:#{BLOCK_ELEMENTS.join('|')})[\s>])").freeze
67
66
  # Format HTML using canonical form
68
67
  # @param html [String] HTML document to canonicalize
69
68
  # @return [String] Canonical form of HTML
@@ -82,19 +81,25 @@ module Canon
82
81
  end
83
82
 
84
83
  # Canonicalize HTML document
85
- # @param doc [Nokogiri::HTML::Document] Parsed HTML document
84
+ # @param doc [Moxml::Document] Parsed document
85
+ # @param serializer [:html, :xml] XHTML input must serialize
86
+ # XML-shaped (self-closed voids) so the output stays
87
+ # well-formed; HTML input takes the HTML shape.
86
88
  # @return [String] Canonical HTML string
87
- def self.canonicalize(doc)
89
+ def self.canonicalize(doc, serializer: :html)
88
90
  # Sort attributes for consistency
89
91
  sort_attributes(doc)
90
92
 
91
93
  # Normalize whitespace between elements
92
94
  normalize_whitespace(doc)
93
95
 
94
- # Serialize with consistent formatting
95
- html = doc.to_html(
96
- save_with: Nokogiri::XML::Node::SaveOptions::NO_DECLARATION,
97
- ).strip
96
+ # Serialize with consistent formatting — the engine emits no
97
+ # declaration; empty elements self-close in the XML shape.
98
+ html = (if serializer == :xml
99
+ doc.to_xml(expand_empty: false)
100
+ else
101
+ doc.to_html
102
+ end).strip
98
103
 
99
104
  # Post-process: ensure spaces between block element tags
100
105
  # This is needed because Nokogiri's serialization may remove
@@ -103,74 +108,105 @@ module Canon
103
108
  end
104
109
 
105
110
  # Sort element attributes alphabetically throughout document
106
- # @param doc [Nokogiri::HTML::Document] Document to process
111
+ # @param doc [Moxml::Document] Parsed document
107
112
  def self.sort_attributes(doc)
108
- doc.traverse do |node|
109
- next unless node.element?
110
- next if node.attributes.empty?
111
-
112
- names = node.attributes.keys
113
- # Already-sorted un-namespaced is the common case — removing
114
- # and re-adding every attribute is expensive, so check first.
115
- # Namespaced attributes must take the slow path: the
116
- # remove/re-add below flattens their prefix, and skipping
117
- # would change the canonical output.
118
- next if names.each_cons(2).all? { |a, b| (a <=> b) <= 0 } &&
119
- node.attributes.each_value.all? { |a| a.namespace.nil? }
120
-
121
- sorted_attrs = node.attributes.sort_by { |name, _| name }
122
- node.attributes.each_key { |name| node.remove_attribute(name) }
123
- sorted_attrs.each { |name, attr| node[name] = attr.value }
113
+ walk_elements(doc.root) do |element|
114
+ attrs = element.attributes
115
+ next if attrs.empty?
116
+
117
+ names = attrs.map(&:name)
118
+ # Already-sorted is the common case — removing and re-adding
119
+ # every attribute is expensive, so check first.
120
+ next if names.each_cons(2).all? { |a, b| (a <=> b) <= 0 }
121
+
122
+ sorted_attrs = attrs.sort_by(&:name)
123
+ names.each { |name| element.remove_attribute(name) }
124
+ sorted_attrs.each { |attr| element[attr.name] = attr.value }
124
125
  end
125
126
  end
126
127
 
128
+ # Depth-first element walk (pre-order).
129
+ def self.walk_elements(node, &block)
130
+ return unless node.is_a?(Moxml::Element)
131
+
132
+ yield(node)
133
+ node.children.to_a.each { |child| walk_elements(child, &block) }
134
+ end
135
+
127
136
  # Normalize whitespace by removing whitespace-only text nodes
128
137
  # between elements and collapsing whitespace within text content
129
- # @param doc [Nokogiri::HTML::Document] Document to process
138
+ # @param doc [Moxml::Document] Parsed document
130
139
  def self.normalize_whitespace(doc)
131
- # Normalize whitespace in text nodes
132
- doc.traverse do |node|
133
- next unless node.text?
134
-
140
+ # Walk parented text nodes only — the engine's content= on a
141
+ # parentless node raises an opaque Invalid argument (upstream
142
+ # note), and parentless text has no canonical meaning anyway.
143
+ walk_text_nodes(doc.root) do |text, parent|
135
144
  # CRITICAL: Skip normalization for whitespace-sensitive elements
136
145
  # In elements like <pre>, <code>, etc., whitespace is semantically
137
146
  # significant and must be preserved exactly as-is
138
- if whitespace_sensitive_element?(node.parent)
139
- next
140
- end
141
-
142
- # Handle whitespace-only text nodes
143
- if node.text.match?(Canon::Xml::WhitespacePolicy::STRIP_ONLY) && node.parent&.element?
144
- # Check if this text node is between block-level elements
145
- prev_sibling = node.previous_sibling
146
- next_sibling = node.next_sibling
147
-
148
- # If between block elements, preserve one space
149
- if block_element?(prev_sibling) || block_element?(next_sibling) ||
150
- block_element?(node.parent)
151
- node.content = " "
147
+ next if whitespace_sensitive_element?(parent)
148
+
149
+ if text.content.match?(Canon::Xml::WhitespacePolicy::STRIP_ONLY)
150
+ # Whitespace-only text: keep one space between block
151
+ # elements (information-preserving canonical form),
152
+ # otherwise remove. The walk yields parented nodes only —
153
+ # the engine content= raises on parentless text upstream.
154
+ if block_element?(prev_element_sibling(parent, text)) ||
155
+ block_element?(next_element_sibling(parent, text)) ||
156
+ block_element?(parent)
157
+ text.content = " "
152
158
  else
153
- # Otherwise remove it
154
- node.remove
159
+ text.remove
155
160
  end
156
161
  else
157
162
  # Collapse multiple whitespace characters into single spaces
158
163
  # but preserve leading/trailing single spaces for inline content
159
- text = node.text
160
- normalized = text.gsub(/\s+/, " ")
164
+ content = text.content
165
+ normalized = content.gsub(/\s+/, " ")
161
166
  # Only strip if the entire parent chain suggests it's appropriate
162
167
  # (e.g., at document boundaries)
163
- if node.parent&.name == "body" &&
164
- (node.previous_sibling.nil? || node.next_sibling.nil?)
168
+ if parent.name == "body" &&
169
+ (first_real_child?(parent, text) || last_real_child?(parent, text))
165
170
  normalized = normalized.strip
166
171
  end
167
- # node.content= re-parses the string — skip it when nothing
172
+ # content= re-parses the string — skip it when nothing
168
173
  # changed (text with no collapsible whitespace).
169
- node.content = normalized unless normalized == text
174
+ text.content = normalized unless normalized == content
170
175
  end
171
176
  end
172
177
  end
173
178
 
179
+ def self.walk_text_nodes(node, parent = nil, &block)
180
+ case node
181
+ when Moxml::Text
182
+ yield(node, parent) if parent
183
+ when Moxml::Element
184
+ node.children.to_a.each { |child| walk_text_nodes(child, node, &block) }
185
+ end
186
+ end
187
+
188
+ def self.prev_element_sibling(parent, text)
189
+ siblings = parent.children.to_a
190
+ idx = siblings.index(text)
191
+ siblings[0...idx].reverse_each.find { |s| s.is_a?(Moxml::Element) }
192
+ end
193
+
194
+ def self.next_element_sibling(parent, text)
195
+ siblings = parent.children.to_a
196
+ idx = siblings.index(text)
197
+ siblings[(idx + 1)..].find { |s| s.is_a?(Moxml::Element) }
198
+ end
199
+
200
+ def self.first_real_child?(parent, text)
201
+ siblings = parent.children.to_a
202
+ siblings.find { |s| !(s.is_a?(Moxml::Text) && s.content.strip.empty?) }.equal?(text)
203
+ end
204
+
205
+ def self.last_real_child?(parent, text)
206
+ siblings = parent.children.to_a
207
+ siblings.reverse_each.find { |s| !(s.is_a?(Moxml::Text) && s.content.strip.empty?) }.equal?(text)
208
+ end
209
+
174
210
  # Ensure spacing between block element tags in serialized HTML
175
211
  # @param html [String] Serialized HTML string
176
212
  # @return [String] HTML with proper spacing between block elements
@@ -184,7 +220,7 @@ module Canon
184
220
  # @param node [Nokogiri::XML::Node, nil] Node to check
185
221
  # @return [Boolean] true if node is a block element
186
222
  def self.block_element?(node)
187
- return false unless node&.element?
223
+ return false unless node.is_a?(Moxml::Element)
188
224
 
189
225
  name = node.name
190
226
  BLOCK_ELEMENT_SET.include?(name) ||
@@ -192,25 +228,18 @@ module Canon
192
228
  end
193
229
 
194
230
  # Check if a node is a whitespace-sensitive element
195
- # @param node [Nokogiri::XML::Node, nil] Node to check
231
+ # @param node [Moxml::Element, nil] Node to check
196
232
  # @return [Boolean] true if node is whitespace-sensitive
197
233
  def self.whitespace_sensitive_element?(node)
198
- return false unless node&.element?
199
-
200
- # Check if this element or any ancestor is whitespace-sensitive
201
234
  current = node
202
- while current
203
- if current.element?
204
- name = current.name
205
- # Nokogiri lowercases HTML names — the downcase fallback
206
- # only allocates for unusual mixed-case input.
207
- if WHITESPACE_SENSITIVE_ELEMENTS.include?(name) ||
208
- WHITESPACE_SENSITIVE_ELEMENTS.include?(name.downcase)
209
- return true
210
- end
235
+ while current.is_a?(Moxml::Element)
236
+ name = current.name
237
+ # The downcase fallback only allocates for unusual mixed-case
238
+ # input.
239
+ if WHITESPACE_SENSITIVE_ELEMENTS.include?(name) ||
240
+ WHITESPACE_SENSITIVE_ELEMENTS.include?(name.downcase)
241
+ return true
211
242
  end
212
- # Stop at document root - documents don't have parents
213
- break if current.is_a?(Nokogiri::XML::Document) || current.is_a?(Nokogiri::HTML5::Document)
214
243
 
215
244
  current = current.parent
216
245
  end
@@ -98,57 +98,31 @@ module Canon
98
98
  # indentation; +AS_XHTML+ produces well-shaped output (void
99
99
  # elements self-closed, non-void paired); +NO_DECLARATION+
100
100
  # suppresses the +<?xml ...?>+ prefix.
101
+ # Engine parse + significance-aware strip + the #354 indent
102
+ # knobs + a canon-side void self-closing pass for the AS_XHTML
103
+ # contract (an engine XHTML mode is asked upstream as moxml#390;
104
+ # until then the shape transform is ours).
101
105
  def format_fixture_ready(html_string)
102
- doc = Nokogiri::HTML5(html_string)
103
- strip_structural_whitespace!(doc)
104
- io = StringIO.new
105
- if @indent_type == "tab"
106
- doc.write_to(io, save_with: fixture_ready_save_options,
107
- indent: 1, indent_text: "\t")
108
- else
109
- doc.write_to(io, save_with: fixture_ready_save_options,
110
- indent: @indent)
111
- end
112
- io.string
106
+ doc = Canon::Html::DataModel.engine_html_parse(html_string)
107
+ Canon::Html::DataModel.strip_insignificant_whitespace!(doc.root)
108
+ html = if @indent_type == "tab"
109
+ doc.to_html(indent: 1, indent_text: "\t")
110
+ else
111
+ doc.to_html(indent: @indent)
112
+ end
113
+ self_closing_voids(html)
113
114
  end
114
115
 
115
- # libxml's +FORMAT+ save flag does not insert indentation around
116
- # the children of any element it sees as mixed content (any
117
- # non-whitespace-only text node child). +Nokogiri::HTML5+ does
118
- # not accept the +noblanks+ option that the XML parser uses to
119
- # strip these inter-sibling text nodes pre-serialisation, so we
120
- # do it manually here: drop whitespace-only text nodes whose
121
- # parent is structural (no real text content) and not a
122
- # whitespace-preserving element. Mixed-content runs like
123
- # +<p>foo <em>bar</em> baz</p>+ are left alone.
124
- def strip_structural_whitespace!(doc)
125
- to_remove = []
126
- doc.traverse do |node|
127
- next unless node.text?
128
- next unless node.content.strip.empty?
129
-
130
- parent = node.parent
131
- next if parent.nil?
132
- next if WHITESPACE_PRESERVING_ELEMENTS.include?(parent.name)
133
- next if parent_has_real_text?(parent)
134
-
135
- to_remove << node
136
- end
137
- to_remove.each(&:remove)
138
- end
116
+ # AS_XHTML: void elements self-closed (<br/>, <img ... />).
117
+ def self_closing_voids(html)
118
+ html.gsub(%r{<([A-Za-z][A-Za-z0-9:-]*)((?:\s+[^<>"]*(?:"[^"]*"[^<>"]*)*)?)>}) do
119
+ name = ::Regexp.last_match(1)
120
+ next ::Regexp.last_match(0) unless HtmlVoidElements.void?(name)
139
121
 
140
- def parent_has_real_text?(parent)
141
- parent.children.any? do |c|
142
- c.text? && !c.content.strip.empty?
122
+ "<#{name}#{::Regexp.last_match(2)}/>"
143
123
  end
144
124
  end
145
125
 
146
- def fixture_ready_save_options
147
- Nokogiri::XML::Node::SaveOptions::FORMAT |
148
- Nokogiri::XML::Node::SaveOptions::AS_XHTML |
149
- Nokogiri::XML::Node::SaveOptions::NO_DECLARATION
150
- end
151
-
152
126
  # Rewrite +<tag …/>+ into +<tag …></tag>+ for every element name
153
127
  # that is not an HTML5 void element. +<a/>+ is illegal HTML;
154
128
  # void tags like +<br/>+ and +<img …/>+ pass through unchanged.
@@ -45,6 +45,8 @@ module Canon
45
45
  return to_tree_from_canon_text(node)
46
46
  when Canon::Xml::Nodes::CommentNode
47
47
  return to_tree_from_canon_comment(node)
48
+ when Canon::Xml::Nodes::ProcessingInstructionNode
49
+ return to_tree_from_canon_processing_instruction(node)
48
50
  end
49
51
 
50
52
  # Fallback to raw engine documents (legacy support). Nokogiri
@@ -61,6 +63,8 @@ module Canon
61
63
  to_tree(root)
62
64
  when Nokogiri::XML::Element
63
65
  convert_element(node)
66
+ when Nokogiri::XML::ProcessingInstruction
67
+ to_tree_from_canon_processing_instruction(node)
64
68
  else
65
69
  raise ArgumentError, "Unsupported node type: #{node.class}"
66
70
  end
@@ -271,6 +275,29 @@ module Canon
271
275
  )
272
276
  end
273
277
 
278
+ # Convert Canon::Xml::Nodes::ProcessingInstructionNode (or a raw
279
+ # engine processing instruction) to TreeNode. The value carries
280
+ # both target and data so differing targets diff.
281
+ #
282
+ # @param pi_node [Canon::Xml::Nodes::ProcessingInstructionNode,
283
+ # Nokogiri::XML::ProcessingInstruction] Processing instruction
284
+ # @return [Core::TreeNode] Tree node
285
+ def to_tree_from_canon_processing_instruction(pi_node)
286
+ target = pi_node.respond_to?(:target) ? pi_node.target : pi_node.name
287
+ data = if pi_node.respond_to?(:data)
288
+ pi_node.data
289
+ else
290
+ pi_node.content.to_s
291
+ end
292
+ Core::TreeNode.new(
293
+ label: "processing-instruction",
294
+ value: "#{target} #{data}".strip,
295
+ attributes: {},
296
+ children: [],
297
+ source_node: pi_node,
298
+ )
299
+ end
300
+
274
301
  # Extract attributes from Canon::Xml::Nodes::ElementNode
275
302
  #
276
303
  # @param element_node [Canon::Xml::Nodes::ElementNode] Element node
data/lib/canon/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
- VERSION = "0.3.100"
4
+ VERSION = "0.3.101"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.100
4
+ version: 0.3.101
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.