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 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