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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58addacdd5e64a7a54ca48ea1a811e310b3ef0d3e7a2084f41244ae8f5b39cbf
4
- data.tar.gz: 40471c6fcceb845dcfadb81dbf30a260eb15f0f37c444ff796fcaa3c08f1edb2
3
+ metadata.gz: 805d05c9872a3562ac59527ada43842f4f28412d4a273d3010986f1182ee8421
4
+ data.tar.gz: 1c0ecd9d43788420553310cefcba6bcca8fe24cd6dc14ff3cbac27f2cfb8ac8f
5
5
  SHA512:
6
- metadata.gz: d9e6329eaafd68058293052d339c7a0edff0ec0fb3cf309757b16c13eb42428ea22d0ae121b8eaf6fb3ab430ccc3ea4b93d9d5068403bd53a385df22056ed755
7
- data.tar.gz: ac48fc982b1a1e38a8ff7c6a4207aa7e6558d3b79ebd8ef8bfb437234ebddd458ae5ec1674f942f4de370dd4660127f160ffa7c58b7487bd22caf494cfc594a6
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: Ruby
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
- # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
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
@@ -1,4 +1,5 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/lm_docstache.svg)](http://badge.fury.io/rb/lm_docstache)
2
+ ![rspec](https://github.com/boost-legal/lm-docstache/workflows/rspec/badge.svg)
2
3
 
3
4
  # LM-Docstache
4
5
 
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/block"
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{|p| Zip::File.open(p)}
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.add_next_sibling(page_break)
15
- @document.css('w|p').last.add_next_sibling(doc.css('w|body > *:not(w|sectPr)'))
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
- def role_tags
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.flat_map do |document|
55
- document.css('w|t')
56
- .select { |tag| tag.text =~ TAGS_REGEXP }
57
- .flat_map { |tag| tag.text.scan(TAGS_REGEXP) }
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
- self.usable_tags.select {|t| !(t =~ ROLES_REGEXP)}.map do |tag|
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
- return unusable_tags
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={}, remove_role_tags = false)
91
- rendered_documents = Hash[
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
- rendered_documents = Hash[
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
- rendered_documents = Hash[
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
- return buffer.sysread
93
+ buffer.sysread
119
94
  end
120
95
 
121
- def render_xml(data={})
122
- rendered_documents = Hash[
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, LMDocstache::Renderer.new(document.dup, data).render]
105
+ [path, render_document(document, data, text, render_options)]
125
106
  end
126
107
  ]
127
-
128
- rendered_documents
129
108
  end
130
109
 
131
- private
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
- tags + document.css('w|p').select {|t| t.text =~ /#{Regexp.escape(tag)}/}
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(p)
142
- runs = p.css('w|r')
127
+ def flatten_paragraph(paragraph)
128
+ run_nodes = paragraph.css('w|r')
129
+ host_run_node = run_nodes.shift
143
130
 
144
- host_run = runs.shift
145
- until host_run.at_css('w|t').present? || runs.size == 0 do
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
- runs.each do |run|
150
- host_run.at_css('w|t').content += run.text
151
- run.unlink
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
- return contents
145
+
146
+ contents
160
147
  end
161
148
 
162
149
  def zip_buffer(documents)
163
- Zip::OutputStream.write_buffer do |out|
164
- @zip_file.entries.each do |e|
165
- unless documents.keys.include?(e.name)
166
- out.put_next_entry(e.name)
167
- out.write(e.get_input_stream.read)
168
- end
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
- out.put_next_entry(path)
172
- out.write(document.to_xml(indent: 0).gsub("\n", ""))
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
- p = Nokogiri::XML::Node.new("p", @document)
179
- p.namespace = @document.at_css('w|p:last').namespace
180
- r = Nokogiri::XML::Node.new("r", @document)
181
- p.add_child(r)
182
- br = Nokogiri::XML::Node.new("br", @document)
183
- r.add_child(br)
184
- br['w:type'] = "page"
185
- return p
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
- if @references.has_key?(header_ref.attributes["id"].value)
205
- ref = @references[header_ref.attributes["id"].value]
206
- @documents["word/#{ref[:target]}"] = Nokogiri::XML(unzip_read(@zip_file, "word/#{ref[:target]}"))
207
- end
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
- def initialize(xml, data, remove_role_tags = false)
5
+ attr_reader :parser
6
+
7
+ def initialize(xml, data, options = {})
6
8
  @content = xml
7
- @data = DataScope.new(data)
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
- find_and_expand_blocks
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
@@ -1,3 +1,3 @@
1
1
  module LMDocstache
2
- VERSION = "1.3.10"
2
+ VERSION = "2.0.0"
3
3
  end
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.}
@@ -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: 1.3.10
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: 2020-11-17 00:00:00.000000000 Z
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/ruby.yml"
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/block.rb
112
- - lib/lm_docstache/data_scope.rb
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
@@ -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
@@ -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
@@ -1,10 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe LMDocstache::EmptyDataScope do
4
- let(:empty_data_scope) { LMDocstache::EmptyDataScope.new }
5
- describe '#get' do
6
- it "should always return nil" do
7
- expect(empty_data_scope.get('foo')).to be_nil
8
- end
9
- end
10
- end