lm_docstache 1.3.10 → 2.0.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 +4 -4
- data/.github/workflows/{ruby.yml → rspec.yml} +2 -5
- data/CHANGELOG.md +20 -0
- data/README.md +1 -0
- data/lib/lm_docstache.rb +3 -2
- data/lib/lm_docstache/condition.rb +37 -0
- data/lib/lm_docstache/conditional_block.rb +105 -0
- data/lib/lm_docstache/document.rb +78 -86
- data/lib/lm_docstache/parser.rb +178 -0
- data/lib/lm_docstache/renderer.rb +5 -128
- data/lib/lm_docstache/version.rb +1 -1
- data/lm_docstache.gemspec +2 -2
- data/spec/integration_spec.rb +0 -4
- metadata +8 -9
- data/lib/lm_docstache/block.rb +0 -71
- data/lib/lm_docstache/data_scope.rb +0 -67
- data/spec/data_scope_spec.rb +0 -56
- data/spec/empty_data_scope_spec.rb +0 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 805d05c9872a3562ac59527ada43842f4f28412d4a273d3010986f1182ee8421
|
4
|
+
data.tar.gz: 1c0ecd9d43788420553310cefcba6bcca8fe24cd6dc14ff3cbac27f2cfb8ac8f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 66881d21495aa30890ebfc392e4292bfd6ef0bccb9b31d147381b606478996707d82cb745d3dd387f98a793b743f54d695be7149282add2b4707438262bc1a0e
|
7
|
+
data.tar.gz: 0a75364125a98fbd22150cbcde7e3826ab2a8d28705e321e0a471290838867d211ebda8a9181fcf7bf4a1428c6351fa1e93fc0ad33c200f850ae71bed3a0808d
|
@@ -5,7 +5,7 @@
|
|
5
5
|
# This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake
|
6
6
|
# For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby
|
7
7
|
|
8
|
-
name:
|
8
|
+
name: rspec
|
9
9
|
|
10
10
|
on: push
|
11
11
|
|
@@ -17,10 +17,7 @@ jobs:
|
|
17
17
|
steps:
|
18
18
|
- uses: actions/checkout@v2
|
19
19
|
- name: Set up Ruby
|
20
|
-
|
21
|
-
# change this to (see https://github.com/ruby/setup-ruby#versioning):
|
22
|
-
# uses: ruby/setup-ruby@v1
|
23
|
-
uses: ruby/setup-ruby@ec106b438a1ff6ff109590de34ddc62c540232e0
|
20
|
+
uses: ruby/setup-ruby@v1
|
24
21
|
with:
|
25
22
|
ruby-version: 2.6
|
26
23
|
- name: Install dependencies
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,25 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## 2.0.0
|
4
|
+
|
5
|
+
### Breaking changes
|
6
|
+
|
7
|
+
* Remove `Document#role_tags` and `Document#unusable_role_tags` methods;
|
8
|
+
* Remove support for `:loop` block type;
|
9
|
+
* Delete internal classes `DataScope` and `Block`;
|
10
|
+
* Third parameter of `Renderer#render_file` has changed: it's not the boolean
|
11
|
+
field `remove_role_tags` anymore, but the `render_options` with default set
|
12
|
+
to `{}`, where there is only one option for it so far, which is
|
13
|
+
`special_variable_replacements` (with default value also set to `{}`). For the
|
14
|
+
possible values for this `Hash` check the explanation for it on top of
|
15
|
+
`Parser#initialize`.
|
16
|
+
|
17
|
+
### Improvements and bugfixes
|
18
|
+
|
19
|
+
* Improve overall template parsing and evaluation, which makes conditional
|
20
|
+
blocks parsing more stable, reliable and bug free. There were lots of bugs
|
21
|
+
happening related to conditional blocks being ignored and not properly parsed.
|
22
|
+
|
3
23
|
## 1.3.10
|
4
24
|
* Fix close tag encoding bug.
|
5
25
|
|
data/README.md
CHANGED
data/lib/lm_docstache.rb
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
require 'nokogiri'
|
2
2
|
require 'zip'
|
3
3
|
require "lm_docstache/version"
|
4
|
-
require "lm_docstache/data_scope"
|
5
4
|
require "lm_docstache/document"
|
6
|
-
require "lm_docstache/
|
5
|
+
require "lm_docstache/parser"
|
6
|
+
require "lm_docstache/condition"
|
7
|
+
require "lm_docstache/conditional_block"
|
7
8
|
require "lm_docstache/renderer"
|
8
9
|
|
9
10
|
module LMDocstache; end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module LMDocstache
|
2
|
+
class Condition
|
3
|
+
InvalidOperator = Class.new(StandardError)
|
4
|
+
|
5
|
+
ALLOWED_OPERATORS = %w(== ~=).freeze
|
6
|
+
STARTING_QUOTES = %w(' " “)
|
7
|
+
ENDING_QUOTES = %w(' " ”)
|
8
|
+
|
9
|
+
attr_reader :left_term, :right_term, :operator, :negation, :original_match
|
10
|
+
|
11
|
+
def initialize(left_term:, right_term:, operator:, negation: false, original_match: nil)
|
12
|
+
@left_term = left_term
|
13
|
+
@right_term = remove_quotes(right_term)
|
14
|
+
@operator = operator
|
15
|
+
@negation = negation
|
16
|
+
@original_match = original_match
|
17
|
+
|
18
|
+
unless ALLOWED_OPERATORS.include?(operator)
|
19
|
+
raise InvalidOperator, "Operator '#{operator}' is invalid"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def truthy?(value)
|
24
|
+
result = value.to_s.send(operator, right_term)
|
25
|
+
negation ? !result : result
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def remove_quotes(value)
|
31
|
+
start_position = STARTING_QUOTES.include?(value[0]) ? 1 : 0
|
32
|
+
end_position = ENDING_QUOTES.include?(value[-1]) ? -2 : -1
|
33
|
+
|
34
|
+
value[start_position..end_position]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'strscan'
|
2
|
+
|
3
|
+
module LMDocstache
|
4
|
+
class ConditionalBlock
|
5
|
+
BLOCK_MATCHER = LMDocstache::Parser::BLOCK_MATCHER
|
6
|
+
|
7
|
+
attr_reader :elements, :condition, :value
|
8
|
+
|
9
|
+
def initialize(elements:, condition:, content: nil)
|
10
|
+
@elements = elements
|
11
|
+
@condition = condition
|
12
|
+
@content = content
|
13
|
+
@evaluated = false
|
14
|
+
end
|
15
|
+
|
16
|
+
def content
|
17
|
+
return @content if inline?
|
18
|
+
end
|
19
|
+
|
20
|
+
def evaluate_with_value!(value)
|
21
|
+
return false if evaluated?
|
22
|
+
|
23
|
+
inline? ? evaluate_inline_block!(value) : evaluate_multiple_nodes_block!(value)
|
24
|
+
|
25
|
+
@evaluated = true
|
26
|
+
end
|
27
|
+
|
28
|
+
def evaluated?
|
29
|
+
!!@evaluated
|
30
|
+
end
|
31
|
+
|
32
|
+
def inline?
|
33
|
+
@elements.size == 1
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.inline_blocks_from_paragraph(paragraph)
|
37
|
+
node_set = Nokogiri::XML::NodeSet.new(paragraph.document, [paragraph])
|
38
|
+
conditional_blocks = []
|
39
|
+
scanner = StringScanner.new(paragraph.text)
|
40
|
+
matches = []
|
41
|
+
|
42
|
+
# This loop will iterate through all existing inline conditional blocks
|
43
|
+
# inside a given paragraph node.
|
44
|
+
while scanner.scan_until(BLOCK_MATCHER)
|
45
|
+
next if matches.include?(scanner.matched)
|
46
|
+
|
47
|
+
# +scanner.matched+ holds the whole regex-matched string, which could be
|
48
|
+
# represented by the following string:
|
49
|
+
#
|
50
|
+
# {{#variable == value}}content{{/variable}}
|
51
|
+
#
|
52
|
+
# While +scanner.captures+ holds the group matches referenced in the
|
53
|
+
# +BLOCK_MATCHER+ regex, and it's basically comprised as the following:
|
54
|
+
#
|
55
|
+
# [
|
56
|
+
# '#',
|
57
|
+
# 'variable',
|
58
|
+
# '==',
|
59
|
+
# 'value'
|
60
|
+
# ]
|
61
|
+
#
|
62
|
+
content = scanner.captures[4]
|
63
|
+
condition = Condition.new(
|
64
|
+
left_term: scanner.captures[1],
|
65
|
+
right_term: scanner.captures[3],
|
66
|
+
operator: scanner.captures[2],
|
67
|
+
negation: scanner.captures[0] == '^',
|
68
|
+
original_match: scanner.matched
|
69
|
+
)
|
70
|
+
|
71
|
+
matches << scanner.matched
|
72
|
+
conditional_blocks << new(
|
73
|
+
elements: node_set,
|
74
|
+
condition: condition,
|
75
|
+
content: content
|
76
|
+
)
|
77
|
+
end
|
78
|
+
|
79
|
+
conditional_blocks
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
# Normally we expect that both starting and closing block paragraph elements
|
85
|
+
# contain only one +<w:r />+ and one +<w:t />+ elements.
|
86
|
+
def evaluate_multiple_nodes_block!(value)
|
87
|
+
return elements.unlink unless condition.truthy?(value)
|
88
|
+
|
89
|
+
Nokogiri::XML::NodeSet.new(
|
90
|
+
elements.first.document,
|
91
|
+
[elements.first, elements.last]
|
92
|
+
).unlink
|
93
|
+
end
|
94
|
+
|
95
|
+
def evaluate_inline_block!(value)
|
96
|
+
elements.first.css('w|t').each do |text_node|
|
97
|
+
replaced_text = text_node.text.gsub(condition.original_match) do |match|
|
98
|
+
condition.truthy?(value) ? content : ''
|
99
|
+
end
|
100
|
+
|
101
|
+
text_node.content = replaced_text
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -1,27 +1,24 @@
|
|
1
1
|
module LMDocstache
|
2
2
|
class Document
|
3
|
-
TAGS_REGEXP =
|
3
|
+
TAGS_REGEXP = /{{.+?}}/
|
4
4
|
ROLES_REGEXP = /(\{\{(sig|sigfirm|date|check|text|initial)\|(req|noreq)\|(.+?)\}\})/
|
5
|
+
|
5
6
|
def initialize(*paths)
|
6
7
|
raise ArgumentError if paths.empty?
|
8
|
+
|
7
9
|
@path = paths.shift
|
8
10
|
@zip_file = Zip::File.open(@path)
|
9
|
-
load_references
|
10
11
|
@document = Nokogiri::XML(unzip_read(@zip_file, "word/document.xml"))
|
11
|
-
zip_files = paths.map{|
|
12
|
-
documents = zip_files.map{|f| Nokogiri::XML(unzip_read(f, "word/document.xml"))}
|
12
|
+
zip_files = paths.map { |path| Zip::File.open(path) }
|
13
|
+
documents = zip_files.map { |f| Nokogiri::XML(unzip_read(f, "word/document.xml")) }
|
14
|
+
|
15
|
+
load_references
|
13
16
|
documents.each do |doc|
|
14
|
-
@document.css('w|p').last.
|
15
|
-
@document.css('w|p').last.
|
17
|
+
@document.css('w|p').last.after(page_break)
|
18
|
+
@document.css('w|p').last.after(doc.css('w|body > *:not(w|sectPr)'))
|
16
19
|
end
|
17
|
-
find_documents_to_interpolate
|
18
|
-
end
|
19
20
|
|
20
|
-
|
21
|
-
@documents.values.flat_map do |document|
|
22
|
-
document.text.strip.scan(ROLES_REGEXP)
|
23
|
-
.map {|r| r.first }
|
24
|
-
end
|
21
|
+
find_documents_to_interpolate
|
25
22
|
end
|
26
23
|
|
27
24
|
def usable_role_tags
|
@@ -35,15 +32,6 @@ module LMDocstache
|
|
35
32
|
end
|
36
33
|
end
|
37
34
|
|
38
|
-
def unusable_role_tags
|
39
|
-
unusable_signature_tags = role_tags
|
40
|
-
usable_role_tags.each do |usable_tag|
|
41
|
-
index = unusable_signature_tags.index(usable_tag)
|
42
|
-
unusable_signature_tags.delete_at(index) if index
|
43
|
-
end
|
44
|
-
return unusable_signature_tags
|
45
|
-
end
|
46
|
-
|
47
35
|
def tags
|
48
36
|
@documents.values.flat_map do |document|
|
49
37
|
document.text.strip.scan(TAGS_REGEXP)
|
@@ -51,15 +39,15 @@ module LMDocstache
|
|
51
39
|
end
|
52
40
|
|
53
41
|
def usable_tags
|
54
|
-
@documents.values.
|
55
|
-
document.css('w|t')
|
56
|
-
.
|
57
|
-
|
42
|
+
@documents.values.reduce([]) do |tags, document|
|
43
|
+
document.css('w|t').reduce(tags) do |document_tags, text_node|
|
44
|
+
document_tags.push(*text_node.text.scan(TAGS_REGEXP))
|
45
|
+
end
|
58
46
|
end
|
59
47
|
end
|
60
48
|
|
61
49
|
def usable_tag_names
|
62
|
-
|
50
|
+
usable_tags.reject { |tag| tag =~ ROLES_REGEXP }.map do |tag|
|
63
51
|
tag.scan(/\{\{[\/#^]?(.+?)(?:(\s((?:==|~=))\s?.+?))?\}\}/)
|
64
52
|
$1
|
65
53
|
end.compact.uniq
|
@@ -67,11 +55,13 @@ module LMDocstache
|
|
67
55
|
|
68
56
|
def unusable_tags
|
69
57
|
unusable_tags = tags
|
58
|
+
|
70
59
|
usable_tags.each do |usable_tag|
|
71
60
|
index = unusable_tags.index(usable_tag)
|
72
61
|
unusable_tags.delete_at(index) if index
|
73
62
|
end
|
74
|
-
|
63
|
+
|
64
|
+
unusable_tags
|
75
65
|
end
|
76
66
|
|
77
67
|
def fix_errors
|
@@ -87,68 +77,64 @@ module LMDocstache
|
|
87
77
|
File.open(path, "w") { |f| f.write buffer.string }
|
88
78
|
end
|
89
79
|
|
90
|
-
def render_file(output, data={},
|
91
|
-
|
92
|
-
@documents.map do |(path, document)|
|
93
|
-
[path, LMDocstache::Renderer.new(document.dup, data, remove_role_tags).render]
|
94
|
-
end
|
95
|
-
]
|
96
|
-
buffer = zip_buffer(rendered_documents)
|
80
|
+
def render_file(output, data = {}, render_options = {})
|
81
|
+
buffer = zip_buffer(render_documents(data, nil, render_options))
|
97
82
|
File.open(output, "w") { |f| f.write buffer.string }
|
98
83
|
end
|
99
84
|
|
100
85
|
def render_replace(output, text)
|
101
|
-
|
102
|
-
@documents.map do |(path, document)|
|
103
|
-
[path, LMDocstache::Renderer.new(document.dup, {}).render_replace(text)]
|
104
|
-
end
|
105
|
-
]
|
106
|
-
buffer = zip_buffer(rendered_documents)
|
86
|
+
buffer = zip_buffer(render_documents({}, text))
|
107
87
|
File.open(output, "w") { |f| f.write buffer.string }
|
108
88
|
end
|
109
89
|
|
110
|
-
def render_stream(data={})
|
111
|
-
|
112
|
-
@documents.map do |(path, document)|
|
113
|
-
[path, LMDocstache::Renderer.new(document.dup, data).render]
|
114
|
-
end
|
115
|
-
]
|
116
|
-
buffer = zip_buffer(rendered_documents)
|
90
|
+
def render_stream(data = {})
|
91
|
+
buffer = zip_buffer(render_documents(data))
|
117
92
|
buffer.rewind
|
118
|
-
|
93
|
+
buffer.sysread
|
119
94
|
end
|
120
95
|
|
121
|
-
def render_xml(data={})
|
122
|
-
|
96
|
+
def render_xml(data = {})
|
97
|
+
render_documents(data)
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def render_documents(data, text = nil, render_options = {})
|
103
|
+
Hash[
|
123
104
|
@documents.map do |(path, document)|
|
124
|
-
[path,
|
105
|
+
[path, render_document(document, data, text, render_options)]
|
125
106
|
end
|
126
107
|
]
|
127
|
-
|
128
|
-
rendered_documents
|
129
108
|
end
|
130
109
|
|
131
|
-
|
110
|
+
def render_document(document, data, text, render_options)
|
111
|
+
renderer = LMDocstache::Renderer.new(document.dup, data, render_options)
|
112
|
+
text ? renderer.render_replace(text) : renderer.render
|
113
|
+
end
|
132
114
|
|
133
115
|
def problem_paragraphs
|
134
116
|
unusable_tags.flat_map do |tag|
|
135
117
|
@documents.values.inject([]) do |tags, document|
|
136
|
-
|
118
|
+
faulty_paragraphs = document
|
119
|
+
.css('w|p')
|
120
|
+
.select { |paragraph| paragraph.text =~ /#{Regexp.escape(tag)}/ }
|
121
|
+
|
122
|
+
tags + faulty_paragraphs
|
137
123
|
end
|
138
124
|
end
|
139
125
|
end
|
140
126
|
|
141
|
-
def flatten_paragraph(
|
142
|
-
|
127
|
+
def flatten_paragraph(paragraph)
|
128
|
+
run_nodes = paragraph.css('w|r')
|
129
|
+
host_run_node = run_nodes.shift
|
143
130
|
|
144
|
-
|
145
|
-
|
146
|
-
host_run = runs.shift
|
131
|
+
until host_run_node.at_css('w|t') || run_nodes.size == 0
|
132
|
+
host_run_node = run_nodes.shift
|
147
133
|
end
|
148
134
|
|
149
|
-
|
150
|
-
|
151
|
-
|
135
|
+
run_nodes.each do |run_node|
|
136
|
+
host_run_node.at_css('w|t').content += run_node.text
|
137
|
+
run_node.unlink
|
152
138
|
end
|
153
139
|
end
|
154
140
|
|
@@ -156,38 +142,42 @@ module LMDocstache
|
|
156
142
|
file = zip.find_entry(zip_path)
|
157
143
|
contents = ""
|
158
144
|
file.get_input_stream { |f| contents = f.read }
|
159
|
-
|
145
|
+
|
146
|
+
contents
|
160
147
|
end
|
161
148
|
|
162
149
|
def zip_buffer(documents)
|
163
|
-
Zip::OutputStream.write_buffer do |
|
164
|
-
@zip_file.entries.each do |
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
150
|
+
Zip::OutputStream.write_buffer do |output|
|
151
|
+
@zip_file.entries.each do |entry|
|
152
|
+
next if documents.keys.include?(entry.name)
|
153
|
+
|
154
|
+
output.put_next_entry(entry.name)
|
155
|
+
output.write(entry.get_input_stream.read)
|
169
156
|
end
|
157
|
+
|
170
158
|
documents.each do |path, document|
|
171
|
-
|
172
|
-
|
159
|
+
output.put_next_entry(path)
|
160
|
+
output.write(document.to_xml(indent: 0).gsub("\n", ""))
|
173
161
|
end
|
174
162
|
end
|
175
163
|
end
|
176
164
|
|
177
165
|
def page_break
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
166
|
+
Nokogiri::XML::Node.new('p', @document).tap do |paragraph_node|
|
167
|
+
paragraph_node.namespace = @document.at_css('w|p:last').namespace
|
168
|
+
run_node = Nokogiri::XML::Node.new('r', @document)
|
169
|
+
page_break_node = Nokogiri::XML::Node.new('br', @document)
|
170
|
+
page_break_node['w:type'] = 'page'
|
171
|
+
|
172
|
+
paragraph_node << run_node
|
173
|
+
paragraph_node << page_break_node
|
174
|
+
end
|
186
175
|
end
|
187
176
|
|
188
177
|
def load_references
|
189
178
|
@references = {}
|
190
179
|
ref_xml = Nokogiri::XML(unzip_read(@zip_file, "word/_rels/document.xml.rels"))
|
180
|
+
|
191
181
|
ref_xml.css("Relationship").each do |ref|
|
192
182
|
id = ref.attributes["Id"].value
|
193
183
|
@references[id] = {
|
@@ -199,12 +189,14 @@ module LMDocstache
|
|
199
189
|
end
|
200
190
|
|
201
191
|
def find_documents_to_interpolate
|
202
|
-
@documents = {"word/document.xml" => @document}
|
192
|
+
@documents = { "word/document.xml" => @document }
|
193
|
+
|
203
194
|
@document.css("w|headerReference, w|footerReference").each do |header_ref|
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
195
|
+
next unless @references.has_key?(header_ref.attributes["id"].value)
|
196
|
+
|
197
|
+
ref = @references[header_ref.attributes["id"].value]
|
198
|
+
document_path = "word/#{ref[:target]}"
|
199
|
+
@documents[document_path] = Nokogiri::XML(unzip_read(@zip_file, document_path))
|
208
200
|
end
|
209
201
|
end
|
210
202
|
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
module LMDocstache
|
2
|
+
class Parser
|
3
|
+
BLOCK_TYPE_PATTERN = '(#|\^)\s*'
|
4
|
+
BLOCK_VARIABLE_PATTERN = '([^\s~=]+)'
|
5
|
+
BLOCK_OPERATOR_PATTERN = '\s*(~=|==)\s*'
|
6
|
+
BLOCK_VALUE_PATTERN = '([^\}]+?)\s*'
|
7
|
+
BLOCK_START_PATTERN = "{{#{BLOCK_TYPE_PATTERN}#{BLOCK_VARIABLE_PATTERN}"\
|
8
|
+
"#{BLOCK_OPERATOR_PATTERN}#{BLOCK_VALUE_PATTERN}}}"
|
9
|
+
BLOCK_CONTENT_PATTERN = '(.*?)'
|
10
|
+
BLOCK_CLOSE_PATTERN = '{{/\s*\k<2>\s*}}'
|
11
|
+
BLOCK_NAMED_CLOSE_PATTERN = '{{/\s*%{tag_name}\s*}}'
|
12
|
+
BLOCK_PATTERN = "#{BLOCK_START_PATTERN}#{BLOCK_CONTENT_PATTERN}"\
|
13
|
+
"#{BLOCK_CLOSE_PATTERN}"
|
14
|
+
|
15
|
+
BLOCK_START_MATCHER = /#{BLOCK_START_PATTERN}/
|
16
|
+
BLOCK_CLOSE_MATCHER = /{{\/\s*.+?\s*}}/
|
17
|
+
BLOCK_MATCHER = /#{BLOCK_PATTERN}/
|
18
|
+
VARIABLE_MATCHER = /{{([^#\^\/].*?)}}/
|
19
|
+
|
20
|
+
attr_reader :document, :data, :blocks, :special_variable_replacements
|
21
|
+
|
22
|
+
# The +special_variable_replacements+ option is a +Hash+ where the key is
|
23
|
+
# expected to be either a +Regexp+ or a +String+ representing the pattern
|
24
|
+
# of more specific type of variables that deserves a special treatment. The
|
25
|
+
# key must not contain the `{{}}` part, but only the pattern characters
|
26
|
+
# inside of it. As for the values of the +Hash+, it tells the replacement
|
27
|
+
# algorithm what to do with the matched string and there are the options:
|
28
|
+
#
|
29
|
+
# * +false+ -> in this case the matched variable will be kept without
|
30
|
+
# replacement
|
31
|
+
# * +Proc+ -> when a +Proc+ instance is provided, it's expected it to be
|
32
|
+
# able to receive the matched string and to return the string that will be
|
33
|
+
# used as replacement
|
34
|
+
# * any other value that will be turned into a string -> in this case, this
|
35
|
+
# will be the value that will replace the matched string
|
36
|
+
#
|
37
|
+
def initialize(document, data, options = {})
|
38
|
+
@document = document
|
39
|
+
@data = data.transform_keys(&:to_s)
|
40
|
+
@special_variable_replacements = options.fetch(:special_variable_replacements, {})
|
41
|
+
end
|
42
|
+
|
43
|
+
def parse_and_update_document!
|
44
|
+
find_blocks
|
45
|
+
replace_conditional_blocks_in_document!
|
46
|
+
replace_variables_in_document!
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def find_blocks
|
52
|
+
return @blocks if instance_variable_defined?(:@blocks)
|
53
|
+
return @blocks = [] unless document.text =~ BLOCK_MATCHER
|
54
|
+
|
55
|
+
@blocks = []
|
56
|
+
paragraphs = document.css('w|p')
|
57
|
+
|
58
|
+
while paragraph = paragraphs.shift do
|
59
|
+
content = paragraph.text
|
60
|
+
full_match = BLOCK_MATCHER.match(content)
|
61
|
+
start_match = !full_match && BLOCK_START_MATCHER.match(content)
|
62
|
+
|
63
|
+
next unless full_match || start_match
|
64
|
+
|
65
|
+
if full_match
|
66
|
+
@blocks.push(*ConditionalBlock.inline_blocks_from_paragraph(paragraph))
|
67
|
+
else
|
68
|
+
condition = condition_from_match_data(start_match)
|
69
|
+
comprised_paragraphs = all_block_elements(start_match[2], paragraph, paragraphs)
|
70
|
+
|
71
|
+
# We'll ignore conditional blocks that have no correspondent closing tag
|
72
|
+
next unless comprised_paragraphs
|
73
|
+
|
74
|
+
@blocks << ConditionalBlock.new(
|
75
|
+
elements: comprised_paragraphs,
|
76
|
+
condition: condition
|
77
|
+
)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
@blocks
|
82
|
+
end
|
83
|
+
|
84
|
+
# Evaluates all conditional blocks inside the given XML document and keep or
|
85
|
+
# remove their content inside the document, depending on the truthiness of
|
86
|
+
# the condition on each given conditional block.
|
87
|
+
def replace_conditional_blocks_in_document!
|
88
|
+
blocks.each do |conditional_block|
|
89
|
+
value = data[conditional_block.condition.left_term]
|
90
|
+
conditional_block.evaluate_with_value!(value)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# It simply replaces all the referenced variables inside document by their
|
95
|
+
# correspondent values provided in the attributes hash +data+.
|
96
|
+
def replace_variables_in_document!
|
97
|
+
document.css('w|t').each do |text_node|
|
98
|
+
text = text_node.text
|
99
|
+
|
100
|
+
next unless text =~ VARIABLE_MATCHER
|
101
|
+
next if has_skippable_variable?(text)
|
102
|
+
|
103
|
+
variable_replacement = special_variable_replacement(text)
|
104
|
+
|
105
|
+
text.gsub!(VARIABLE_MATCHER) do |_match|
|
106
|
+
next data[$1].to_s unless variable_replacement
|
107
|
+
|
108
|
+
variable_replacement.is_a?(Proc) ?
|
109
|
+
variable_replacement.call($1) :
|
110
|
+
variable_replacement.to_s
|
111
|
+
end
|
112
|
+
|
113
|
+
text_node.content = text
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def has_skippable_variable?(text)
|
118
|
+
!!special_variable_replacements.find do |(pattern, value)|
|
119
|
+
pattern = pattern.is_a?(String) ? /{{#{pattern}}}/ : /{{#{pattern.source}}}/
|
120
|
+
text =~ pattern && value == false
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def special_variable_replacement(text)
|
125
|
+
Array(
|
126
|
+
special_variable_replacements.find do |(pattern, value)|
|
127
|
+
pattern = pattern.is_a?(String) ? /{{#{pattern}}}/ : /{{#{pattern.source}}}/
|
128
|
+
text =~ pattern && !!value
|
129
|
+
end
|
130
|
+
).last
|
131
|
+
end
|
132
|
+
|
133
|
+
# This method created a +Condition+ instance for a partial conditional
|
134
|
+
# block, which in this case it's the start block part of it, represented by
|
135
|
+
# a string like the following:
|
136
|
+
#
|
137
|
+
# {{#variable == value}}
|
138
|
+
#
|
139
|
+
# @param match [MatchData]
|
140
|
+
#
|
141
|
+
# If converted into an +Array+, +match+ could be represented as follows:
|
142
|
+
#
|
143
|
+
# [
|
144
|
+
# '{{#variable == value}}',
|
145
|
+
# '#',
|
146
|
+
# 'variable',
|
147
|
+
# '==',
|
148
|
+
# 'value'
|
149
|
+
# ]
|
150
|
+
#
|
151
|
+
def condition_from_match_data(match)
|
152
|
+
Condition.new(
|
153
|
+
left_term: match[2],
|
154
|
+
right_term: match[4],
|
155
|
+
operator: match[3],
|
156
|
+
negation: match[1] == '^',
|
157
|
+
original_match: match[0]
|
158
|
+
)
|
159
|
+
end
|
160
|
+
|
161
|
+
# Gets all the XML nodes that involve a non-inline conditonal block,
|
162
|
+
# starting from the element that contains the conditional block start up
|
163
|
+
# to the element containing the conditional block ending
|
164
|
+
def all_block_elements(tag_name, initial_element, next_elements)
|
165
|
+
closing_block_pattern = BLOCK_NAMED_CLOSE_PATTERN % { tag_name: tag_name }
|
166
|
+
closing_block_matcher = /#{closing_block_pattern}/
|
167
|
+
paragraphs = Nokogiri::XML::NodeSet.new(document, [initial_element])
|
168
|
+
|
169
|
+
return unless next_elements.text =~ closing_block_matcher
|
170
|
+
|
171
|
+
until (paragraph = next_elements.shift).text =~ closing_block_matcher do
|
172
|
+
paragraphs << paragraph
|
173
|
+
end
|
174
|
+
|
175
|
+
paragraphs << paragraph
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
@@ -2,16 +2,15 @@ module LMDocstache
|
|
2
2
|
class Renderer
|
3
3
|
BLOCK_REGEX = /\{\{([\#\^])([\w\.]+)(?:(\s(?:==|~=)\s?.+?))?\}\}.+?\{\{\/\k<2>\}\}/m
|
4
4
|
|
5
|
-
|
5
|
+
attr_reader :parser
|
6
|
+
|
7
|
+
def initialize(xml, data, options = {})
|
6
8
|
@content = xml
|
7
|
-
@
|
8
|
-
@remove_role_tags = remove_role_tags
|
9
|
+
@parser = Parser.new(xml, data, options.slice(:special_variable_replacements))
|
9
10
|
end
|
10
11
|
|
11
12
|
def render
|
12
|
-
|
13
|
-
replace_tags(@content, @data)
|
14
|
-
remove_role_tags if @remove_role_tags
|
13
|
+
parser.parse_and_update_document!
|
15
14
|
@content
|
16
15
|
end
|
17
16
|
|
@@ -23,127 +22,5 @@ module LMDocstache
|
|
23
22
|
end
|
24
23
|
@content
|
25
24
|
end
|
26
|
-
|
27
|
-
private
|
28
|
-
|
29
|
-
def find_and_expand_blocks
|
30
|
-
blocks = @content.text.scan(BLOCK_REGEX)
|
31
|
-
found_blocks = blocks.uniq.flat_map do |block|
|
32
|
-
inverted = block[0] == "^"
|
33
|
-
Block.find_all(name: block[1], elements: @content.elements, data: @data, inverted: inverted, condition: block[2])
|
34
|
-
end
|
35
|
-
found_blocks.each do |block|
|
36
|
-
if block.inline
|
37
|
-
replace_conditionals
|
38
|
-
else
|
39
|
-
expand_and_replace_block(block) if block.present?
|
40
|
-
end
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
44
|
-
def expand_and_replace_block(block)
|
45
|
-
case block.type
|
46
|
-
when :conditional
|
47
|
-
condition = get_condition(block.name, block.condition, block.inverted)
|
48
|
-
unless condition
|
49
|
-
block.content_elements.each(&:unlink)
|
50
|
-
end
|
51
|
-
when :loop
|
52
|
-
set = @data.get(block.name, condition: block.condition)
|
53
|
-
content = set.map do |item|
|
54
|
-
data = DataScope.new(item, @data)
|
55
|
-
elements = block.content_elements.map(&:clone)
|
56
|
-
replace_tags(Nokogiri::XML::NodeSet.new(@content, elements), data)
|
57
|
-
end
|
58
|
-
content.each do |els|
|
59
|
-
el = els[0]
|
60
|
-
els[1..-1].each do |next_el|
|
61
|
-
el.after(next_el)
|
62
|
-
el = next_el
|
63
|
-
end
|
64
|
-
block.closing_element.before(els[0])
|
65
|
-
end
|
66
|
-
block.content_elements.each(&:unlink)
|
67
|
-
end
|
68
|
-
block.opening_element.unlink
|
69
|
-
block.closing_element.unlink
|
70
|
-
end
|
71
|
-
|
72
|
-
def replace_conditionals
|
73
|
-
@content.css('w|t').each do |text_el|
|
74
|
-
rendered_string = text_el.text
|
75
|
-
|
76
|
-
if !(results = rendered_string.scan(/{{#(.*?)}}(.*?){{\/(.*?)}}/)).empty?
|
77
|
-
results.each do |r|
|
78
|
-
vals = r[0].split('==')
|
79
|
-
condition = get_condition(vals[0].strip, "== #{vals[1]}")
|
80
|
-
if condition
|
81
|
-
rendered_string.sub!("{{##{r[0]}}}", "")
|
82
|
-
rendered_string.sub!("{{/#{r[2]}}}", "")
|
83
|
-
else
|
84
|
-
rendered_string.sub!("{{##{r[0]}}}#{r[1]}{{/#{r[2]}}}", "")
|
85
|
-
end
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
|
-
# the only difference in this code block is caret instead of pound in three places,
|
90
|
-
# the inverted value passed to get_condition, and the condition being inverted. maybe combine them?
|
91
|
-
if !(results = rendered_string.scan(/{{\^(.*?)}}(.*?){{\/(.*?)}}/)).empty?
|
92
|
-
results.each do |r|
|
93
|
-
vals = r[0].split('==')
|
94
|
-
condition = get_condition(vals[0].strip, "== #{vals[1]}", true)
|
95
|
-
if condition
|
96
|
-
rendered_string.sub!("{{^#{r[0]}}}", "")
|
97
|
-
rendered_string.sub!("{{/#{r[2]}}}", "")
|
98
|
-
else
|
99
|
-
rendered_string.sub!("{{^#{r[0]}}}#{r[1]}{{/#{r[2]}}}", "")
|
100
|
-
end
|
101
|
-
end
|
102
|
-
end
|
103
|
-
|
104
|
-
text_el.content = rendered_string
|
105
|
-
end
|
106
|
-
end
|
107
|
-
|
108
|
-
def replace_tags(elements, data)
|
109
|
-
elements.css('w|t').select {|t| !(t.text =~ Document::ROLES_REGEXP)}.each do |text_el|
|
110
|
-
if !(results = text_el.text.scan(/\{\{([\w\.\|]+)\}\}/).flatten).empty? &&
|
111
|
-
rendered_string = text_el.text
|
112
|
-
results.each do |r|
|
113
|
-
rendered_string.gsub!("{{#{r}}}", data.get(r).to_s)
|
114
|
-
end
|
115
|
-
text_el.content = rendered_string
|
116
|
-
end
|
117
|
-
end
|
118
|
-
elements
|
119
|
-
end
|
120
|
-
|
121
|
-
def remove_role_tags
|
122
|
-
@content.css('w|t').each do |text_el|
|
123
|
-
results = text_el.text.scan(Document::ROLES_REGEXP).map {|r| r.first }
|
124
|
-
unless results.empty?
|
125
|
-
rendered_string = text_el.text
|
126
|
-
results.each do |result|
|
127
|
-
padding = "".ljust(result.size, " ")
|
128
|
-
rendered_string.gsub!(result, padding)
|
129
|
-
end
|
130
|
-
text_el.content = rendered_string
|
131
|
-
end
|
132
|
-
end
|
133
|
-
end
|
134
|
-
|
135
|
-
private
|
136
|
-
|
137
|
-
def get_condition(name, condition, inverted = false)
|
138
|
-
case condition = @data.get(name, condition: condition)
|
139
|
-
when Array
|
140
|
-
condition = !condition.empty?
|
141
|
-
else
|
142
|
-
condition = !!condition
|
143
|
-
end
|
144
|
-
condition = !condition if inverted
|
145
|
-
|
146
|
-
condition
|
147
|
-
end
|
148
25
|
end
|
149
26
|
end
|
data/lib/lm_docstache/version.rb
CHANGED
data/lm_docstache.gemspec
CHANGED
@@ -5,8 +5,8 @@ require "lm_docstache/version"
|
|
5
5
|
Gem::Specification.new do |s|
|
6
6
|
s.name = "lm_docstache"
|
7
7
|
s.version = LMDocstache::VERSION
|
8
|
-
s.authors = ["Roey Chasman", "Frederico Assunção", "Jonathan Stevens", "Will Cosgrove"]
|
9
|
-
s.email = ["roey@lawmatics.com", "fred@lawmatics.com", "jonathan@lawmatics.com", "will@willcosgrove.com"]
|
8
|
+
s.authors = ["Roey Chasman", "Frederico Assunção", "Jonathan Stevens", "Leandro Camargo", "Will Cosgrove"]
|
9
|
+
s.email = ["roey@lawmatics.com", "fred@lawmatics.com", "jonathan@lawmatics.com", "leandro@lawmatics.com", "will@willcosgrove.com"]
|
10
10
|
s.homepage = "https://www.lawmatics.com"
|
11
11
|
s.summary = %q{Merges Hash of Data into Word docx template files using mustache syntax}
|
12
12
|
s.description = %q{Integrates data into MS Word docx template files. Processing supports loops and replacement of strings of data both outside and within loops.}
|
data/spec/integration_spec.rb
CHANGED
@@ -67,10 +67,6 @@ describe 'integration test', integration: true do
|
|
67
67
|
expect(document.usable_tags.count).to be(30)
|
68
68
|
end
|
69
69
|
|
70
|
-
it 'has the expected amount of role tags' do
|
71
|
-
expect(document.role_tags.count).to be(6)
|
72
|
-
end
|
73
|
-
|
74
70
|
it 'has the expected amount of usable roles tags' do
|
75
71
|
document.fix_errors
|
76
72
|
expect(document.usable_role_tags.count).to be(6)
|
metadata
CHANGED
@@ -1,17 +1,18 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: lm_docstache
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Roey Chasman
|
8
8
|
- Frederico Assunção
|
9
9
|
- Jonathan Stevens
|
10
|
+
- Leandro Camargo
|
10
11
|
- Will Cosgrove
|
11
12
|
autorequire:
|
12
13
|
bindir: bin
|
13
14
|
cert_chain: []
|
14
|
-
date:
|
15
|
+
date: 2021-02-17 00:00:00.000000000 Z
|
15
16
|
dependencies:
|
16
17
|
- !ruby/object:Gem::Dependency
|
17
18
|
name: nokogiri
|
@@ -95,12 +96,13 @@ email:
|
|
95
96
|
- roey@lawmatics.com
|
96
97
|
- fred@lawmatics.com
|
97
98
|
- jonathan@lawmatics.com
|
99
|
+
- leandro@lawmatics.com
|
98
100
|
- will@willcosgrove.com
|
99
101
|
executables: []
|
100
102
|
extensions: []
|
101
103
|
extra_rdoc_files: []
|
102
104
|
files:
|
103
|
-
- ".github/workflows/
|
105
|
+
- ".github/workflows/rspec.yml"
|
104
106
|
- ".gitignore"
|
105
107
|
- CHANGELOG.md
|
106
108
|
- Gemfile
|
@@ -108,15 +110,14 @@ files:
|
|
108
110
|
- README.md
|
109
111
|
- Rakefile
|
110
112
|
- lib/lm_docstache.rb
|
111
|
-
- lib/lm_docstache/
|
112
|
-
- lib/lm_docstache/
|
113
|
+
- lib/lm_docstache/condition.rb
|
114
|
+
- lib/lm_docstache/conditional_block.rb
|
113
115
|
- lib/lm_docstache/document.rb
|
116
|
+
- lib/lm_docstache/parser.rb
|
114
117
|
- lib/lm_docstache/renderer.rb
|
115
118
|
- lib/lm_docstache/version.rb
|
116
119
|
- lm_docstache.gemspec
|
117
120
|
- spec/conditional_block_spec.rb
|
118
|
-
- spec/data_scope_spec.rb
|
119
|
-
- spec/empty_data_scope_spec.rb
|
120
121
|
- spec/example_input/ExampleTemplate.docx
|
121
122
|
- spec/example_input/blank.docx
|
122
123
|
- spec/integration_spec.rb
|
@@ -147,8 +148,6 @@ specification_version: 4
|
|
147
148
|
summary: Merges Hash of Data into Word docx template files using mustache syntax
|
148
149
|
test_files:
|
149
150
|
- spec/conditional_block_spec.rb
|
150
|
-
- spec/data_scope_spec.rb
|
151
|
-
- spec/empty_data_scope_spec.rb
|
152
151
|
- spec/example_input/ExampleTemplate.docx
|
153
152
|
- spec/example_input/blank.docx
|
154
153
|
- spec/integration_spec.rb
|
data/lib/lm_docstache/block.rb
DELETED
@@ -1,71 +0,0 @@
|
|
1
|
-
module LMDocstache
|
2
|
-
class Block
|
3
|
-
attr_reader :name, :opening_element, :content_elements, :closing_element, :inverted, :condition, :inline
|
4
|
-
def initialize(name:, data:, opening_element:, content_elements:, closing_element:, inverted:, condition: nil, inline: false)
|
5
|
-
@name = name
|
6
|
-
@data = data
|
7
|
-
@opening_element = opening_element
|
8
|
-
@content_elements = content_elements
|
9
|
-
@closing_element = closing_element
|
10
|
-
@inverted = inverted
|
11
|
-
@condition = condition
|
12
|
-
@inline = inline
|
13
|
-
end
|
14
|
-
|
15
|
-
def type
|
16
|
-
@type ||= if @inverted
|
17
|
-
:conditional
|
18
|
-
else
|
19
|
-
if @data.get(@name).is_a? Array
|
20
|
-
:loop
|
21
|
-
else
|
22
|
-
:conditional
|
23
|
-
end
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
|
-
def loop?
|
28
|
-
type == :loop
|
29
|
-
end
|
30
|
-
|
31
|
-
def conditional?
|
32
|
-
type == :conditional
|
33
|
-
end
|
34
|
-
|
35
|
-
def self.find_all(name:, data:, elements:, inverted:, condition: nil, ignore_missing: true, child: false)
|
36
|
-
inverted_op = inverted ? '\^' : '\#'
|
37
|
-
full_tag_regex = /\{\{#{inverted_op}(#{name})\s?#{condition}\}\}.+?\{\{\/\k<1>\}\}/m
|
38
|
-
start_tag_regex = /\{\{#{inverted_op}#{name}\s?#{condition}\}\}/m
|
39
|
-
close_tag_regex = /\{\{\/#{name}\}\}/
|
40
|
-
|
41
|
-
if elements.text.match(full_tag_regex)
|
42
|
-
if elements.any? { |e| e.text.match(full_tag_regex) }
|
43
|
-
matches = elements.select { |e| e.text.match(full_tag_regex) }
|
44
|
-
return matches.flat_map do |match|
|
45
|
-
if match.elements.any?
|
46
|
-
find_all(name: name, data: data, elements: match.elements, inverted: inverted, condition: condition, child: true)
|
47
|
-
else
|
48
|
-
extract_block_from_element(name, data, match, inverted, condition)
|
49
|
-
end
|
50
|
-
end
|
51
|
-
else
|
52
|
-
opening = elements.find { |e| e.text.match(start_tag_regex) }
|
53
|
-
content = []
|
54
|
-
next_sibling = opening.next
|
55
|
-
while !next_sibling.text.match(close_tag_regex)
|
56
|
-
content << next_sibling
|
57
|
-
next_sibling = next_sibling.next
|
58
|
-
end
|
59
|
-
closing = next_sibling
|
60
|
-
return Block.new(name: name, data: data, opening_element: opening, content_elements: content, closing_element: closing, inverted: inverted, condition: condition)
|
61
|
-
end
|
62
|
-
else
|
63
|
-
raise "Block not found in given elements" unless ignore_missing
|
64
|
-
end
|
65
|
-
end
|
66
|
-
|
67
|
-
def self.extract_block_from_element(name, data, element, inverted, condition)
|
68
|
-
return Block.new(name: name, data: data, opening_element: element.parent.previous, content_elements: [element.parent], closing_element: element.parent.next, inverted: inverted, condition: condition, inline: true)
|
69
|
-
end
|
70
|
-
end
|
71
|
-
end
|
@@ -1,67 +0,0 @@
|
|
1
|
-
module LMDocstache
|
2
|
-
class DataScope
|
3
|
-
|
4
|
-
def initialize(data, parent=EmptyDataScope.new)
|
5
|
-
@data = data
|
6
|
-
@parent = parent
|
7
|
-
end
|
8
|
-
|
9
|
-
def get(key, hash: @data, original_key: key, condition: nil)
|
10
|
-
symbolize_keys!(hash)
|
11
|
-
tokens = key.split('.')
|
12
|
-
if tokens.length == 1
|
13
|
-
result = hash.fetch(key.to_sym) { |_| @parent.get(original_key) }
|
14
|
-
unless result.respond_to?(:select)
|
15
|
-
return result if evaluate_condition(condition, result)
|
16
|
-
else
|
17
|
-
return result.select { |el| evaluate_condition(condition, el) }
|
18
|
-
end
|
19
|
-
elsif tokens.length > 1
|
20
|
-
key = tokens.shift
|
21
|
-
subhash = hash.fetch(key.to_sym) { |_| @parent.get(original_key) }
|
22
|
-
return get(tokens.join('.'), hash: subhash, original_key: original_key)
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
private
|
27
|
-
|
28
|
-
def symbolize_keys!(hash)
|
29
|
-
hash.transform_keys!{ |key| key.to_sym rescue key }
|
30
|
-
end
|
31
|
-
|
32
|
-
def evaluate_condition(condition, data)
|
33
|
-
return true if condition.nil?
|
34
|
-
condition = condition.match(/(==|~=)\s*(.+)/)
|
35
|
-
operator = condition[1]
|
36
|
-
expression = condition[2]
|
37
|
-
case condition[1]
|
38
|
-
when "=="
|
39
|
-
# Equality condition
|
40
|
-
expression = evaluate_expression(expression, data)
|
41
|
-
return data == expression
|
42
|
-
else
|
43
|
-
# Matches condition
|
44
|
-
expression = evaluate_expression(expression, data)
|
45
|
-
right = Regex.new(expression.match(/\/(.+)\//)[1])
|
46
|
-
return data.match(right)
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
def evaluate_expression(expression, data)
|
51
|
-
if expression.match(/(["'“]?)(.+)(\k<1>|”)/)
|
52
|
-
$2
|
53
|
-
elsif data.respond_to?(:select)
|
54
|
-
get(expression, hash: data)
|
55
|
-
else
|
56
|
-
false
|
57
|
-
end
|
58
|
-
end
|
59
|
-
end
|
60
|
-
|
61
|
-
class EmptyDataScope
|
62
|
-
def get(_)
|
63
|
-
return nil
|
64
|
-
end
|
65
|
-
end
|
66
|
-
|
67
|
-
end
|
data/spec/data_scope_spec.rb
DELETED
@@ -1,56 +0,0 @@
|
|
1
|
-
require 'spec_helper'
|
2
|
-
|
3
|
-
describe LMDocstache::DataScope do
|
4
|
-
describe "#get" do
|
5
|
-
context "main body" do
|
6
|
-
let(:data_scope) {
|
7
|
-
LMDocstache::DataScope.new({foo: "bar1", bar: {baz: "bar2", qux: {quux: "bar3"}}})
|
8
|
-
}
|
9
|
-
it "should resolve keys with no nesting" do
|
10
|
-
expect(data_scope.get('foo')).to eq("bar1")
|
11
|
-
end
|
12
|
-
|
13
|
-
it "should resolve nested keys" do
|
14
|
-
expect(data_scope.get('bar.baz')).to eq("bar2")
|
15
|
-
end
|
16
|
-
|
17
|
-
it "should resolve super nested keys" do
|
18
|
-
expect(data_scope.get('bar.qux.quux')).to eq("bar3")
|
19
|
-
end
|
20
|
-
end
|
21
|
-
|
22
|
-
context "loop" do
|
23
|
-
let(:parent_data_scope) {
|
24
|
-
LMDocstache::DataScope.new({
|
25
|
-
users: [ {
|
26
|
-
id: 1, name: "John Smith", brother: {id: 3, name: "Will Smith"}
|
27
|
-
}], id: 2, foo: "bar", brother: {baz: "qux"}}) }
|
28
|
-
|
29
|
-
let(:data_scope) {
|
30
|
-
LMDocstache::DataScope.new({
|
31
|
-
id: 1, name: "John Smith", brother: {id: 3, name: "Will Smith"}}, parent_data_scope)
|
32
|
-
}
|
33
|
-
|
34
|
-
it "should resolve keys with no nesting" do
|
35
|
-
expect(data_scope.get("id")).to eq(1)
|
36
|
-
end
|
37
|
-
|
38
|
-
it "should resolve nested keys" do
|
39
|
-
expect(data_scope.get("brother.id")).to eq(3)
|
40
|
-
end
|
41
|
-
|
42
|
-
it "should fall back to parent scope if key not found" do
|
43
|
-
expect(data_scope.get("foo")).to eq("bar")
|
44
|
-
end
|
45
|
-
|
46
|
-
it "should fall back to parent even during a partial match" do
|
47
|
-
expect(data_scope.get("brother.baz")).to eq("qux")
|
48
|
-
end
|
49
|
-
|
50
|
-
it "should return nil for no match" do
|
51
|
-
expect(data_scope.get("bat")).to be_nil
|
52
|
-
expect(data_scope.get("brother.qux")).to be_nil
|
53
|
-
end
|
54
|
-
end
|
55
|
-
end
|
56
|
-
end
|