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.
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