metanorma-utils 1.11.8 → 2.0.1

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: 9a99bb7fdd555d6d2fff452c36700104b7d80fab5ceefa37c271bd8f8a99b0d1
4
- data.tar.gz: e35dd105b562b8fd16a2d8695f0bf9c84bee7a946c404a3ffadc34eebb51a4e7
3
+ metadata.gz: a291c816aab2db45409cc9c255e5dc4214cac348b2161941226f6dcea35068f0
4
+ data.tar.gz: c9d66f40e1f454cb59dca034a5db883f28b7fe625d9a83a29b7d0142721234da
5
5
  SHA512:
6
- metadata.gz: b99789e9ff3abf5ab4f6d39db2a93f3b0c4edd8cd15667bcb9679328e17535c3c4a8246bb474fce4fbe1e61644573c42b0143803c8557dd5c4cbe0ecacb8a8b3
7
- data.tar.gz: 794cefd11516d460c5f3cd2d73b13cec42c097b6e7cc28cb32fa10097c8b89c6257a7c290313f0311d63145dd15f4d28e32f5c2d1df869282d3687ae76db504c
6
+ metadata.gz: 7a84e5fd6d93b9ed55259e1702214bf00183fa739f057533efb456e0350c54fb1b7f7c0166e11687f610adf8be5e344d5117ea187c876b29e762d17a283b3e51
7
+ data.tar.gz: 73a7acbae9dfa698a47e93e7d5dc3b0175df6c6430458160a3a8b5c6fc75a5d4f11c553200235e3d4321959b0d18b85d075e0d87a3ed8cf802f0f73e8f562a84
@@ -6,3 +6,4 @@ require_relative "utils/linestatus"
6
6
  require_relative "utils/log"
7
7
  require_relative "utils/xml"
8
8
  require_relative "utils/anchor"
9
+ require_relative "utils/anchor_ranges"
@@ -0,0 +1,148 @@
1
+ module Metanorma
2
+ module Utils
3
+ # AnchorRanges provides efficient range checking for nodes based on
4
+ # anchor positions in a document. It determines whether an arbitrary node
5
+ # falls within the range # defined by two anchor points (A-B), where
6
+ # the range includes node A through all descendants of node B.
7
+ class AnchorRanges
8
+ attr_reader :anchor_map
9
+
10
+ # Initialize with a Nokogiri document
11
+ # @param doc [Nokogiri::XML::Document] The document to process
12
+ def initialize(doc)
13
+ @anchor_map = build_anchor_id_map(doc)
14
+ @anchor_to_ord = nil
15
+ @anchor_to_last_ord = nil
16
+ @id_to_ord = nil
17
+ end
18
+
19
+ # Get mapping of anchor to ord
20
+ # @return [Hash] anchor => ord
21
+ def anchor_to_ord
22
+ @anchor_to_ord ||= build_anchor_to_ord
23
+ end
24
+
25
+ # Get mapping of anchor to last descendant ord
26
+ # @return [Hash] anchor => last_ord
27
+ def anchor_to_last_ord
28
+ @anchor_to_last_ord ||= build_anchor_to_last_ord
29
+ end
30
+
31
+ # Get mapping of id to ord
32
+ # @return [Hash] id => ord
33
+ def id_to_ord
34
+ @id_to_ord ||= build_id_to_ord
35
+ end
36
+
37
+ # Check if a node (by id or anchor) is within the range A-B
38
+ # @param node_id_or_anchor [String] The id or anchor of the node to check
39
+ # @param anchor_a [String] The anchor defining the start of the range
40
+ # @param anchor_b [String] The anchor defining the end of the range
41
+ # @return [Boolean] true if the node is within the range A-B
42
+ def in_range?(node_id_or_anchor, anchor_a, anchor_b)
43
+ node_ord = find_node_ord(node_id_or_anchor)
44
+ return false if node_ord.nil?
45
+
46
+ start_ord = anchor_to_ord[anchor_a]
47
+ end_ord = anchor_to_last_ord[anchor_b]
48
+
49
+ return false if start_ord.nil? || end_ord.nil?
50
+
51
+ node_ord >= start_ord && node_ord <= end_ord
52
+ end
53
+
54
+ # Get the ordinal range for an anchor (start to last descendant)
55
+ # @param anchor [String] The anchor to get the range for
56
+ # @return [Range, nil] The range of ordinals, or nil if anchor not found
57
+ def anchor_range(anchor)
58
+ start_ord = anchor_to_ord[anchor]
59
+ end_ord = anchor_to_last_ord[anchor]
60
+ return nil if start_ord.nil? || end_ord.nil?
61
+
62
+ (start_ord..end_ord)
63
+ end
64
+
65
+ private
66
+
67
+ # Generate a map of all nodes with anchor or id attributes,
68
+ # recording their linear order and the next non-descendant anchor
69
+ # @return [Array<Hash>] Array of hashes with keys: :anchor, :id, :ord, :next_anchor
70
+ def build_anchor_id_map(doc)
71
+ nodes = doc.xpath("//*[@id or @anchor]")
72
+ nodes.each_with_index.map do |node, i|
73
+ {
74
+ anchor: node["anchor"],
75
+ id: node["id"],
76
+ ord: i,
77
+ next_anchor: find_next_non_descendant_anchor(nodes, i),
78
+ }
79
+ end
80
+ end
81
+
82
+ # Find the anchor attribute of the next node that is not a descendant
83
+ # of the node at the given index
84
+ #
85
+ # @param nodes [Nokogiri::XML::NodeSet] All nodes with anchor or id
86
+ # @param current_index [Integer] Index of the current node
87
+ # @return [String, nil] The anchor attribute of the next non-descendant node
88
+ def find_next_non_descendant_anchor(nodes, current_index)
89
+ current_node = nodes[current_index]
90
+ current_path = current_node.path
91
+
92
+ # Look through subsequent nodes
93
+ ((current_index + 1)...nodes.length).each do |i|
94
+ next_node = nodes[i]
95
+ next_path = next_node.path
96
+
97
+ # Check if next_node is a descendant of current_node
98
+ # A node is a descendant if its path starts with the current path
99
+ # followed by a path separator
100
+ unless next_path.start_with?("#{current_path}/")
101
+ return next_node["anchor"]
102
+ end
103
+ end
104
+
105
+ nil # No non-descendant node found
106
+ end
107
+
108
+ def build_anchor_to_ord
109
+ hash = {}
110
+ @anchor_map.each do |entry|
111
+ hash[entry[:anchor]] = entry[:ord] if entry[:anchor]
112
+ end
113
+ hash
114
+ end
115
+
116
+ def build_id_to_ord
117
+ hash = {}
118
+ @anchor_map.each do |entry|
119
+ hash[entry[:id]] = entry[:ord] if entry[:id]
120
+ end
121
+ hash
122
+ end
123
+
124
+ def build_anchor_to_last_ord
125
+ hash = {}
126
+ @anchor_map.each do |entry|
127
+ next unless entry[:anchor]
128
+
129
+ # The last descendant is the ord right before the next_anchor
130
+ # If there's no next_anchor, it's the last node in the map
131
+ if entry[:next_anchor]
132
+ next_ord = anchor_to_ord[entry[:next_anchor]]
133
+ hash[entry[:anchor]] = next_ord - 1 if next_ord
134
+ else
135
+ # No next anchor means this extends to the end of the document
136
+ hash[entry[:anchor]] = @anchor_map.last[:ord]
137
+ end
138
+ end
139
+ hash
140
+ end
141
+
142
+ def find_node_ord(node_id_or_anchor)
143
+ # Try as anchor first, then as id
144
+ anchor_to_ord[node_id_or_anchor] || id_to_ord[node_id_or_anchor]
145
+ end
146
+ end
147
+ end
148
+ end
data/lib/utils/log.rb CHANGED
@@ -1,19 +1,33 @@
1
1
  require "htmlentities"
2
+ require_relative "log_html"
2
3
 
3
4
  module Metanorma
4
5
  module Utils
5
6
  class Log
6
- attr_writer :xml, :suppress_log
7
+ attr_accessor :suppress_log
7
8
 
8
- def initialize
9
+ # messages: hash of message IDs to {error, severity, category}
10
+ # severity: 0: abort; 1: serious; 2: not serious; 3: info only
11
+ def initialize(messages = {})
9
12
  @log = {}
10
13
  @c = HTMLEntities.new
11
14
  @mapid = {}
12
- @suppress_log = { severity: 4, category: [] }
15
+ @suppress_log = { severity: 4, category: [], error_ids: [],
16
+ locations: [] }
17
+ @msg = messages.each_value do |v|
18
+ v[:error] = v[:error]
19
+ .encode("UTF-8", invalid: :replace, undef: :replace)
20
+ end
21
+ end
22
+
23
+ def add_msg(messages)
24
+ @msg.merge!(messages)
13
25
  end
14
26
 
15
- def to_ncname(tag)
16
- ::Metanorma::Utils.to_ncname(tag)
27
+ # pass Nokogiri XML in, to record where all the anchors and ids
28
+ # are in the target document
29
+ def add_error_ranges(xml)
30
+ @anchor_ranges = AnchorRanges.new(xml)
17
31
  end
18
32
 
19
33
  def save_to(filename, dir = nil)
@@ -24,21 +38,27 @@ module Metanorma
24
38
  @htmlfilename = "#{b}.html"
25
39
  end
26
40
 
27
- # severity: 0: abort; 1: serious; 2: not serious; 3: info only
28
- def add(category, loc, msg, severity: 2, display: true)
29
- @novalid || suppress_log?(category, severity, msg) and return
30
- @log[category] ||= []
31
- item = create_entry(loc, msg, severity)
32
- @log[category] << item
41
+ def add_prep(id)
42
+ id = id.to_sym
43
+ @msg[id] or raise "Logging: Error #{id} is not defined!"
44
+ @novalid || suppress_log?(id) and return nil
45
+ @log[@msg[id][:category]] ||= []
46
+ @msg[id]
47
+ end
48
+
49
+ def add(id, loc, display: true, params: [])
50
+ m = add_prep(id) or return
51
+ msg = create_entry(loc, m[:error], m[:severity], id, params)
52
+ @log[m[:category]] << msg
33
53
  loc = loc.nil? ? "" : "(#{current_location(loc)}): "
34
- suppress_display?(category, loc, msg, display) or
35
- warn "#{category}: #{loc}#{msg}"
54
+ suppress_display?(m[:category], loc, msg, display) or
55
+ warn "#{m[:category]}: #{loc}#{msg[:error]}"
36
56
  end
37
57
 
38
58
  def abort_messages
39
59
  @log.values.each_with_object([]) do |v, m|
40
60
  v.each do |e|
41
- e[:severity].zero? and m << e[:message]
61
+ e[:severity].zero? and m << e[:error]
42
62
  end
43
63
  end
44
64
  end
@@ -51,10 +71,12 @@ module Metanorma
51
71
  end
52
72
  end
53
73
 
54
- def suppress_log?(category, severity, msg)
55
- category == "Relaton" && /^Fetching /.match?(msg) ||
56
- @suppress_log[:severity] <= severity ||
57
- @suppress_log[:category].include?(category)
74
+ def suppress_log?(id)
75
+ category = @msg[id][:category]
76
+ category && /^Fetching /.match?(@msg[id][:error]) ||
77
+ @suppress_log[:severity] <= @msg[id][:severity] ||
78
+ @suppress_log[:category].include?(category) ||
79
+ @suppress_log[:error_ids].include?(id.to_s)
58
80
  end
59
81
 
60
82
  def suppress_display?(category, _loc, _msg, display)
@@ -62,48 +84,72 @@ module Metanorma
62
84
  !display
63
85
  end
64
86
 
65
- def create_entry(loc, msg, severity)
66
- msg = msg.encode("UTF-8", invalid: :replace, undef: :replace)
67
- item = { location: current_location(loc), severity: severity,
68
- message: msg, context: context(loc), line: line(loc, msg) }
69
- if item[:message].include?(" :: ")
70
- a = item[:message].split(" :: ", 2)
87
+ def create_entry(loc, msg, severity, error_id, params)
88
+ loc_str, anchor, node_id = current_location(loc)
89
+ item = { error_id: error_id, location: loc_str, severity: severity,
90
+ error: interpolate_msg(msg, params), context: context(loc),
91
+ line: line(loc, msg), anchor: anchor, id: node_id }
92
+ if item[:error].include?(" :: ")
93
+ a = item[:error].split(" :: ", 2)
71
94
  item[:context] = a[1]
72
- item[:message] = a[0]
95
+ item[:error] = a[0]
73
96
  end
74
97
  item
75
98
  end
76
99
 
100
+ def interpolate_msg(msg, params)
101
+ # Count %s placeholders in the message
102
+ placeholder_count = msg.scan(/%s/).length
103
+ interpolation_params = if params.empty?
104
+ ::Array.new(placeholder_count, "")
105
+ else
106
+ params
107
+ end
108
+ placeholder_count.zero? ? msg : (msg % interpolation_params)
109
+ end
110
+
77
111
  def current_location(node)
78
- if node.nil? then ""
79
- elsif node.respond_to?(:id) && !node.id.nil? then "ID #{node.id}"
80
- elsif node.respond_to?(:id) && node.id.nil? && node.respond_to?(:parent)
81
- while !node.nil? && node.id.nil?
82
- node = node.parent
83
- end
84
- node.nil? ? "" : "ID #{node.id}"
85
- elsif node.respond_to?(:to_xml) && node.respond_to?(:parent)
86
- while !node.nil? && node["id"].nil? && node.respond_to?(:parent)
87
- node = node.parent
88
- end
89
- node.respond_to?(:parent) ? "ID #{node['anchor'] || node['id']}" : ""
90
- elsif node.is_a? String then node
91
- elsif node.respond_to?(:lineno) && !node.lineno.nil? &&
92
- !node.lineno.empty?
93
- "Asciidoctor Line #{'%06d' % node.lineno}"
94
- elsif node.respond_to?(:line) && !node.line.nil?
95
- "XML Line #{'%06d' % node.line}"
96
- elsif node.respond_to?(:parent)
97
- while !node.nil? &&
98
- (!node.respond_to?(:level) || node.level.positive?) &&
99
- (!node.respond_to?(:context) || node.context != :section)
100
- node = node.parent
101
- return "Section: #{node.title}" if node.respond_to?(:context) &&
102
- node&.context == :section
103
- end
104
- "??"
105
- else "??"
112
+ anchor = nil
113
+ id = nil
114
+ ret = if node.nil? then ""
115
+ elsif node.respond_to?(:id) && !node.id.nil? then "ID #{node.id}"
116
+ elsif node.respond_to?(:id) && node.id.nil? &&
117
+ node.respond_to?(:parent)
118
+ while !node.nil? && node.id.nil?
119
+ node = node.parent
120
+ end
121
+ node.nil? ? "" : "ID #{node.id}"
122
+ elsif node.respond_to?(:to_xml) && node.respond_to?(:parent)
123
+ loc, anchor, id = xml_current_location(node)
124
+ loc
125
+ elsif node.is_a? String then node
126
+ elsif node.respond_to?(:lineno) && !node.lineno.nil? &&
127
+ !node.lineno.empty?
128
+ "Asciidoctor Line #{'%06d' % node.lineno}"
129
+ elsif node.respond_to?(:line) && !node.line.nil?
130
+ "XML Line #{'%06d' % node.line}"
131
+ elsif node.respond_to?(:parent)
132
+ while !node.nil? &&
133
+ (!node.respond_to?(:level) || node.level.positive?) &&
134
+ (!node.respond_to?(:context) || node.context != :section)
135
+ node = node.parent
136
+ node.respond_to?(:context) && node&.context == :section and
137
+ return "Section: #{node.title}"
138
+ end
139
+ "??"
140
+ else "??"
141
+ end
142
+ [ret, anchor, id]
143
+ end
144
+
145
+ def xml_current_location(node)
146
+ while !node.nil? && node["id"].nil? && node.respond_to?(:parent)
147
+ node = node.parent
106
148
  end
149
+ anchor = node["anchor"]
150
+ id = node["id"]
151
+ loc = node.respond_to?(:parent) ? "ID #{anchor || id}" : ""
152
+ [loc, anchor, id]
107
153
  end
108
154
 
109
155
  def line(node, msg)
@@ -134,111 +180,38 @@ module Metanorma
134
180
  ret.to_xml
135
181
  end
136
182
 
137
- def log_hdr(file)
138
- <<~HTML
139
- <html><head><title>#{file} errors</title>
140
- <meta charset="UTF-8"/>
141
- <style> pre { white-space: pre-wrap; }
142
- thead th { font-weight: bold; background-color: aqua; }
143
- .severity0 { font-weight: bold; background-color: lightpink }
144
- .severity1 { font-weight: bold; }
145
- .severity2 { }
146
- .severity3 { font-style: italic; color: grey; }
147
- </style>
148
- </head><body><h1>#{file} errors</h1>
149
- <ul>#{log_index}</ul>
150
- HTML
151
- end
152
-
153
- def log_index
154
- @log.each_with_object([]) do |(k, v), m|
155
- m << <<~HTML
156
- <li><p><b><a href="##{to_ncname(k)}">#{k}</a></b>: #{index_severities(v)}</p></li>
157
- HTML
158
- end.join("\n")
159
- end
160
-
161
- def index_severities(entries)
162
- s = entries.each_with_object({}) do |e, m|
163
- m[e[:severity]] ||= 0
164
- m[e[:severity]] += 1
165
- end.compact
166
- s.keys.sort.map do |k|
167
- "Severity #{k}: <b>#{s[k]}</b> errors"
168
- end.join("; ")
169
- end
170
-
171
- def write(file = nil)
172
- (!file && @filename) or save_to(file || "metanorma", nil)
173
- File.open(@filename, "w:UTF-8") do |f|
174
- f.puts log_hdr(@filename)
175
- @log.each_key { |key| write_key(f, key) }
176
- f.puts "</body></html>\n"
177
- end
183
+ def filter_locations?
184
+ @suppress_log[:locations] && !@suppress_log[:locations].empty? or return
185
+ @anchor_ranges or return
186
+ true
178
187
  end
179
188
 
180
- def write_key(file, key)
181
- file.puts <<~HTML
182
- <h2 id="#{to_ncname(key)}">#{key}</h2>\n<table border="1">
183
- <thead><th width="5%">Line</th><th width="20%">ID</th>
184
- <th width="30%">Message</th><th width="40%">Context</th><th width="5%">Severity</th></thead>
185
- <tbody>
186
- HTML
187
- @log[key].sort_by { |a| [a[:line], a[:location], a[:message]] }
188
- .each do |n|
189
- write_entry(file, render_preproc_entry(n))
189
+ def filter_locations
190
+ filter_locations? or return
191
+ @log.transform_values! do |entries|
192
+ entries.reject do |entry|
193
+ # Use anchor if present, otherwise use id
194
+ entry_in_suppress_range?(entry, entry[:anchor] || entry[:id])
195
+ end
190
196
  end
191
- file.puts "</tbody></table>\n"
192
- end
193
-
194
- def render_preproc_entry(entry)
195
- ret = entry.dup
196
- ret[:line] = nil if ret[:line] == "000000"
197
- ret[:location] = loc_link(entry)
198
- ret[:message] = break_up_long_str(entry[:message], 10, 2)
199
- .gsub(/`([^`]+)`/, "<code>\\1</code>")
200
- ret[:context] = context_render(entry)
201
- ret.compact
202
- end
203
-
204
- def context_render(entry)
205
- entry[:context] or return nil
206
- entry[:context].split("\n").first(5)
207
- .join("\n").gsub("><", "> <")
208
- end
209
-
210
- def mapid(old, new)
211
- @mapid[old] = new
212
- end
213
-
214
- def loc_link(entry)
215
- loc = entry[:location]
216
- loc.nil? || loc.empty? and loc = "--"
217
- loc, url = loc_to_url(loc)
218
- loc &&= break_up_long_str(loc, 10, 2)
219
- url and loc = "<a href='#{url}'>#{loc}</a>"
220
- loc
221
197
  end
222
198
 
223
- def loc_to_url(loc)
224
- /^ID /.match?(loc) or return [loc, nil]
225
- loc.sub!(/^ID /, "")
226
- loc = @mapid[loc] while @mapid[loc]
227
- url = "#{@htmlfilename}##{to_ncname loc}"
228
- [loc, url]
199
+ def entry_in_suppress_range_prep(entry)
200
+ entry[:to] ||= entry[:from]
201
+ entry[:error_ids] ||= []
202
+ entry
229
203
  end
230
204
 
231
- def break_up_long_str(str, threshold, punct)
232
- Metanorma::Utils.break_up_long_str(str, threshold, punct)
233
- end
234
-
235
- def write_entry(file, entry)
236
- entry[:context] &&= @c.encode(break_up_long_str(entry[:context], 40, 2))
237
- file.print <<~HTML
238
- <tr class="severity#{entry[:severity]}">
239
- <td>#{entry[:line]}</td><th><code>#{entry[:location]}</code></th>
240
- <td>#{entry[:message]}</td><td><pre>#{entry[:context]}</pre></td><td>#{entry[:severity]}</td></tr>
241
- HTML
205
+ def entry_in_suppress_range?(entry, id)
206
+ # Use anchor if present, otherwise use id
207
+ id.nil? and return false
208
+ @suppress_log[:locations].each do |loc|
209
+ entry_in_suppress_range_prep(loc)
210
+ @anchor_ranges.in_range?(id, loc[:from], loc[:to]) or next
211
+ loc[:error_ids].empty? || loc[:error_ids]
212
+ .include?(entry[:error_id].to_s) and return true
213
+ end
214
+ false
242
215
  end
243
216
  end
244
217
  end
@@ -0,0 +1,165 @@
1
+ module Metanorma
2
+ module Utils
3
+ class Log
4
+ def to_ncname(tag)
5
+ ::Metanorma::Utils.to_ncname(tag)
6
+ end
7
+
8
+ def log_hdr(file)
9
+ <<~HTML
10
+ <html><head><title>#{file} errors</title>
11
+ <meta charset="UTF-8"/>
12
+ <style> pre { white-space: pre-wrap; }
13
+ thead th { font-weight: bold; background-color: aqua; }
14
+ .severity0 { font-weight: bold; background-color: lightpink }
15
+ .severity1 { font-weight: bold; }
16
+ .severity2 { }
17
+ .severity3 { font-style: italic; color: grey; }
18
+ </style>
19
+ </head><body><h1>#{file} errors</h1>
20
+ <ul>#{log_index}</ul>
21
+ HTML
22
+ end
23
+
24
+ def log_index
25
+ @log.each_with_object([]) do |(k, v), m|
26
+ m << <<~HTML
27
+ <li><p><b><a href="##{to_ncname(k)}">#{k}</a></b>: #{index_severities(v)}</p></li>
28
+ HTML
29
+ end.join("\n")
30
+ end
31
+
32
+ def index_severities(entries)
33
+ s = entries.each_with_object({}) do |e, m|
34
+ m[e[:severity]] ||= 0
35
+ m[e[:severity]] += 1
36
+ end.compact
37
+ s.keys.sort.map do |k|
38
+ error = s[k] == 1 ? "error" : "errors"
39
+ "Severity #{k}: <b>#{s[k]}</b> #{error}"
40
+ end.join("; ")
41
+ end
42
+
43
+ def write(file = nil)
44
+ (!file && @filename) or save_to(file || "metanorma", nil)
45
+ filter_locations
46
+ File.open(@filename, "w:UTF-8") do |f|
47
+ f.puts log_hdr(@filename)
48
+ @log.each_key { |key| write_key(f, key) }
49
+ f.puts "</body></html>\n"
50
+ end
51
+ end
52
+
53
+ def write_key(file, key)
54
+ file.puts <<~HTML
55
+ <h2 id="#{to_ncname(key)}">#{key}</h2>\n<table border="1">
56
+ <thead><th width="5%">Line</th><th width="20%">ID</th><th width="10%">Error</th>
57
+ <th width="20%">Message</th><th width="40%">Context</th><th width="5%">Severity</th></thead>
58
+ <tbody>
59
+ HTML
60
+ @log[key].sort_by { |a| [a[:line], a[:location], a[:error]] }
61
+ .each do |n|
62
+ write_entry(file, render_preproc_entry(n))
63
+ end
64
+ file.puts "</tbody></table>\n"
65
+ end
66
+
67
+ def render_preproc_entry(entry)
68
+ ret = entry.dup
69
+ ret[:line] = nil if ret[:line] == "000000"
70
+ ret[:location] = loc_link(entry)
71
+ ret[:error] = break_up_long_str(entry[:error], 10, 2)
72
+ .gsub(/`([^`]+)`/, "<code>\\1</code>")
73
+ ret[:context] = context_render(entry)
74
+ ret.compact
75
+ end
76
+
77
+ def context_render(entry)
78
+ entry[:context] or return nil
79
+ entry[:context].split("\n").first(5)
80
+ .join("\n").gsub("><", "> <")
81
+ end
82
+
83
+ def mapid(old, new)
84
+ @mapid[old] = new
85
+ end
86
+
87
+ def loc_link(entry)
88
+ loc = entry[:location]
89
+ loc.nil? || loc.empty? and loc = "--"
90
+ loc, url = loc_to_url(loc)
91
+ loc &&= break_up_long_str(loc, 10, 2)
92
+ url and loc = "<a href='#{url}'>#{loc}</a>"
93
+ loc
94
+ end
95
+
96
+ def loc_to_url(loc)
97
+ /^ID /.match?(loc) or return [loc, nil]
98
+ loc.sub!(/^ID /, "")
99
+ loc = @mapid[loc] while @mapid[loc]
100
+ url = "#{@htmlfilename}##{to_ncname loc}"
101
+ [loc, url]
102
+ end
103
+
104
+ def break_up_long_str(str, threshold, punct)
105
+ Metanorma::Utils.break_up_long_str(str, threshold, punct)
106
+ end
107
+
108
+ def write_entry(file, entry)
109
+ entry[:context] &&= @c.encode(break_up_long_str(entry[:context], 40, 2))
110
+ file.print <<~HTML
111
+ <tr class="severity#{entry[:severity]}">
112
+ <td>#{entry[:line]}</td><th><code>#{entry[:location]}</code></th><td>#{entry[:error_id]}</td>
113
+ <td>#{entry[:error]}</td><td><pre>#{entry[:context]}</pre></td><td>#{entry[:severity]}</td></tr>
114
+ HTML
115
+ end
116
+
117
+ def display_messages
118
+ grouped = group_messages_by_category
119
+ grouped.map { |cat, keys| format_category_section(cat, keys) }
120
+ .join("\n\n")
121
+ end
122
+
123
+ def group_messages_by_category
124
+ sort_messages_by_category_and_key
125
+ .group_by { |k| @msg[k][:category] }
126
+ .sort_by { |cat, _| cat }
127
+ end
128
+
129
+ def format_category_section(category, keys)
130
+ lines = keys.map { |k| format_error_line(k) }
131
+ "#{category}:\n#{lines.join("\n")}"
132
+ end
133
+
134
+ def format_error_line(key)
135
+ padded_key = key.to_s.ljust(12)
136
+ "\t#{padded_key}: #{@msg[key][:error].gsub("\n", ' ')}"
137
+ end
138
+
139
+ def sort_messages_by_category_and_key
140
+ @msg.keys.sort do |a, b|
141
+ cat_cmp = @msg[a][:category] <=> @msg[b][:category]
142
+ a_parts = parse_message_key(a)
143
+ b_parts = parse_message_key(b)
144
+ cat_cmp.zero? ? compare_key_parts(a_parts, b_parts) : cat_cmp
145
+ end
146
+ end
147
+
148
+ def parse_message_key(key)
149
+ match = key.to_s.match(/^(.+?)_(\d+)$/)
150
+ match ? [match[1], match[2].to_i] : [key.to_s, nil]
151
+ end
152
+
153
+ def compare_key_parts(a_parts, b_parts)
154
+ a_str, a_num = a_parts
155
+ b_str, b_num = b_parts
156
+ if a_num.nil? || b_num.nil?
157
+ a_str <=> b_str
158
+ else
159
+ str_cmp = a_str <=> b_str
160
+ str_cmp.zero? ? a_num <=> b_num : str_cmp
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
data/lib/utils/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Metanorma
2
2
  module Utils
3
- VERSION = "1.11.8".freeze
3
+ VERSION = "2.0.1".freeze
4
4
  end
5
5
  end
@@ -46,6 +46,6 @@ Gem::Specification.new do |spec|
46
46
  spec.add_development_dependency "simplecov", "~> 0.15"
47
47
  spec.add_development_dependency "timecop", "~> 0.9"
48
48
  spec.add_development_dependency "webmock"
49
- spec.add_development_dependency "canon"
49
+ spec.add_development_dependency "canon", "= 0.1.3"
50
50
  # spec.metadata["rubygems_mfa_required"] = "true"
51
51
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metanorma-utils
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.8
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-09-29 00:00:00.000000000 Z
11
+ date: 2025-11-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: asciidoctor
@@ -280,16 +280,16 @@ dependencies:
280
280
  name: canon
281
281
  requirement: !ruby/object:Gem::Requirement
282
282
  requirements:
283
- - - ">="
283
+ - - '='
284
284
  - !ruby/object:Gem::Version
285
- version: '0'
285
+ version: 0.1.3
286
286
  type: :development
287
287
  prerelease: false
288
288
  version_requirements: !ruby/object:Gem::Requirement
289
289
  requirements:
290
- - - ">="
290
+ - - '='
291
291
  - !ruby/object:Gem::Version
292
- version: '0'
292
+ version: 0.1.3
293
293
  description: 'metanorma-utils provides utilities for the Metanorma stack
294
294
 
295
295
  '
@@ -308,11 +308,13 @@ files:
308
308
  - lib/metanorma-utils.rb
309
309
  - lib/sterile/sterile.rb
310
310
  - lib/utils/anchor.rb
311
+ - lib/utils/anchor_ranges.rb
311
312
  - lib/utils/cjk.rb
312
313
  - lib/utils/hash_transform_keys.rb
313
314
  - lib/utils/image.rb
314
315
  - lib/utils/linestatus.rb
315
316
  - lib/utils/log.rb
317
+ - lib/utils/log_html.rb
316
318
  - lib/utils/main.rb
317
319
  - lib/utils/namespace.rb
318
320
  - lib/utils/version.rb