-
Notifications
You must be signed in to change notification settings - Fork 6
/
template.rb
455 lines (372 loc) · 13.9 KB
/
template.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
# frozen_string_literal: true
module Tilt
# @private
module CompiledTemplates
end
# @private
TOPOBJECT = CompiledTemplates
# @private
LOCK = Mutex.new
# Base class for template implementations. Subclasses must implement
# the #prepare method and one of the #evaluate or #precompiled_template
# methods.
class Template
# Template source; loaded from a file or given directly.
attr_reader :data
# The name of the file where the template data was loaded from.
attr_reader :file
# The line number in #file where template data was loaded from.
attr_reader :line
# A Hash of template engine specific options. This is passed directly
# to the underlying engine and is not used by the generic template
# interface.
attr_reader :options
# A path ending in .rb that the template code will be written to, then
# required, instead of being evaled. This is useful for determining
# coverage of compiled template code, or to use static analysis tools
# on the compiled template code.
attr_reader :compiled_path
class << self
# An empty Hash that the template engine can populate with various
# metadata.
def metadata
@metadata ||= {}
end
# Use `.metadata[:mime_type]` instead.
def default_mime_type
metadata[:mime_type]
end
# Use `.metadata[:mime_type] = val` instead.
def default_mime_type=(value)
metadata[:mime_type] = value
end
end
# Create a new template with the file, line, and options specified. By
# default, template data is read from the file. When a block is given,
# it should read template data and return as a String. When file is nil,
# a block is required.
#
# All arguments are optional.
def initialize(file=nil, line=nil, options=nil)
@file, @line, @options = nil, 1, nil
process_arg(options)
process_arg(line)
process_arg(file)
raise ArgumentError, "file or block required" unless @file || block_given?
@options ||= {}
set_compiled_method_cache
# Force the encoding of the input data
@default_encoding = @options.delete :default_encoding
# Skip encoding detection from magic comments and forcing that encoding
# for compiled templates
@skip_compiled_encoding_detection = @options.delete :skip_compiled_encoding_detection
# load template data and prepare (uses binread to avoid encoding issues)
@data = block_given? ? yield(self) : read_template_file
if @data.respond_to?(:force_encoding)
if default_encoding
@data = @data.dup if @data.frozen?
@data.force_encoding(default_encoding)
end
if !@data.valid_encoding?
raise Encoding::InvalidByteSequenceError, "#{eval_file} is not valid #{@data.encoding}"
end
end
prepare
end
# Render the template in the given scope with the locals specified. If a
# block is given, it is typically available within the template via
# +yield+.
def render(scope=nil, locals=nil, &block)
evaluate(scope || Object.new, locals || EMPTY_HASH, &block)
end
# The basename of the template file.
def basename(suffix='')
File.basename(@file, suffix) if @file
end
# The template file's basename with all extensions chomped off.
def name
if bname = basename
bname.split('.', 2).first
end
end
# The filename used in backtraces to describe the template.
def eval_file
@file || '(__TEMPLATE__)'
end
# An empty Hash that the template engine can populate with various
# metadata.
def metadata
if respond_to?(:allows_script?)
self.class.metadata.merge(:allows_script => allows_script?)
else
self.class.metadata
end
end
# Set the prefix to use for compiled paths.
def compiled_path=(path)
if path
# Use expanded paths when loading, since that is helpful
# for coverage. Remove any .rb suffix, since that will
# be added back later.
path = File.expand_path(path.sub(/\.rb\z/i, ''))
end
@compiled_path = path
end
# The compiled method for the locals keys and scope_class provided.
# Returns an UnboundMethod, which can be used to define methods
# directly on the scope class, which are much faster to call than
# Tilt's normal rendering.
def compiled_method(locals_keys, scope_class=nil)
key = [scope_class, locals_keys].freeze
LOCK.synchronize do
if meth = @compiled_method[key]
return meth
end
end
meth = compile_template_method(locals_keys, scope_class)
LOCK.synchronize do
@compiled_method[key] = meth
end
meth
end
protected
# @!group For template implementations
# The encoding of the source data. Defaults to the
# default_encoding-option if present. You may override this method
# in your template class if you have a better hint of the data's
# encoding.
attr_reader :default_encoding
def skip_compiled_encoding_detection?
@skip_compiled_encoding_detection
end
# Do whatever preparation is necessary to setup the underlying template
# engine. Called immediately after template data is loaded. Instance
# variables set in this method are available when #evaluate is called.
#
# Empty by default as some subclasses do not need separate preparation.
def prepare
end
CLASS_METHOD = Kernel.instance_method(:class)
USE_BIND_CALL = RUBY_VERSION >= '2.7'
# Execute the compiled template and return the result string. Template
# evaluation is guaranteed to be performed in the scope object with the
# locals specified and with support for yielding to the block.
#
# This method is only used by source generating templates. Subclasses that
# override render() may not support all features.
def evaluate(scope, locals, &block)
locals_keys = locals.keys
locals_keys.sort!{|x, y| x.to_s <=> y.to_s}
case scope
when Object
scope_class = Module === scope ? scope : scope.class
else
# :nocov:
scope_class = USE_BIND_CALL ? CLASS_METHOD.bind_call(scope) : CLASS_METHOD.bind(scope).call
# :nocov:
end
method = compiled_method(locals_keys, scope_class)
if USE_BIND_CALL
method.bind_call(scope, locals, &block)
# :nocov:
else
method.bind(scope).call(locals, &block)
# :nocov:
end
end
# Generates all template source by combining the preamble, template, and
# postamble and returns a two-tuple of the form: [source, offset], where
# source is the string containing (Ruby) source code for the template and
# offset is the integer line offset where line reporting should begin.
#
# Template subclasses may override this method when they need complete
# control over source generation or want to adjust the default line
# offset. In most cases, overriding the #precompiled_template method is
# easier and more appropriate.
def precompiled(local_keys)
preamble = precompiled_preamble(local_keys)
template = precompiled_template(local_keys)
postamble = precompiled_postamble(local_keys)
source = String.new
unless skip_compiled_encoding_detection?
# Ensure that our generated source code has the same encoding as the
# the source code generated by the template engine.
template_encoding = extract_encoding(template){|t| template = t}
if template.encoding != template_encoding
# template should never be frozen here. If it was frozen originally,
# then extract_encoding should yield a dup.
template.force_encoding(template_encoding)
end
end
source.force_encoding(template.encoding)
source << preamble << "\n" << template << "\n" << postamble
[source, preamble.count("\n")+1]
end
# A string containing the (Ruby) source code for the template. The
# default Template#evaluate implementation requires either this
# method or the #precompiled method be overridden. When defined,
# the base Template guarantees correct file/line handling, locals
# support, custom scopes, proper encoding, and support for template
# compilation.
def precompiled_template(local_keys)
raise NotImplementedError
end
def precompiled_preamble(local_keys)
''
end
def precompiled_postamble(local_keys)
''
end
# !@endgroup
private
def process_arg(arg)
if arg
case
when arg.respond_to?(:to_str) ; @file = arg.to_str
when arg.respond_to?(:to_int) ; @line = arg.to_int
when arg.respond_to?(:to_hash) ; @options = arg.to_hash.dup
when arg.respond_to?(:path) ; @file = arg.path
when arg.respond_to?(:to_path) ; @file = arg.to_path
else raise TypeError, "Can't load the template file. Pass a string with a path " +
"or an object that responds to 'to_str', 'path' or 'to_path'"
end
end
end
def read_template_file
data = File.binread(file)
# Set it to the default external (without verifying)
# :nocov:
data.force_encoding(Encoding.default_external) if Encoding.default_external
# :nocov:
data
end
def set_compiled_method_cache
@compiled_method = {}
end
def local_extraction(local_keys)
assignments = local_keys.map do |k|
if k.to_s =~ /\A[a-z_][a-zA-Z_0-9]*\z/
"#{k} = locals[#{k.inspect}]"
else
raise "invalid locals key: #{k.inspect} (keys must be variable names)"
end
end
s = "locals = locals[:locals]"
if assignments.delete(s)
# If there is a locals key itself named `locals`, delete it from the ordered keys so we can
# assign it last. This is important because the assignment of all other locals depends on the
# `locals` local variable still matching the `locals` method argument given to the method
# created in `#compile_template_method`.
assignments << s
end
assignments.join("\n")
end
def compile_template_method(local_keys, scope_class=nil)
source, offset = precompiled(local_keys)
local_code = local_extraction(local_keys)
method_name = "__tilt_#{Thread.current.object_id.abs}"
method_source = String.new
method_source.force_encoding(source.encoding)
if freeze_string_literals?
method_source << "# frozen-string-literal: true\n"
end
# Don't indent method source, to avoid indentation warnings when using compiled paths
method_source << "::Tilt::TOPOBJECT.class_eval do\ndef #{method_name}(locals)\n#{local_code}\n"
offset += method_source.count("\n")
method_source << source
method_source << "\nend;end;"
bind_compiled_method(method_source, offset, scope_class)
unbind_compiled_method(method_name)
end
def bind_compiled_method(method_source, offset, scope_class)
path = compiled_path
if path && scope_class.name
path = path.dup
if defined?(@compiled_path_counter)
path << '-' << @compiled_path_counter.succ!
else
@compiled_path_counter = "0".dup
end
path << ".rb"
# Wrap method source in a class block for the scope, so constant lookup works
if freeze_string_literals?
method_source_prefix = "# frozen-string-literal: true\n"
method_source = method_source.sub(/\A# frozen-string-literal: true\n/, '')
end
method_source = "#{method_source_prefix}class #{scope_class.name}\n#{method_source}\nend"
load_compiled_method(path, method_source)
else
if path
warn "compiled_path (#{compiled_path.inspect}) ignored on template with anonymous scope_class (#{scope_class.inspect})"
end
eval_compiled_method(method_source, offset, scope_class)
end
end
def eval_compiled_method(method_source, offset, scope_class)
(scope_class || Object).class_eval(method_source, eval_file, line - offset)
end
def load_compiled_method(path, method_source)
File.binwrite(path, method_source)
# Use load and not require, so unbind_compiled_method does not
# break if the same path is used more than once.
load path
end
def unbind_compiled_method(method_name)
method = TOPOBJECT.instance_method(method_name)
TOPOBJECT.class_eval { remove_method(method_name) }
method
end
def extract_encoding(script, &block)
extract_magic_comment(script, &block) || script.encoding
end
def extract_magic_comment(script)
if script.frozen?
script = script.dup
yield script
end
binary(script) do
script[/\A[ \t]*\#.*coding\s*[=:]\s*([[:alnum:]\-_]+).*$/n, 1]
end
end
def freeze_string_literals?
false
end
def binary(string)
original_encoding = string.encoding
string.force_encoding(Encoding::BINARY)
yield
ensure
string.force_encoding(original_encoding)
end
end
class StaticTemplate < Template
def self.subclass(mime_type: 'text/html', &block)
Class.new(self) do
self.default_mime_type = mime_type
private
define_method(:_prepare_output, &block)
end
end
# Static templates always return the prepared output.
def render(scope=nil, locals=nil)
@output
end
# Raise NotImplementedError, since static templates
# do not support compiled methods.
def compiled_method(locals_keys, scope_class=nil)
raise NotImplementedError
end
# Static templates never allow script.
def allows_script?
false
end
protected
def prepare
@output = _prepare_output
end
private
# Do nothing, since compiled method cache is not used.
def set_compiled_method_cache
end
end
end