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 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: &lt;script&gt;alert('xss')&lt;/script&gt;"
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,10 @@
1
+ module MustachioRuby
2
+ class ExtendedParseInformation
3
+ attr_accessor :inferred_model, :parsed_template
4
+
5
+ def initialize
6
+ @inferred_model = nil
7
+ @parsed_template = nil
8
+ end
9
+ end
10
+ 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,11 @@
1
+ module MustachioRuby
2
+ class ParsingOptions
3
+ attr_accessor :disable_content_safety, :source_name, :token_expanders
4
+
5
+ def initialize
6
+ @disable_content_safety = false
7
+ @source_name = ""
8
+ @token_expanders = []
9
+ end
10
+ end
11
+ 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,10 @@
1
+ module MustachioRuby
2
+ class TokenizeResult
3
+ attr_accessor :tokens, :errors
4
+
5
+ def initialize
6
+ @tokens = []
7
+ @errors = []
8
+ end
9
+ end
10
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MustachioRuby
4
+ VERSION = "0.1.0"
5
+ 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: []