cutaneous 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/Gemfile +3 -0
  2. data/LICENSE +0 -0
  3. data/README.md +0 -0
  4. data/Rakefile +150 -0
  5. data/cutaneous.gemspec +103 -0
  6. data/lib/cutaneous/compiler/expression.rb +85 -0
  7. data/lib/cutaneous/compiler.rb +223 -0
  8. data/lib/cutaneous/context.rb +70 -0
  9. data/lib/cutaneous/engine.rb +66 -0
  10. data/lib/cutaneous/lexer.rb +92 -0
  11. data/lib/cutaneous/loader.rb +147 -0
  12. data/lib/cutaneous/syntax.rb +39 -0
  13. data/lib/cutaneous/template.rb +40 -0
  14. data/lib/cutaneous.rb +23 -0
  15. data/test/fixtures/a.html.cut +13 -0
  16. data/test/fixtures/b.html.cut +8 -0
  17. data/test/fixtures/c.html.cut +8 -0
  18. data/test/fixtures/comments.html.cut +1 -0
  19. data/test/fixtures/d.html.cut +8 -0
  20. data/test/fixtures/e.html.cut +4 -0
  21. data/test/fixtures/error.html.cut +30 -0
  22. data/test/fixtures/expressions.html.cut +1 -0
  23. data/test/fixtures/include.html.cut +3 -0
  24. data/test/fixtures/include.rss.cut +3 -0
  25. data/test/fixtures/included_error.html.cut +1 -0
  26. data/test/fixtures/instance.html.cut +2 -0
  27. data/test/fixtures/instance_include.html.cut +1 -0
  28. data/test/fixtures/missing.html.cut +1 -0
  29. data/test/fixtures/other/different.html.cut +1 -0
  30. data/test/fixtures/other/error.html.cut +5 -0
  31. data/test/fixtures/partial.html.cut +1 -0
  32. data/test/fixtures/partial.rss.cut +1 -0
  33. data/test/fixtures/render.html.cut +6 -0
  34. data/test/fixtures/statements.html.cut +3 -0
  35. data/test/fixtures/target.html.cut +1 -0
  36. data/test/fixtures/whitespace.html.cut +6 -0
  37. data/test/helper.rb +18 -0
  38. data/test/test_blocks.rb +19 -0
  39. data/test/test_cache.rb +104 -0
  40. data/test/test_core.rb +168 -0
  41. 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,13 @@
1
+ aa
2
+ %{ block :b }
3
+ ab
4
+ %{ endblock }
5
+ ac
6
+ %{ block :d }
7
+ ad
8
+ %{ endblock :d }
9
+ ae
10
+ %{ block :f }
11
+ af
12
+ %{ endblock :f }
13
+ ag
@@ -0,0 +1,8 @@
1
+ %{ extends "a" }
2
+ %{ block :b }
3
+ %{ blocksuper }
4
+ bb
5
+ %{ endblock }
6
+ %{ block :f }
7
+ bf
8
+ %{ endblock }
@@ -0,0 +1,8 @@
1
+ %{ extends "b" }
2
+ %{ block :b }
3
+ %{ blocksuper }
4
+ cb
5
+ %{ endblock }
6
+ %{ block :f }
7
+ cf
8
+ %{ endblock }
@@ -0,0 +1 @@
1
+ !{ this is a comment }
@@ -0,0 +1,8 @@
1
+ da
2
+ %{ block :b }
3
+ db
4
+ %{ endblock :b }
5
+ dc
6
+ %{ block :d }
7
+ %{ raise "WRONG" }
8
+ %{ endblock :d }
@@ -0,0 +1,4 @@
1
+ %{ extends "d" }
2
+ %{ block :d }
3
+ ed
4
+ %{ endblock }
@@ -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,3 @@
1
+ %{ include "partial" -}
2
+ %{ include "partial", right: "wrong" -}
3
+ %{ include "partial", right: "left" -}
@@ -0,0 +1,3 @@
1
+ %{ include "partial" -}
2
+ %{ include "partial", right: "wrong" -}
3
+ %{ include "partial", right: "left" -}
@@ -0,0 +1 @@
1
+ %{ include "other/error" }
@@ -0,0 +1,2 @@
1
+ %{ @right = "left" -}
2
+ %{ include "instance_include", right: "wrong" -}
@@ -0,0 +1 @@
1
+ ${ @right } = ${ right }
@@ -0,0 +1 @@
1
+ missing: ${ unknown }
@@ -0,0 +1 @@
1
+ ${ right }
@@ -0,0 +1,5 @@
1
+ !{
2
+
3
+
4
+ }
5
+ %{ raise "5" }
@@ -0,0 +1 @@
1
+ right = ${ right }
@@ -0,0 +1 @@
1
+ ${ right } = rss
@@ -0,0 +1,6 @@
1
+ %{
2
+ require 'ostruct'
3
+ target = OpenStruct.new
4
+ target.name = "John"
5
+ -}
6
+ %{ render target, "target" -}
@@ -0,0 +1,3 @@
1
+ %{ 3.times do |n| }
2
+ This is ${ right } ${ n }
3
+ %{ end }
@@ -0,0 +1 @@
1
+ ${ name }
@@ -0,0 +1,6 @@
1
+ %{ extends "a" -}
2
+ %{ block :b -}
3
+ %{ 3.times do |n| -}
4
+ here ${ n }
5
+ %{ end -}
6
+ %{ endblock :b -}
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(/</, "&lt;").gsub(/>/, "&gt;")
11
+ end
12
+ end
13
+
14
+ class MiniTest::Spec
15
+ def ContextHash(params = {})
16
+ TestContext.new(Object.new, params)
17
+ end
18
+ end
@@ -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
@@ -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