canon 0.3.100 → 0.3.102

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: 7585f36fdd0ec2e32f315db6393c265e885eeff9e1ad705ad792bfcaeab3373d
4
+ data.tar.gz: f29aa48def44453b036c2dad82883839f19fc846b6e9e4d095f25da4b1a82eb8
5
5
  SHA512:
6
- metadata.gz: 3555261e4b8280e6967bd63900dec75bb19d2225ff2da16748136ee7ddcb076fcee4f04b6502801afd92d344ef67b578c692868e63c3c181dff8008efc472986
7
- data.tar.gz: 590b8d12f49526bd3965d78f2000b4d7e64452e0b026547285d0762f6fb0565311ffb00c595aaac85813c90658656842aca1a8970e25f88f20e4f6d09e6ddeb8
6
+ metadata.gz: 6e05e33a87145653f7986ee62c076bd12ded0c95ff96dc74cc5ddf81c2bf48b5286d000715b9c7c32da4160c73f419b6fde7597e46f6e4fbf667324bf0a3d5ca
7
+ data.tar.gz: 3c11b01ca57ab4d737aafd8c159040934ab4e7c80d697d9be07c0b1e05bbfc9bbc7db6e1718a0bb54f1629cad16f5a0f4c80431846babceb236d74b63ec4226e
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.
@@ -571,8 +571,8 @@ module Canon
571
571
  return Comparison::UNEQUAL_NODES_TYPES
572
572
  end
573
573
 
574
- content1 = Canon::XmlParsing.xml_node?(n1) ? n1.content.to_s.strip : ""
575
- content2 = Canon::XmlParsing.xml_node?(n2) ? n2.content.to_s.strip : ""
574
+ content1 = pi_content(n1)
575
+ content2 = pi_content(n2)
576
576
 
577
577
  if content1 == content2
578
578
  Comparison::EQUIVALENT
@@ -597,8 +597,60 @@ module Canon
597
597
  return Comparison::MISSING_NODE
598
598
  end
599
599
 
600
- compare_nodes(root1, root2, opts, child_opts, diff_children,
601
- differences)
600
+ status = compare_nodes(root1, root2, opts, child_opts, diff_children,
601
+ differences)
602
+ compare_document_siblings(n1, n2, root1, root2, opts, differences)
603
+ status
604
+ end
605
+
606
+ # Document-level processing instructions and comments (the XML
607
+ # declaration never appears here - it is stripped at parse time
608
+ # by policy) participate in the comparison as an ordered sibling
609
+ # sequence around the root element.
610
+ def compare_document_siblings(n1, n2, root1, root2, opts, differences)
611
+ sibs1 = document_siblings(n1, root1)
612
+ sibs2 = document_siblings(n2, root2)
613
+
614
+ if sibs1.length != sibs2.length
615
+ add_difference(n1, n2, Comparison::MISSING_NODE,
616
+ Comparison::MISSING_NODE, :text_content, opts,
617
+ differences)
618
+ return
619
+ end
620
+
621
+ sibs1.zip(sibs2).each do |a, b|
622
+ next unless a.class != b.class ||
623
+ (a.is_a?(Canon::Xml::Nodes::ProcessingInstructionNode) &&
624
+ compare_processing_instruction_nodes(a, b, opts,
625
+ differences) ==
626
+ Comparison::UNEQUAL_TEXT_CONTENTS) ||
627
+ (a.is_a?(Canon::Xml::Nodes::CommentNode) &&
628
+ compare_comment_nodes(a, b, opts, differences) ==
629
+ Comparison::UNEQUAL_COMMENTS)
630
+
631
+ add_difference(a, b, Comparison::UNEQUAL_NODES_TYPES,
632
+ Comparison::UNEQUAL_NODES_TYPES, :text_content,
633
+ opts, differences)
634
+ end
635
+ end
636
+
637
+ def document_siblings(doc, root)
638
+ return [] unless doc.respond_to?(:children)
639
+
640
+ doc.children.reject do |child|
641
+ child.equal?(root) || child.is_a?(Canon::Xml::Nodes::TextNode) ||
642
+ child.is_a?(Canon::Xml::Nodes::ElementNode)
643
+ end
644
+ end
645
+
646
+ # PI content lives in different accessors per node flavour:
647
+ # Canon::Xml::Nodes::ProcessingInstructionNode#data, engine
648
+ # processing instructions expose #content.
649
+ def pi_content(node)
650
+ return node.data.to_s if node.is_a?(Canon::Xml::Nodes::ProcessingInstructionNode)
651
+ return node.content.to_s if Canon::XmlParsing.xml_node?(node)
652
+
653
+ node.respond_to?(:data) ? node.data.to_s : ""
602
654
  end
603
655
 
604
656
  # Compare children of two nodes using semantic matching
@@ -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,55 +98,24 @@ 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 engine's
102
+ # XHTML serialization mode (moxml#390 delivered to_xhtml /
103
+ # to_html(xhtml: true)): void elements self-closed, non-void
104
+ # paired — the AS_XHTML contract, engine-served.
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
113
- end
114
-
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
139
-
140
- def parent_has_real_text?(parent)
141
- parent.children.any? do |c|
142
- c.text? && !c.content.strip.empty?
143
- end
144
- end
145
-
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
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_xhtml(indent: 1, indent_text: "\t")
110
+ else
111
+ doc.to_xhtml(indent: @indent)
112
+ end
113
+ # The AS_XHTML contract pairs empty NON-void elements (<a .../>)
114
+ # for browser-safe pasting; the engine's to_xhtml self-closes
115
+ # every empty element. The existing transform restores the
116
+ # pairing for non-voids while voids stay self-closed — the
117
+ # #135 regression pins it.
118
+ expand_non_void_self_closing(html)
150
119
  end
151
120
 
152
121
  # Rewrite +<tag …/>+ into +<tag …></tag>+ for every element name
@@ -45,6 +45,14 @@ 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)
50
+ end
51
+
52
+ # Raw moxml documents/elements (the leptris engine's parse
53
+ # output) convert through the shared data model.
54
+ if defined?(Moxml) && node.is_a?(Moxml::Node)
55
+ return to_tree(Canon::Xml::DataModel.build_from_moxml(node))
48
56
  end
49
57
 
50
58
  # Fallback to raw engine documents (legacy support). Nokogiri
@@ -61,6 +69,8 @@ module Canon
61
69
  to_tree(root)
62
70
  when Nokogiri::XML::Element
63
71
  convert_element(node)
72
+ when Nokogiri::XML::ProcessingInstruction
73
+ to_tree_from_canon_processing_instruction(node)
64
74
  else
65
75
  raise ArgumentError, "Unsupported node type: #{node.class}"
66
76
  end
@@ -196,10 +206,34 @@ module Canon
196
206
  # @param root_node [Canon::Xml::Nodes::RootNode] Root node
197
207
  # @return [Core::TreeNode, nil] Tree node for first child (document element)
198
208
  def to_tree_from_canon_root(root_node)
199
- # Root node: process first child (document element)
209
+ # Root node: convert the document element, keeping
210
+ # document-level processing instructions / comments as
211
+ # siblings of it (before it first, then after) so they
212
+ # participate in the diff. The XML declaration never reaches
213
+ # here - it is stripped at parse time by policy.
200
214
  return nil if root_node.children.empty?
201
215
 
202
- to_tree(root_node.children.first)
216
+ element_child = root_node.children.find do |child|
217
+ child.is_a?(Canon::Xml::Nodes::ElementNode)
218
+ end
219
+ return to_tree(root_node.children.first) if element_child.nil?
220
+
221
+ tree = to_tree(element_child)
222
+ seen_element = false
223
+ root_node.children.each do |child|
224
+ if child.equal?(element_child)
225
+ seen_element = true
226
+ next
227
+ end
228
+ next if child.is_a?(Canon::Xml::Nodes::ElementNode)
229
+ next if child.is_a?(Canon::Xml::Nodes::TextNode)
230
+
231
+ sibling = to_tree(child)
232
+ next if sibling.nil?
233
+
234
+ seen_element ? tree.children << sibling : tree.children.unshift(sibling)
235
+ end
236
+ tree
203
237
  end
204
238
 
205
239
  # Convert Canon::Xml::Nodes::ElementNode to TreeNode
@@ -271,6 +305,29 @@ module Canon
271
305
  )
272
306
  end
273
307
 
308
+ # Convert Canon::Xml::Nodes::ProcessingInstructionNode (or a raw
309
+ # engine processing instruction) to TreeNode. The value carries
310
+ # both target and data so differing targets diff.
311
+ #
312
+ # @param pi_node [Canon::Xml::Nodes::ProcessingInstructionNode,
313
+ # Nokogiri::XML::ProcessingInstruction] Processing instruction
314
+ # @return [Core::TreeNode] Tree node
315
+ def to_tree_from_canon_processing_instruction(pi_node)
316
+ target = pi_node.respond_to?(:target) ? pi_node.target : pi_node.name
317
+ data = if pi_node.respond_to?(:data)
318
+ pi_node.data
319
+ else
320
+ pi_node.content.to_s
321
+ end
322
+ Core::TreeNode.new(
323
+ label: "processing-instruction",
324
+ value: "#{target} #{data}".strip,
325
+ attributes: {},
326
+ children: [],
327
+ source_node: pi_node,
328
+ )
329
+ end
330
+
274
331
  # Extract attributes from Canon::Xml::Nodes::ElementNode
275
332
  #
276
333
  # @param element_node [Canon::Xml::Nodes::ElementNode] Element node
@@ -4,7 +4,9 @@ module Canon
4
4
  module Validators
5
5
  # Validator for HTML input
6
6
  #
7
- # Validates HTML input (HTML4, HTML5, or XHTML) using Nokogiri.
7
+ # Validates HTML input (HTML4, HTML5, or XHTML) through the
8
+ # engine (strict XML parse for XHTML; the WHATWG parse-lint
9
+ # surface for HTML5 — leptris#1771).
8
10
  # Automatically detects the HTML type and applies appropriate validation.
9
11
  # Raises detailed ValidationError with line and column information
10
12
  # when malformed HTML is detected.
@@ -73,74 +75,44 @@ module Canon
73
75
  # @param input [String] The HTML5 string to validate
74
76
  # @raise [Canon::ValidationError] If HTML5 is malformed
75
77
  # @return [void]
78
+ # The HTML5 lint layer is engine-served (leptris#1771 delivered
79
+ # the §13.2.5 parse-error token stream: machine-readable code +
80
+ # line + column, RECOVER class). Engine-direct call — the
81
+ # documented seam exception (moxml's parse_html does not forward
82
+ # the collect_errors option yet; ask filed upstream).
76
83
  def self.validate_html5!(input)
77
- # The HTML5 lint layer stays on Nokogiri/gumbo: the WHATWG
78
- # parser never fails, so the engine has no parse-error token
79
- # stream to classify (filed as leptris#1771 - the ask carries
80
- # canon's acceptance protocol).
81
- Canon::NokogiriLoader.require!("HTML5 validation (leptris#1771)")
82
- doc = Nokogiri::HTML5(input, max_errors: 100)
84
+ doc = Leptris::XML.parse_html(input, mode: :whatwg,
85
+ collect_errors: true)
86
+ errors = doc.errors
87
+ return if errors.empty?
83
88
 
84
- # Check for parse errors
85
- return unless doc.errors.any?
86
-
87
- # Find first significant error (level 2 = error, level 1 = warning)
88
- # Filter out doctype warnings and other non-critical issues
89
- significant_errors = doc.errors.select do |e|
90
- e.level >= 2 && !doctype_or_warning?(e)
91
- end
92
-
93
- return if significant_errors.empty?
94
-
95
- error = significant_errors.first
96
- location = extract_location(error)
89
+ error = errors.first
97
90
  raise Canon::ValidationError.new(
98
- error.message,
91
+ "[#{error.code}] #{error.message}",
99
92
  format: :html,
100
- line: location[:line],
101
- column: location[:column],
102
- details: build_error_details(significant_errors),
93
+ line: error.line,
94
+ column: error.column,
95
+ details: build_error_details(errors),
103
96
  )
104
97
  end
105
98
 
106
- # Extract additional error details
107
- #
108
- # @param error [Nokogiri::XML::SyntaxError] The syntax error
109
- # @return [String, nil] Additional details about the error
110
- def self.extract_details(_error)
111
- nil
112
- end
113
-
114
99
  # Build error details from multiple errors
115
100
  #
116
- # @param errors [Array<Nokogiri::XML::SyntaxError>] Array of errors
101
+ # @param errors [Array<Leptris::XML::SyntaxError>] Array of errors
117
102
  # @return [String, nil] Combined error details
118
103
  def self.build_error_details(errors)
119
104
  return nil if errors.size <= 1
120
105
 
121
- significant = errors.select { |e| e.level >= 2 }
122
- return nil if significant.empty?
123
-
124
- details = significant[1..3].map do |e|
125
- loc = extract_location(e)
106
+ errors[1..3].map do |e|
126
107
  msg = e.message
127
- msg += " (line #{loc[:line]})" if loc[:line]
108
+ msg += " (line #{e.line})" if e.respond_to?(:line) && e.line
128
109
  msg
129
- end
130
- details.join("; ")
131
- end
132
-
133
- # Check if error is a doctype or other non-critical warning
134
- #
135
- # @param error [Nokogiri::XML::SyntaxError] The error to check
136
- # @return [Boolean] true if error is non-critical
137
- def self.doctype_or_warning?(error)
138
- error.message.match?(/doctype|Expected a doctype token/i)
110
+ end.join("; ")
139
111
  end
140
112
 
141
113
  private_class_method :xhtml?, :validate_xhtml!, :validate_html5!,
142
- :extract_details, :build_error_details,
143
- :doctype_or_warning?, :extract_engine_location
114
+ :build_error_details,
115
+ :extract_engine_location
144
116
  end
145
117
  end
146
118
  end
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.102"
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.102
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.