cutaneous 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|