canon 0.3.98 → 0.3.100

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: 07c9d8c96426b670ad088e3b96e662b45283c4868e99c27a5afb5a6ecb537780
4
- data.tar.gz: 5e8bc5ee7aa5b1e1b75b158d1afc68f3035e1c58bcd3da4624fdebbbbc83b4a6
3
+ metadata.gz: 47ebab684f05c7445139ea4b0ed5133ab734c1d16918f3dadc055b3ddbc5692f
4
+ data.tar.gz: d80c1ae4a66f43ffb92c3eb8a14ebca8e2bcd2eb373130797a71e7f79a748d9a
5
5
  SHA512:
6
- metadata.gz: 813b81bd8fe090e71c9729d18326e864b0af54129e422ae4d624676ed8e191f6b2b278e5dab5e00c1827fccaed9658e5bd85a9ef6ac908c9e0cc4ff97d33839e
7
- data.tar.gz: 6819b9252b0e6f27efec816241b1444d1ff92456562996ddc0c89a8f07b04eb41d48302025fda8776581437fb793da24db3ed21fec2b1589522c0680739bd99c
6
+ metadata.gz: 3555261e4b8280e6967bd63900dec75bb19d2225ff2da16748136ee7ddcb076fcee4f04b6502801afd92d344ef67b578c692868e63c3c181dff8008efc472986
7
+ data.tar.gz: 590b8d12f49526bd3965d78f2000b4d7e64452e0b026547285d0762f6fb0565311ffb00c595aaac85813c90658656842aca1a8970e25f88f20e4f6d09e6ddeb8
@@ -295,8 +295,8 @@ module Canon
295
295
  Canon::Xml::C14n.canonicalize(html_string,
296
296
  with_comments: false)
297
297
  when :format
298
- # Pretty format
299
- Canon.format(html_string, :html)
298
+ # Engine single-line structural normalizer
299
+ Canon::Html::DataModel.engine_html_format(html_string)
300
300
  else
301
301
  # :none or unrecognized
302
302
  html_string
@@ -375,8 +375,8 @@ module Canon
375
375
  Canon::Xml::C14n.canonicalize(node,
376
376
  with_comments: false)
377
377
  when :format
378
- # Pretty format the HTML
379
- Canon.format(node, :html)
378
+ # Engine single-line structural normalizer
379
+ Canon::Html::DataModel.engine_html_format(node)
380
380
  else
381
381
  # :none, :rendered or unrecognized - use as-is
382
382
  node
@@ -60,29 +60,10 @@ module Canon
60
60
  end
61
61
 
62
62
  # Parse an HTML string through the engine's WHATWG fragment
63
- # parsing and build a Canon tree from the fragment children.
64
- # Whitespace text nodes are preserved unconditionally — HTML
65
- # whitespace significance is the comparison profile's decision
66
- # (#112/#137 ride on stray whitespace surviving the parse),
67
- # unlike the XML DOM lane's parse-time policy. Top-level
68
- # element children convert through the materialize-records
69
- # fast path; the wrapper walk serves the rare non-element
70
- # top-level nodes (text, comments).
63
+ # parsing and build a Canon tree. Canon::Html::DataModel owns
64
+ # the conversion (the single home for HTML→Canon-tree building).
71
65
  def engine_fragment(html)
72
- frag = Canon::XmlParsing.moxml_context.parse_html_fragment(html)
73
- root = Canon::Xml::Nodes::RootNode.new
74
- frag.children.each do |child|
75
- node =
76
- if child.is_a?(Moxml::Element)
77
- Canon::Xml::DataModel.build_moxml_subtree_from_records(
78
- child, preserve_whitespace: true
79
- )
80
- else
81
- Canon::Xml::DataModel.walk_moxml(child, preserve_whitespace: true)
82
- end
83
- root.add_child(node) if node
84
- end
85
- root
66
+ Canon::Html::DataModel.engine_fragment(html)
86
67
  end
87
68
 
88
69
  # Engine HTML fragments are served by adapters with an engine
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Canon::NokogiriLoader.require!("HTML support")
4
-
5
3
  module Canon
6
4
  module Html
7
5
  # Builds XPath data model from HTML
@@ -10,58 +8,104 @@ module Canon
10
8
  class DataModel < Canon::DataModel
11
9
  # Build XPath data model from HTML string
12
10
  #
11
+ # The engine lane: moxml Context#parse_html_fragment (WHATWG
12
+ # fragment parsing) → Canon tree. The +version:+ parameter is
13
+ # accepted for API compatibility — the engine serves every input
14
+ # through one WHATWG parse (the #118 html4/html5 unification).
15
+ # Full documents keep their head content at the fragment top
16
+ # level (the in-body rules ignore the html/body start tags but
17
+ # preserve every other node); the former Nokogiri body-extraction
18
+ # dropped head content entirely.
19
+ #
13
20
  # @param html_string [String] HTML content to parse
14
- # @param version [Symbol] HTML version (:html4 or :html5)
21
+ # @param version [Symbol] accepted for compatibility; unused
15
22
  # @return [Canon::Xml::Nodes::RootNode] Root of the data model tree
23
+ # rubocop:disable-next Lint/UnusedMethodArgument -- API compatibility
16
24
  def self.from_html(html_string, version: :html4)
17
- # Detect if this is a full document (has <html> tag) or fragment
18
- # Full documents should use document parser to preserve structure
19
- # Fragments should use fragment parser to avoid adding implicit wrappers
20
- is_full_document = html_string.match?(%r{<html[\s>]}i)
21
-
22
- # Parse with Nokogiri using appropriate parser
23
- doc = if is_full_document
24
- # CRITICAL FIX: For full HTML documents, parse as document first
25
- # and extract the body element. This avoids Nokogiri::HTML.fragment()
26
- # incorrectly moving head elements (like meta) to the body.
27
- # Parse as full document to get proper structure
28
- full_doc = if version == :html5
29
- Nokogiri::HTML5(html_string)
30
- else
31
- Nokogiri::HTML4(html_string)
32
- end
33
- # Extract body element and create fragment from it
34
- body = full_doc.at_css("body")
35
- if body
36
- # Create a fragment and copy body children to it
37
- # This preserves the body structure without head elements
38
- frag = if version == :html5
39
- Nokogiri::HTML5::DocumentFragment.new(full_doc)
40
- else
41
- Nokogiri::HTML4::DocumentFragment.new(full_doc)
42
- end
43
- body.children.each do |child|
44
- frag.add_child(child.dup)
45
- end
46
- frag
47
- elsif version == :html5
48
- # No body found, fall back to fragment parsing
49
- Nokogiri::HTML5.fragment(html_string)
50
- else
51
- Nokogiri::HTML4.fragment(html_string)
52
- end
53
- elsif version == :html5
54
- # Fragment - use fragment parser to avoid implicit wrappers
55
- Nokogiri::HTML5.fragment(html_string)
56
- else
57
- Nokogiri::HTML4.fragment(html_string)
58
- end
59
-
60
- # HTML doesn't have strict namespace requirements like XML,
61
- # so skip the relative namespace URI check
62
-
63
- # Convert to XPath data model (reuse XML infrastructure)
64
- build_from_nokogiri(doc)
25
+ engine_fragment(html_string)
26
+ end
27
+
28
+ # Parse an HTML string through the engine's WHATWG fragment
29
+ # parsing and build a Canon tree from the fragment children.
30
+ # Whitespace text nodes are preserved unconditionally — HTML
31
+ # whitespace significance is the comparison profile's decision
32
+ # (#112/#137), unlike the XML DOM lane's parse-time policy.
33
+ # Top-level element children convert through the
34
+ # materialize-records fast path; the wrapper walk serves the
35
+ # rare non-element top-level nodes (text, comments).
36
+ def self.engine_fragment(html)
37
+ frag = Canon::XmlParsing.moxml_context.parse_html_fragment(html)
38
+ root = Canon::Xml::Nodes::RootNode.new
39
+ frag.children.each do |child|
40
+ node =
41
+ if child.is_a?(Moxml::Element)
42
+ Canon::Xml::DataModel.build_moxml_subtree_from_records(
43
+ child, preserve_whitespace: true
44
+ )
45
+ else
46
+ Canon::Xml::DataModel.walk_moxml(child, preserve_whitespace: true)
47
+ end
48
+ root.add_child(node) if node
49
+ end
50
+ root
51
+ end
52
+
53
+ # Elements whose inner whitespace is always significant.
54
+ WHITESPACE_PRESERVING = %w[pre textarea script style].freeze
55
+
56
+ # Deterministic single-line engine formatting for the :format
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.
63
+ def self.engine_html_format(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)
65
109
  end
66
110
 
67
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
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Canon::NokogiriLoader.require!("HTML tree diffing")
4
-
5
3
  module Canon
6
4
  module TreeDiff
7
5
  module Adapters
@@ -46,17 +44,22 @@ module Canon
46
44
  return to_tree_from_canon_comment(node)
47
45
  end
48
46
 
49
- # Fallback to Nokogiri (legacy support)
50
- case node
51
- when Nokogiri::HTML::Document, Nokogiri::HTML4::Document, Nokogiri::HTML5::Document
52
- # Start from html element or root element
53
- root = node.at_css("html") || node.root
54
- root ? to_tree(root) : nil
55
- when Nokogiri::HTML4::DocumentFragment, Nokogiri::HTML5::DocumentFragment, Nokogiri::XML::DocumentFragment
56
- # For DocumentFragment, create a wrapper root node and add all fragment children
57
- convert_fragment(node)
58
- when Nokogiri::XML::Element
59
- convert_element(node)
47
+ # Fallback to Nokogiri (legacy support for user-supplied
48
+ # nodes) — the Canon tree branches above run nokogiri-less.
49
+ if defined?(Nokogiri)
50
+ case node
51
+ when Nokogiri::HTML::Document, Nokogiri::HTML4::Document, Nokogiri::HTML5::Document
52
+ # Start from html element or root element
53
+ root = node.at_css("html") || node.root
54
+ root ? to_tree(root) : nil
55
+ when Nokogiri::HTML4::DocumentFragment, Nokogiri::HTML5::DocumentFragment, Nokogiri::XML::DocumentFragment
56
+ # For DocumentFragment, create a wrapper root node and add all fragment children
57
+ convert_fragment(node)
58
+ when Nokogiri::XML::Element
59
+ convert_element(node)
60
+ else
61
+ raise ArgumentError, "Unsupported node type: #{node.class}"
62
+ end
60
63
  else
61
64
  raise ArgumentError, "Unsupported node type: #{node.class}"
62
65
  end
@@ -68,7 +71,10 @@ module Canon
68
71
  # @param doc [Nokogiri::HTML::Document] Optional document to use
69
72
  # @return [Nokogiri::HTML::Document, Nokogiri::XML::Element]
70
73
  def from_tree(tree_node, doc = nil)
71
- doc ||= Nokogiri::HTML::Document.new
74
+ unless doc
75
+ Canon::NokogiriLoader.require!("HTML tree reconstruction")
76
+ doc = Nokogiri::HTML::Document.new
77
+ end
72
78
 
73
79
  element = build_element(tree_node, doc)
74
80
 
@@ -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.98"
4
+ VERSION = "0.3.100"
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.98
4
+ version: 0.3.100
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