canon 0.3.85 → 0.3.87

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: 5dbb59b967dbaedab05c9dd8aa4186851ed71d055c44d89ecc23f2b8c8b92e96
4
- data.tar.gz: c665d661c0e4984f112a82239406ddc349b6120be032849adf5de7db75331217
3
+ metadata.gz: 1f8305566ebcb025deeeaf3a7493373c378ba4b62ef6895c0689ecfbb9ce751e
4
+ data.tar.gz: 58ba3679b7819da639da058fe82f7c9d14bd005bb33a8851f06ed654bbea66fd
5
5
  SHA512:
6
- metadata.gz: f75058359a37d053f57d6c6d86b27330a0553f925ba49755fa24d31fd248ff0c72b3393d539f75e9cd16fefd02813a590b2e9192031ba1a201cadf4dc7175bc1
7
- data.tar.gz: e64b15e520378443018037c78931a7ce9172728ca7c25b8d0caad7acdc1bd6379682e63f8bd28bf88659eef99ec62d03dd86812db282ad936b92d5128d4031b0
6
+ metadata.gz: e65a54f2e878a4d1d073690bbda3fded68ca6e3546a8508f1623848905b0d28f1c32bffd88fe4eb4b573f084a566b98909ed4940ac23d8d0c65ccb7b4c7eae24
7
+ data.tar.gz: 11602849c2da9b9e53ac209b48c4b6faa8295f1e4c46f475f866449b7cfab83b7f04884caa8146dbdf8bbf4c7757bdcdabfbf33ef045f1ae067c712340c58bc1
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 is always Nokogiri on CRuby (moxml has no HTML adapter, leptris no HTML parser); independent of the XML 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.
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.
data/README.adoc CHANGED
@@ -18,7 +18,8 @@ Key features:
18
18
  * **Smart diff output**: By-line or by-object modes with syntax highlighting
19
19
  * **Fast native engines, zero mandatory C deps**:
20
20
  ** XML on https://github.com/leptris/leptris[libleptris] (via moxml) whenever
21
- installed — Nokogiri otherwise and for HTML; both byte-compatible, engine
21
+ installed — Nokogiri otherwise; HTML comparison parses through the engine's
22
+ WHATWG fragment mode. Both byte-compatible, engine
22
23
  parity is spec-gated
23
24
  ** JSON on https://github.com/leptris/yeptris[libyeptris]' strict JSON surface
24
25
  (yeptris is a required gem — platform gems, zero compilation)
@@ -974,7 +975,9 @@ bundle exec rake performance:yaml
974
975
  Canon is engine-agnostic across its formats:
975
976
 
976
977
  * **XML**: libleptris via moxml whenever installed (parse, comparison, C14N,
977
- pretty-printing); raw Nokogiri otherwise, and always Nokogiri for HTML.
978
+ pretty-printing); raw Nokogiri otherwise. HTML comparison parses through the
979
+ engine's WHATWG fragment mode (Canon trees, since 0.3.86); Nokogiri serves
980
+ HTML display modes, canonical formatters, and the semantic data model.
978
981
  `CANON_XML_BACKEND=nokogiri|moxml` forces either engine; parity between the
979
982
  two is spec-gated (`spec/canon/xml/engine_parity_spec.rb`).
980
983
  * **JSON**: yeptris' strict JSON surface when the native materializer is
@@ -310,13 +310,12 @@ module Canon
310
310
 
311
311
  # Parse a node from string or return as-is
312
312
  # Applies preprocessing transformation before parsing if specified
313
- # Returns Nokogiri nodes for DOM comparison (preserves original behavior)
313
+ # Canon trees walk directly (engine HTML lane, semantic handoff);
314
+ # Nokogiri nodes and strings keep their existing paths.
314
315
  def parse_node(node, preprocessing = :none, match_opts = {})
315
- # If already a Canon::Xml::Node, convert to Nokogiri for DOM path
316
316
  if node.is_a?(Canon::Xml::Node)
317
- # Canon nodes used in semantic diff path, convert to Nokogiri for DOM path
318
- xml_str = Canon::Xml::DataModel.serialize(node)
319
- node = xml_str
317
+ apply_datamodel_preprocessing!(node, preprocessing, match_opts)
318
+ return node
320
319
  end
321
320
 
322
321
  # If already a Nokogiri node, check for incompatible XML documents
@@ -406,6 +405,102 @@ module Canon
406
405
  find_and_normalize_style_script(root)
407
406
  end
408
407
 
408
+ # Post-parse preprocessing for Canon trees (engine HTML lane,
409
+ # semantic handoff) — the canon-tree mirror of the Nokogiri
410
+ # fragment normalizers at the bottom of this module, applied at
411
+ # the same preprocessing points (:normalize/:format get the
412
+ # style/script pass and whitespace-only removal; :rendered adds
413
+ # whitespace collapsing).
414
+ def apply_datamodel_preprocessing!(root, preprocessing, match_opts)
415
+ return root unless %i[normalize format rendered].include?(preprocessing)
416
+
417
+ normalize_html_style_script_comments_datamodel(root)
418
+ if preprocessing == :rendered
419
+ normalize_rendered_whitespace_datamodel(root, match_opts)
420
+ end
421
+ remove_whitespace_only_text_nodes_datamodel(root)
422
+ root
423
+ end
424
+
425
+ # Canon-tree mirror of normalize_rendered_whitespace: collapse
426
+ # whitespace runs in text nodes under non-preserving ancestors
427
+ # and trim edge-child text. Mutates TextNode#value in place.
428
+ def normalize_rendered_whitespace_datamodel(root, match_opts = {})
429
+ return if match_opts[:text_content] == :strict
430
+
431
+ preserve_set = WhitespaceSensitivity
432
+ .format_default_preserve_elements(match_opts)
433
+ .to_set(&:to_s)
434
+ name_cache = {}
435
+ whitespace_result_cache = {}
436
+
437
+ each_datamodel_text_node(root) do |text|
438
+ next if ancestor_preserves_whitespace?(text.parent, preserve_set,
439
+ name_cache,
440
+ whitespace_result_cache)
441
+
442
+ content = text.value
443
+ normalized = if content.match?(/[\t\n\r\f\v]/) || content.include?(" ")
444
+ content.tr("\t\n\r\f\v", " ").squeeze(" ")
445
+ else
446
+ content
447
+ end
448
+
449
+ siblings = text.parent.children
450
+ if siblings.first.equal?(text) || siblings.last.equal?(text)
451
+ normalized = normalized.strip
452
+ end
453
+ text.value = normalized
454
+ end
455
+ end
456
+
457
+ # Canon-tree mirror of remove_whitespace_only_text_nodes:
458
+ # drop whitespace-only text under non-preserving ancestors,
459
+ # keeping NBSP-bearing and inline-significant nodes. Removals
460
+ # are batched per parent (writers go through children=).
461
+ def remove_whitespace_only_text_nodes_datamodel(root)
462
+ preserve_set = WhitespaceSensitivity
463
+ .format_default_preserve_elements(format: :html)
464
+ .to_set(&:to_s)
465
+ name_cache = {}
466
+ whitespace_result_cache = {}
467
+ to_remove = []
468
+
469
+ each_datamodel_text_node(root) do |text|
470
+ next if ancestor_preserves_whitespace?(text.parent, preserve_set,
471
+ name_cache,
472
+ whitespace_result_cache)
473
+
474
+ content = text.value
475
+ next if content.include?("\u00A0")
476
+ next if WhitespaceSensitivity.inline_whitespace_significant?(text)
477
+
478
+ to_remove << text if content.strip.empty?
479
+ end
480
+
481
+ to_remove.each do |text|
482
+ parent = text.parent
483
+ parent.children = parent.children.reject { |c| c.equal?(text) }
484
+ end
485
+ end
486
+
487
+ # Yield every TextNode in the tree in document order
488
+ # (pre-order walk — mirrors xpath(".//text()") order).
489
+ def each_datamodel_text_node(root)
490
+ stack = [root]
491
+ until stack.empty?
492
+ node = stack.pop
493
+ yield node if node.is_a?(Canon::Xml::Nodes::TextNode)
494
+
495
+ children = node.children
496
+ i = children.size
497
+ while i.positive?
498
+ i -= 1
499
+ stack.push(children[i])
500
+ end
501
+ end
502
+ end
503
+
409
504
  def find_and_normalize_style_script(node)
410
505
  return unless node.is_a?(Canon::Xml::Node)
411
506
 
@@ -16,20 +16,29 @@ module Canon
16
16
  # HtmlParser.detect_and_parse("<!DOCTYPE html><html>...</html>")
17
17
  class HtmlParser
18
18
  class << self
19
- # Parse HTML string into Nokogiri document with the correct parser
19
+ # Parse HTML string into the comparison tree.
20
+ #
21
+ # The engine lane (moxml Context#parse_html_fragment, moxml#353
22
+ # riding leptris' WHATWG fragment parsing) builds a Canon tree
23
+ # through the same TreeBuilder walk the XML lane uses: same
24
+ # tree construction as Nokogiri::HTML5.fragment (the #118
25
+ # html4/html5 unification semantics), native named-entity
26
+ # decoding, and a ~5x faster parse. The Nokogiri fragment
27
+ # path remains for runtimes without the engine surface.
20
28
  #
21
29
  # @param content [String, Object] Content to parse (returns as-is if not a string)
22
30
  # @param format [Symbol] HTML format (:html, :html4, :html5)
23
- # @return [Nokogiri::HTML::Document, Nokogiri::HTML5::Document, Nokogiri::HTML::DocumentFragment, Object]
31
+ # @return [Canon::Xml::Nodes::RootNode, Object] Canon tree or content
24
32
  def parse(content, format)
25
33
  return content unless content.is_a?(String)
26
- return content if already_parsed?(content)
27
34
 
28
35
  # Normalize HTML to ensure consistent parsing by HTML4.fragment
29
36
  # The key issue is that HTML4.fragment treats newlines after </head>
30
37
  # differently than no newlines, causing inconsistent parsing
31
38
  content = normalize_html_for_parsing(content)
32
39
 
40
+ return engine_fragment(content) if engine_html_fragments?
41
+
33
42
  begin
34
43
  case format
35
44
  when :html5
@@ -47,6 +56,39 @@ module Canon
47
56
  end
48
57
  end
49
58
 
59
+ # Parse an HTML string through the engine's WHATWG fragment
60
+ # parsing and build a Canon tree from the fragment children.
61
+ # Whitespace text nodes are preserved unconditionally — HTML
62
+ # whitespace significance is the comparison profile's decision
63
+ # (#112/#137 ride on stray whitespace surviving the parse),
64
+ # unlike the XML DOM lane's parse-time policy. Top-level
65
+ # element children convert through the materialize-records
66
+ # fast path; the wrapper walk serves the rare non-element
67
+ # top-level nodes (text, comments).
68
+ def engine_fragment(html)
69
+ frag = Canon::XmlParsing.moxml_context.parse_html_fragment(html)
70
+ root = Canon::Xml::Nodes::RootNode.new
71
+ frag.children.each do |child|
72
+ node =
73
+ if child.is_a?(Moxml::Element)
74
+ Canon::Xml::DataModel.build_moxml_subtree_from_records(
75
+ child, preserve_whitespace: true
76
+ )
77
+ else
78
+ Canon::Xml::DataModel.walk_moxml(child, preserve_whitespace: true)
79
+ end
80
+ root.add_child(node) if node
81
+ end
82
+ root
83
+ end
84
+
85
+ # Engine HTML fragments are served by adapters with an engine
86
+ # HTML mode (leptris, nokogiri) — moxml#353 pins WHATWG tree
87
+ # construction on both.
88
+ def engine_html_fragments?
89
+ %i[leptris nokogiri].include?(Canon::XmlParsing.moxml_adapter_name)
90
+ end
91
+
50
92
  # Check if content is already a parsed HTML document/fragment
51
93
  #
52
94
  # @param content [Object] Content to check
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.85"
4
+ VERSION = "0.3.87"
5
5
  end
@@ -100,7 +100,15 @@ module Canon
100
100
  limit = attributes.size
101
101
  while base < limit
102
102
  attr_name = attributes[base]
103
+ attr_prefix = attributes[base + 3]
104
+ # The xml prefix is bound by definition to the XML namespace
105
+ # (XML Namespaces 3): when a feed delivers the prefix without
106
+ # the URI — the HTML fragment records lane, moxml#367 — the
107
+ # constant is the one legal binding.
103
108
  attr_namespace_uri = attributes[base + 2]
109
+ if attr_namespace_uri.nil? && attr_prefix == "xml"
110
+ attr_namespace_uri = "http://www.w3.org/XML/1998/namespace"
111
+ end
104
112
  duplicate = false
105
113
  prior = 0
106
114
  while prior < base
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.85
4
+ version: 0.3.87
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-11 00:00:00.000000000 Z
11
+ date: 2026-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -44,28 +44,28 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 1.9.341
47
+ version: 1.9.344
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 1.9.341
54
+ version: 1.9.344
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: moxml
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: 0.5.126
61
+ version: 0.5.130
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: 0.5.126
68
+ version: 0.5.130
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: paint
71
71
  requirement: !ruby/object:Gem::Requirement