lm_docstache 1.3.10 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|