moxml 0.5.148 → 0.5.149
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/.github/workflows/nightly-fuzz.yml +80 -0
- data/docs/_pages/divergences.adoc +58 -0
- data/docs/_references/processing-model.adoc +30 -3
- data/lib/compat/opal/moxml_boot.rb +4 -0
- data/lib/moxml/adapter/leptris.rb +110 -19
- data/lib/moxml/adapter/libxml.rb +145 -23
- data/lib/moxml/adapter/oga.rb +33 -3
- data/lib/moxml/adapter/ox.rb +26 -2
- data/lib/moxml/adapter/rexml.rb +65 -15
- data/lib/moxml/context.rb +5 -1
- data/lib/moxml/document.rb +6 -6
- data/lib/moxml/node.rb +22 -6
- data/lib/moxml/node_set.rb +11 -0
- data/lib/moxml/reference/normalizer.rb +63 -0
- data/lib/moxml/reference/parser.rb +417 -0
- data/lib/moxml/reference/writer.rb +121 -0
- data/lib/moxml/reference.rb +205 -0
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xpath/compiler.rb +17 -2
- data/lib/moxml/xpath/ordering.rb +97 -0
- data/lib/moxml/xpath.rb +1 -0
- data/lib/moxml.rb +1 -0
- data/spec/consistency/adapter_parity_spec.rb +2 -0
- data/spec/consistency/differential_taxonomy_spec.rb +117 -0
- data/spec/consistency/divergence_register_spec.rb +9 -1
- data/spec/consistency/eol_normalization_spec.rb +2 -0
- data/spec/consistency/loud_degradation_spec.rb +2 -0
- data/spec/consistency/metamorphic_properties_spec.rb +195 -0
- data/spec/consistency/metamorphic_spec.rb +138 -0
- data/spec/consistency/model_coverage_audit_spec.rb +108 -0
- data/spec/consistency/reference_differential_spec.rb +186 -0
- data/spec/consistency/xpath_idioms_spec.rb +11 -0
- data/spec/consistency/xslt_spec.rb +2 -0
- data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
- data/spec/moxml/adapter/ox_spec.rb +1 -1
- data/spec/moxml/attribute_resolver_spec.rb +2 -0
- data/spec/moxml/c14n/adapter_delegation_spec.rb +2 -0
- data/spec/moxml/doctype_spec.rb +7 -0
- data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
- data/spec/moxml/entity_spec.rb +2 -0
- data/spec/moxml/plan_conformance_spec.rb +2 -0
- data/spec/moxml/processing_instruction_spec.rb +9 -0
- data/spec/moxml/reference_opal_spec.rb +29 -0
- data/spec/moxml/reference_spec.rb +162 -0
- data/spec/moxml/sax_spec.rb +2 -0
- data/spec/moxml/text_spec.rb +2 -0
- data/spec/moxml/to_html_spec.rb +2 -0
- data/spec/moxml/xhtml_output_spec.rb +2 -0
- data/spec/moxml/xpath/axes_spec.rb +2 -2
- data/spec/moxml/xsd_spec.rb +38 -0
- data/spec/support/input_generator.rb +154 -0
- data/spec/support/input_taxonomy.rb +212 -0
- data/spec/support/xml_generator.rb +54 -0
- metadata +16 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module Reference
|
|
5
|
+
# A small, total XML reader for the model's input taxonomy. No
|
|
6
|
+
# recovery: malformed input raises a named ParseError. The only
|
|
7
|
+
# dependencies are Ruby's String and Regexp, so the reference
|
|
8
|
+
# transpiles to the Opal lane.
|
|
9
|
+
class Parser
|
|
10
|
+
NAME = /[A-Za-z_:][-A-Za-z0-9_:.]*/
|
|
11
|
+
XML_WHITESPACE = /[ \t\n\r]/
|
|
12
|
+
ATTRIBUTE_LITERAL_WHITESPACE = /[\t\n\r]/
|
|
13
|
+
BUILTIN_ENTITIES = {
|
|
14
|
+
"amp" => "&", "lt" => "<", "gt" => ">",
|
|
15
|
+
"quot" => '"', "apos" => "'"
|
|
16
|
+
}.freeze
|
|
17
|
+
BOM = "\xEF\xBB\xBF"
|
|
18
|
+
QUOTES = ['"', "'"].freeze
|
|
19
|
+
DECLARATION_OPEN_SIZE = 5
|
|
20
|
+
DOCTYPE_OPEN_SIZE = 9
|
|
21
|
+
COMMENT_OPEN_SIZE = 4
|
|
22
|
+
COMMENT_CLOSE_SIZE = 3
|
|
23
|
+
CDATA_OPEN_SIZE = 9
|
|
24
|
+
CDATA_CLOSE_SIZE = 3
|
|
25
|
+
PI_OPEN_SIZE = 2
|
|
26
|
+
PI_CLOSE_SIZE = 2
|
|
27
|
+
|
|
28
|
+
def initialize(xml)
|
|
29
|
+
@source = ""
|
|
30
|
+
@pos = 0
|
|
31
|
+
@source = prepare_source(xml)
|
|
32
|
+
@length = @source.length
|
|
33
|
+
@entities = BUILTIN_ENTITIES.dup
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse
|
|
37
|
+
document = Document.new
|
|
38
|
+
parse_prolog(document)
|
|
39
|
+
raise error("no root element") if at_end?
|
|
40
|
+
|
|
41
|
+
document.children << parse_element(nil)
|
|
42
|
+
parse_epilog(document)
|
|
43
|
+
document.entities = @entities
|
|
44
|
+
document
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def prepare_source(xml)
|
|
50
|
+
source = xml.to_s
|
|
51
|
+
source = source.encode(Encoding::UTF_8) unless source.encoding == Encoding::UTF_8
|
|
52
|
+
raise error("source is not valid UTF-8") unless source.valid_encoding?
|
|
53
|
+
|
|
54
|
+
source = source.delete_prefix(BOM)
|
|
55
|
+
# XML 1.0 §2.11: literal CRLF and CR normalize to LF. Character
|
|
56
|
+
# references are expanded later and keep their value.
|
|
57
|
+
source.gsub("\r\n", "\n").gsub("\r", "\n")
|
|
58
|
+
rescue EncodingError => e
|
|
59
|
+
raise error("source cannot be read as UTF-8: #{e.message}")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_prolog(document)
|
|
63
|
+
loop do
|
|
64
|
+
skip_whitespace
|
|
65
|
+
if declaration_start?
|
|
66
|
+
if document.children.empty? && document.declaration.nil?
|
|
67
|
+
document.declaration = parse_declaration
|
|
68
|
+
else
|
|
69
|
+
raise error("XML declaration is not first")
|
|
70
|
+
end
|
|
71
|
+
elsif start_with?("<!--")
|
|
72
|
+
document.children << parse_comment
|
|
73
|
+
elsif start_with?("<?")
|
|
74
|
+
document.children << parse_processing_instruction
|
|
75
|
+
elsif start_with?("<!DOCTYPE")
|
|
76
|
+
document.doctype = parse_doctype
|
|
77
|
+
else
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_epilog(document)
|
|
84
|
+
loop do
|
|
85
|
+
skip_whitespace
|
|
86
|
+
return if at_end?
|
|
87
|
+
|
|
88
|
+
if start_with?("<!--")
|
|
89
|
+
document.children << parse_comment
|
|
90
|
+
elsif start_with?("<?")
|
|
91
|
+
document.children << parse_processing_instruction
|
|
92
|
+
else
|
|
93
|
+
raise error("content after the root element")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def declaration_start?
|
|
99
|
+
return false unless start_with?("<?xml")
|
|
100
|
+
|
|
101
|
+
following = @source[@pos + 5]
|
|
102
|
+
following.nil? || following == "?" || XML_WHITESPACE.match?(following)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_declaration
|
|
106
|
+
start = @pos
|
|
107
|
+
@pos += DECLARATION_OPEN_SIZE
|
|
108
|
+
attributes = parse_attributes_until("?>").to_h
|
|
109
|
+
{
|
|
110
|
+
version: attributes["version"],
|
|
111
|
+
encoding: attributes["encoding"],
|
|
112
|
+
standalone: attributes["standalone"],
|
|
113
|
+
raw: @source[start...@pos],
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_doctype
|
|
118
|
+
start = @pos
|
|
119
|
+
@pos += DOCTYPE_OPEN_SIZE
|
|
120
|
+
depth = 0
|
|
121
|
+
quote = nil
|
|
122
|
+
until at_end?
|
|
123
|
+
char = @source[@pos]
|
|
124
|
+
if quote
|
|
125
|
+
quote = nil if char == quote
|
|
126
|
+
elsif QUOTES.include?(char)
|
|
127
|
+
quote = char
|
|
128
|
+
elsif char == "["
|
|
129
|
+
depth += 1
|
|
130
|
+
elsif char == "]"
|
|
131
|
+
depth -= 1
|
|
132
|
+
elsif char == ">" && depth.zero?
|
|
133
|
+
@pos += 1
|
|
134
|
+
raw = @source[start...@pos]
|
|
135
|
+
collect_entities(raw)
|
|
136
|
+
return raw
|
|
137
|
+
end
|
|
138
|
+
@pos += 1
|
|
139
|
+
end
|
|
140
|
+
raise error("unterminated DOCTYPE")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def collect_entities(raw)
|
|
144
|
+
raw.scan(/<!ENTITY\s+([^\s%][^\s]*)\s+(?:"([^"]*)"|'([^']*)')/) do |name, dq, sq|
|
|
145
|
+
@entities[name] = dq || sq
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_element(parent, scope = {})
|
|
150
|
+
expect("<")
|
|
151
|
+
name = scan_name || raise(error("element name expected"))
|
|
152
|
+
attributes = parse_attributes_until(">", allow_empty_close: true)
|
|
153
|
+
node = Node.new(kind: :element, name: name, attributes: attributes)
|
|
154
|
+
node.parent = parent
|
|
155
|
+
|
|
156
|
+
element_scope = extend_scope(scope, attributes)
|
|
157
|
+
validate_prefixes(name, attributes, element_scope)
|
|
158
|
+
|
|
159
|
+
# parse_attributes_until consumed either ">" or "/>".
|
|
160
|
+
return node if @pos >= 2 && @source[@pos - 2, 2] == "/>"
|
|
161
|
+
|
|
162
|
+
parse_element_content(node, element_scope)
|
|
163
|
+
node
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Namespace bindings are parser state: xmlns declarations extend
|
|
167
|
+
# the in-scope table for the element and its descendants.
|
|
168
|
+
def extend_scope(scope, attributes)
|
|
169
|
+
extended = nil
|
|
170
|
+
attributes.each do |(name, value)|
|
|
171
|
+
if name == "xmlns"
|
|
172
|
+
(extended ||= scope.dup)[""] = value
|
|
173
|
+
elsif name.start_with?("xmlns:")
|
|
174
|
+
(extended ||= scope.dup)[name.delete_prefix("xmlns:")] = value
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
extended || scope
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def validate_prefixes(name, attributes, scope)
|
|
181
|
+
check_prefix(name, scope, "element")
|
|
182
|
+
attributes.each do |(attribute_name, _)|
|
|
183
|
+
next if attribute_name == "xmlns" ||
|
|
184
|
+
attribute_name.start_with?("xmlns:")
|
|
185
|
+
|
|
186
|
+
check_prefix(attribute_name, scope, "attribute")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def check_prefix(name, scope, kind)
|
|
191
|
+
return unless name.include?(":")
|
|
192
|
+
|
|
193
|
+
prefix = name.split(":", 2).first
|
|
194
|
+
return if prefix == "xml"
|
|
195
|
+
|
|
196
|
+
return if scope.key?(prefix)
|
|
197
|
+
|
|
198
|
+
raise error("undeclared namespace prefix #{prefix.inspect} on #{kind} #{name}")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse_element_content(node, scope)
|
|
202
|
+
loop do
|
|
203
|
+
raise error("unclosed element <#{node.name}>") if at_end?
|
|
204
|
+
|
|
205
|
+
if start_with?("</")
|
|
206
|
+
@pos += 2
|
|
207
|
+
close_name = scan_name
|
|
208
|
+
skip_whitespace
|
|
209
|
+
expect(">")
|
|
210
|
+
unless close_name == node.name
|
|
211
|
+
raise error("mismatched closing tag </#{close_name}> for <#{node.name}>")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
elsif start_with?("<!--")
|
|
217
|
+
node.children << parse_comment(node)
|
|
218
|
+
elsif start_with?("<![CDATA[")
|
|
219
|
+
node.children << parse_cdata(node)
|
|
220
|
+
elsif start_with?("<?")
|
|
221
|
+
node.children << parse_processing_instruction(node)
|
|
222
|
+
elsif start_with?("<")
|
|
223
|
+
node.children << parse_element(node, scope)
|
|
224
|
+
else
|
|
225
|
+
text = parse_text(node)
|
|
226
|
+
node.children << text if text
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def parse_text(parent = nil)
|
|
232
|
+
stop = @source.index("<", @pos)
|
|
233
|
+
stop ||= @length
|
|
234
|
+
raw = @source[@pos...stop]
|
|
235
|
+
@pos = stop
|
|
236
|
+
return if raw.empty?
|
|
237
|
+
|
|
238
|
+
node = Node.new(kind: :text, data: expand_entities(raw))
|
|
239
|
+
node.parent = parent
|
|
240
|
+
node
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def parse_comment(parent = nil)
|
|
244
|
+
@pos += COMMENT_OPEN_SIZE
|
|
245
|
+
stop = @source.index("-->", @pos)
|
|
246
|
+
raise error("unterminated comment") unless stop
|
|
247
|
+
|
|
248
|
+
data = @source[@pos...stop]
|
|
249
|
+
@pos = stop + COMMENT_CLOSE_SIZE
|
|
250
|
+
node = Node.new(kind: :comment, data: data)
|
|
251
|
+
node.parent = parent
|
|
252
|
+
node
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def parse_cdata(parent = nil)
|
|
256
|
+
@pos += CDATA_OPEN_SIZE
|
|
257
|
+
stop = @source.index("]]>", @pos)
|
|
258
|
+
raise error("unterminated CDATA section") unless stop
|
|
259
|
+
|
|
260
|
+
data = @source[@pos...stop]
|
|
261
|
+
@pos = stop + CDATA_CLOSE_SIZE
|
|
262
|
+
node = Node.new(kind: :cdata, data: data)
|
|
263
|
+
node.parent = parent
|
|
264
|
+
node
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def parse_processing_instruction(parent = nil)
|
|
268
|
+
@pos += PI_OPEN_SIZE
|
|
269
|
+
target = scan_name || raise(error("PI target expected"))
|
|
270
|
+
stop = @source.index("?>", @pos)
|
|
271
|
+
raise error("unterminated processing instruction") unless stop
|
|
272
|
+
|
|
273
|
+
data = @source[@pos...stop]
|
|
274
|
+
@pos = stop + PI_CLOSE_SIZE
|
|
275
|
+
# The separating whitespace after the target is parser syntax,
|
|
276
|
+
# not data (model, Access semantics).
|
|
277
|
+
data = data.sub(/\A[ \t\n\r]+/, "")
|
|
278
|
+
node = Node.new(kind: :processing_instruction, name: target, data: data)
|
|
279
|
+
node.parent = parent
|
|
280
|
+
node
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Parses attributes until the terminator. With allow_empty_close,
|
|
284
|
+
# a "/>" terminator is consumed and leaves the position after
|
|
285
|
+
# it; otherwise the exact terminator is expected.
|
|
286
|
+
def parse_attributes_until(terminator, allow_empty_close: false)
|
|
287
|
+
attributes = []
|
|
288
|
+
seen = {}
|
|
289
|
+
loop do
|
|
290
|
+
skip_whitespace
|
|
291
|
+
if allow_empty_close && start_with?("/>")
|
|
292
|
+
@pos += 2
|
|
293
|
+
return attributes
|
|
294
|
+
end
|
|
295
|
+
if start_with?(terminator)
|
|
296
|
+
@pos += terminator.length
|
|
297
|
+
return attributes
|
|
298
|
+
end
|
|
299
|
+
raise error("attribute or #{terminator} expected") if at_end?
|
|
300
|
+
|
|
301
|
+
name = scan_name || raise(error("attribute name expected"))
|
|
302
|
+
skip_whitespace
|
|
303
|
+
expect("=")
|
|
304
|
+
skip_whitespace
|
|
305
|
+
value = scan_attribute_value
|
|
306
|
+
raise error("duplicate attribute #{name}") if seen[name]
|
|
307
|
+
|
|
308
|
+
seen[name] = true
|
|
309
|
+
attributes << [name, value]
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def scan_attribute_value
|
|
314
|
+
quote = @source[@pos]
|
|
315
|
+
raise error("quoted attribute value expected") unless QUOTES.include?(quote)
|
|
316
|
+
|
|
317
|
+
@pos += 1
|
|
318
|
+
stop = @source.index(quote, @pos)
|
|
319
|
+
raise error("unterminated attribute value") unless stop
|
|
320
|
+
|
|
321
|
+
raw = @source[@pos...stop]
|
|
322
|
+
@pos = stop + 1
|
|
323
|
+
expand_entities(raw.gsub(ATTRIBUTE_LITERAL_WHITESPACE, " "))
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def expand_entities(raw)
|
|
327
|
+
return raw unless raw.include?("&")
|
|
328
|
+
|
|
329
|
+
parts = []
|
|
330
|
+
index = 0
|
|
331
|
+
while (amp = raw.index("&", index))
|
|
332
|
+
parts << raw[index...amp]
|
|
333
|
+
semi = raw.index(";", amp)
|
|
334
|
+
raise error("unterminated entity reference") unless semi
|
|
335
|
+
|
|
336
|
+
reference = raw[(amp + 1)...semi]
|
|
337
|
+
raise error("empty entity reference") if reference.empty?
|
|
338
|
+
|
|
339
|
+
parts << resolve_entity(reference)
|
|
340
|
+
index = semi + 1
|
|
341
|
+
end
|
|
342
|
+
parts << raw[index..]
|
|
343
|
+
parts.join
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def resolve_entity(reference)
|
|
347
|
+
if reference.start_with?("#x")
|
|
348
|
+
codepoint(reference[2..], 16)
|
|
349
|
+
elsif reference.start_with?("#")
|
|
350
|
+
codepoint(reference[1..], 10)
|
|
351
|
+
elsif BUILTIN_ENTITIES.key?(reference)
|
|
352
|
+
BUILTIN_ENTITIES[reference]
|
|
353
|
+
else
|
|
354
|
+
# DTD-declared general entities are preserved lexically in
|
|
355
|
+
# the facade's default lane (every adapter answers the
|
|
356
|
+
# reference text). An undeclared reference is malformed.
|
|
357
|
+
raise error("undefined entity &#{reference};") unless @entities.key?(reference)
|
|
358
|
+
|
|
359
|
+
"&#{reference};"
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def codepoint(digits, base)
|
|
364
|
+
unless digits.match?(base == 16 ? /\A[0-9A-Fa-f]+\z/ : /\A[0-9]+\z/)
|
|
365
|
+
raise error("malformed character reference")
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
code = digits.to_i(base)
|
|
369
|
+
unless valid_xml_char?(code)
|
|
370
|
+
raise error("character reference out of XML range: #{code}")
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
[code].pack("U")
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def valid_xml_char?(code)
|
|
377
|
+
code == 0x9 || code == 0xA || code == 0xD ||
|
|
378
|
+
code.between?(0x20, 0xD7FF) ||
|
|
379
|
+
code.between?(0xE000, 0xFFFD) ||
|
|
380
|
+
code.between?(0x10000, 0x10FFFF)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def scan_name
|
|
384
|
+
match = NAME.match(@source, @pos)
|
|
385
|
+
return unless match && match.begin(0) == @pos
|
|
386
|
+
|
|
387
|
+
@pos = match.end(0)
|
|
388
|
+
match[0]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def skip_whitespace
|
|
392
|
+
@pos += 1 while !at_end? && XML_WHITESPACE.match?(@source[@pos])
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def expect(token)
|
|
396
|
+
return @pos += token.length if start_with?(token)
|
|
397
|
+
|
|
398
|
+
raise error("expected #{token.inspect}")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def start_with?(token)
|
|
402
|
+
@source[@pos, token.length] == token
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def at_end?
|
|
406
|
+
@pos >= @length
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def error(message)
|
|
410
|
+
prefix = @source[0...@pos] || ""
|
|
411
|
+
line = prefix.count("\n") + 1
|
|
412
|
+
column = @pos - (prefix.rindex("\n") || -1)
|
|
413
|
+
ParseError.new(message, line: line, column: column)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module Reference
|
|
5
|
+
# Pure-Ruby serializers for the model's three shapes. The XML
|
|
6
|
+
# writer is the reference for the facade's canonical output at
|
|
7
|
+
# indent: 0; the HTML/XHTML writers pin the shape contracts (void
|
|
8
|
+
# elements unclosed vs self-closed, raw text nodes, no XML
|
|
9
|
+
# declaration).
|
|
10
|
+
#
|
|
11
|
+
# Output is assembled as an array of parts and joined: the
|
|
12
|
+
# reference must run under Opal, where mutable String#<< is
|
|
13
|
+
# unsupported.
|
|
14
|
+
class Writer
|
|
15
|
+
VOID_ELEMENTS = %w[
|
|
16
|
+
area base br col embed hr img input link meta param source
|
|
17
|
+
track wbr
|
|
18
|
+
].freeze
|
|
19
|
+
RAW_TEXT_ELEMENTS = %w[script style].freeze
|
|
20
|
+
|
|
21
|
+
TEXT_ESCAPES = { "&" => "&", "<" => "<", ">" => ">" }.freeze
|
|
22
|
+
ATTRIBUTE_ESCAPES = TEXT_ESCAPES.merge('"' => """).freeze
|
|
23
|
+
TEXT_ESCAPE_RE = /[&<>]/
|
|
24
|
+
ATTRIBUTE_ESCAPE_RE = /[&<>"]/
|
|
25
|
+
CDATA_END = "]]>"
|
|
26
|
+
CDATA_SPLIT = "]]]]><![CDATA[>"
|
|
27
|
+
|
|
28
|
+
def initialize(document)
|
|
29
|
+
@document = document
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_xml
|
|
33
|
+
emit(:xml)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_html
|
|
37
|
+
emit(:html)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_xhtml
|
|
41
|
+
emit(:xhtml)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def emit(shape)
|
|
47
|
+
parts = []
|
|
48
|
+
parts << declaration if shape == :xml && @document.declaration
|
|
49
|
+
@document.children.each { |node| write(node, parts, shape) }
|
|
50
|
+
parts.join
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def declaration
|
|
54
|
+
source = @document.declaration
|
|
55
|
+
attributes = []
|
|
56
|
+
attributes << %(version="#{escape_attribute(source[:version])}") if source[:version]
|
|
57
|
+
attributes << %(encoding="#{escape_attribute(source[:encoding])}") if source[:encoding]
|
|
58
|
+
attributes << %(standalone="#{escape_attribute(source[:standalone])}") if source[:standalone]
|
|
59
|
+
"<?xml #{attributes.join(' ')}?>"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def write(node, parts, shape)
|
|
63
|
+
case node.kind
|
|
64
|
+
when :element then write_element(node, parts, shape)
|
|
65
|
+
when :text then write_text(node, parts, shape)
|
|
66
|
+
when :comment then parts << "<!--#{node.data}-->"
|
|
67
|
+
when :cdata then parts << cdata(node.data)
|
|
68
|
+
when :processing_instruction then write_pi(node, parts)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def write_element(node, parts, shape)
|
|
73
|
+
parts << "<" << node.name
|
|
74
|
+
node.attributes.each do |(name, value)|
|
|
75
|
+
parts << " " << name << '="' << escape_attribute(value) << '"'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if shape == :html && VOID_ELEMENTS.include?(node.name)
|
|
79
|
+
parts << ">"
|
|
80
|
+
elsif shape == :xhtml && VOID_ELEMENTS.include?(node.name)
|
|
81
|
+
parts << "/>"
|
|
82
|
+
elsif node.children.empty?
|
|
83
|
+
if shape == :xml
|
|
84
|
+
parts << "/>"
|
|
85
|
+
else
|
|
86
|
+
parts << "></" << node.name << ">"
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
parts << ">"
|
|
90
|
+
node.children.each { |child| write(child, parts, shape) }
|
|
91
|
+
parts << "</" << node.name << ">"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def write_text(node, parts, shape)
|
|
96
|
+
parent = node.parent
|
|
97
|
+
raw = shape != :xhtml && parent &&
|
|
98
|
+
RAW_TEXT_ELEMENTS.include?(parent.name)
|
|
99
|
+
parts << (raw ? node.data : escape_text(node.data))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write_pi(node, parts)
|
|
103
|
+
parts << "<?" << node.name
|
|
104
|
+
parts << " " << node.data unless node.data.nil? || node.data.empty?
|
|
105
|
+
parts << "?>"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def cdata(data)
|
|
109
|
+
"<![CDATA[#{data.gsub(CDATA_END, CDATA_SPLIT)}]]>"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def escape_text(text)
|
|
113
|
+
text.to_s.gsub(TEXT_ESCAPE_RE, TEXT_ESCAPES)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def escape_attribute(value)
|
|
117
|
+
value.to_s.gsub(ATTRIBUTE_ESCAPE_RE, ATTRIBUTE_ESCAPES)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|