canon 0.3.99 → 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: 4e993fe0bac445ec82e1a02983bfa2f297c1b7d9d112bcb7c724f3f010a1b353
4
- data.tar.gz: 869324e74378af4f4cb9493fbfdc04ac1ba8e76c0378159547bc7e23ebf66084
3
+ metadata.gz: dc66e03ef02db4a7cbb21e496076e2794027a7a52996eb797b90b697c77013ee
4
+ data.tar.gz: 6ab70c76adfd5fe466b0fa8a54f542097344f4726c34192b1f85f59a542b5752
5
5
  SHA512:
6
- metadata.gz: 70c6dcb6bb9da7ef764f9aed9e1e8d02fb95193c446102df62496a10dbe33010d29b5d0e90293d929cf3adc267b5128ad1dd7e9666dff38b892d221bc681f20c
7
- data.tar.gz: dc45835bdab2c58479e8413eb87aee5c8615237ebf6bd2c435ae725b58b7df247c3740c25a3ab192fb0aaf86c6776d3634c5a66170ea6834a3f3bedf5582fd27
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
@@ -50,11 +50,62 @@ module Canon
50
50
  root
51
51
  end
52
52
 
53
+ # Elements whose inner whitespace is always significant.
54
+ WHITESPACE_PRESERVING = %w[pre textarea script style].freeze
55
+
53
56
  # Deterministic single-line engine formatting for the :format
54
- # string preprocessor — parse + re-serialize as a pure function
55
- # of the parsed tree.
57
+ # string preprocessor: parse + strip whitespace-only text that
58
+ # is insignificant between block siblings + re-serialize — a
59
+ # pure function of the parsed tree, so equivalent inputs
60
+ # converge to identical bytes regardless of source indentation.
61
+ # Inline-significant whitespace (between two inline siblings,
62
+ # inside whitespace-preserving elements) survives.
56
63
  def self.engine_html_format(html)
57
- Canon::XmlParsing.moxml_context.parse_html(html).to_html
64
+ doc = engine_html_parse(html)
65
+ strip_insignificant_whitespace!(doc.root)
66
+ doc.to_html
67
+ end
68
+
69
+ # Parse a full HTML document through the engine WHATWG lane.
70
+ def self.engine_html_parse(html)
71
+ Canon::XmlParsing.moxml_context.parse_html(html)
72
+ end
73
+
74
+ def self.strip_insignificant_whitespace!(node)
75
+ return unless node.is_a?(Moxml::Element)
76
+
77
+ preserve_parent = WHITESPACE_PRESERVING.include?(node.name)
78
+ node.children.to_a.each do |child|
79
+ if child.is_a?(Moxml::Text)
80
+ next unless child.content.strip.empty?
81
+ next if preserve_parent
82
+ next if inline_significant?(child, node)
83
+
84
+ child.remove
85
+ elsif child.is_a?(Moxml::Element)
86
+ strip_insignificant_whitespace!(child)
87
+ end
88
+ end
89
+ end
90
+
91
+ # Whitespace between two inline siblings (or anchored by content)
92
+ # is significant — the #112 rule. Edge or block-anchored runs are
93
+ # formatting artifacts.
94
+ def self.inline_significant?(text, parent)
95
+ siblings = parent.children.to_a
96
+ idx = siblings.index(text)
97
+ prev = siblings[0...idx].reverse_each.find { |s| !whitespace_only?(s) }
98
+ nxt = siblings[(idx + 1)..].find { |s| !whitespace_only?(s) }
99
+ inline_node?(prev) && inline_node?(nxt)
100
+ end
101
+
102
+ def self.whitespace_only?(node)
103
+ node.is_a?(Moxml::Text) && node.content.strip.empty?
104
+ end
105
+
106
+ def self.inline_node?(node)
107
+ !node.nil? && node.is_a?(Moxml::Element) &&
108
+ Canon::Comparison::WhitespaceSensitivity::INLINE_ELEMENTS.include?(node.name)
58
109
  end
59
110
 
60
111
  # Alias for compatibility
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Canon::NokogiriLoader.require!("HTML pretty-printing")
4
3
  require "stringio"
5
4
 
6
5
  module Canon
@@ -37,7 +36,14 @@ module Canon
37
36
  end
38
37
 
39
38
  def format(html_string)
40
- return format_fixture_ready(html_string) if @fixture_ready
39
+ if @fixture_ready
40
+ # The fixture-ready display mode is the one Nokogiri lane
41
+ # left (libxml FORMAT|AS_XHTML save flags — moxml#390 filed
42
+ # for an engine XHTML mode); everything else is engine-
43
+ # served.
44
+ Canon::NokogiriLoader.require!("HTML fixture-ready display")
45
+ return format_fixture_ready(html_string)
46
+ end
41
47
 
42
48
  if xhtml?(html_string)
43
49
  format_as_xhtml(html_string)
@@ -73,13 +79,15 @@ module Canon
73
79
  expand_non_void_self_closing(out)
74
80
  end
75
81
 
82
+ # Engine serialization with the #354 indent knobs — the engine's
83
+ # own nested layout (a deliberate byte change vs the Nokogiri
84
+ # lane: synthesis shape differs, e.g. no empty <head></head>).
76
85
  def format_as_html(html_string)
77
- doc = Nokogiri::HTML5(html_string)
78
-
86
+ doc = Canon::Html::DataModel.engine_html_parse(html_string)
79
87
  if @indent_type == "tab"
80
- doc.to_html(indent: 1, indent_text: "\t", encoding: "UTF-8")
88
+ doc.to_html(indent: 1, indent_text: "\t")
81
89
  else
82
- doc.to_html(indent: @indent, encoding: "UTF-8")
90
+ doc.to_html(indent: @indent)
83
91
  end
84
92
  end
85
93
 
@@ -90,57 +98,31 @@ module Canon
90
98
  # indentation; +AS_XHTML+ produces well-shaped output (void
91
99
  # elements self-closed, non-void paired); +NO_DECLARATION+
92
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).
93
105
  def format_fixture_ready(html_string)
94
- doc = Nokogiri::HTML5(html_string)
95
- strip_structural_whitespace!(doc)
96
- io = StringIO.new
97
- if @indent_type == "tab"
98
- doc.write_to(io, save_with: fixture_ready_save_options,
99
- indent: 1, indent_text: "\t")
100
- else
101
- doc.write_to(io, save_with: fixture_ready_save_options,
102
- indent: @indent)
103
- end
104
- 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)
105
114
  end
106
115
 
107
- # libxml's +FORMAT+ save flag does not insert indentation around
108
- # the children of any element it sees as mixed content (any
109
- # non-whitespace-only text node child). +Nokogiri::HTML5+ does
110
- # not accept the +noblanks+ option that the XML parser uses to
111
- # strip these inter-sibling text nodes pre-serialisation, so we
112
- # do it manually here: drop whitespace-only text nodes whose
113
- # parent is structural (no real text content) and not a
114
- # whitespace-preserving element. Mixed-content runs like
115
- # +<p>foo <em>bar</em> baz</p>+ are left alone.
116
- def strip_structural_whitespace!(doc)
117
- to_remove = []
118
- doc.traverse do |node|
119
- next unless node.text?
120
- next unless node.content.strip.empty?
121
-
122
- parent = node.parent
123
- next if parent.nil?
124
- next if WHITESPACE_PRESERVING_ELEMENTS.include?(parent.name)
125
- next if parent_has_real_text?(parent)
126
-
127
- to_remove << node
128
- end
129
- to_remove.each(&:remove)
130
- 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)
131
121
 
132
- def parent_has_real_text?(parent)
133
- parent.children.any? do |c|
134
- c.text? && !c.content.strip.empty?
122
+ "<#{name}#{::Regexp.last_match(2)}/>"
135
123
  end
136
124
  end
137
125
 
138
- def fixture_ready_save_options
139
- Nokogiri::XML::Node::SaveOptions::FORMAT |
140
- Nokogiri::XML::Node::SaveOptions::AS_XHTML |
141
- Nokogiri::XML::Node::SaveOptions::NO_DECLARATION
142
- end
143
-
144
126
  # Rewrite +<tag …/>+ into +<tag …></tag>+ for every element name
145
127
  # that is not an HTML5 void element. +<a/>+ is illegal HTML;
146
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
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Canon::NokogiriLoader.require!("HTML validation")
4
-
5
3
  module Canon
6
4
  module Validators
7
5
  # Validator for HTML input
@@ -44,27 +42,43 @@ module Canon
44
42
  # @param input [String] The XHTML string to validate
45
43
  # @raise [Canon::ValidationError] If XHTML is malformed
46
44
  # @return [void]
45
+ # XHTML is XML: the engine strict parse is the validator. The
46
+ # engine reports the failure position as a byte offset embedded
47
+ # in the error message; line/column derive from it.
47
48
  def self.validate_xhtml!(input)
48
- Nokogiri::XML(input) do |config|
49
- config.strict.nonet
50
- end
51
- rescue Nokogiri::XML::SyntaxError => e
52
- location = extract_location(e)
49
+ Canon::XmlParsing.moxml_context.parse(input, strict: true)
50
+ rescue Moxml::ParseError => e
51
+ location = extract_engine_location(e, input)
53
52
  raise Canon::ValidationError.new(
54
53
  e.message.split("\n").first,
55
54
  format: :html,
56
55
  line: location[:line],
57
56
  column: location[:column],
58
- details: "XHTML validation failed: #{extract_details(e)}",
57
+ details: "XHTML validation failed",
59
58
  )
60
59
  end
61
60
 
61
+ # Derive line/column from the engine error's "at byte N" offset.
62
+ def self.extract_engine_location(error, input)
63
+ byte = error.message[/at byte (\d+)/, 1].to_i
64
+ before = input.byteslice(0, byte) || ""
65
+ line = before.count("\n") + 1
66
+ last_nl = before.rindex("\n")
67
+ column = last_nl ? byte - (last_nl + 1) : byte
68
+ { line: line, column: column }
69
+ end
70
+
62
71
  # Validate HTML5 input
63
72
  #
64
73
  # @param input [String] The HTML5 string to validate
65
74
  # @raise [Canon::ValidationError] If HTML5 is malformed
66
75
  # @return [void]
67
76
  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)")
68
82
  doc = Nokogiri::HTML5(input, max_errors: 100)
69
83
 
70
84
  # Check for parse errors
@@ -126,7 +140,7 @@ module Canon
126
140
 
127
141
  private_class_method :xhtml?, :validate_xhtml!, :validate_html5!,
128
142
  :extract_details, :build_error_details,
129
- :doctype_or_warning?
143
+ :doctype_or_warning?, :extract_engine_location
130
144
  end
131
145
  end
132
146
  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.99"
4
+ VERSION = "0.3.101"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.99
4
+ version: 0.3.101
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
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs