├── .github └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── crustache_spec.cr ├── engine_spec.cr ├── generate_spec_from_json.cr ├── mustache-spec-extra │ ├── ~lambdas.json │ └── ~lambdas.yml ├── mustache_spec.cr ├── spec_helper.cr ├── view │ ├── template.mustache │ ├── template_html.html │ ├── template_test.html │ └── template_test.test └── view_loader_spec.cr └── src ├── crustache.cr ├── crustache ├── context.cr ├── engine.cr ├── filesystem.cr ├── indent_io.cr ├── parse_error.cr ├── parser.cr ├── renderer.cr ├── stringify.cr ├── syntax.cr ├── util.cr └── version.cr ├── loader_static.cr └── parse_file_static.cr /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "45 3 * * 6" # Runs at 03:45, only on Saturday 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | submodules: true 16 | - uses: crystal-lang/install-crystal@v1 17 | with: 18 | crystal: latest 19 | - run: crystal spec 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.deps/ 2 | /libs/ 3 | /doc/ 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "spec/mustache-spec"] 2 | path = spec/mustache-spec 3 | url = https://github.com/mustache/spec.git 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v2.4.4 (2021-06-08) 2 | 3 | Changes: 4 | 5 | - Update supported Crystal version to 1.0.0 6 | 7 | ## v2.4.3 (2020-06-21) 8 | 9 | Changes: 10 | 11 | - Support Crystal v0.35.1 ([#29](https://github.com/MakeNowJust/crustache/pull/29), thanks @waghanza) 12 | 13 | ## v2.4.2 (2020-06-10) 14 | 15 | Changes: 16 | 17 | - Support Crystal v0.35.0 18 | 19 | ## v2.4.1 (2019-10-10) 20 | 21 | Changes: 22 | 23 | - Support Crystal v0.31.1. 24 | 25 | ## v2.4.0 (2018-03-01) 26 | 27 | Changes: 28 | 29 | - Support Crystal v0.24.1. 30 | 31 | ## v2.3.0 (2016-11-24) 32 | 33 | Changes: 34 | 35 | - Support Crystal v0.20.0. 36 | 37 | ## v2.2.4 (2016-05-19) 38 | 39 | Changes: 40 | 41 | - Support Crysyal v0.17.0. 42 | 43 | ## v2.2.3 (2016-05-12) 44 | 45 | Changes: 46 | 47 | - Added type annotations to instance variables to follow Crystal v0.16.0 changes. 48 | - Fixed `Crustache::Context` type for above. 49 | 50 | ## v2.2.2 (2016-03-24) 51 | 52 | Changes: 53 | 54 | - Fixed type annotated default argument syntax, follow Crystal v0.14.1 changes. 55 | 56 | ## v2.2.1 (2016-03-09) 57 | 58 | Changes: 59 | 60 | - Use `Util.escape` instead of `HTML.escape` for Mustache spec compatibility. 61 | 62 | ## v2.2.0 (2016-02-17) 63 | 64 | Features: 65 | 66 | - Rename `embed_mustache` and `mustache_file` to `Mustache#.embed` and `Mustahce#.def_to_s`, follow Crystal v0.12.0 changes. 67 | 68 | ## v2.1.0 (2016-02-15) 69 | 70 | Features: 71 | 72 | - Added ECR compatible macros, `embed_mustache` and `mustache_file`. 73 | 74 | ## v2.0.1 (2016-02-10) 75 | 76 | Changes: 77 | 78 | - Fixed a bug to represent `Section` and `Invert` by `to_code` incorrectly. 79 | 80 | ## v2.0.0 (2016-02-09) 81 | 82 | Features: 83 | 84 | - Support compile time template parsing. 85 | - Added `Crustache::parse_file_static` to parse Mustache file on compile time. 86 | - Added `Crustache::loader` to create `FileSystem` object to use `Crustache::Engine`. 87 | - Added `Crustache::loader_static`, it is compile time version of `Crustache::loader`. 88 | 89 | Changes: 90 | 91 | - Add type annotation to class constructors for Crystal next compiler. 92 | 93 | ## v1.0.2 (2016-01-13) 94 | 95 | Changes: 96 | 97 | - Support Crystal v0.10.1. 98 | - Implemented `IO#read(slice : Slice(UInt8))` in `IndentIO`. 99 | 100 | ## v1.0.1 (2015-12-25) 101 | 102 | Changes: 103 | 104 | - Support Crystal v0.10.0. 105 | - `MemoryIO#clear` raised an error when it is not resizable. Fixed it. 106 | - Fixed JSON type. 107 | 108 | ## v1.0.0 (2015-10-18) 109 | 110 | Changes: 111 | 112 | - Replaced from `StringIO` to `MemoryIO` for Crystal v0.9.0 (see [manastech/crystal@`9b8e6c7`](https://github.com/manastech/crystal/commit/9b8e6c7e5f35b62503cd1507b1097d6c20c398dd)) 113 | - Support [shards](https://github.com/ysbaddaden/shards). 114 | - Support [semver](http://semver.org/) to release. 115 | 116 | ## v0.3.2 (2015-09-12) 117 | 118 | Changes: 119 | 120 | - Replaced `#length` to `#size` for Crystal HEAD (see [manastech/crystal#1363](https://github.com/manastech/crystal/issues/1363)) [`89ade1a`](https://github.com/MakeNowJust/crustache/commit/89ade1a026e45517a0a0a1aac126ed21490a2094) 121 | - Fixed arguments of `IO#write` for Crystal v0.7.7 [`b972686`](https://github.com/MakeNowJust/crustache/commit/b972686a2ac666000d67ed4add8fd2d7bd3e56b7) 122 | - Refactored `Crustache::Context#lookup` [`fdd2b22`](https://github.com/MakeNowJust/crustache/commit/fdd2b2214b98842c90ac887a4d32364ee86e1de9) 123 | 124 | ## v0.3.1 (2015-07-26) 125 | 126 | Features: 127 | 128 | - Used `Enumerable` instead of `Array` as model type [`c91d35f`](https://github.com/MakeNowJust/crustache/commit/c91d35f3507e2f93f86e2632abb67f8c21722900) 129 | 130 | Changes: 131 | 132 | - Removed `Crustache::Template` type [`ef6931c`](https://github.com/MakeNowJust/crustache/commit/ef6931c71d33ab8a6c841e30087befdb416220d9) 133 | - Moved `Crustache`'s class methods to some files to fix circular reference [`a507890`](https://github.com/MakeNowJust/crustache/commit/a507890a14bd8c6d466cfd31ce043560f009f938) 134 | 135 | ## v0.3.0 (2015-07-18) 136 | 137 | Features: 138 | 139 | - Added `Crustache::Context` for solving complex model type (see [#1](https://github.com/MakeNowJust/crustache/issues/1)) [`e41a453`](https://github.com/MakeNowJust/crustache/commit/e41a453734164dd3337f325a120f11ee1975832e) 140 | - Added `Crustache::DEFAULT_FILENAME` [`2d80f1c`](https://github.com/MakeNowJust/crustache/commit/2d80f1c179d21225809579fe8ebd32eaa90cfd20) 141 | 142 | Changes: 143 | 144 | - Remove some type restrictions (see [#1](https://github.com/MakeNowJust/crustache/issues/1)) [`0b10d98`](https://github.com/MakeNowJust/crustache/commit/0b10d987a4122607c79593ba305f333de65089cf) 145 | - Fixed the name of `Crustache.parse_file` from `Crustahce.parseFile` (mismatch naming) [`53dfd00`](https://github.com/MakeNowJust/crustache/commit/53dfd00f4298e9cd9f46b195e1d5e78a3459e2d3) 146 | - Rename from `src/crustache/tree.cr` to `src/crustache/syntax.cr` [`f860a5a`](https://github.com/MakeNowJust/crustache/commit/f860a5a4d7a194309e38fb026cc12cd5d8941e6a) 147 | because `Crustache::Tree` is renamed to `Crustache::Syntax` 148 | 149 | ## v0.2.1 (2015-07-15) 150 | 151 | Features: 152 | 153 | - Added `extension` argument for `Crustache::ViewLoader`. It specify filename extensions of implicit loading [`8c69afc`](https://github.com/MakeNowJust/crustache/commit/8c69afc70cf40c6ea93329135d23af1dc4bab7ab) 154 | 155 | Changes: 156 | 157 | - Added `spec/spec.cr` for running specs [`db86034`](https://github.com/MakeNowJust/crustache/commit/db86034c49b4bfcd1b793c03b663ebb8f915bcd6) 158 | 159 | ## v0.2.0 (2015-07-14) 160 | 161 | Features: 162 | 163 | - Added `Crustache::Engine`. It is a wrapper class for typical usage [`d813bd2`](https://github.com/MakeNowJust/crustache/commit/d813bd202336f4730cc704e1d607eca8618cb044) 164 | - Added `Crustache::FileSystem#load!`. It is a strict version `load` [`8a8683b`](https://github.com/MakeNowJust/crustache/commit/8a8683b193a257aa89c8a838d8f3e37037900f6d) 165 | 166 | Changes: 167 | 168 | - Now, `Crustache::Renderer` is generic class, 169 | so you can use many model types in a program [`ee5e258`](https://github.com/MakeNowJust/crustache/commit/ee5e258a54892d679efad03362ae34546c9645f3) 170 | - Fixed `Crustache.render`'s argument `fs`'s bug [`0f97690`](https://github.com/MakeNowJust/crustache/commit/0f97690c97c35df9cb157b602fb9b999c818449b) 171 | 172 | ## v0.1.1 (2015-07-12) 173 | 174 | Features: 175 | 176 | - Added `Crustache::ViewLoader` 177 | - Added `Crustache.parseFile` 178 | 179 | Changes: 180 | 181 | - Move `Crustache::Data` and this subclasses under `Crustache::Tree` 182 | 183 | ## v0.1.0 (2015-07-12) 184 | 185 | Features: 186 | 187 | - Added implementation of Mustache v1.1.2+λ 188 | - Added support flexible contexts (but type is complex!) 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2020 TSUYUSATO "MakeNowJust" Kitsune 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # crustache 2 | 3 | crustache is the implementation of __[mustache](https://mustache.github.io/)__ logic-less templates. 4 | 5 | This library implemated [mustache's spec v1.1.2+λ](https://github.com/mustache/spec/tree/v1.1.2). 6 | 7 | [![test](https://github.com/makenowjust/crustache/actions/workflows/test.yml/badge.svg)](https://github.com/makenowjust/crustache/actions/workflows/test.yml) 8 | 9 | 10 | ## Installation 11 | 12 | Add this to your application's `shard.yml`: 13 | 14 | ```yaml 15 | dependencies: 16 | crustache: 17 | github: MakeNowJust/crustache 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```crystal 23 | require "crustache" 24 | 25 | # Parse a mustache template 26 | template = Crustache.parse "Hello {{Name}} World!" 27 | 28 | # Make a model 29 | model = {"Name" => "Crustache"} 30 | 31 | # Render! 32 | puts Crustache.render template, model 33 | #=> Hello Crustache World! 34 | ``` 35 | 36 | ## Development 37 | 38 | **NOTE:** Please run `git submodule update --init` before running spec. 39 | 40 | This library's specs are put in `spec` directory. 41 | They can run by `crystal spec` command. 42 | 43 | ## Contributing 44 | 45 | 1. [Fork it](https://github.com/MakeNowJust/crustache/fork) 46 | 2. Create your feature branch (`git checkout -b my-new-feature`) 47 | 3. Commit your changes (`git commit -am 'Add some feature'`) 48 | 4. Push to the branch (`git push origin my-new-feature`) 49 | 5. Create a new Pull Request 50 | 51 | ## License 52 | 53 | MIT 54 | © TSUYUSATO "[MakeNowJust](https://quine.codes)" Kitsune <> 2015-2020 55 | 56 | ## Contributors 57 | 58 | - [@MakeNowJust](https://github.com/MakeNowJust) TSUYUSATO Kitsune - creator, maintainer 59 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: crustache 2 | version: 2.4.3 3 | 4 | authors: 5 | - TSUYUSATO Kitsune 6 | 7 | description: | 8 | {{Mustache}} for Crystal 9 | 10 | crystal: ">= 0.34.0" 11 | 12 | license: MIT 13 | -------------------------------------------------------------------------------- /spec/crustache_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Crustache do 4 | describe "#parse" do 5 | it "shold parse a string" do 6 | Crustache.parse("Hello, {{Mustache}} World").should be_truthy 7 | end 8 | 9 | it "should parse a IO" do 10 | Crustache.parse(IO::Memory.new "Hello, {{Mustache}} World").should be_truthy 11 | end 12 | 13 | it "raise a parse error" do 14 | expect_raises(Crustache::ParseError) do 15 | Crustache.parse("Hello, {{Mustache? World") 16 | end 17 | end 18 | end 19 | 20 | describe "#parse_file" do 21 | it "should parse a file" do 22 | tmpl = Crustache.parse_file("#{__DIR__}/view/template.mustache") 23 | tmpl.should be_a Crustache::Template 24 | end 25 | end 26 | 27 | describe "#parse_file_static" do 28 | it "should parse a file on compile time" do 29 | tmpl = Crustache.parse_file_static("#{__DIR__}/view/template.mustache") 30 | tmpl.should be_a Crustache::Template 31 | end 32 | end 33 | 34 | describe "#loader" do 35 | it "should create loader object" do 36 | loader = Crustache.loader "#{__DIR__}/view/" 37 | 38 | loader.load("template").should be_a Crustache::Template 39 | loader.load("template.mustache").should be_a Crustache::Template 40 | loader.load("template_html").should be_a Crustache::Template 41 | loader.load("template_html.html").should be_a Crustache::Template 42 | loader.load("template_test").should be_a Crustache::Template 43 | loader.load("template_test.html").should be_a Crustache::Template 44 | end 45 | 46 | it "should create loader object" do 47 | loader = Crustache.loader "#{__DIR__}/view" 48 | 49 | loader.load("template").should be_a Crustache::Template 50 | loader.load("template.mustache").should be_a Crustache::Template 51 | loader.load("template_html").should be_a Crustache::Template 52 | loader.load("template_html.html").should be_a Crustache::Template 53 | loader.load("template_test").should be_a Crustache::Template 54 | loader.load("template_test.html").should be_a Crustache::Template 55 | end 56 | end 57 | describe "#loader_static" do 58 | it "should create loader object on compile time" do 59 | loader = Crustache.loader_static "#{__DIR__}/view/" 60 | 61 | loader.load("template").should be_a Crustache::Template 62 | loader.load("template.mustache").should be_a Crustache::Template 63 | loader.load("template_html").should be_a Crustache::Template 64 | loader.load("template_html.html").should be_a Crustache::Template 65 | loader.load("template_test").should be_a Crustache::Template 66 | loader.load("template_test.html").should be_a Crustache::Template 67 | end 68 | 69 | it "should create loader object on compile time" do 70 | loader = Crustache.loader_static "#{__DIR__}/view" 71 | 72 | loader.load("template").should be_a Crustache::Template 73 | loader.load("template.mustache").should be_a Crustache::Template 74 | loader.load("template_html").should be_a Crustache::Template 75 | loader.load("template_html.html").should be_a Crustache::Template 76 | loader.load("template_test").should be_a Crustache::Template 77 | loader.load("template_test.html").should be_a Crustache::Template 78 | end 79 | end 80 | 81 | describe "#render" do 82 | it "should render a template" do 83 | Crustache.render(Crustache.parse("Test {{.}}"), "Test").should eq("Test Test") 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/engine_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Crustache::Engine do 4 | describe "#initialize" do 5 | it "should return a new instance" do 6 | Crustache::Engine.new(Crustache::HashFileSystem.new).should be_truthy 7 | Crustache::Engine.new("", true).should be_truthy 8 | end 9 | end 10 | 11 | describe "#render" do 12 | it "should render a template" do 13 | engine = Crustache::Engine.new Crustache::HashFileSystem.new 14 | engine.render(Crustache.parse("Test {{.}}"), "Test").should eq("Test Test") 15 | end 16 | 17 | it "should render a template" do 18 | fs = Crustache::HashFileSystem.new 19 | fs.register "test", Crustache.parse "Test {{.}}" 20 | engine = Crustache::Engine.new fs 21 | engine.render("test", "Test").should eq("Test Test") 22 | end 23 | 24 | it "should render a template with output IO object" do 25 | fs = Crustache::HashFileSystem.new 26 | fs.register "test", Crustache.parse "Test {{.}}" 27 | engine = Crustache::Engine.new fs 28 | output = IO::Memory.new 29 | engine.render("test", "Test", output) 30 | output.to_s.should eq("Test Test") 31 | end 32 | end 33 | 34 | describe "#render!" do 35 | it "should render a template" do 36 | fs = Crustache::HashFileSystem.new 37 | fs.register "test", Crustache.parse "Test {{.}}" 38 | engine = Crustache::Engine.new fs 39 | engine.render!("test", "Test").should eq("Test Test") 40 | end 41 | 42 | it "should raise an error" do 43 | fs = Crustache::HashFileSystem.new 44 | engine = Crustache::Engine.new fs 45 | expect_raises Exception do 46 | engine.render!("test", "Test") 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/generate_spec_from_json.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | def inspect_any(any) 4 | if hash = any.as_h? 5 | if hash.empty? 6 | "{} of String => String" 7 | else 8 | pairs = hash.map do |key, value| 9 | if key == "lambda" 10 | languages = value.as_h 11 | crystal_proc = if languages.has_key? "crystal" 12 | languages["crystal"].as_s 13 | else 14 | languages["ruby"].as_s 15 | .gsub(/^proc \{ (?:(?:\|)([^|]+)(?:\|))?/){|m, p| "->(#{p[1]? ? "#{p[1]} : String" : ""}){"} 16 | .gsub(/\$/, "Global.").gsub(/1/, "1; Global.calls.to_s").gsub(/false/, "false.to_s") 17 | end 18 | "#{key.inspect} => #{crystal_proc}" 19 | else 20 | "#{key.inspect} => #{inspect_any value}" 21 | end 22 | end 23 | 24 | "{#{pairs.join ","}}" 25 | end 26 | elsif array = any.as_a? 27 | if array.empty? 28 | "[] of String" 29 | else 30 | vals = array.map{|x| inspect_any(x).as(String) } 31 | "[#{vals.join ","}]" 32 | end 33 | else 34 | "#{any.inspect}" 35 | end 36 | end 37 | 38 | spec_path = ARGV[0] 39 | filename = ARGV[1] 40 | 41 | file = (JSON.parse File.read "./spec/#{spec_path}/#{filename}").as_h 42 | 43 | puts "describe #{filename.inspect} do" 44 | file["tests"].as_a.each do |test| 45 | test = test.as_h 46 | 47 | puts " it #{test["desc"].inspect} do" 48 | puts " template = Crustache.parse #{test["template"].inspect}" 49 | puts " expected = #{test["expected"].inspect}" 50 | 51 | puts " data = #{inspect_any test["data"]}" 52 | 53 | puts " fs = Crustache::HashFileSystem.new" 54 | if test.has_key?("partials") 55 | test["partials"].as_h.each do |name, tmpl| 56 | puts " fs.register #{name.inspect}, Crustache.parse #{tmpl.inspect}" 57 | end 58 | end 59 | 60 | puts " result = Crustache.render template, data, fs" 61 | puts " result.should eq expected" 62 | puts " end" 63 | end 64 | puts " end" 65 | -------------------------------------------------------------------------------- /spec/mustache-spec-extra/~lambdas.json: -------------------------------------------------------------------------------- 1 | {"overview":"Lambdas are a special-cased data type for use in interpolations and\nsections.\n\nWhen used as the data value for a Section tag, the lambda MAY be treatable\nas an arity 2 function, and invoked as such (passing a String containing the\nunprocessed section contents, and a Proc as renderer). The returned value\nMUST be rendered against the current delimiters, then interpolated in place\nof the section.\n","tests":[{"name":"Section - Renderer","desc":"Lambda used for sections can receive renderer.","data":{"x":"rendered","lambda":{"crystal":"->(text : String, render : String -> String) { render.call(text) == \"rendered\" ? \"yes\" : \"no\" }"}},"template":"<{{#lambda}}{{x}}{{/lambda}}>","expected":""},{"name":"Section - Renderer - Scoped","desc":"Renderer provided to lambda should be scoped.","data":{"scope":{"inner":"scoped"},"lambda":{"crystal":"->(text : String, render : String -> String) { \"#{text}#{render.call(\"context:{{inner}}\")}#{text}\" }"}},"template":"<{{#scope}}{{#lambda}}-{{/lambda}}{{/scope}}>","expected":"<-context:scoped->"},{"name":"Section - Renderer - Alternate Delimiters","desc":"Renderer provided to lambda should should parse with the current delimiters.","data":{"planet":"Earth","lambda":{"crystal":"->(text : String, render : String -> String) { \"#{text}#{render.call(\"{{planet}} => |planet|\")}#{text}\" }"}},"template":"{{= | | =}}<|#lambda|-|/lambda|>","expected":"<-{{planet}} => Earth->"}],"__ATTN__":"Do not edit this file; changes belong in the appropriate YAML file."} 2 | -------------------------------------------------------------------------------- /spec/mustache-spec-extra/~lambdas.yml: -------------------------------------------------------------------------------- 1 | overview: | 2 | Lambdas are a special-cased data type for use in interpolations and 3 | sections. 4 | 5 | When used as the data value for a Section tag, the lambda MAY be treatable 6 | as an arity 2 function, and invoked as such (passing a String containing the 7 | unprocessed section contents, and a Proc as renderer). The returned value 8 | MUST be rendered against the current delimiters, then interpolated in place 9 | of the section. 10 | tests: 11 | - name: Section - Renderer 12 | desc: Lambda used for sections can receive renderer. 13 | data: 14 | x: 'rendered' 15 | lambda: !code 16 | crystal: '->(text : String, render : String -> String) { render.call(text) == "rendered" ? "yes" : "no" }' 17 | template: "<{{#lambda}}{{x}}{{/lambda}}>" 18 | expected: "" 19 | 20 | - name: Section - Renderer - Scoped 21 | desc: Renderer provided to lambda should be scoped. 22 | data: 23 | scope: 24 | inner: 'scoped' 25 | lambda: !code 26 | crystal: '->(text : String, render : String -> String) { "#{text}#{render.call("context:{{inner}}")}#{text}" }' 27 | template: "<{{#scope}}{{#lambda}}-{{/lambda}}{{/scope}}>" 28 | expected: "<-context:scoped->" 29 | 30 | - name: Section - Renderer - Alternate Delimiters 31 | desc: Renderer provided to lambda should should parse with the current delimiters. 32 | data: 33 | planet: "Earth" 34 | lambda: !code 35 | crystal: '->(text : String, render : String -> String) { "#{text}#{render.call("{{planet}} => |planet|")}#{text}" }' 36 | template: "{{= | | =}}<|#lambda|-|/lambda|>" 37 | expected: "<-{{planet}} => Earth->" 38 | -------------------------------------------------------------------------------- /spec/mustache_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | # for ~lambda.json test 4 | class Global 5 | @@calls = 0 6 | 7 | def self.calls 8 | @@calls 9 | end 10 | 11 | def self.calls=(value) 12 | @@calls = value 13 | end 14 | end 15 | 16 | {% for name in %w(interpolation sections inverted delimiters comments partials ~lambdas) %} 17 | {{ run "./generate_spec_from_json", "mustache-spec/specs", "#{name.id}.json" }} 18 | {% end %} 19 | 20 | {% for name in %w(~lambdas) %} 21 | {{ run "./generate_spec_from_json", "mustache-spec-extra", "#{name.id}.json" }} 22 | {% end %} 23 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | 3 | require "../src/crustache" 4 | -------------------------------------------------------------------------------- /spec/view/template.mustache: -------------------------------------------------------------------------------- 1 | Hello {{Mustache}} World! 2 | 3 | {{#Cond}} 4 | {{.}} 5 | {{/Cond}} 6 | {{^Inv}} 7 | {{.}} 8 | {{/Inv}} 9 | {{!Comment}} 10 | {{{Raw}}} 11 | {{&Raw}} 12 | {{>Partial}} 13 | {{=de lim=}} 14 | -------------------------------------------------------------------------------- /spec/view/template_html.html: -------------------------------------------------------------------------------- 1 | Hello {{HTML}} World! 2 | -------------------------------------------------------------------------------- /spec/view/template_test.html: -------------------------------------------------------------------------------- 1 | Hello {{Test}} World! 2 | -------------------------------------------------------------------------------- /spec/view/template_test.test: -------------------------------------------------------------------------------- 1 | Hello {{Test}} World! 2 | -------------------------------------------------------------------------------- /spec/view_loader_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper.cr" 2 | 3 | describe Crustache::ViewLoader do 4 | it "should load a template file" do 5 | fs = Crustache::ViewLoader.new "#{__DIR__}/view" 6 | fs.load("template.mustache").should be_truthy 7 | end 8 | 9 | it "should load a template file without an extension" do 10 | fs = Crustache::ViewLoader.new "#{__DIR__}/view" 11 | fs.load("template").should be_truthy 12 | end 13 | 14 | it "should load a template file without an extension" do 15 | fs = Crustache::ViewLoader.new "#{__DIR__}/view" 16 | fs.load("template_html").should be_truthy 17 | end 18 | 19 | it "should load a template file without an extension" do 20 | fs = Crustache::ViewLoader.new "#{__DIR__}/view", use_cache: false, extension: [".test"] 21 | fs.load("template_test").should be_truthy 22 | end 23 | 24 | it "should return nil if specified template file is not found" do 25 | fs = Crustache::ViewLoader.new "#{__DIR__}/view" 26 | fs.load("template_not_found").should be_falsey 27 | end 28 | 29 | it "should cache a template file" do 30 | fs = Crustache::ViewLoader.new "#{__DIR__}/view", true 31 | File.write "#{__DIR__}/view/template2.mustache", "Hello, {{Mustache}} World!" 32 | fs.load("template2").should be_truthy 33 | File.delete "#{__DIR__}/view/template2.mustache" 34 | fs.load("template2").should be_truthy 35 | end 36 | 37 | it "shouldn't cache a template file" do 38 | fs = Crustache::ViewLoader.new "#{__DIR__}/view", false 39 | File.write "#{__DIR__}/view/template2.mustache", "Hello, {{Mustache}} World!" 40 | fs.load("template2").should be_truthy 41 | File.delete "#{__DIR__}/view/template2.mustache" 42 | fs.load("template2").should be_falsey 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /src/crustache.cr: -------------------------------------------------------------------------------- 1 | require "./crustache/**" 2 | 3 | module Crustache 4 | # :nodoc: 5 | OPEN_TAG = "{{".to_slice 6 | # :nodoc: 7 | CLOSE_TAG = "}}".to_slice 8 | 9 | DEFAULT_FILENAME = "__str__" 10 | 11 | alias Template = Syntax::Template 12 | 13 | def self.parse(io : IO, filename = DEFAULT_FILENAME, row = 1) 14 | Parser.new(OPEN_TAG, CLOSE_TAG, io, filename, row).parse 15 | end 16 | 17 | def self.parse(string : String, filename = DEFAULT_FILENAME, row = 1) 18 | self.parse IO::Memory.new(string), filename, row 19 | end 20 | 21 | def self.parse_file(filename) 22 | self.parse(File.new(filename), filename, 1) 23 | end 24 | 25 | macro parse_file_static(filename) 26 | {{ run("./parse_file_static.cr", filename) }} 27 | end 28 | 29 | def self.loader(basedir, extension = ViewLoader::EXTENSION) 30 | ViewLoader.new basedir, extension: extension 31 | end 32 | 33 | macro loader_static(basedir, extension = [".mustache", ".html", ""]) 34 | {{ run("./loader_static.cr", basedir, extension.join("/")) }} 35 | end 36 | 37 | def self.render(tmpl, model, fs = HashFileSystem.new) 38 | String.build do |io| 39 | self.render tmpl, model, fs, io 40 | end 41 | end 42 | 43 | def self.render(tmpl, model, fs, io) 44 | tmpl.visit Renderer.new OPEN_TAG, CLOSE_TAG, Context(typeof(Context.resolve_scope_type(model))).new(model), fs, io 45 | end 46 | end 47 | 48 | module Mustache 49 | macro embed(filename, io_name, model = nil) 50 | ::Crustache.render(::Crustache.parse_file_static({{ filename }}), {{ model }}, ::Crustache::HashFileSystem.new, {{ io_name.id }}) 51 | end 52 | 53 | macro def_to_s(filename) 54 | def to_s(io) 55 | ::embed_mustache({{ filename }}, "io", self) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /src/crustache/context.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | class Crustache::Context(T) 3 | getter parent 4 | 5 | def initialize(initial_context) 6 | @scope = Array(T).new 7 | @scope << initial_context 8 | end 9 | 10 | # :nodoc: 11 | def self.resolve_scope_type(ctx) 12 | if ctx.responds_to?(:[]) && ctx.responds_to?(:has_key?) 13 | resolve_scope_type(ctx["resolve_scope_type"]) || ctx 14 | elsif ctx.is_a?(Indexable) 15 | ctx.each { |c| return resolve_scope_type(c) } || ctx 16 | else 17 | ctx 18 | end 19 | end 20 | 21 | def scope(ctx) 22 | @scope.push ctx 23 | yield 24 | @scope.pop 25 | nil 26 | end 27 | 28 | def lookup(key) 29 | if key == "." 30 | return @scope.last 31 | end 32 | 33 | keys = key.split(".") 34 | size = keys.size 35 | 36 | @scope.reverse_each do |ctx| 37 | i = 0 38 | while i < size 39 | k = keys[i] 40 | case 41 | when ctx.responds_to?(:has_key?) && ctx.responds_to?(:[]) 42 | if ctx.has_key?(k) 43 | ctx = ctx[k] 44 | else 45 | break 46 | end 47 | 48 | else 49 | break 50 | end 51 | i += 1 52 | end 53 | 54 | if i == size 55 | return ctx 56 | end 57 | end 58 | 59 | nil 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /src/crustache/engine.cr: -------------------------------------------------------------------------------- 1 | module Crustache 2 | class Engine 3 | def initialize(@fs : FileSystem); end 4 | 5 | def initialize(basedir, cache = false) 6 | @fs = ViewLoader.new basedir, cache 7 | end 8 | 9 | # It renders a template loaded from `filename` with `model` 10 | # and it returns rendered string. 11 | # If `filename` is not found, it returns `nil`, but it dosen't raise an error. 12 | def render(filename : String, model) 13 | @fs.load(filename).try{|tmpl| self.render tmpl, model} 14 | end 15 | 16 | def render(filename : String, model, io) 17 | @fs.load(filename).try{|tmpl| self.render tmpl, model, io} 18 | end 19 | 20 | # It is a strict version `Engine#render`. 21 | # If `filename` is not found, it raise an error. 22 | def render!(filename : String, model) 23 | @fs.load!(filename).try{|tmpl| self.render tmpl, model} 24 | end 25 | 26 | def render!(filename : String, model, io) 27 | @fs.load!(filename).try{|tmpl| self.render tmpl, model, io} 28 | end 29 | 30 | def render(tmpl, model) 31 | Crustache.render tmpl, model, @fs 32 | end 33 | 34 | def render(tmpl, model, io) 35 | Crustache.render tmpl, model, @fs, io 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /src/crustache/filesystem.cr: -------------------------------------------------------------------------------- 1 | require "./syntax" 2 | 3 | module Crustache 4 | abstract class FileSystem 5 | abstract def load(value) : Syntax::Template? 6 | 7 | def load!(value) 8 | if tmpl = self.load value 9 | return tmpl 10 | else 11 | raise "#{value} is not found" 12 | end 13 | end 14 | end 15 | 16 | class HashFileSystem < FileSystem 17 | def initialize 18 | @tmpls = {} of String => Syntax::Template 19 | end 20 | 21 | def register(name, tmpl) 22 | @tmpls[name] = tmpl 23 | end 24 | 25 | def load(value) : Syntax::Template? 26 | return @tmpls[value]? 27 | end 28 | end 29 | 30 | class ViewLoader < FileSystem 31 | EXTENSION = [".mustache", ".html", ""] 32 | 33 | def initialize(@basedir : String, @use_cache = false, @extension : Array(String) = EXTENSION ) 34 | @cache = {} of String => Syntax::Template? 35 | end 36 | 37 | def load(value) : Syntax::Template? 38 | if @cache.has_key?(value) 39 | return @cache[value] 40 | end 41 | 42 | @extension.each do |ext| 43 | filename = "#{@basedir}/#{value}" 44 | filename_ext = "#{filename}#{ext}" 45 | if File.exists?(filename_ext) 46 | tmpl = Crustache.parse_file filename_ext 47 | @cache[value] = tmpl if @use_cache 48 | return tmpl 49 | end 50 | end 51 | 52 | @cache[value] = nil if @use_cache 53 | return nil 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /src/crustache/indent_io.cr: -------------------------------------------------------------------------------- 1 | require "./parser" 2 | 3 | # :nodoc: 4 | class Crustache::IndentIO < IO 5 | def initialize(@indent : String, @io : IO) 6 | @indent_flag = 0 7 | @eol_flag = true 8 | end 9 | 10 | def indent_flag_on 11 | @indent_flag -= 1 12 | end 13 | 14 | def indent_flag_off 15 | @indent_flag += 1 16 | end 17 | 18 | def read(s : Slice(UInt8)) 19 | raise "Unsupported" 20 | end 21 | 22 | {% begin %} 23 | def write(s) : {% if compare_versions(Crystal::VERSION, "0.35.0") == 0 %}Int64{% else %}Nil{% end %} 24 | start = 0 25 | size = s.size 26 | i = 0 27 | while i < size 28 | if @eol_flag 29 | @io.write s[start, i - start] 30 | @io << @indent 31 | @eol_flag = false 32 | start = i 33 | end 34 | 35 | if s[i] == Parser::NEWLINE_N && @indent_flag == 0 36 | @eol_flag = true 37 | end 38 | 39 | i += 1 40 | end 41 | 42 | @io.write s[start, i - start] 43 | end 44 | {% end %} 45 | end 46 | -------------------------------------------------------------------------------- /src/crustache/parse_error.cr: -------------------------------------------------------------------------------- 1 | module Crustache 2 | class ParseError < Exception 3 | getter filename 4 | getter row 5 | 6 | def initialize(@msg : String, @filename : String, @row : Int32); super(message) end 7 | 8 | def message 9 | "#{@filename.inspect} line at #{@row}: #{@msg}" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /src/crustache/parser.cr: -------------------------------------------------------------------------------- 1 | require "./syntax" 2 | require "./util" 3 | 4 | # :nodoc: 5 | class Crustache::Parser 6 | CURLY_START = '{'.ord.to_u8 7 | CURLY_END = '}'.ord.to_u8 8 | AMP = '&'.ord.to_u8 9 | EQ = '='.ord.to_u8 10 | GT = '>'.ord.to_u8 11 | HASH = '#'.ord.to_u8 12 | HAT = '^'.ord.to_u8 13 | SLASH = '/'.ord.to_u8 14 | BANG = '!'.ord.to_u8 15 | NEWLINE_N = '\n'.ord.to_u8 16 | NEWLINE_R = '\r'.ord.to_u8 17 | EQ_SLICE = Slice(UInt8).new(1){EQ} 18 | CURLY_END_SLICE = Slice(UInt8).new(1){CURLY_END} 19 | 20 | def initialize(@open_tag : Slice(UInt8), @close_tag : Slice(UInt8), @io : IO, @filename : String, @row : Int32 = 1) 21 | @peek = 0_u8 22 | @peek_flag = false 23 | 24 | @save_row = @row 25 | 26 | @text_io = IO::Memory.new 27 | @value_io = IO::Memory.new 28 | 29 | @line_flag = true 30 | end 31 | 32 | def parse 33 | tmpl = Syntax::Template.new 34 | tmpl_stack = [] of Syntax::Template 35 | open_tag = @open_tag 36 | close_tag = @close_tag 37 | 38 | while scan_until open_tag, @text_io 39 | save_row 40 | 41 | case peek 42 | when CURLY_START # raw output `{{{value}}}` 43 | read 44 | parse_error "Unclosed tag" unless scan_until CURLY_END_SLICE, @value_io 45 | parse_error "Unclosed tag" unless scan close_tag 46 | 47 | tmpl << Syntax::Text.new get_text 48 | tmpl << Syntax::Raw.new get_value.strip 49 | 50 | when EQ # set delimiter `{{=| |=}}` 51 | read 52 | parse_error "Unclosed tag" unless scan_until EQ_SLICE, @value_io 53 | parse_error "Unclosed tag" unless scan close_tag 54 | 55 | tmpl << Syntax::Text.new(get_text_as_standalone) 56 | value = get_value.strip 57 | delim = value.split(/\s+/, 2) 58 | parse_error "Invalid delmiter #{value.inspect}" if delim[0].match(/\s|=/) 59 | parse_error "Invalid delimiter #{value.inspect}" if delim[1].match(/\s|=/) 60 | 61 | open_tag = delim[0].to_slice 62 | close_tag = delim[1].to_slice 63 | tmpl << Syntax::Delim.new open_tag, close_tag 64 | 65 | when HASH # section open `{{#value}}` 66 | read 67 | parse_error "Unclosed tag" unless scan_until close_tag, @value_io 68 | 69 | tmpl << Syntax::Text.new get_text_as_standalone 70 | tmpl = Syntax::Section.new(get_value.strip).tap{|t| tmpl_stack << (tmpl << t)} 71 | 72 | when HAT # invert section open `{{^value}}` 73 | read 74 | parse_error "Unclosed tag" unless scan_until close_tag, @value_io 75 | 76 | tmpl << Syntax::Text.new get_text_as_standalone 77 | tmpl = Syntax::Invert.new(get_value.strip).tap{|t| tmpl_stack << (tmpl << t)} 78 | 79 | when SLASH # section close `{{/value}}` 80 | read 81 | parse_error "Unclosed tag" unless scan_until close_tag, @value_io 82 | 83 | tmpl << Syntax::Text.new get_text_as_standalone 84 | value = get_value.strip 85 | if tmpl_stack.empty? || value != tmpl.as(Syntax::Tag).value 86 | parse_error "Unopened tag #{value.inspect}" 87 | end 88 | tmpl = tmpl_stack.pop 89 | 90 | when AMP # raw output `{{&value}}` 91 | read 92 | parse_error "Unclosed tag" unless scan_until close_tag, @value_io 93 | 94 | tmpl << Syntax::Text.new get_text 95 | tmpl << Syntax::Raw.new get_value.strip 96 | 97 | when BANG # comment `{{!value}}` 98 | read 99 | parse_error "Unclosed tag" unless scan_until close_tag, @value_io 100 | 101 | tmpl << Syntax::Text.new get_text_as_standalone 102 | tmpl << Syntax::Comment.new get_value 103 | 104 | when GT # partial `{{>partial}}` 105 | read 106 | parse_error "Unclosed tag" unless scan_until close_tag, @value_io 107 | 108 | text, indent = get_text_as_standalone_with_indent 109 | tmpl << Syntax::Text.new text 110 | tmpl << Syntax::Partial.new indent, get_value.strip 111 | 112 | else # output `{{value}}` 113 | parse_error "Unclosed tag" unless scan_until close_tag, @value_io 114 | 115 | tmpl << Syntax::Text.new get_text 116 | tmpl << Syntax::Output.new get_value.strip 117 | 118 | end 119 | end 120 | 121 | unless tmpl_stack.empty? 122 | save_row 123 | parse_error "Unclosed section #{tmpl.as(Syntax::Tag).value.inspect}" 124 | end 125 | 126 | tmpl << Syntax::Text.new get_text 127 | 128 | tmpl 129 | end 130 | 131 | private def read 132 | if @peek_flag 133 | @peek_flag = false 134 | return @peek 135 | end 136 | 137 | if c = @io.read_byte 138 | if c == NEWLINE_N 139 | @line_flag = true 140 | @row += 1 141 | end 142 | c 143 | else 144 | nil 145 | end 146 | end 147 | 148 | private def peek 149 | if c = read 150 | @peek = c 151 | @peek_flag = true 152 | c 153 | else 154 | nil 155 | end 156 | end 157 | 158 | private def scan(tag) 159 | i = 0 160 | size = tag.size 161 | while i < size 162 | unless read == tag[i] 163 | return false 164 | end 165 | i += 1 166 | end 167 | 168 | return true 169 | end 170 | 171 | private def scan_until(tag, out_io) 172 | i = 0 173 | size = tag.size 174 | text = Slice(UInt8).new size 175 | while i < size 176 | if c = read 177 | text[i] = c 178 | else 179 | out_io.write text[0, i] 180 | return false 181 | end 182 | i += 1 183 | end 184 | 185 | until text.to_unsafe.memcmp(tag.to_unsafe, size) == 0 186 | out_io.write_byte text[0] 187 | text.to_unsafe.copy_from((text + 1).to_unsafe, size - 1) 188 | if c = read 189 | text[size - 1] = c 190 | else 191 | out_io.write text[0, size - 1] 192 | return false 193 | end 194 | end 195 | 196 | return true 197 | end 198 | 199 | private def parse_error(mes) 200 | raise ParseError.new(mes, @filename, @save_row) 201 | end 202 | 203 | private def save_row 204 | @save_row = @row 205 | end 206 | 207 | private def get_text 208 | @line_flag = false 209 | @text_io.to_s.tap{@text_io.clear} 210 | end 211 | 212 | private def get_value 213 | @value_io.to_s.tap{@value_io.clear} 214 | end 215 | 216 | private def get_text_as_standalone 217 | get_text_as_standalone_with_indent[0] 218 | end 219 | 220 | private def get_text_as_standalone_with_indent 221 | unless @line_flag 222 | return {get_text, ""} 223 | end 224 | 225 | text = get_text 226 | i = text.size - 1 227 | while i >= 0 228 | case text[i] 229 | when ' ', '\t' 230 | i -= 1 231 | next 232 | when '\n' 233 | break 234 | else 235 | return {text, ""} 236 | end 237 | end 238 | 239 | i += 1 240 | case peek 241 | when NEWLINE_N 242 | read 243 | return {text[0, i], text[i..-1]} 244 | when NEWLINE_R 245 | read 246 | if peek == NEWLINE_N 247 | read 248 | return {text[0, i], text[i..-1]} 249 | else 250 | @text_io.write_byte NEWLINE_R 251 | return {text, ""} 252 | end 253 | when nil 254 | return {text[0, i], text[i..-1]} 255 | else 256 | return {text, ""} 257 | end 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /src/crustache/renderer.cr: -------------------------------------------------------------------------------- 1 | require "./context" 2 | require "./filesystem" 3 | require "./indent_io" 4 | require "./parser" 5 | require "./stringify" 6 | require "./syntax" 7 | require "./util" 8 | 9 | # :nodoc: 10 | class Crustache::Renderer(T) 11 | def initialize(@open_tag : Slice(UInt8), @close_tag : Slice(UInt8), @context : Context(T), @fs : FileSystem, @out_io : IO) 12 | @open_tag_default = @open_tag 13 | @close_tag_default = @close_tag 14 | end 15 | 16 | def template(t) 17 | t.content.each &.visit(self) 18 | end 19 | 20 | def section(s) 21 | if value = @context.lookup s.value 22 | case 23 | when value.is_a?(Indexable) 24 | value.each do |ctx| 25 | scope ctx do 26 | s.content.each &.visit(self) 27 | end 28 | end 29 | 30 | when value.is_a?(String -> String) 31 | io = IO::Memory.new 32 | t = Syntax::Template.new s.content 33 | t.visit Stringify.new @open_tag, @close_tag, io 34 | io = IO::Memory.new value.call io.to_s 35 | t = Parser.new(@open_tag, @close_tag, io, value.to_s).parse 36 | io = IO::Memory.new io.size 37 | t.visit(Renderer.new @open_tag, @close_tag, @context, @fs, io) 38 | @out_io << io.to_s 39 | 40 | # lambda accepting render function 41 | when value.is_a?((String, (String -> String)) -> String) 42 | io = IO::Memory.new 43 | t = Syntax::Template.new s.content 44 | t.visit Stringify.new @open_tag, @close_tag, io 45 | io = IO::Memory.new value.call io.to_s, ->(s : String) { 46 | io = IO::Memory.new s 47 | t = Parser.new(@open_tag, @close_tag, io, s).parse 48 | io = IO::Memory.new io.size 49 | t.visit(Renderer.new @open_tag, @close_tag, @context, @fs, io) 50 | io.to_s 51 | } 52 | t = Parser.new(@open_tag, @close_tag, io, value.to_s).parse 53 | io = IO::Memory.new io.size 54 | t.visit(Renderer.new @open_tag, @close_tag, @context, @fs, io) 55 | @out_io << io.to_s 56 | 57 | else 58 | scope value do 59 | s.content.each &.visit(self) 60 | end 61 | end 62 | end 63 | end 64 | 65 | def invert(i) 66 | if value = @context.lookup i.value 67 | if value.is_a?(Enumerable) 68 | i.content.each(&.visit(self)) if value.empty? 69 | end 70 | else 71 | i.content.each &.visit(self) 72 | end 73 | end 74 | 75 | def output(o) 76 | if (out_io = @out_io).is_a?(IndentIO) 77 | out_io.indent_flag_off 78 | end 79 | 80 | if value = @context.lookup o.value 81 | if value.is_a?(-> String) 82 | io = IO::Memory.new value.call 83 | t = Parser.new(@open_tag_default, @close_tag_default, io, value.to_s).parse 84 | io = IO::Memory.new io.size 85 | t.visit(Renderer.new @open_tag_default, @close_tag_default, @context, @fs, io) 86 | Util.escape io.to_s, @out_io 87 | else 88 | Util.escape value.to_s, @out_io 89 | end 90 | end 91 | 92 | if (out_io = @out_io).is_a?(IndentIO) 93 | out_io.indent_flag_on 94 | end 95 | end 96 | 97 | def raw(r) 98 | if (out_io = @out_io).is_a?(IndentIO) 99 | out_io.indent_flag_off 100 | end 101 | 102 | if value = @context.lookup r.value 103 | if value.is_a?(-> String) 104 | io = IO::Memory.new value.call 105 | t = Parser.new(@open_tag_default, @close_tag_default, io, value.to_s).parse 106 | io = IO::Memory.new io.size 107 | t.visit(Renderer.new @open_tag_default, @close_tag_default, @context, @fs, io) 108 | @out_io << io.to_s 109 | else 110 | @out_io << value.to_s 111 | end 112 | end 113 | 114 | if (out_io = @out_io).is_a?(IndentIO) 115 | out_io.indent_flag_on 116 | end 117 | end 118 | 119 | def partial(p) 120 | if part = @fs.load p.value 121 | part.visit(Renderer.new @open_tag_default, @close_tag_default, @context, @fs, IndentIO.new(p.indent, @out_io)) 122 | end 123 | end 124 | 125 | def comment(c); end 126 | 127 | def text(t) 128 | @out_io << t.value 129 | end 130 | 131 | def delim(d) 132 | @open_tag = d.open_tag 133 | @close_tag = d.close_tag 134 | end 135 | 136 | private def scope(ctx) 137 | @context.scope(ctx) do 138 | yield 139 | end 140 | end 141 | end 142 | 143 | -------------------------------------------------------------------------------- /src/crustache/stringify.cr: -------------------------------------------------------------------------------- 1 | require "./syntax" 2 | 3 | module Crustache 4 | # :nodoc: 5 | class Stringify 6 | def initialize(@open_tag : Slice(UInt8), @close_tag : Slice(UInt8), @io : IO); end 7 | 8 | def template(t) 9 | t.content.each &.visit(self) 10 | end 11 | 12 | def section(s) 13 | @io.write @open_tag 14 | @io << "#" << s.value 15 | @io.write @close_tag 16 | s.content.each &.visit(self) 17 | @io.write @open_tag 18 | @io << "/" << s.value 19 | @io.write @close_tag 20 | end 21 | 22 | def invert(i) 23 | @io.write @open_tag 24 | @io << "#" << i.value 25 | @io.write @close_tag 26 | i.content.each &.visit(self) 27 | @io.write @open_tag 28 | @io << "/" << i.value 29 | @io.write @close_tag 30 | end 31 | 32 | def output(o) 33 | @io.write @open_tag 34 | @io << o.value 35 | @io.write @close_tag 36 | end 37 | 38 | def raw(r) 39 | @io.write @open_tag 40 | @io << "&" << r.value 41 | @io.write @close_tag 42 | end 43 | 44 | def partial(p) 45 | @io.write @open_tag 46 | @io << ">" << p.value 47 | @io.write @close_tag 48 | end 49 | 50 | def comment(c) 51 | @io.write @open_tag 52 | @io << "!" << c.value 53 | @io.write @close_tag 54 | end 55 | 56 | def text(t) 57 | @io << t.value 58 | end 59 | 60 | def delim(d) 61 | @io.write @open_tag 62 | @io << "=" 63 | @io.write d.open_tag 64 | @io << " " 65 | @io.write d.close_tag 66 | @io << "=" 67 | @io.write @close_tag 68 | 69 | @open_tag = d.open_tag 70 | @close_tag = d.close_tag 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /src/crustache/syntax.cr: -------------------------------------------------------------------------------- 1 | module Crustache::Syntax 2 | abstract class Node 3 | def initialize; end 4 | 5 | macro inherited 6 | @[AlwaysInline] 7 | def visit(v) : Nil 8 | v.{{ @type.name.gsub(/^.+::/, "").downcase.id }}(self) 9 | nil 10 | end 11 | end 12 | end 13 | 14 | class Template < Node 15 | getter content 16 | 17 | def initialize(@content = [] of Node); end 18 | 19 | def <<(data) 20 | unless data.is_a?(Text) && data.value.not_nil!.empty? 21 | @content << data 22 | end 23 | self 24 | end 25 | 26 | def to_code(io) 27 | io << "::Crustache::Syntax::Template.new([" 28 | flag = false 29 | @content.each do |node| 30 | io << ", " if flag 31 | node.to_code(io) 32 | flag = true 33 | end 34 | io << "] of ::Crustache::Syntax::Node)" 35 | end 36 | end 37 | 38 | module Tag 39 | getter! value 40 | 41 | def initialize(@value : String); super() end 42 | 43 | def to_code(io) : Nil 44 | {% begin %} 45 | io << "::{{ @type.name.id }}.new(" 46 | @value.inspect io 47 | io << ")" 48 | nil 49 | {% end %} 50 | end 51 | end 52 | 53 | {% for type in %w(Section Invert) %} 54 | class {{ type.id }} < Template 55 | include Tag 56 | 57 | def initialize(@value : String, @content = [] of Node); end 58 | 59 | def to_code(io) : Nil 60 | \{% begin %} 61 | io << "::\{{ @type.name.id }}.new(" 62 | @value.inspect io 63 | io << ", [" 64 | flag = false 65 | @content.each do |node| 66 | io << ", " if flag 67 | node.to_code(io) 68 | flag = true 69 | end 70 | io << "] of ::Crustache::Syntax::Node)" 71 | nil 72 | \{% end %} 73 | end 74 | end 75 | {% end %} 76 | 77 | {% for type in %w(Output Raw Comment Text) %} 78 | class {{ type.id }} < Node 79 | include Tag 80 | end 81 | {% end %} 82 | 83 | class Partial < Node 84 | getter indent 85 | getter value 86 | 87 | def initialize(@indent : String, @value : String); end 88 | 89 | def to_code(io) 90 | io << "::Crustache::Syntax::Partial.new(" 91 | @indent.inspect io 92 | io << ", " 93 | @value.inspect io 94 | io << ")" 95 | end 96 | end 97 | 98 | class Delim < Node 99 | getter open_tag 100 | getter close_tag 101 | 102 | def initialize(@open_tag : Slice(UInt8), @close_tag : Slice(UInt8)); end 103 | 104 | def to_code(io) 105 | io << "::Crustache::Syntax::Delim.new(" 106 | String.new(@open_tag).inspect io; io << ".to_slice" 107 | io << ", " 108 | String.new(@close_tag).inspect io; io << ".to_slice" 109 | io << ")" 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /src/crustache/util.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module Crustache::Util 3 | ESCAPE = { 4 | '&' => "&", 5 | '<' => "<", 6 | '>' => ">", 7 | '"' => """, 8 | '\'' => "'", 9 | } 10 | 11 | # Since Crystal v0.13.0, `HTML.escape` escapes too many characters, it breaks 12 | # Mustache spec compatibility. This utility method can keep it. 13 | def self.escape(str, io) 14 | str.each_char do |char| 15 | io << ESCAPE.fetch(char, char) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/crustache/version.cr: -------------------------------------------------------------------------------- 1 | module Crustache 2 | VERSION = "2.4.4" 3 | end 4 | -------------------------------------------------------------------------------- /src/loader_static.cr: -------------------------------------------------------------------------------- 1 | require "./crustache" 2 | 3 | basedir = ARGV[0] 4 | extension = ARGV[1].split "/" 5 | 6 | puts <<-CODE 7 | begin 8 | {% begin %} 9 | %loader = ::Crustache::HashFileSystem.new 10 | CODE 11 | 12 | extension.each do |ext| 13 | Dir.glob("#{basedir}/**/*#{ext}") do |filename| 14 | File.open(filename) do |io| 15 | print " %tmpl = " 16 | Crustache.parse(io, filename).to_code STDOUT 17 | puts 18 | end 19 | puts " %loader.register #{filename[(basedir.size + (basedir.ends_with?("/") ? 0 : 1))..-(ext.size + 1)].inspect}, %tmpl" 20 | end 21 | end 22 | 23 | puts <<-CODE 24 | %loader 25 | {% end %} 26 | end 27 | CODE 28 | -------------------------------------------------------------------------------- /src/parse_file_static.cr: -------------------------------------------------------------------------------- 1 | require "./crustache" 2 | 3 | File.open(ARGV[0]) do |io| 4 | Crustache.parse(io, ARGV[0]).to_code STDOUT 5 | end 6 | --------------------------------------------------------------------------------