canon 0.3.100 → 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 +4 -4
- data/CLAUDE.md +1 -1
- data/lib/canon/formatters/html4_formatter.rb +4 -3
- data/lib/canon/formatters/html5_formatter.rb +3 -3
- data/lib/canon/formatters/html_formatter.rb +19 -8
- data/lib/canon/formatters/html_formatter_base.rb +97 -68
- data/lib/canon/pretty_printer/html.rb +18 -44
- data/lib/canon/tree_diff/adapters/xml_adapter.rb +27 -0
- data/lib/canon/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc66e03ef02db4a7cbb21e496076e2794027a7a52996eb797b90b697c77013ee
|
|
4
|
+
data.tar.gz: 6ab70c76adfd5fe466b0fa8a54f542097344f4726c34192b1f85f59a542b5752
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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 [
|
|
10
|
+
# @return [Moxml::Document] Parsed document
|
|
10
11
|
def self.parse(html)
|
|
11
|
-
|
|
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
|
|
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 [
|
|
9
|
+
# @return [Moxml::Document] Parsed document
|
|
10
10
|
def self.parse(html)
|
|
11
|
-
|
|
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
|
|
7
|
+
# Parse HTML into an engine document
|
|
10
8
|
# @param html [String] HTML document to parse
|
|
11
|
-
# @return [
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 [
|
|
111
|
+
# @param doc [Moxml::Document] Parsed document
|
|
107
112
|
def self.sort_attributes(doc)
|
|
108
|
-
doc.
|
|
109
|
-
|
|
110
|
-
next if
|
|
111
|
-
|
|
112
|
-
names =
|
|
113
|
-
# Already-sorted
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 [
|
|
138
|
+
# @param doc [Moxml::Document] Parsed document
|
|
130
139
|
def self.normalize_whitespace(doc)
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
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?(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
normalized =
|
|
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
|
|
164
|
-
(
|
|
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
|
-
#
|
|
172
|
+
# content= re-parses the string — skip it when nothing
|
|
168
173
|
# changed (text with no collapsible whitespace).
|
|
169
|
-
|
|
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
|
|
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 [
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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,57 +98,31 @@ 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 #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).
|
|
101
105
|
def format_fixture_ready(html_string)
|
|
102
|
-
doc =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
indent: @indent)
|
|
111
|
-
end
|
|
112
|
-
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)
|
|
113
114
|
end
|
|
114
115
|
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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)
|
|
139
121
|
|
|
140
|
-
|
|
141
|
-
parent.children.any? do |c|
|
|
142
|
-
c.text? && !c.content.strip.empty?
|
|
122
|
+
"<#{name}#{::Regexp.last_match(2)}/>"
|
|
143
123
|
end
|
|
144
124
|
end
|
|
145
125
|
|
|
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
|
|
150
|
-
end
|
|
151
|
-
|
|
152
126
|
# Rewrite +<tag …/>+ into +<tag …></tag>+ for every element name
|
|
153
127
|
# that is not an HTML5 void element. +<a/>+ is illegal HTML;
|
|
154
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
|
data/lib/canon/version.rb
CHANGED