mustachio-ruby 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +210 -0
- data/lib/mustachio_ruby/context_object.rb +80 -0
- data/lib/mustachio_ruby/extended_parse_information.rb +10 -0
- data/lib/mustachio_ruby/indexed_parse_exception.rb +14 -0
- data/lib/mustachio_ruby/inferred_template_model.rb +29 -0
- data/lib/mustachio_ruby/parser.rb +209 -0
- data/lib/mustachio_ruby/parsing_options.rb +11 -0
- data/lib/mustachio_ruby/token_expander.rb +15 -0
- data/lib/mustachio_ruby/token_tuple.rb +28 -0
- data/lib/mustachio_ruby/tokenize_result.rb +10 -0
- data/lib/mustachio_ruby/tokenizer.rb +230 -0
- data/lib/mustachio_ruby/version.rb +5 -0
- data/lib/mustachio_ruby.rb +25 -0
- metadata +87 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 750349ee5695dec895a3677bae2df4d3938e4c8df4e6c8854957a9f54bed272f
|
4
|
+
data.tar.gz: b9573a2790c82d7a364c81ae07e8ae0e9d0561b4794951e0ce5e15b8a75b0fb7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: af1247bcfefba49c2f5c6d5ee588079dc5f34798d37955f110431c284c2ca0464a394bfe5c08c4284ee74322e0a8e00fef04033325f4f4943bba86c1c69ccd0d
|
7
|
+
data.tar.gz: 18cc85a1b1a020ca78eb8655fd094b127ea4920fde7354679f7bc198f66bea40e03a4db43a640b9461e31f39a2d1ffb476ddbcfca298a7798a115c240b645dcd
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 devjoaov
|
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 all
|
13
|
+
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 THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,210 @@
|
|
1
|
+
# MustachioRuby
|
2
|
+
|
3
|
+
A Ruby port of the powerful [Mustachio](https://github.com/ActiveCampaign/mustachio) templating engine for C# and .NET - a lightweight, fast, and safe templating engine with model inference capabilities.
|
4
|
+
|
5
|
+
> **Note**: This is an unofficial Ruby port of the original [Mustachio](https://github.com/ActiveCampaign/mustachio) templating engine created by ActiveCampaign. All credit for the original design, concepts, and innovations goes to the ActiveCampaign team and the Mustachio contributors.
|
6
|
+
|
7
|
+
#### What's this for?
|
8
|
+
|
9
|
+
_MustachioRuby_ allows you to create simple text-based templates that are fast and safe to render. It's a Ruby implementation that brings the power of the templating engine behind [Postmark Templates](https://postmarkapp.com/blog/special-delivery-postmark-templates) to the Ruby ecosystem.
|
10
|
+
|
11
|
+
#### How to use MustachioRuby:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
# Parse the template:
|
15
|
+
source_template = "Dear {{name}}, this is definitely a personalized note to you. Very truly yours, {{sender}}"
|
16
|
+
template = MustachioRuby.parse(source_template)
|
17
|
+
|
18
|
+
# Create the values for the template model:
|
19
|
+
model = {
|
20
|
+
"name" => "John",
|
21
|
+
"sender" => "Sally"
|
22
|
+
}
|
23
|
+
|
24
|
+
# Combine the model with the template to get content:
|
25
|
+
content = template.call(model)
|
26
|
+
# => "Dear John, this is definitely a personalized note to you. Very truly yours, Sally"
|
27
|
+
```
|
28
|
+
|
29
|
+
#### Extending MustachioRuby with Token Expanders:
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
# You can add support for Partials via Token Expanders.
|
33
|
+
# Token Expanders can be used to extend MustachioRuby for many other use cases,
|
34
|
+
# such as: Date/Time formatters, Localization, etc., allowing also custom Token Render functions.
|
35
|
+
|
36
|
+
source_template = "Welcome to our website! [[CONTENT]] Yours Truly, John Smith."
|
37
|
+
string_data = "This is a partial. You can also add variables here {{ testVar }} or use other expanders. Watch out for infinite loops!"
|
38
|
+
|
39
|
+
token_expander = MustachioRuby::TokenExpander.new
|
40
|
+
token_expander.regex = /\[\[CONTENT\]\]/ # Custom syntax to avoid conflicts
|
41
|
+
token_expander.expand_tokens = lambda do |s, base_options|
|
42
|
+
# Create new ParsingOptions to avoid infinite loops
|
43
|
+
new_options = MustachioRuby::ParsingOptions.new
|
44
|
+
MustachioRuby::Tokenizer.tokenize(string_data, new_options)
|
45
|
+
end
|
46
|
+
|
47
|
+
parsing_options = MustachioRuby::ParsingOptions.new
|
48
|
+
parsing_options.token_expanders = [token_expander]
|
49
|
+
template = MustachioRuby.parse(source_template, parsing_options)
|
50
|
+
|
51
|
+
# Create the values for the template model:
|
52
|
+
model = { "testVar" => "Test" }
|
53
|
+
|
54
|
+
# Combine the model with the template to get content:
|
55
|
+
content = template.call(model)
|
56
|
+
```
|
57
|
+
|
58
|
+
#### Installing MustachioRuby:
|
59
|
+
|
60
|
+
MustachioRuby can be installed via [RubyGems](https://rubygems.org/):
|
61
|
+
|
62
|
+
```bash
|
63
|
+
gem install mustachio_ruby
|
64
|
+
```
|
65
|
+
|
66
|
+
Or add this line to your application's Gemfile:
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
gem 'mustachio_ruby'
|
70
|
+
```
|
71
|
+
|
72
|
+
And then execute:
|
73
|
+
|
74
|
+
$ bundle install
|
75
|
+
|
76
|
+
## Usage Examples
|
77
|
+
|
78
|
+
### Working with Arrays using `each` blocks:
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
template = "{{#each items}}Item: {{name}} - ${{price}}\n{{/each}}"
|
82
|
+
renderer = MustachioRuby.parse(template)
|
83
|
+
|
84
|
+
model = {
|
85
|
+
"items" => [
|
86
|
+
{ "name" => "Apple", "price" => 1.50 },
|
87
|
+
{ "name" => "Banana", "price" => 0.75 }
|
88
|
+
]
|
89
|
+
}
|
90
|
+
|
91
|
+
content = renderer.call(model)
|
92
|
+
# => "Item: Apple - $1.5\nItem: Banana - $0.75\n"
|
93
|
+
```
|
94
|
+
|
95
|
+
### Complex Paths for Nested Objects:
|
96
|
+
|
97
|
+
```ruby
|
98
|
+
template = "User: {{user.profile.name}} ({{user.profile.age}}) from {{user.location.city}}, {{user.location.country}}"
|
99
|
+
renderer = MustachioRuby.parse(template)
|
100
|
+
|
101
|
+
model = {
|
102
|
+
"user" => {
|
103
|
+
"profile" => { "name" => "Alice Smith", "age" => 30 },
|
104
|
+
"location" => { "city" => "New York", "country" => "USA" }
|
105
|
+
}
|
106
|
+
}
|
107
|
+
|
108
|
+
content = renderer.call(model)
|
109
|
+
# => "User: Alice Smith (30) from New York, USA"
|
110
|
+
```
|
111
|
+
|
112
|
+
### Conditional Sections:
|
113
|
+
|
114
|
+
```ruby
|
115
|
+
template = "{{#showMessage}}Hello, {{name}}!{{/showMessage}}{{^showMessage}}Goodbye!{{/showMessage}}"
|
116
|
+
renderer = MustachioRuby.parse(template)
|
117
|
+
|
118
|
+
# When showMessage is true
|
119
|
+
model = { "showMessage" => true, "name" => "World" }
|
120
|
+
content = renderer.call(model)
|
121
|
+
# => "Hello, World!"
|
122
|
+
|
123
|
+
# When showMessage is false
|
124
|
+
model = { "showMessage" => false, "name" => "World" }
|
125
|
+
content = renderer.call(model)
|
126
|
+
# => "Goodbye!"
|
127
|
+
```
|
128
|
+
|
129
|
+
### HTML Escaping (Safe by Default):
|
130
|
+
|
131
|
+
```ruby
|
132
|
+
# Escaped by default for safety
|
133
|
+
template = "Content: {{html}}"
|
134
|
+
renderer = MustachioRuby.parse(template)
|
135
|
+
model = { "html" => "<script>alert('xss')</script>" }
|
136
|
+
content = renderer.call(model)
|
137
|
+
# => "Content: <script>alert('xss')</script>"
|
138
|
+
|
139
|
+
# Unescaped with triple braces
|
140
|
+
template = "Content: {{{html}}}"
|
141
|
+
renderer = MustachioRuby.parse(template)
|
142
|
+
content = renderer.call(model)
|
143
|
+
# => "Content: <script>alert('xss')</script>"
|
144
|
+
```
|
145
|
+
|
146
|
+
### Model Inference:
|
147
|
+
|
148
|
+
```ruby
|
149
|
+
template = "Hello {{name}}! You have {{#each orders}}{{total}}{{/each}} orders."
|
150
|
+
result = MustachioRuby.parse_with_model_inference(template)
|
151
|
+
|
152
|
+
# result.parsed_template is the compiled template function
|
153
|
+
# result.inferred_model contains information about expected model structure
|
154
|
+
model = { "name" => "Alice", "orders" => [{ "total" => 5 }, { "total" => 3 }] }
|
155
|
+
content = result.parsed_template.call(model)
|
156
|
+
# => "Hello Alice! You have 53 orders."
|
157
|
+
```
|
158
|
+
|
159
|
+
### Advanced: Parsing Options
|
160
|
+
|
161
|
+
```ruby
|
162
|
+
options = MustachioRuby::ParsingOptions.new
|
163
|
+
options.disable_content_safety = true # Disable HTML escaping
|
164
|
+
options.source_name = "my_template" # For error reporting
|
165
|
+
|
166
|
+
template = "Unsafe content: {{html}}"
|
167
|
+
renderer = MustachioRuby.parse(template, options)
|
168
|
+
model = { "html" => "<b>Bold Text</b>" }
|
169
|
+
content = renderer.call(model)
|
170
|
+
# => "Unsafe content: <b>Bold Text</b>"
|
171
|
+
```
|
172
|
+
|
173
|
+
##### Key differences between Mustachio and [Mustache](https://mustache.github.io/)
|
174
|
+
|
175
|
+
MustachioRuby contains a few modifications to the core Mustache language that are important:
|
176
|
+
|
177
|
+
1. `each` blocks are recommended for handling arrays of values. (We have a good reason!)
|
178
|
+
2. Complex paths are supported, for example `{{ this.is.a.valid.path }}` and `{{ ../this.goes.up.one.level }}`
|
179
|
+
3. Template partials are supported via Token Expanders.
|
180
|
+
|
181
|
+
###### A little more about the differences:
|
182
|
+
|
183
|
+
One awesome feature of Mustachio is that with a minor alteration in the mustache syntax, we can infer what model will be required to completely fill out a template. By using the `each` keyword when iterating over an array, our parser can infer whether an array or object (or scalar) should be expected when the template is used. Normal mustache syntax would prevent us from determining this.
|
184
|
+
|
185
|
+
We think the model inference feature is compelling, because it allows for error detection, and faster debugging iterations when developing templates, which justifies this minor change to 'vanilla' mustache syntax.
|
186
|
+
|
187
|
+
## Development
|
188
|
+
|
189
|
+
After checking out the repo, run:
|
190
|
+
|
191
|
+
```bash
|
192
|
+
bundle install
|
193
|
+
bundle exec rspec
|
194
|
+
```
|
195
|
+
|
196
|
+
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
197
|
+
|
198
|
+
## Acknowledgments
|
199
|
+
|
200
|
+
This Ruby port would not be possible without the incredible work of the original [Mustachio](https://github.com/ActiveCampaign/mustachio) team at ActiveCampaign. Special thanks to:
|
201
|
+
|
202
|
+
- **ActiveCampaign Team**: For creating and maintaining the original Mustachio templating engine
|
203
|
+
- **All Mustachio Contributors**: For their valuable contributions to the original project
|
204
|
+
- **Postmark Team**: For demonstrating the power of this templating approach in production
|
205
|
+
|
206
|
+
MustachioRuby aims to faithfully port the original concepts and functionality while adapting them to Ruby's idioms and conventions.
|
207
|
+
|
208
|
+
## License
|
209
|
+
|
210
|
+
The gem is available as open source under the [MIT License](LICENSE).
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require "cgi"
|
2
|
+
|
3
|
+
module MustachioRuby
|
4
|
+
class ContextObject
|
5
|
+
PATH_FINDER = /(\.\.[\\\/]{1})|([^.]+)/
|
6
|
+
|
7
|
+
attr_accessor :parent, :value, :key
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@parent = nil
|
11
|
+
@value = nil
|
12
|
+
@key = ""
|
13
|
+
end
|
14
|
+
|
15
|
+
def get_context_for_path(path)
|
16
|
+
elements = path.scan(PATH_FINDER).map { |match| match.compact.first }
|
17
|
+
get_context_for_path_elements(elements)
|
18
|
+
end
|
19
|
+
|
20
|
+
def exists?
|
21
|
+
return false if @value.nil?
|
22
|
+
return false if @value == false
|
23
|
+
return false if @value == 0
|
24
|
+
return false if @value == 0.0
|
25
|
+
return false if @value == ""
|
26
|
+
|
27
|
+
if @value.respond_to?(:empty?)
|
28
|
+
return !@value.empty?
|
29
|
+
end
|
30
|
+
|
31
|
+
true
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_s
|
35
|
+
return "" if @value.nil?
|
36
|
+
|
37
|
+
case @value
|
38
|
+
when String
|
39
|
+
@value.to_s
|
40
|
+
when Integer
|
41
|
+
@value.to_s
|
42
|
+
when Float
|
43
|
+
# Format floats like integers when they're whole numbers
|
44
|
+
@value == @value.to_i ? @value.to_i.to_s : @value.to_s
|
45
|
+
when TrueClass, FalseClass
|
46
|
+
@value.to_s
|
47
|
+
else
|
48
|
+
""
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def get_context_for_path_elements(elements)
|
53
|
+
return self if elements.empty?
|
54
|
+
|
55
|
+
element = elements.shift
|
56
|
+
|
57
|
+
if element&.start_with?("..")
|
58
|
+
if @parent
|
59
|
+
@parent.get_context_for_path_elements(elements)
|
60
|
+
else
|
61
|
+
get_context_for_path_elements(elements)
|
62
|
+
end
|
63
|
+
else
|
64
|
+
inner_context = ContextObject.new
|
65
|
+
inner_context.key = element
|
66
|
+
inner_context.parent = self
|
67
|
+
|
68
|
+
if @value.respond_to?(:[])
|
69
|
+
begin
|
70
|
+
inner_context.value = @value[element]
|
71
|
+
rescue
|
72
|
+
inner_context.value = nil
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
inner_context.get_context_for_path_elements(elements)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module MustachioRuby
|
2
|
+
class IndexedParseException < StandardError
|
3
|
+
attr_reader :source_name, :line_number, :character_on_line
|
4
|
+
|
5
|
+
def initialize(source_name, location, message, *args)
|
6
|
+
@source_name = source_name
|
7
|
+
@line_number = location ? location[:line] : 0
|
8
|
+
@character_on_line = location ? location[:character] : 0
|
9
|
+
|
10
|
+
formatted_message = message % args
|
11
|
+
super("#{source_name} Line: #{@line_number} Column: #{@character_on_line} #{formatted_message}")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module MustachioRuby
|
2
|
+
module UsedAs
|
3
|
+
SCALAR = :scalar
|
4
|
+
COLLECTION = :collection
|
5
|
+
CONDITIONAL_VALUE = :conditional_value
|
6
|
+
end
|
7
|
+
|
8
|
+
class InferredTemplateModel
|
9
|
+
attr_accessor :used_as, :children
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@children = {}
|
13
|
+
@used_as = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def get_inferred_model_for_path(path, usage)
|
17
|
+
path_parts = path.split('.')
|
18
|
+
current = self
|
19
|
+
|
20
|
+
path_parts.each do |part|
|
21
|
+
current.children[part] ||= InferredTemplateModel.new
|
22
|
+
current = current.children[part]
|
23
|
+
end
|
24
|
+
|
25
|
+
current.used_as = usage if current.used_as.nil?
|
26
|
+
current
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,209 @@
|
|
1
|
+
require "cgi"
|
2
|
+
|
3
|
+
module MustachioRuby
|
4
|
+
class Parser
|
5
|
+
def self.parse(template, options = ParsingOptions.new)
|
6
|
+
tokens_result = get_tokens_queue(template, options)
|
7
|
+
if !tokens_result.errors.empty?
|
8
|
+
raise tokens_result.errors.first
|
9
|
+
end
|
10
|
+
|
11
|
+
internal_template = parse_tokens(tokens_result.tokens, options)
|
12
|
+
|
13
|
+
lambda do |model|
|
14
|
+
output = ""
|
15
|
+
context = ContextObject.new
|
16
|
+
context.value = model
|
17
|
+
context.key = ""
|
18
|
+
|
19
|
+
internal_template.call(output, context)
|
20
|
+
output
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.parse_with_model_inference(template_source, options = ParsingOptions.new)
|
25
|
+
tokens_result = get_tokens_queue(template_source, options)
|
26
|
+
if !tokens_result.errors.empty?
|
27
|
+
raise tokens_result.errors.first
|
28
|
+
end
|
29
|
+
|
30
|
+
inferred_model = InferredTemplateModel.new
|
31
|
+
internal_template = parse_tokens(tokens_result.tokens, options, inferred_model)
|
32
|
+
|
33
|
+
template = lambda do |model|
|
34
|
+
output = ""
|
35
|
+
context = ContextObject.new
|
36
|
+
context.value = model
|
37
|
+
context.key = ""
|
38
|
+
|
39
|
+
internal_template.call(output, context)
|
40
|
+
output
|
41
|
+
end
|
42
|
+
|
43
|
+
result = ExtendedParseInformation.new
|
44
|
+
result.inferred_model = inferred_model
|
45
|
+
result.parsed_template = template
|
46
|
+
result
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def self.get_tokens_queue(template, options)
|
52
|
+
Tokenizer.tokenize(template, options)
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.parse_tokens(tokens, options, current_scope = nil)
|
56
|
+
build_array = []
|
57
|
+
|
58
|
+
while !tokens.empty?
|
59
|
+
current_token = tokens.shift
|
60
|
+
|
61
|
+
case current_token.type
|
62
|
+
when TokenType::COMMENT
|
63
|
+
# Skip comments
|
64
|
+
when TokenType::CONTENT
|
65
|
+
build_array << handle_content(current_token.value)
|
66
|
+
when TokenType::COLLECTION_OPEN
|
67
|
+
build_array << handle_collection_open(current_token, tokens, options, current_scope)
|
68
|
+
when TokenType::ELEMENT_OPEN
|
69
|
+
build_array << handle_element_open(current_token, tokens, options, current_scope)
|
70
|
+
when TokenType::INVERTED_ELEMENT_OPEN
|
71
|
+
build_array << handle_inverted_element_open(current_token, tokens, options, current_scope)
|
72
|
+
when TokenType::COLLECTION_CLOSE, TokenType::ELEMENT_CLOSE
|
73
|
+
# Return current template function
|
74
|
+
return lambda do |output, context|
|
75
|
+
build_array.each { |action| action.call(output, context) }
|
76
|
+
end
|
77
|
+
when TokenType::ESCAPED_SINGLE_VALUE, TokenType::UNESCAPED_SINGLE_VALUE
|
78
|
+
build_array << handle_single_value(current_token, options, current_scope)
|
79
|
+
when TokenType::CUSTOM
|
80
|
+
if current_token.renderer
|
81
|
+
build_array << current_token.renderer.call(current_token.value, tokens, options, current_scope)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
lambda do |output, context|
|
87
|
+
build_array.each { |action| action.call(output, context) }
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.html_encode_string(content)
|
92
|
+
CGI.escapeHTML(content)
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.handle_single_value(token, options, scope)
|
96
|
+
if scope
|
97
|
+
scope.get_inferred_model_for_path(token.value, UsedAs::SCALAR)
|
98
|
+
end
|
99
|
+
|
100
|
+
lambda do |output, context|
|
101
|
+
if context
|
102
|
+
c = context.get_context_for_path(token.value)
|
103
|
+
unless c.value.nil?
|
104
|
+
if token.type == TokenType::ESCAPED_SINGLE_VALUE && !options.disable_content_safety
|
105
|
+
output << html_encode_string(c.to_s)
|
106
|
+
else
|
107
|
+
output << c.to_s
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.handle_content(token)
|
115
|
+
lambda { |output, context| output << token }
|
116
|
+
end
|
117
|
+
|
118
|
+
def self.handle_inverted_element_open(token, remainder, options, scope)
|
119
|
+
if scope
|
120
|
+
scope.get_inferred_model_for_path(token.value, UsedAs::CONDITIONAL_VALUE)
|
121
|
+
end
|
122
|
+
|
123
|
+
inner_template = parse_tokens(remainder, options, scope)
|
124
|
+
|
125
|
+
lambda do |output, context|
|
126
|
+
c = context.get_context_for_path(token.value)
|
127
|
+
unless c.exists?
|
128
|
+
inner_template.call(output, c)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def self.handle_collection_open(token, remainder, options, scope)
|
134
|
+
if scope
|
135
|
+
scope.get_inferred_model_for_path(token.value, UsedAs::COLLECTION)
|
136
|
+
end
|
137
|
+
|
138
|
+
inner_template = parse_tokens(remainder, options, scope)
|
139
|
+
|
140
|
+
lambda do |output, context|
|
141
|
+
c = context.get_context_for_path(token.value)
|
142
|
+
return unless c.exists?
|
143
|
+
|
144
|
+
if c.value.is_a?(Array)
|
145
|
+
# Handle arrays
|
146
|
+
index = 0
|
147
|
+
c.value.each do |item|
|
148
|
+
inner_context = ContextObject.new
|
149
|
+
inner_context.value = item
|
150
|
+
inner_context.key = "[#{index}]"
|
151
|
+
inner_context.parent = c
|
152
|
+
|
153
|
+
inner_template.call(output, inner_context)
|
154
|
+
index += 1
|
155
|
+
end
|
156
|
+
elsif c.value.respond_to?(:each) && !c.value.is_a?(String)
|
157
|
+
# Handle other enumerable objects (like hashes)
|
158
|
+
if c.value.respond_to?(:keys)
|
159
|
+
# Hash-like objects
|
160
|
+
index = 0
|
161
|
+
c.value.each do |key, item|
|
162
|
+
inner_context = ContextObject.new
|
163
|
+
inner_context.value = item
|
164
|
+
inner_context.key = "[#{index}]"
|
165
|
+
inner_context.parent = c
|
166
|
+
|
167
|
+
inner_template.call(output, inner_context)
|
168
|
+
index += 1
|
169
|
+
end
|
170
|
+
else
|
171
|
+
# Other enumerable objects
|
172
|
+
index = 0
|
173
|
+
c.value.each do |item|
|
174
|
+
inner_context = ContextObject.new
|
175
|
+
inner_context.value = item
|
176
|
+
inner_context.key = "[#{index}]"
|
177
|
+
inner_context.parent = c
|
178
|
+
|
179
|
+
inner_template.call(output, inner_context)
|
180
|
+
index += 1
|
181
|
+
end
|
182
|
+
end
|
183
|
+
else
|
184
|
+
raise IndexedParseException.new(
|
185
|
+
"",
|
186
|
+
nil,
|
187
|
+
"'%s' is used like an array by the template, but is a scalar value or object in your model.",
|
188
|
+
token.value
|
189
|
+
)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def self.handle_element_open(token, remainder, options, scope)
|
195
|
+
if scope
|
196
|
+
scope.get_inferred_model_for_path(token.value, UsedAs::CONDITIONAL_VALUE)
|
197
|
+
end
|
198
|
+
|
199
|
+
inner_template = parse_tokens(remainder, options, scope)
|
200
|
+
|
201
|
+
lambda do |output, context|
|
202
|
+
c = context.get_context_for_path(token.value)
|
203
|
+
if c.exists?
|
204
|
+
inner_template.call(output, c)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module MustachioRuby
|
2
|
+
module Precedence
|
3
|
+
LOW = :low
|
4
|
+
MEDIUM = :medium
|
5
|
+
HIGH = :high
|
6
|
+
end
|
7
|
+
|
8
|
+
class TokenExpander
|
9
|
+
attr_accessor :regex, :precedence, :expand_tokens, :renderer
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@precedence = Precedence::MEDIUM
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module MustachioRuby
|
2
|
+
module TokenType
|
3
|
+
ESCAPED_SINGLE_VALUE = :escaped_single_value
|
4
|
+
UNESCAPED_SINGLE_VALUE = :unescaped_single_value
|
5
|
+
INVERTED_ELEMENT_OPEN = :inverted_element_open
|
6
|
+
ELEMENT_OPEN = :element_open
|
7
|
+
ELEMENT_CLOSE = :element_close
|
8
|
+
COMMENT = :comment
|
9
|
+
CONTENT = :content
|
10
|
+
COLLECTION_OPEN = :collection_open
|
11
|
+
COLLECTION_CLOSE = :collection_close
|
12
|
+
CUSTOM = :custom
|
13
|
+
end
|
14
|
+
|
15
|
+
class TokenTuple
|
16
|
+
attr_accessor :type, :value, :renderer
|
17
|
+
|
18
|
+
def initialize(type, value, renderer = nil)
|
19
|
+
@type = type
|
20
|
+
@value = value
|
21
|
+
@renderer = renderer
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_s
|
25
|
+
"#{@type}, #{@value}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,230 @@
|
|
1
|
+
module MustachioRuby
|
2
|
+
class Tokenizer
|
3
|
+
TOKEN_FINDER = /(\{\{[^{}]+?\}\})|(\{\{\{[^{}]+?\}\}\})/
|
4
|
+
NEWLINE_FINDER = /\n/
|
5
|
+
NEGATIVE_PATH_SPEC = /(\.{3,})|([^\w.\/_]+)|((?<!\.{2})[\/])|(\.{2,}($|[^\/]))/
|
6
|
+
|
7
|
+
def self.tokenize(template_string, parsing_options)
|
8
|
+
template_string ||= ""
|
9
|
+
matches = template_string.scan(TOKEN_FINDER).flatten.compact
|
10
|
+
scope_stack = []
|
11
|
+
source_name = parsing_options.source_name
|
12
|
+
|
13
|
+
idx = 0
|
14
|
+
tokens = []
|
15
|
+
parse_errors = []
|
16
|
+
lines = nil
|
17
|
+
|
18
|
+
template_string.scan(/(\{\{[^{}]+?\}\})|(\{\{\{[^{}]+?\}\}\})/) do |match|
|
19
|
+
token_match = match.compact.first
|
20
|
+
match_start = $~.begin(0)
|
21
|
+
|
22
|
+
# Add content before token
|
23
|
+
if match_start > idx
|
24
|
+
content = template_string[idx...match_start]
|
25
|
+
tokens << TokenTuple.new(TokenType::CONTENT, content)
|
26
|
+
end
|
27
|
+
|
28
|
+
process_token(token_match, match_start, template_string, tokens, parse_errors,
|
29
|
+
scope_stack, source_name, parsing_options, lines)
|
30
|
+
|
31
|
+
idx = match_start + token_match.length
|
32
|
+
end
|
33
|
+
|
34
|
+
# Add remaining content
|
35
|
+
if idx < template_string.length
|
36
|
+
tokens << TokenTuple.new(TokenType::CONTENT, template_string[idx..-1])
|
37
|
+
end
|
38
|
+
|
39
|
+
# Check for unclosed scopes
|
40
|
+
unless scope_stack.empty?
|
41
|
+
scope_stack.reverse.each do |scope_info|
|
42
|
+
value = scope_info[:value].gsub(/[\{\}#]/, '').strip
|
43
|
+
value = value.sub(/^each /, '') if value.start_with?('each ')
|
44
|
+
location = humanize_character_location(template_string, scope_info[:index], lines)
|
45
|
+
|
46
|
+
parse_errors << IndexedParseException.new(
|
47
|
+
source_name,
|
48
|
+
location,
|
49
|
+
"A scope block to the following path was opened but not closed: '%s', please close it using the appropriate syntax.",
|
50
|
+
value
|
51
|
+
)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
result = TokenizeResult.new
|
56
|
+
result.tokens = tokens
|
57
|
+
result.errors = parse_errors
|
58
|
+
result
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def self.process_token(token_match, match_start, template_string, tokens, parse_errors,
|
64
|
+
scope_stack, source_name, parsing_options, lines)
|
65
|
+
|
66
|
+
if token_match.match?(/^{{#each(\s|$)/)
|
67
|
+
handle_each_open(token_match, match_start, template_string, tokens, parse_errors,
|
68
|
+
scope_stack, source_name, lines)
|
69
|
+
elsif token_match == "{{/each}}"
|
70
|
+
handle_each_close(token_match, match_start, template_string, tokens, parse_errors,
|
71
|
+
scope_stack, source_name, lines)
|
72
|
+
elsif token_match.start_with?("{{#")
|
73
|
+
handle_element_open(token_match, match_start, template_string, tokens, parse_errors,
|
74
|
+
scope_stack, source_name, lines)
|
75
|
+
elsif token_match.start_with?("{{^")
|
76
|
+
handle_inverted_element_open(token_match, match_start, template_string, tokens,
|
77
|
+
parse_errors, scope_stack, source_name, lines)
|
78
|
+
elsif token_match.start_with?("{{/")
|
79
|
+
handle_element_close(token_match, match_start, template_string, tokens, parse_errors,
|
80
|
+
scope_stack, source_name, lines)
|
81
|
+
elsif token_match.start_with?("{{{") || token_match.start_with?("{{&")
|
82
|
+
handle_unescaped_value(token_match, match_start, template_string, tokens,
|
83
|
+
parse_errors, source_name, lines)
|
84
|
+
elsif token_match.start_with?("{{!")
|
85
|
+
# Comment - ignore
|
86
|
+
else
|
87
|
+
handle_escaped_value(token_match, match_start, template_string, tokens,
|
88
|
+
parse_errors, source_name, lines)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def self.handle_each_open(token_match, match_start, template_string, tokens, parse_errors,
|
93
|
+
scope_stack, source_name, lines)
|
94
|
+
scope_stack << { value: token_match, index: match_start }
|
95
|
+
token_value = token_match.gsub(/[\{\}#]/, '').strip
|
96
|
+
token_value = token_value.sub(/^each/, '').strip
|
97
|
+
|
98
|
+
if token_value.empty?
|
99
|
+
location = humanize_character_location(template_string, match_start, lines)
|
100
|
+
parse_errors << IndexedParseException.new(
|
101
|
+
source_name,
|
102
|
+
location,
|
103
|
+
"The 'each' block being opened requires a model path to be specified in the form '{{#each <name>}}'."
|
104
|
+
)
|
105
|
+
else
|
106
|
+
validated_token = validate_token(token_value, template_string, match_start,
|
107
|
+
source_name, lines, parse_errors)
|
108
|
+
tokens << TokenTuple.new(TokenType::COLLECTION_OPEN, validated_token)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def self.handle_each_close(token_match, match_start, template_string, tokens, parse_errors,
|
113
|
+
scope_stack, source_name, lines)
|
114
|
+
if !scope_stack.empty? && scope_stack.last[:value].match?(/^{{#each(\s|$)/)
|
115
|
+
scope_info = scope_stack.pop
|
116
|
+
tokens << TokenTuple.new(TokenType::COLLECTION_CLOSE, scope_info[:value])
|
117
|
+
else
|
118
|
+
location = humanize_character_location(template_string, match_start, lines)
|
119
|
+
parse_errors << IndexedParseException.new(
|
120
|
+
source_name,
|
121
|
+
location,
|
122
|
+
"An 'each' block is being closed, but no corresponding opening element ('{{#each <name>}}') was detected."
|
123
|
+
)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def self.handle_element_open(token_match, match_start, template_string, tokens, parse_errors,
|
128
|
+
scope_stack, source_name, lines)
|
129
|
+
token_value = token_match.gsub(/[\{\}#]/, '').strip
|
130
|
+
|
131
|
+
if !scope_stack.empty? && scope_stack.last[:value] == token_value
|
132
|
+
validated_token = validate_token(token_value, template_string, match_start,
|
133
|
+
source_name, lines, parse_errors)
|
134
|
+
tokens << TokenTuple.new(TokenType::ELEMENT_CLOSE, validated_token)
|
135
|
+
else
|
136
|
+
scope_stack << { value: token_value, index: match_start }
|
137
|
+
end
|
138
|
+
|
139
|
+
validated_token = validate_token(token_value, template_string, match_start,
|
140
|
+
source_name, lines, parse_errors)
|
141
|
+
tokens << TokenTuple.new(TokenType::ELEMENT_OPEN, validated_token)
|
142
|
+
end
|
143
|
+
|
144
|
+
def self.handle_inverted_element_open(token_match, match_start, template_string, tokens,
|
145
|
+
parse_errors, scope_stack, source_name, lines)
|
146
|
+
token_value = token_match.gsub(/[\{\}\^]/, '').strip
|
147
|
+
|
148
|
+
if !scope_stack.empty? && scope_stack.last[:value] == token_value
|
149
|
+
validated_token = validate_token(token_value, template_string, match_start,
|
150
|
+
source_name, lines, parse_errors)
|
151
|
+
tokens << TokenTuple.new(TokenType::ELEMENT_CLOSE, validated_token)
|
152
|
+
else
|
153
|
+
scope_stack << { value: token_value, index: match_start }
|
154
|
+
end
|
155
|
+
|
156
|
+
validated_token = validate_token(token_value, template_string, match_start,
|
157
|
+
source_name, lines, parse_errors)
|
158
|
+
tokens << TokenTuple.new(TokenType::INVERTED_ELEMENT_OPEN, validated_token)
|
159
|
+
end
|
160
|
+
|
161
|
+
def self.handle_element_close(token_match, match_start, template_string, tokens, parse_errors,
|
162
|
+
scope_stack, source_name, lines)
|
163
|
+
token_value = token_match.gsub(/[\{\}\/]/, '').strip
|
164
|
+
|
165
|
+
if !scope_stack.empty? && scope_stack.last[:value] == token_value
|
166
|
+
scope_stack.pop
|
167
|
+
validated_token = validate_token(token_value, template_string, match_start,
|
168
|
+
source_name, lines, parse_errors)
|
169
|
+
tokens << TokenTuple.new(TokenType::ELEMENT_CLOSE, validated_token)
|
170
|
+
else
|
171
|
+
location = humanize_character_location(template_string, match_start, lines)
|
172
|
+
parse_errors << IndexedParseException.new(
|
173
|
+
source_name,
|
174
|
+
location,
|
175
|
+
"It appears that open and closing elements are mismatched."
|
176
|
+
)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def self.handle_unescaped_value(token_match, match_start, template_string, tokens,
|
181
|
+
parse_errors, source_name, lines)
|
182
|
+
token_value = token_match.gsub(/[\{\}&]/, '').strip
|
183
|
+
validated_token = validate_token(token_value, template_string, match_start,
|
184
|
+
source_name, lines, parse_errors)
|
185
|
+
tokens << TokenTuple.new(TokenType::UNESCAPED_SINGLE_VALUE, validated_token)
|
186
|
+
end
|
187
|
+
|
188
|
+
def self.handle_escaped_value(token_match, match_start, template_string, tokens,
|
189
|
+
parse_errors, source_name, lines)
|
190
|
+
token_value = token_match.gsub(/[\{\}]/, '').strip
|
191
|
+
validated_token = validate_token(token_value, template_string, match_start,
|
192
|
+
source_name, lines, parse_errors)
|
193
|
+
tokens << TokenTuple.new(TokenType::ESCAPED_SINGLE_VALUE, validated_token)
|
194
|
+
end
|
195
|
+
|
196
|
+
def self.validate_token(token, content, index, source_name, lines, exceptions)
|
197
|
+
token = token.strip
|
198
|
+
|
199
|
+
if NEGATIVE_PATH_SPEC.match(token)
|
200
|
+
location = humanize_character_location(content, index, lines)
|
201
|
+
exceptions << IndexedParseException.new(
|
202
|
+
source_name,
|
203
|
+
location,
|
204
|
+
"The path '%s' is not valid. Please see documentation for examples of valid paths.",
|
205
|
+
token
|
206
|
+
)
|
207
|
+
end
|
208
|
+
|
209
|
+
token
|
210
|
+
end
|
211
|
+
|
212
|
+
def self.humanize_character_location(content, character_index, lines)
|
213
|
+
lines ||= content.enum_for(:scan, NEWLINE_FINDER).map { Regexp.last_match.begin(0) }
|
214
|
+
|
215
|
+
line = lines.bsearch_index { |pos| pos > character_index } || lines.length
|
216
|
+
char_idx = character_index
|
217
|
+
|
218
|
+
if line > 0 && line <= lines.length
|
219
|
+
char_idx = character_index - (lines[line - 1] + 1)
|
220
|
+
elsif line > 0
|
221
|
+
char_idx = character_index - (lines.last + 1) if !lines.empty?
|
222
|
+
end
|
223
|
+
|
224
|
+
{
|
225
|
+
line: line + 1,
|
226
|
+
character: char_idx + 1
|
227
|
+
}
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "mustachio_ruby/version"
|
4
|
+
require_relative "mustachio_ruby/parser"
|
5
|
+
require_relative "mustachio_ruby/tokenizer"
|
6
|
+
require_relative "mustachio_ruby/context_object"
|
7
|
+
require_relative "mustachio_ruby/parsing_options"
|
8
|
+
require_relative "mustachio_ruby/token_tuple"
|
9
|
+
require_relative "mustachio_ruby/token_expander"
|
10
|
+
require_relative "mustachio_ruby/tokenize_result"
|
11
|
+
require_relative "mustachio_ruby/inferred_template_model"
|
12
|
+
require_relative "mustachio_ruby/extended_parse_information"
|
13
|
+
require_relative "mustachio_ruby/indexed_parse_exception"
|
14
|
+
|
15
|
+
module MustachioRuby
|
16
|
+
class << self
|
17
|
+
def parse(template, options = ParsingOptions.new)
|
18
|
+
Parser.parse(template, options)
|
19
|
+
end
|
20
|
+
|
21
|
+
def parse_with_model_inference(template, options = ParsingOptions.new)
|
22
|
+
Parser.parse_with_model_inference(template, options)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
metadata
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mustachio-ruby
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- João Victor Assis
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: rspec
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '3.0'
|
19
|
+
type: :development
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '3.0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: rake
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '13.0'
|
33
|
+
type: :development
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '13.0'
|
40
|
+
description: A Ruby port of the Postmark C# Mustachio templating engine with model
|
41
|
+
inference and extensible token support
|
42
|
+
email:
|
43
|
+
- joaovictorass95@gmail.com
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- LICENSE
|
49
|
+
- README.md
|
50
|
+
- lib/mustachio_ruby.rb
|
51
|
+
- lib/mustachio_ruby/context_object.rb
|
52
|
+
- lib/mustachio_ruby/extended_parse_information.rb
|
53
|
+
- lib/mustachio_ruby/indexed_parse_exception.rb
|
54
|
+
- lib/mustachio_ruby/inferred_template_model.rb
|
55
|
+
- lib/mustachio_ruby/parser.rb
|
56
|
+
- lib/mustachio_ruby/parsing_options.rb
|
57
|
+
- lib/mustachio_ruby/token_expander.rb
|
58
|
+
- lib/mustachio_ruby/token_tuple.rb
|
59
|
+
- lib/mustachio_ruby/tokenize_result.rb
|
60
|
+
- lib/mustachio_ruby/tokenizer.rb
|
61
|
+
- lib/mustachio_ruby/version.rb
|
62
|
+
homepage: https://github.com/bleu/mustachio-ruby
|
63
|
+
licenses:
|
64
|
+
- MIT
|
65
|
+
metadata:
|
66
|
+
allowed_push_host: https://rubygems.org
|
67
|
+
homepage_uri: https://github.com/bleu/mustachio-ruby
|
68
|
+
source_code_uri: https://github.com/bleu/mustachio-ruby
|
69
|
+
changelog_uri: https://github.com/bleu/mustachio-ruby/blob/main/CHANGELOG.md
|
70
|
+
rdoc_options: []
|
71
|
+
require_paths:
|
72
|
+
- lib
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: 3.1.0
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
requirements: []
|
84
|
+
rubygems_version: 3.6.7
|
85
|
+
specification_version: 4
|
86
|
+
summary: A powerful templating engine for Ruby
|
87
|
+
test_files: []
|