canon 0.3.21 → 0.3.22

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: fd899c17d1512c815d189076fe08721dbe66b7d964f8bac69abde35f207b8c76
4
- data.tar.gz: 205ad0b365288bf883f2ce57f68b612217151e30ae90fb0f959b29f252063826
3
+ metadata.gz: 4a369d56aae65493ad94c6352f01a15915eb0af020cde2c98868b7d51a8b0658
4
+ data.tar.gz: f667f5bf42ed4f1e581ee32951d561653187c06b74b6b996f9bcd1bb9fb0f073
5
5
  SHA512:
6
- metadata.gz: 5aa4d552592cda5a55417415efb225151eb0d39d899fe0471eb26f10ceeca64ae56de786d8e807e0085264ba800cfd5937a6ec15e182e71957381414f3036105
7
- data.tar.gz: b52ceb55e3ca6c6affcca3c7091a0e3f6e22d1c077b65732e7073915c3f6914cb86a61e675b11636baeb1ea09e94c223c933e961c0b366fb3e621c592a50170b
6
+ metadata.gz: 6741dac3a19b779fbbd9fe49edaa7f3d6c71497fe4a1cc6339eb65f0d749bcfe9e0800323cb321f4cfd35a756e0ebfab5f8740f8c3aa5e895b70d7c71adc3a84
7
+ data.tar.gz: 8a360875b2c5597c9e87e81eb0a8037979efa6b8166fe18155a68efc432626d4efebc1aa8ad82ea66ec398681aa1d03f83d35aa1c8fe0684e74a616f0ee96913
@@ -68,8 +68,11 @@ module Canon
68
68
  def self.whitespace_only_text?(node)
69
69
  return false unless text_node?(node)
70
70
 
71
+ # This runs per child pair in the realignment walk — the
72
+ # zero-allocation form of `text.strip.empty?` (exactly
73
+ # String#strip's character set; see WhitespacePolicy).
71
74
  text = text_content(node)
72
- !text.empty? && text.strip.empty?
75
+ !text.empty? && text.match?(Canon::Xml::WhitespacePolicy::STRIP_ONLY)
73
76
  end
74
77
 
75
78
  # --- Noise classification ---
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "nokogiri" unless RUBY_ENGINE == "opal"
4
+ require "set"
4
5
 
5
6
  module Canon
6
7
  module Formatters
@@ -55,6 +56,14 @@ module Canon
55
56
  WHITESPACE_SENSITIVE_ELEMENTS = %w[
56
57
  pre code textarea script style
57
58
  ].freeze
59
+ # Set form for the per-sibling hot lookup; Nokogiri lowercases
60
+ # HTML element names, so the downcase fallback only runs for
61
+ # unusual (already-mixed-case) input.
62
+ BLOCK_ELEMENT_SET = BLOCK_ELEMENTS.to_set
63
+ # Compiled once — building the alternation and compiling the
64
+ # regex per format call cost more than the gsub it drives.
65
+ BLOCK_SPACING_PATTERN =
66
+ Regexp.new("(</(?:#{BLOCK_ELEMENTS.join('|')})>)(<(?:#{BLOCK_ELEMENTS.join('|')})[\s>])").freeze
58
67
  # Format HTML using canonical form
59
68
  # @param html [String] HTML document to canonicalize
60
69
  # @return [String] Canonical form of HTML
@@ -100,6 +109,15 @@ module Canon
100
109
  next unless node.element?
101
110
  next if node.attributes.empty?
102
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
+
103
121
  sorted_attrs = node.attributes.sort_by { |name, _| name }
104
122
  node.attributes.each_key { |name| node.remove_attribute(name) }
105
123
  sorted_attrs.each { |name, attr| node[name] = attr.value }
@@ -122,7 +140,7 @@ module Canon
122
140
  end
123
141
 
124
142
  # Handle whitespace-only text nodes
125
- if node.text.strip.empty? && node.parent&.element?
143
+ if node.text.match?(Canon::Xml::WhitespacePolicy::STRIP_ONLY) && node.parent&.element?
126
144
  # Check if this text node is between block-level elements
127
145
  prev_sibling = node.previous_sibling
128
146
  next_sibling = node.next_sibling
@@ -138,14 +156,17 @@ module Canon
138
156
  else
139
157
  # Collapse multiple whitespace characters into single spaces
140
158
  # but preserve leading/trailing single spaces for inline content
141
- normalized = node.text.gsub(/\s+/, " ")
159
+ text = node.text
160
+ normalized = text.gsub(/\s+/, " ")
142
161
  # Only strip if the entire parent chain suggests it's appropriate
143
162
  # (e.g., at document boundaries)
144
163
  if node.parent&.name == "body" &&
145
164
  (node.previous_sibling.nil? || node.next_sibling.nil?)
146
165
  normalized = normalized.strip
147
166
  end
148
- node.content = normalized
167
+ # node.content= re-parses the string — skip it when nothing
168
+ # changed (text with no collapsible whitespace).
169
+ node.content = normalized unless normalized == text
149
170
  end
150
171
  end
151
172
  end
@@ -154,19 +175,20 @@ module Canon
154
175
  # @param html [String] Serialized HTML string
155
176
  # @return [String] HTML with proper spacing between block elements
156
177
  def self.ensure_block_element_spacing(html)
157
- # Build regex pattern for block element tags
158
- block_tags = BLOCK_ELEMENTS.join("|")
159
-
160
178
  # Add space between closing and opening block element tags
161
- # Match: ><opening_block_tag or </closing_block_tag><opening_block_tag
162
- html.gsub(/(<\/(?:#{block_tags})>)(<(?:#{block_tags})[\s>])/, '\1 \2')
179
+ # (pattern compiled once — see BLOCK_SPACING_PATTERN)
180
+ html.gsub(BLOCK_SPACING_PATTERN, '\1 \2')
163
181
  end
164
182
 
165
183
  # Check if a node is a block-level element
166
184
  # @param node [Nokogiri::XML::Node, nil] Node to check
167
185
  # @return [Boolean] true if node is a block element
168
186
  def self.block_element?(node)
169
- node&.element? && BLOCK_ELEMENTS.include?(node.name.downcase)
187
+ return false unless node&.element?
188
+
189
+ name = node.name
190
+ BLOCK_ELEMENT_SET.include?(name) ||
191
+ BLOCK_ELEMENT_SET.include?(name.downcase)
170
192
  end
171
193
 
172
194
  # Check if a node is a whitespace-sensitive element
@@ -178,8 +200,14 @@ module Canon
178
200
  # Check if this element or any ancestor is whitespace-sensitive
179
201
  current = node
180
202
  while current
181
- if current.element? && WHITESPACE_SENSITIVE_ELEMENTS.include?(current.name.downcase)
182
- return true
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
183
211
  end
184
212
  # Stop at document root - documents don't have parents
185
213
  break if current.is_a?(Nokogiri::XML::Document) || current.is_a?(Nokogiri::HTML5::Document)
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.21"
4
+ VERSION = "0.3.22"
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.21
4
+ version: 0.3.22
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-09-06 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs