curlybars 0.9.13
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 +7 -0
- data/lib/curlybars.rb +108 -0
- data/lib/curlybars/configuration.rb +41 -0
- data/lib/curlybars/dependency_tracker.rb +8 -0
- data/lib/curlybars/error/base.rb +18 -0
- data/lib/curlybars/error/compile.rb +11 -0
- data/lib/curlybars/error/lex.rb +22 -0
- data/lib/curlybars/error/parse.rb +41 -0
- data/lib/curlybars/error/presenter/not_found.rb +23 -0
- data/lib/curlybars/error/render.rb +11 -0
- data/lib/curlybars/error/validate.rb +18 -0
- data/lib/curlybars/lexer.rb +60 -0
- data/lib/curlybars/method_whitelist.rb +69 -0
- data/lib/curlybars/node/block_helper_else.rb +108 -0
- data/lib/curlybars/node/boolean.rb +24 -0
- data/lib/curlybars/node/each_else.rb +69 -0
- data/lib/curlybars/node/if_else.rb +33 -0
- data/lib/curlybars/node/item.rb +31 -0
- data/lib/curlybars/node/literal.rb +28 -0
- data/lib/curlybars/node/option.rb +25 -0
- data/lib/curlybars/node/output.rb +24 -0
- data/lib/curlybars/node/partial.rb +24 -0
- data/lib/curlybars/node/path.rb +137 -0
- data/lib/curlybars/node/root.rb +29 -0
- data/lib/curlybars/node/string.rb +24 -0
- data/lib/curlybars/node/template.rb +32 -0
- data/lib/curlybars/node/text.rb +24 -0
- data/lib/curlybars/node/unless_else.rb +33 -0
- data/lib/curlybars/node/variable.rb +34 -0
- data/lib/curlybars/node/with_else.rb +54 -0
- data/lib/curlybars/parser.rb +183 -0
- data/lib/curlybars/position.rb +7 -0
- data/lib/curlybars/presenter.rb +288 -0
- data/lib/curlybars/processor/tilde.rb +31 -0
- data/lib/curlybars/processor/token_factory.rb +9 -0
- data/lib/curlybars/railtie.rb +18 -0
- data/lib/curlybars/rendering_support.rb +222 -0
- data/lib/curlybars/safe_buffer.rb +11 -0
- data/lib/curlybars/template_handler.rb +93 -0
- data/lib/curlybars/version.rb +3 -0
- data/spec/acceptance/application_layout_spec.rb +60 -0
- data/spec/acceptance/collection_blocks_spec.rb +28 -0
- data/spec/acceptance/global_helper_spec.rb +25 -0
- data/spec/curlybars/configuration_spec.rb +57 -0
- data/spec/curlybars/error/base_spec.rb +41 -0
- data/spec/curlybars/error/compile_spec.rb +19 -0
- data/spec/curlybars/error/lex_spec.rb +25 -0
- data/spec/curlybars/error/parse_spec.rb +74 -0
- data/spec/curlybars/error/render_spec.rb +19 -0
- data/spec/curlybars/error/validate_spec.rb +19 -0
- data/spec/curlybars/lexer_spec.rb +466 -0
- data/spec/curlybars/method_whitelist_spec.rb +168 -0
- data/spec/curlybars/processor/tilde_spec.rb +60 -0
- data/spec/curlybars/rendering_support_spec.rb +426 -0
- data/spec/curlybars/safe_buffer_spec.rb +46 -0
- data/spec/curlybars/template_handler_spec.rb +222 -0
- data/spec/integration/cache_spec.rb +124 -0
- data/spec/integration/comment_spec.rb +60 -0
- data/spec/integration/exception_spec.rb +31 -0
- data/spec/integration/node/block_helper_else_spec.rb +422 -0
- data/spec/integration/node/each_else_spec.rb +204 -0
- data/spec/integration/node/each_spec.rb +291 -0
- data/spec/integration/node/escape_spec.rb +27 -0
- data/spec/integration/node/helper_spec.rb +176 -0
- data/spec/integration/node/if_else_spec.rb +129 -0
- data/spec/integration/node/if_spec.rb +143 -0
- data/spec/integration/node/output_spec.rb +68 -0
- data/spec/integration/node/partial_spec.rb +66 -0
- data/spec/integration/node/path_spec.rb +286 -0
- data/spec/integration/node/root_spec.rb +15 -0
- data/spec/integration/node/template_spec.rb +86 -0
- data/spec/integration/node/unless_else_spec.rb +129 -0
- data/spec/integration/node/unless_spec.rb +130 -0
- data/spec/integration/node/with_spec.rb +116 -0
- data/spec/integration/processor/tilde_spec.rb +38 -0
- data/spec/integration/processors_spec.rb +30 -0
- metadata +358 -0
@@ -0,0 +1,31 @@
|
|
1
|
+
module Curlybars
|
2
|
+
module Processor
|
3
|
+
class Tilde
|
4
|
+
extend TokenFactory
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def process!(tokens, identifier)
|
8
|
+
tokens.each_with_index do |token, index|
|
9
|
+
case token.type
|
10
|
+
when :TILDE_START
|
11
|
+
tokens[index] = create_token(:START, token.value, token.position)
|
12
|
+
next if index == 0
|
13
|
+
strip_token_if_text(tokens, index - 1, :rstrip)
|
14
|
+
when :TILDE_END
|
15
|
+
tokens[index] = create_token(:END, token.value, token.position)
|
16
|
+
next if index == (tokens.length - 1)
|
17
|
+
strip_token_if_text(tokens, index + 1, :lstrip)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def strip_token_if_text(tokens, index, strip_method)
|
23
|
+
token = tokens[index]
|
24
|
+
return if token.type != :TEXT
|
25
|
+
stripped_value = token.value.public_send(strip_method)
|
26
|
+
tokens[index] = create_token(token.type, stripped_value, token.position)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'curlybars/template_handler'
|
2
|
+
require 'curlybars/dependency_tracker'
|
3
|
+
|
4
|
+
module Curlybars
|
5
|
+
class Railtie < Rails::Railtie
|
6
|
+
initializer 'curlybars.initialize_template_handler' do
|
7
|
+
ActionView::Template.register_template_handler(:hbs, Curlybars::TemplateHandler)
|
8
|
+
end
|
9
|
+
|
10
|
+
if defined?(CacheDigests::DependencyTracker)
|
11
|
+
CacheDigests::DependencyTracker.register_tracker :hbs, Curlybars::DependencyTracker
|
12
|
+
end
|
13
|
+
|
14
|
+
if defined?(ActionView::DependencyTracker)
|
15
|
+
ActionView::DependencyTracker.register_tracker :hbs, Curlybars::DependencyTracker
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
module Curlybars
|
2
|
+
class RenderingSupport
|
3
|
+
def initialize(timeout, contexts, variables, file_name, global_helpers_providers = [], cache = ->(key, &block) { block.call })
|
4
|
+
@timeout = timeout
|
5
|
+
@start_time = Time.now
|
6
|
+
|
7
|
+
@contexts = contexts
|
8
|
+
@variables = variables
|
9
|
+
@file_name = file_name
|
10
|
+
@cached_calls = {}
|
11
|
+
@cache = cache
|
12
|
+
|
13
|
+
@global_helpers = {}
|
14
|
+
|
15
|
+
global_helpers_providers.each do |provider|
|
16
|
+
provider.allowed_methods.each do |global_helper_name|
|
17
|
+
symbol = global_helper_name.to_sym
|
18
|
+
@global_helpers[symbol] = provider.method(symbol)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def check_timeout!
|
24
|
+
return unless timeout.present?
|
25
|
+
return unless (Time.now - start_time) > timeout
|
26
|
+
message = "Rendering took too long (> #{timeout} seconds)"
|
27
|
+
raise ::Curlybars::Error::Render.new('timeout', message, nil)
|
28
|
+
end
|
29
|
+
|
30
|
+
def check_context_is_presenter(context, path, position)
|
31
|
+
return if presenter?(context)
|
32
|
+
message = "`#{path}` is not a context type object"
|
33
|
+
raise Curlybars::Error::Render.new('context_is_not_a_presenter', message, position)
|
34
|
+
end
|
35
|
+
|
36
|
+
def check_context_is_hash_or_enum_of_presenters(collection, path, position)
|
37
|
+
return if presenter_collection?(collection)
|
38
|
+
|
39
|
+
message = "`#{path}` is not an array of presenters or a hash of such"
|
40
|
+
raise Curlybars::Error::Render.new('context_is_not_an_array_of_presenters', message, position)
|
41
|
+
end
|
42
|
+
|
43
|
+
def to_bool(condition)
|
44
|
+
condition != false &&
|
45
|
+
condition != [] &&
|
46
|
+
condition != {} &&
|
47
|
+
condition != 0 &&
|
48
|
+
condition != '' &&
|
49
|
+
!condition.nil?
|
50
|
+
end
|
51
|
+
|
52
|
+
def variable(variable_path, position)
|
53
|
+
check_traverse_not_too_deep(variable_path, position)
|
54
|
+
|
55
|
+
variable_split_by_slashes = variable_path.split('/')
|
56
|
+
variable = variable_split_by_slashes.last.to_sym
|
57
|
+
backward_steps_on_variables = variable_split_by_slashes.count - 1
|
58
|
+
variables_position = variables.length - backward_steps_on_variables
|
59
|
+
scope = variables.first(variables_position).reverse.find do |vars|
|
60
|
+
vars.key? variable
|
61
|
+
end
|
62
|
+
return scope[variable] if scope
|
63
|
+
end
|
64
|
+
|
65
|
+
def path(path, position)
|
66
|
+
return global_helpers[path.to_sym] if global_helpers.key?(path.to_sym)
|
67
|
+
|
68
|
+
check_traverse_not_too_deep(path, position)
|
69
|
+
|
70
|
+
path_split_by_slashes = path.split('/')
|
71
|
+
backward_steps_on_contexts = path_split_by_slashes.count - 1
|
72
|
+
base_context_position = contexts.length - backward_steps_on_contexts
|
73
|
+
|
74
|
+
return -> {} unless base_context_position > 0
|
75
|
+
|
76
|
+
base_context_index = base_context_position - 1
|
77
|
+
base_context = contexts[base_context_index]
|
78
|
+
|
79
|
+
dotted_path_side = path_split_by_slashes.last
|
80
|
+
chain = dotted_path_side.split('.')
|
81
|
+
method_to_return = chain.pop
|
82
|
+
|
83
|
+
resolved = chain.inject(base_context) do |context, meth|
|
84
|
+
next context if meth == 'this'
|
85
|
+
next context.count if meth == 'length' && presenter_collection?(context)
|
86
|
+
raise_if_not_traversable(context, meth, position)
|
87
|
+
outcome = instrument(context.method(meth)) { context.public_send(meth) }
|
88
|
+
return -> {} if outcome.nil?
|
89
|
+
outcome
|
90
|
+
end
|
91
|
+
|
92
|
+
return -> { resolved } if method_to_return == 'this'
|
93
|
+
|
94
|
+
if method_to_return == 'length' && presenter_collection?(resolved)
|
95
|
+
return -> { resolved.count }
|
96
|
+
end
|
97
|
+
|
98
|
+
raise_if_not_traversable(resolved, method_to_return, position)
|
99
|
+
resolved.method(method_to_return.to_sym)
|
100
|
+
end
|
101
|
+
|
102
|
+
def cached_call(meth)
|
103
|
+
return cached_calls[meth] if cached_calls.key? meth
|
104
|
+
instrument(meth) { cached_calls[meth] = meth.call }
|
105
|
+
end
|
106
|
+
|
107
|
+
def call(helper, helper_path, helper_position, arguments, options, &block)
|
108
|
+
parameters = helper.parameters
|
109
|
+
parameter_types = parameters.map(&:first)
|
110
|
+
|
111
|
+
# parameters has value [[:rest]] when the presenter is using method_missing to catch all calls
|
112
|
+
has_invalid_parameters = parameter_types.map { |type| type != :req }.any? && parameter_types != [:rest]
|
113
|
+
if has_invalid_parameters
|
114
|
+
source_location = helper.source_location
|
115
|
+
|
116
|
+
file_path = source_location ? source_location.first : "n/a"
|
117
|
+
line_number = source_location ? helper.source_location.last : "n/a"
|
118
|
+
|
119
|
+
message = "#{file_path}:#{line_number} - `#{helper_path}` bad signature "
|
120
|
+
message << "for #{helper} - helpers must have only required parameters"
|
121
|
+
raise Curlybars::Error::Render.new('invalid_helper_signature', message, helper_position)
|
122
|
+
end
|
123
|
+
|
124
|
+
instrument(helper) do
|
125
|
+
helper.call(*arguments_for_signature(helper, arguments, options), &block)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def position(line_number, line_offset)
|
130
|
+
Curlybars::Position.new(file_name, line_number, line_offset)
|
131
|
+
end
|
132
|
+
|
133
|
+
def coerce_to_hash!(collection, path, position)
|
134
|
+
check_context_is_hash_or_enum_of_presenters(collection, path, position)
|
135
|
+
if collection.is_a?(Hash)
|
136
|
+
collection
|
137
|
+
elsif collection.respond_to? :each_with_index
|
138
|
+
collection.each_with_index.map { |value, index| [index, value] }.to_h
|
139
|
+
else
|
140
|
+
raise "Collection is not coerceable to hash"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def presenter?(context)
|
145
|
+
context.respond_to? :allows_method?
|
146
|
+
end
|
147
|
+
|
148
|
+
def presenter_collection?(collection)
|
149
|
+
collection = collection.values if collection.is_a?(Hash)
|
150
|
+
|
151
|
+
collection.respond_to?(:each) && collection.all? do |presenter|
|
152
|
+
presenter?(presenter)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def optional_presenter_cache(presenter, template_cache_key, buffer)
|
157
|
+
presenter_cache_key = presenter.respond_to?(:cache_key) ? presenter.cache_key : nil
|
158
|
+
|
159
|
+
if presenter_cache_key
|
160
|
+
cache_key = "#{presenter_cache_key}/#{template_cache_key}"
|
161
|
+
|
162
|
+
buffer << cache.call(cache_key) do
|
163
|
+
# Output from the block must be isolated from the main output buffer
|
164
|
+
SafeBuffer.new.tap do |cache_buffer|
|
165
|
+
yield cache_buffer
|
166
|
+
end
|
167
|
+
end
|
168
|
+
else
|
169
|
+
yield buffer
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
private
|
174
|
+
|
175
|
+
attr_reader :contexts, :variables, :cached_calls, :file_name, :global_helpers, :start_time, :timeout, :cache
|
176
|
+
|
177
|
+
def instrument(meth, &block)
|
178
|
+
# Instruments only callables that give enough details (eg. methods)
|
179
|
+
return yield unless meth.respond_to?(:name) && meth.respond_to?(:owner)
|
180
|
+
|
181
|
+
payload = { presenter: meth.owner, method: meth.name }
|
182
|
+
ActiveSupport::Notifications.instrument("call_to_presenter.curlybars", payload, &block)
|
183
|
+
end
|
184
|
+
|
185
|
+
def arguments_for_signature(helper, arguments, options)
|
186
|
+
return [] if helper.parameters.empty?
|
187
|
+
return arguments if helper.parameters.map(&:first) == [:rest]
|
188
|
+
|
189
|
+
number_of_parameters_available_for_arguments = helper.parameters.length - 1
|
190
|
+
arguments_that_can_fit = arguments.first(number_of_parameters_available_for_arguments)
|
191
|
+
nil_padding_length = number_of_parameters_available_for_arguments - arguments_that_can_fit.length
|
192
|
+
nil_padding = Array.new(nil_padding_length)
|
193
|
+
|
194
|
+
[arguments_that_can_fit, nil_padding, options].flatten(1)
|
195
|
+
end
|
196
|
+
|
197
|
+
def raise_if_not_traversable(context, meth, position)
|
198
|
+
check_context_is_presenter(context, meth, position)
|
199
|
+
check_context_allows_method(context, meth, position)
|
200
|
+
check_context_has_method(context, meth, position)
|
201
|
+
end
|
202
|
+
|
203
|
+
def check_context_allows_method(context, meth, position)
|
204
|
+
return if context.allows_method?(meth.to_sym)
|
205
|
+
message = "`#{meth}` is not available - "
|
206
|
+
message += "add `allow_methods :#{meth}` to #{context.class} to allow this path"
|
207
|
+
raise Curlybars::Error::Render.new('unallowed_path', message, position, meth: meth.to_sym)
|
208
|
+
end
|
209
|
+
|
210
|
+
def check_context_has_method(context, meth, position)
|
211
|
+
return if context.respond_to?(meth.to_sym)
|
212
|
+
message = "`#{meth}` is not available in #{context.class}"
|
213
|
+
raise Curlybars::Error::Render.new('unallowed_path', message, position)
|
214
|
+
end
|
215
|
+
|
216
|
+
def check_traverse_not_too_deep(traverse, position)
|
217
|
+
return unless traverse.count('.') > Curlybars.configuration.traversing_limit
|
218
|
+
message = "`#{traverse}` too deep"
|
219
|
+
raise Curlybars::Error::Render.new('traverse_too_deep', message, position)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module Curlybars
|
2
|
+
class SafeBuffer < ActiveSupport::SafeBuffer
|
3
|
+
def concat(buffer)
|
4
|
+
if (length + buffer.length) > Curlybars.configuration.output_limit
|
5
|
+
message = "Output too long (> %s bytes)" % Curlybars.configuration.output_limit
|
6
|
+
raise Curlybars::Error::Render.new('output_too_long', message, nil)
|
7
|
+
end
|
8
|
+
super
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'action_view'
|
3
|
+
require 'curlybars'
|
4
|
+
require 'curlybars/error/presenter/not_found'
|
5
|
+
|
6
|
+
module Curlybars
|
7
|
+
class TemplateHandler
|
8
|
+
class << self
|
9
|
+
# Handles a Curlybars template, compiling it to Ruby code. The code will be
|
10
|
+
# evaluated in the context of an ActionView::Base instance, having access
|
11
|
+
# to a number of variables.
|
12
|
+
#
|
13
|
+
# template - The ActionView::Template template that should be compiled.
|
14
|
+
#
|
15
|
+
# Returns a String containing the Ruby code representing the template.
|
16
|
+
def call(template)
|
17
|
+
instrument(template) do
|
18
|
+
compile(template)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def cache_if_key_is_not_nil(context, presenter)
|
23
|
+
key = presenter.cache_key
|
24
|
+
if key.present?
|
25
|
+
presenter_key = if presenter.class.respond_to?(:cache_key)
|
26
|
+
presenter.class.cache_key
|
27
|
+
end
|
28
|
+
|
29
|
+
cache_options = presenter.cache_options || {}
|
30
|
+
cache_options[:expires_in] ||= presenter.cache_duration
|
31
|
+
|
32
|
+
# Curlybars doesn't allow Rails to handle the template digest.
|
33
|
+
# So, we disable it.
|
34
|
+
cache_options[:skip_digest] = true
|
35
|
+
|
36
|
+
context.cache([key, presenter_key].compact, cache_options) do
|
37
|
+
yield
|
38
|
+
end
|
39
|
+
else
|
40
|
+
yield
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def compile(template)
|
47
|
+
# Template is empty, so there's no need to initialize a presenter.
|
48
|
+
return %("") if template.source.empty?
|
49
|
+
|
50
|
+
path = template.virtual_path
|
51
|
+
presenter_class = Curlybars::Presenter.presenter_for_path(path)
|
52
|
+
|
53
|
+
raise Curlybars::Error::Presenter::NotFound.new(path) if presenter_class.nil?
|
54
|
+
|
55
|
+
# For security reason, we strip the encoding directive in order to avoid
|
56
|
+
# potential issues when rendering the template in another character
|
57
|
+
# encoding.
|
58
|
+
safe_source = template.source.gsub(/\A#{ActionView::ENCODING_FLAG}/, '')
|
59
|
+
|
60
|
+
source = Curlybars.compile(safe_source, template.identifier)
|
61
|
+
|
62
|
+
<<-RUBY
|
63
|
+
if local_assigns.empty?
|
64
|
+
options = assigns
|
65
|
+
else
|
66
|
+
options = local_assigns
|
67
|
+
end
|
68
|
+
|
69
|
+
provider_classes = ::Curlybars.configuration.global_helpers_provider_classes
|
70
|
+
global_helpers_providers = provider_classes.map { |klass| klass.new(self) }
|
71
|
+
|
72
|
+
presenter = ::#{presenter_class}.new(self, options)
|
73
|
+
presenter.setup!
|
74
|
+
|
75
|
+
@output_buffer = output_buffer || ::ActiveSupport::SafeBuffer.new
|
76
|
+
|
77
|
+
::Curlybars::TemplateHandler.cache_if_key_is_not_nil(self, presenter) do
|
78
|
+
safe_concat begin
|
79
|
+
#{source}
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
@output_buffer
|
84
|
+
RUBY
|
85
|
+
end
|
86
|
+
|
87
|
+
def instrument(template, &block)
|
88
|
+
payload = { path: template.virtual_path }
|
89
|
+
ActiveSupport::Notifications.instrument("compile.curlybars", payload, &block)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
describe "Using Curlybars for the application layout", type: :request do
|
2
|
+
example "A simple layout view in Curlybars" do
|
3
|
+
get '/'
|
4
|
+
|
5
|
+
expect(body).to eq(<<-HTML.strip_heredoc)
|
6
|
+
<html>
|
7
|
+
<head>
|
8
|
+
<title>Dummy app</title>
|
9
|
+
</head>
|
10
|
+
<body>
|
11
|
+
<h1>Dashboard</h1>
|
12
|
+
<p>Hello, World!</p>
|
13
|
+
<p>Welcome!</p>
|
14
|
+
|
15
|
+
</body>
|
16
|
+
</html>
|
17
|
+
HTML
|
18
|
+
end
|
19
|
+
|
20
|
+
describe "Curlybars" do
|
21
|
+
before do
|
22
|
+
Curlybars.configure do |config|
|
23
|
+
config.presenters_namespace = 'curlybars_presenters'
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
after do
|
28
|
+
Curlybars.reset
|
29
|
+
end
|
30
|
+
|
31
|
+
example "A simple layout view in Curlybars with html safe logic" do
|
32
|
+
get '/articles/1'
|
33
|
+
|
34
|
+
expect(body).to eq(<<-HTML.strip_heredoc)
|
35
|
+
<html>
|
36
|
+
<head>
|
37
|
+
<title>Dummy app</title>
|
38
|
+
</head>
|
39
|
+
<body>
|
40
|
+
<p>Hi Admin</p>
|
41
|
+
|
42
|
+
<h1>Article: The Prince</h1>
|
43
|
+
|
44
|
+
<p>This is <strong>important</strong>!</p>
|
45
|
+
|
46
|
+
<p><script>alert('bad')</script></p>
|
47
|
+
|
48
|
+
|
49
|
+
<p>
|
50
|
+
author Nicolò
|
51
|
+
<img src="http://example.com/foo.png" />
|
52
|
+
</p>
|
53
|
+
|
54
|
+
|
55
|
+
</body>
|
56
|
+
</html>
|
57
|
+
HTML
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|