cutaneous 0.1.0
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.
- data/Gemfile +3 -0
- data/LICENSE +0 -0
- data/README.md +0 -0
- data/Rakefile +150 -0
- data/cutaneous.gemspec +103 -0
- data/lib/cutaneous/compiler/expression.rb +85 -0
- data/lib/cutaneous/compiler.rb +223 -0
- data/lib/cutaneous/context.rb +70 -0
- data/lib/cutaneous/engine.rb +66 -0
- data/lib/cutaneous/lexer.rb +92 -0
- data/lib/cutaneous/loader.rb +147 -0
- data/lib/cutaneous/syntax.rb +39 -0
- data/lib/cutaneous/template.rb +40 -0
- data/lib/cutaneous.rb +23 -0
- data/test/fixtures/a.html.cut +13 -0
- data/test/fixtures/b.html.cut +8 -0
- data/test/fixtures/c.html.cut +8 -0
- data/test/fixtures/comments.html.cut +1 -0
- data/test/fixtures/d.html.cut +8 -0
- data/test/fixtures/e.html.cut +4 -0
- data/test/fixtures/error.html.cut +30 -0
- data/test/fixtures/expressions.html.cut +1 -0
- data/test/fixtures/include.html.cut +3 -0
- data/test/fixtures/include.rss.cut +3 -0
- data/test/fixtures/included_error.html.cut +1 -0
- data/test/fixtures/instance.html.cut +2 -0
- data/test/fixtures/instance_include.html.cut +1 -0
- data/test/fixtures/missing.html.cut +1 -0
- data/test/fixtures/other/different.html.cut +1 -0
- data/test/fixtures/other/error.html.cut +5 -0
- data/test/fixtures/partial.html.cut +1 -0
- data/test/fixtures/partial.rss.cut +1 -0
- data/test/fixtures/render.html.cut +6 -0
- data/test/fixtures/statements.html.cut +3 -0
- data/test/fixtures/target.html.cut +1 -0
- data/test/fixtures/whitespace.html.cut +6 -0
- data/test/helper.rb +18 -0
- data/test/test_blocks.rb +19 -0
- data/test/test_cache.rb +104 -0
- data/test/test_core.rb +168 -0
- metadata +90 -0
@@ -0,0 +1,92 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require 'strscan'
|
4
|
+
|
5
|
+
module Cutaneous
|
6
|
+
class Lexer
|
7
|
+
attr_reader :template, :syntax
|
8
|
+
|
9
|
+
def initialize(template, syntax)
|
10
|
+
@template, @syntax = template, syntax
|
11
|
+
end
|
12
|
+
|
13
|
+
def tokens
|
14
|
+
@tokens ||= parse
|
15
|
+
end
|
16
|
+
|
17
|
+
# def script
|
18
|
+
# @script ||= compile
|
19
|
+
# end
|
20
|
+
|
21
|
+
protected
|
22
|
+
|
23
|
+
BRACES ||= /\{|\}/
|
24
|
+
STRIP_WS = "-"
|
25
|
+
|
26
|
+
def parse
|
27
|
+
tokens = []
|
28
|
+
scanner = StringScanner.new(@template.to_s)
|
29
|
+
tag_start = syntax.tag_start_pattern
|
30
|
+
tags = syntax.tags
|
31
|
+
token_map = syntax.token_map
|
32
|
+
previous = nil
|
33
|
+
|
34
|
+
while (text = scanner.scan_until(tag_start))
|
35
|
+
tag = scanner.matched
|
36
|
+
type, brace_count, endtag_length = token_map[tag]
|
37
|
+
text.slice!(text.length - tag.length, text.length)
|
38
|
+
expression = ""
|
39
|
+
strip_whitespace = false
|
40
|
+
|
41
|
+
begin
|
42
|
+
expression << scanner.scan_until(BRACES)
|
43
|
+
brace = scanner.matched
|
44
|
+
brace_count += ((123 - brace.ord)+1) # '{' = 1, '}' = -1
|
45
|
+
end while (brace_count > 0)
|
46
|
+
|
47
|
+
length = expression.length
|
48
|
+
expression.slice!(length - endtag_length, length)
|
49
|
+
|
50
|
+
if expression.end_with?(STRIP_WS)
|
51
|
+
strip_whitespace = true
|
52
|
+
length = expression.length
|
53
|
+
expression.slice!(length - 1, length)
|
54
|
+
end
|
55
|
+
|
56
|
+
tokens << place_text_token(text) if text.length > 0
|
57
|
+
tokens << create_token(type, expression, strip_whitespace)
|
58
|
+
previous = type
|
59
|
+
end
|
60
|
+
tokens << place_text_token(scanner.rest) unless scanner.eos?
|
61
|
+
tokens
|
62
|
+
end
|
63
|
+
|
64
|
+
def create_token(type, expression, strip_whitespace)
|
65
|
+
[type, expression, strip_whitespace]
|
66
|
+
end
|
67
|
+
|
68
|
+
#BEGINNING_WHITESPACE ||= /\A\s*?[\r\n]+/
|
69
|
+
#ENDING_WHITESPACE ||= /(\r?\n)[ \t]*\z/
|
70
|
+
ESCAPE_STRING ||= /[`\\]/
|
71
|
+
|
72
|
+
def place_text_token(expression)
|
73
|
+
expression.gsub!(syntax.escaped_tag_pattern, '\1')
|
74
|
+
expression.gsub!(ESCAPE_STRING, '\\\\\&')
|
75
|
+
[:text, expression]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
FirstPassSyntax = Cutaneous::Syntax.new({
|
80
|
+
:comment => %w(!{ }),
|
81
|
+
:expression => %w(${ }),
|
82
|
+
:escaped_expression => %w($${ }),
|
83
|
+
:statement => %w(%{ })
|
84
|
+
})
|
85
|
+
|
86
|
+
SecondPassSyntax = Cutaneous::Syntax.new({
|
87
|
+
:comment => %w(!{ }),
|
88
|
+
:expression => %w({{ }}),
|
89
|
+
:escaped_expression => %w({$ $}),
|
90
|
+
:statement => %w({% %})
|
91
|
+
})
|
92
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
module Cutaneous
|
2
|
+
# Converts a template path or Proc into a Template instance for a particular format
|
3
|
+
class FileLoader
|
4
|
+
attr_accessor :syntax
|
5
|
+
attr_writer :template_class
|
6
|
+
attr_reader :format
|
7
|
+
|
8
|
+
def initialize(template_roots, format, extension = Cutaneous.extension)
|
9
|
+
@roots, @format, @extension = template_roots, format, extension
|
10
|
+
@template_class = Template
|
11
|
+
end
|
12
|
+
|
13
|
+
def render(template, context)
|
14
|
+
template(template).render(context)
|
15
|
+
end
|
16
|
+
|
17
|
+
def template(template)
|
18
|
+
return proc_template(template) if template.is_a?(Proc)
|
19
|
+
template_path = path(template)
|
20
|
+
raise UnknownTemplateError.new(@roots, filename(template)) if template_path.nil?
|
21
|
+
|
22
|
+
@template_class.new(file_lexer(template_path)).tap do |template|
|
23
|
+
template.path = template_path
|
24
|
+
template.loader = self
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def proc_template(lmda)
|
29
|
+
StringLoader.new(self).template(lmda.call)
|
30
|
+
end
|
31
|
+
|
32
|
+
def file_lexer(template_path)
|
33
|
+
lexer(SourceFile.new(template_path))
|
34
|
+
end
|
35
|
+
|
36
|
+
def lexer(template_string)
|
37
|
+
Lexer.new(template_string, syntax)
|
38
|
+
end
|
39
|
+
|
40
|
+
def path(template_name)
|
41
|
+
filename = filename(template_name)
|
42
|
+
return filename if ::File.exists?(filename) # Test for an absolute path
|
43
|
+
@roots.map { |root| ::File.join(root, filename)}.detect { |path| ::File.exists?(path) }
|
44
|
+
end
|
45
|
+
|
46
|
+
def filename(template_name)
|
47
|
+
[template_name, @format, @extension].join(".")
|
48
|
+
end
|
49
|
+
|
50
|
+
def exists?(template_root, template_name)
|
51
|
+
File.exists?(File.join(template_root, filename(template_name)))
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Converts a template string into a Template instance.
|
56
|
+
#
|
57
|
+
# Because a string template can only come from the engine instance
|
58
|
+
# we need a FileLoader to delegate all future template loading to.
|
59
|
+
class StringLoader < FileLoader
|
60
|
+
def initialize(file_loader)
|
61
|
+
@file_loader = file_loader
|
62
|
+
end
|
63
|
+
|
64
|
+
def syntax
|
65
|
+
@file_loader.syntax
|
66
|
+
end
|
67
|
+
|
68
|
+
def template(template_string)
|
69
|
+
Template.new(lexer(template_string)).tap do |template|
|
70
|
+
template.loader = @file_loader
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Converts a filepath to a template string as and when necessary
|
76
|
+
class SourceFile
|
77
|
+
attr_reader :path
|
78
|
+
|
79
|
+
def initialize(filepath)
|
80
|
+
@path = filepath
|
81
|
+
end
|
82
|
+
|
83
|
+
def to_s
|
84
|
+
File.read(@path)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Caches Template instances
|
89
|
+
class CachedFileLoader < FileLoader
|
90
|
+
def initialize(template_roots, format, extension = Cutaneous.extension)
|
91
|
+
super
|
92
|
+
@template_class = CachedTemplate
|
93
|
+
end
|
94
|
+
|
95
|
+
def template_cache
|
96
|
+
@template_cache ||= {}
|
97
|
+
end
|
98
|
+
|
99
|
+
def write_compiled_scripts=(flag)
|
100
|
+
if flag
|
101
|
+
@template_class = CachedTemplate
|
102
|
+
else
|
103
|
+
@template_class = Template
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def template(template)
|
108
|
+
return template_cache[template] if template_cache.key?(template)
|
109
|
+
template_cache[template] = super
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Provides an additional caching mechanism by writing generated template
|
114
|
+
# scripts to a .rb file.
|
115
|
+
class CachedTemplate < Template
|
116
|
+
|
117
|
+
def script
|
118
|
+
if cached?
|
119
|
+
script = File.read(script_path)
|
120
|
+
else
|
121
|
+
script = super
|
122
|
+
File.open(script_path, "w") do |f|
|
123
|
+
f.write(script)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
script
|
127
|
+
end
|
128
|
+
|
129
|
+
def cached?
|
130
|
+
File.exist?(script_path) && (File.mtime(script_path) >= File.mtime(template_path))
|
131
|
+
end
|
132
|
+
|
133
|
+
def template_path
|
134
|
+
lexer.template.path
|
135
|
+
end
|
136
|
+
|
137
|
+
def script_path
|
138
|
+
@source_path ||= generate_script_path
|
139
|
+
end
|
140
|
+
|
141
|
+
def generate_script_path
|
142
|
+
path = template_path
|
143
|
+
ext = File.extname path
|
144
|
+
path.gsub(/#{ext}$/, ".rb")
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
module Cutaneous
|
4
|
+
class Syntax
|
5
|
+
attr_reader :tags
|
6
|
+
|
7
|
+
def initialize(tag_definitions)
|
8
|
+
@tags = tag_definitions
|
9
|
+
end
|
10
|
+
|
11
|
+
def is_dynamic?(text)
|
12
|
+
!text.index(tag_start_pattern).nil?
|
13
|
+
end
|
14
|
+
|
15
|
+
def tag_start_pattern
|
16
|
+
@tag_start_pattern ||= compile_start_pattern
|
17
|
+
end
|
18
|
+
|
19
|
+
def escaped_tag_pattern
|
20
|
+
@escaped_tag_pattern ||= compile_start_pattern_with_prefix("\\\\")
|
21
|
+
end
|
22
|
+
|
23
|
+
def compile_start_pattern
|
24
|
+
not_escaped = "(?<!\\\\)"
|
25
|
+
compile_start_pattern_with_prefix(not_escaped)
|
26
|
+
end
|
27
|
+
|
28
|
+
def compile_start_pattern_with_prefix(prefix)
|
29
|
+
openings = self.tags.map { |type, tags| Regexp.escape(tags[0]) }
|
30
|
+
Regexp.new("#{prefix}(#{ openings.join("|") })")
|
31
|
+
end
|
32
|
+
|
33
|
+
# map the set of tags into a hash used by the parse routine that converts an opening tag into a
|
34
|
+
# list of: tag type, the number of opening braces in the tag and the length of the closing tag
|
35
|
+
def token_map
|
36
|
+
@token_map ||= Hash[tags.map { |type, tags| [tags[0], [type, tags[0].count(?{), tags[1].length]] }]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Cutaneous
|
2
|
+
class Template
|
3
|
+
attr_accessor :loader, :lexer, :path
|
4
|
+
|
5
|
+
def initialize(lexer)
|
6
|
+
@lexer = lexer
|
7
|
+
end
|
8
|
+
|
9
|
+
def compiler
|
10
|
+
@compiler ||= Compiler.new(lexer, loader)
|
11
|
+
end
|
12
|
+
|
13
|
+
def render(context)
|
14
|
+
context.__loader = loader
|
15
|
+
context.instance_eval(&template_proc)
|
16
|
+
end
|
17
|
+
|
18
|
+
def template_proc
|
19
|
+
@template_proc ||= eval(template_proc_src, nil, path || "(cutaneous)").tap do |proc|
|
20
|
+
@lexer = nil # release any memory used by the lexer, we don't need it anymore
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def template_proc_src
|
25
|
+
"lambda { |context| self.__buf = __buf = ''; #{script}; __buf.to_s }"
|
26
|
+
end
|
27
|
+
|
28
|
+
def script
|
29
|
+
compiler.script
|
30
|
+
end
|
31
|
+
|
32
|
+
def block_order
|
33
|
+
compiler.block_order
|
34
|
+
end
|
35
|
+
|
36
|
+
def block(block_name)
|
37
|
+
compiler.block(block_name)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/cutaneous.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'cutaneous/engine'
|
2
|
+
require 'cutaneous/template'
|
3
|
+
require 'cutaneous/loader'
|
4
|
+
require 'cutaneous/context'
|
5
|
+
require 'cutaneous/syntax'
|
6
|
+
require 'cutaneous/lexer'
|
7
|
+
require 'cutaneous/compiler'
|
8
|
+
|
9
|
+
module Cutaneous
|
10
|
+
VERSION = "0.1.0"
|
11
|
+
|
12
|
+
class CompilationError < Exception; end
|
13
|
+
|
14
|
+
class UnknownTemplateError < Exception
|
15
|
+
def initialize(template_roots, relative_path)
|
16
|
+
super("Template '#{relative_path}' not found under #{template_roots.inspect}")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.extension
|
21
|
+
"cut"
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
!{ this is a comment }
|
@@ -0,0 +1,30 @@
|
|
1
|
+
|
2
|
+
|
3
|
+
%{
|
4
|
+
3.times do |n|
|
5
|
+
}
|
6
|
+
|
7
|
+
!{
|
8
|
+
|
9
|
+
comment
|
10
|
+
|
11
|
+
}
|
12
|
+
${ n -}
|
13
|
+
%{
|
14
|
+
end }
|
15
|
+
%{ include "partial" -}
|
16
|
+
%{ test = "here"
|
17
|
+
|
18
|
+
-}
|
19
|
+
|
20
|
+
|
21
|
+
|
22
|
+
|
23
|
+
here ${ test }
|
24
|
+
|
25
|
+
!{
|
26
|
+
the error message should be the same as the line number of the expression
|
27
|
+
}
|
28
|
+
%{
|
29
|
+
raise "29"
|
30
|
+
}
|
@@ -0,0 +1 @@
|
|
1
|
+
This is ${ right } $${ code }
|
@@ -0,0 +1 @@
|
|
1
|
+
%{ include "other/error" }
|
@@ -0,0 +1 @@
|
|
1
|
+
${ @right } = ${ right }
|
@@ -0,0 +1 @@
|
|
1
|
+
missing: ${ unknown }
|
@@ -0,0 +1 @@
|
|
1
|
+
${ right }
|
@@ -0,0 +1 @@
|
|
1
|
+
right = ${ right }
|
@@ -0,0 +1 @@
|
|
1
|
+
${ right } = rss
|
@@ -0,0 +1 @@
|
|
1
|
+
${ name }
|
data/test/helper.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
$:.push File.expand_path("../../lib", __FILE__)
|
2
|
+
|
3
|
+
require 'minitest/spec'
|
4
|
+
require 'minitest/autorun'
|
5
|
+
|
6
|
+
require 'cutaneous'
|
7
|
+
|
8
|
+
class TestContext < Cutaneous::Context
|
9
|
+
def escape(value)
|
10
|
+
value.gsub(/</, "<").gsub(/>/, ">")
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class MiniTest::Spec
|
15
|
+
def ContextHash(params = {})
|
16
|
+
TestContext.new(Object.new, params)
|
17
|
+
end
|
18
|
+
end
|
data/test/test_blocks.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require File.expand_path('../helper', __FILE__)
|
2
|
+
|
3
|
+
describe Cutaneous do
|
4
|
+
let(:template_root) { File.expand_path("../fixtures", __FILE__) }
|
5
|
+
let(:engine) { Cutaneous::Engine.new(template_root, Cutaneous::FirstPassSyntax, "html") }
|
6
|
+
|
7
|
+
it "Will parse & execute a simple template with expressions" do
|
8
|
+
context = ContextHash(right: "right", code: "<tag/>")
|
9
|
+
result = engine.render("c", context)
|
10
|
+
expected = ["aa\n\n", "ab", "bb", "cb", "ac", "ad", "ae", "cf", "ag\n"].join("\n\n")
|
11
|
+
result.must_equal expected
|
12
|
+
end
|
13
|
+
|
14
|
+
it "Won't run code in inherited templates unless called" do
|
15
|
+
context = ContextHash(right: "right", code: "<tag/>")
|
16
|
+
result = engine.render("e", context)
|
17
|
+
result.must_equal ["da", "db", "dc", "ed\n\n"].join("\n\n")
|
18
|
+
end
|
19
|
+
end
|
data/test/test_cache.rb
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
require File.expand_path('../helper', __FILE__)
|
2
|
+
|
3
|
+
require 'tmpdir'
|
4
|
+
|
5
|
+
describe Cutaneous do
|
6
|
+
let(:source_template_root) { File.expand_path("../fixtures", __FILE__) }
|
7
|
+
let(:dest_template_root) { Dir.mktmpdir }
|
8
|
+
let(:engine) { cached_engine }
|
9
|
+
|
10
|
+
def cached_engine
|
11
|
+
Cutaneous::CachingEngine.new(dest_template_root, Cutaneous::FirstPassSyntax, "html")
|
12
|
+
end
|
13
|
+
|
14
|
+
def template(source, format = "html")
|
15
|
+
dest_path = template_path(source, format)
|
16
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
17
|
+
FileUtils.cp(File.join(source_template_root, File.basename(dest_path)), dest_path)
|
18
|
+
source
|
19
|
+
end
|
20
|
+
|
21
|
+
def remove_template(source, format = "html")
|
22
|
+
FileUtils.rm(template_path(source, format))
|
23
|
+
end
|
24
|
+
|
25
|
+
def template_path(source, format = "html", extension = "cut")
|
26
|
+
filename = "#{source}.#{format}.#{extension}"
|
27
|
+
File.join(dest_template_root, filename)
|
28
|
+
end
|
29
|
+
|
30
|
+
it "Reads templates from the cache if they have been used before" do
|
31
|
+
templates = %w(a b c)
|
32
|
+
context = ContextHash(right: "right")
|
33
|
+
|
34
|
+
templates.each do |t|
|
35
|
+
template(t)
|
36
|
+
end
|
37
|
+
result1 = engine.render("c", context)
|
38
|
+
|
39
|
+
templates.each do |t|
|
40
|
+
remove_template(t)
|
41
|
+
end
|
42
|
+
|
43
|
+
result2 = engine.render("c", context)
|
44
|
+
result2.must_equal result1
|
45
|
+
end
|
46
|
+
|
47
|
+
it "Saves the ruby script as a .rb file and uses it if present" do
|
48
|
+
templates = %w(a b c)
|
49
|
+
templates.each do |t|
|
50
|
+
template(t)
|
51
|
+
end
|
52
|
+
|
53
|
+
context = ContextHash(right: "right")
|
54
|
+
result1 = engine.render("c", context)
|
55
|
+
|
56
|
+
# Ensure that the cached script file is being used by overwriting its contents
|
57
|
+
path = template_path("c", "html", "rb")
|
58
|
+
assert ::File.exists?(path), "Template cache should have created '#{path}'"
|
59
|
+
File.open(path, "w") do |f|
|
60
|
+
f.write("__buf << 'right'")
|
61
|
+
end
|
62
|
+
|
63
|
+
engine = cached_engine
|
64
|
+
result2 = engine.render("c", context)
|
65
|
+
result2.must_equal "right"
|
66
|
+
end
|
67
|
+
|
68
|
+
it "Recompiles the cached script if the template is newer" do
|
69
|
+
templates = %w(a b c)
|
70
|
+
templates.each do |t|
|
71
|
+
template(t)
|
72
|
+
end
|
73
|
+
context = ContextHash(right: "right")
|
74
|
+
|
75
|
+
result1 = engine.render("c", context)
|
76
|
+
|
77
|
+
now = Time.now
|
78
|
+
|
79
|
+
template_path = template_path("c", "html")
|
80
|
+
script_path = template_path("c", "html", "rb")
|
81
|
+
assert ::File.exists?(script_path), "Template cache should have created '#{script_path}'"
|
82
|
+
|
83
|
+
File.open(template_path, "w") { |f| f.write("template") }
|
84
|
+
File.utime(now, now, template_path)
|
85
|
+
File.utime(now - 100, now - 100, script_path)
|
86
|
+
|
87
|
+
engine = cached_engine
|
88
|
+
result1 = engine.render("c", context)
|
89
|
+
result1.must_equal "template"
|
90
|
+
end
|
91
|
+
|
92
|
+
it "Doesn't write a compiled script file if configured not to do so" do
|
93
|
+
engine.write_compiled_scripts = false
|
94
|
+
templates = %w(a b c)
|
95
|
+
templates.each do |t|
|
96
|
+
template(t)
|
97
|
+
end
|
98
|
+
context = ContextHash(right: "right")
|
99
|
+
|
100
|
+
result1 = engine.render("c", context)
|
101
|
+
script_path = template_path("c", "html", "rb")
|
102
|
+
refute ::File.exists?(script_path), "Template cache should not have created '#{script_path}'"
|
103
|
+
end
|
104
|
+
end
|