Almirah 0.3.1 → 0.4.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 +4 -4
- data/lib/almirah/console_reporter.rb +46 -0
- data/lib/almirah/doc_fabric.rb +16 -7
- data/lib/almirah/doc_items/controlled_paragraph.rb +28 -2
- data/lib/almirah/doc_items/controlled_table.rb +5 -4
- data/lib/almirah/doc_items/doc_item.rb +12 -0
- data/lib/almirah/doc_items/markdown_list.rb +2 -2
- data/lib/almirah/doc_items/markdown_table.rb +17 -8
- data/lib/almirah/doc_items/text_line.rb +243 -41
- data/lib/almirah/doc_parser.rb +20 -3
- data/lib/almirah/doc_types/base_document.rb +41 -24
- data/lib/almirah/doc_types/decision.rb +161 -0
- data/lib/almirah/doc_types/decisions_overview.rb +202 -0
- data/lib/almirah/doc_types/protocol.rb +3 -0
- data/lib/almirah/doc_types/source_file.rb +4 -8
- data/lib/almirah/link_registry.rb +38 -0
- data/lib/almirah/project/doc_linker.rb +24 -0
- data/lib/almirah/project/project_data.rb +7 -2
- data/lib/almirah/project.rb +117 -14
- data/lib/almirah/project_configuration.rb +1 -1
- data/lib/almirah/project_template.rb +89 -1
- data/lib/almirah/relative_url.rb +22 -0
- data/lib/almirah/search/specifications_db.rb +4 -4
- data/lib/almirah/templates/css/main.css +41 -1
- data/lib/almirah/templates/scripts/main.js +7 -1
- data/lib/almirah/templates/scripts/orama_search.js +3 -3
- metadata +6 -1
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require_relative '../relative_url'
|
|
4
|
+
|
|
1
5
|
class TextLineToken
|
|
2
6
|
attr_accessor :value
|
|
3
7
|
|
|
@@ -48,20 +52,47 @@ class SquareBracketRight < TextLineToken
|
|
|
48
52
|
end
|
|
49
53
|
end
|
|
50
54
|
|
|
55
|
+
class DoubleSquareBracketLeft < TextLineToken
|
|
56
|
+
def initialize # rubocop:disable Lint/MissingSuper
|
|
57
|
+
@value = '[['
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class DoubleSquareBracketRight < TextLineToken
|
|
62
|
+
def initialize # rubocop:disable Lint/MissingSuper
|
|
63
|
+
@value = ']]'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
51
67
|
class SquareBracketRightAndParentheseLeft < TextLineToken
|
|
52
68
|
def initialize
|
|
53
69
|
@value = ']('
|
|
54
70
|
end
|
|
55
71
|
end
|
|
56
72
|
|
|
73
|
+
class BacktickToken < TextLineToken
|
|
74
|
+
def initialize # rubocop:disable Lint/MissingSuper
|
|
75
|
+
@value = '`'
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class InlineCodeToken < TextLineToken
|
|
80
|
+
def initialize(raw) # rubocop:disable Lint/MissingSuper
|
|
81
|
+
@value = raw
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
57
85
|
class TextLineParser
|
|
58
86
|
attr_accessor :supported_tokens
|
|
59
87
|
|
|
60
|
-
def initialize # rubocop:disable Metrics/AbcSize
|
|
88
|
+
def initialize # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
61
89
|
@supported_tokens = []
|
|
62
90
|
@supported_tokens.append(BoldAndItalicToken.new)
|
|
63
91
|
@supported_tokens.append(BoldToken.new)
|
|
64
92
|
@supported_tokens.append(ItalicToken.new)
|
|
93
|
+
@supported_tokens.append(BacktickToken.new)
|
|
94
|
+
@supported_tokens.append(DoubleSquareBracketLeft.new)
|
|
95
|
+
@supported_tokens.append(DoubleSquareBracketRight.new)
|
|
65
96
|
@supported_tokens.append(SquareBracketRightAndParentheseLeft.new)
|
|
66
97
|
@supported_tokens.append(ParentheseLeft.new)
|
|
67
98
|
@supported_tokens.append(ParentheseRight.new)
|
|
@@ -70,7 +101,7 @@ class TextLineParser
|
|
|
70
101
|
@supported_tokens.append(TextLineToken.new)
|
|
71
102
|
end
|
|
72
103
|
|
|
73
|
-
def tokenize(str)
|
|
104
|
+
def tokenize(str) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
|
|
74
105
|
result = []
|
|
75
106
|
sl = str.length
|
|
76
107
|
si = 0
|
|
@@ -79,29 +110,110 @@ class TextLineParser
|
|
|
79
110
|
tl = t.value.length
|
|
80
111
|
if tl != 0 # literal is the last supported token in the list
|
|
81
112
|
projected_end_position = si + tl - 1
|
|
82
|
-
next if projected_end_position
|
|
113
|
+
next if projected_end_position >= sl
|
|
83
114
|
|
|
84
115
|
buf = str[si..projected_end_position]
|
|
85
|
-
|
|
116
|
+
next unless buf == t.value
|
|
117
|
+
|
|
118
|
+
if emphasis_token?(t) && !can_flank?(str, si, projected_end_position)
|
|
119
|
+
append_literal(result, buf)
|
|
120
|
+
else
|
|
86
121
|
result.append(t)
|
|
87
|
-
si = projected_end_position + 1
|
|
88
|
-
break
|
|
89
122
|
end
|
|
123
|
+
si = projected_end_position + 1
|
|
124
|
+
break
|
|
90
125
|
else
|
|
91
|
-
|
|
92
|
-
literal = result[-1]
|
|
93
|
-
literal.value += str[si]
|
|
94
|
-
else
|
|
95
|
-
literal = TextLineToken.new
|
|
96
|
-
literal.value = str[si]
|
|
97
|
-
result.append(literal)
|
|
98
|
-
end
|
|
126
|
+
append_literal(result, str[si])
|
|
99
127
|
si += 1
|
|
100
128
|
end
|
|
101
129
|
end
|
|
102
130
|
end
|
|
131
|
+
fuse_backticks(result)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def fuse_backticks(tokens) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
137
|
+
result = []
|
|
138
|
+
i = 0
|
|
139
|
+
while i < tokens.length
|
|
140
|
+
if tokens[i].instance_of?(BacktickToken)
|
|
141
|
+
closer = next_backtick_index(tokens, i + 1)
|
|
142
|
+
if closer
|
|
143
|
+
raw = tokens[(i + 1)..(closer - 1)].map(&:value).join
|
|
144
|
+
result.append(InlineCodeToken.new(raw))
|
|
145
|
+
i = closer + 1
|
|
146
|
+
else
|
|
147
|
+
append_literal(result, '`')
|
|
148
|
+
i += 1
|
|
149
|
+
end
|
|
150
|
+
else
|
|
151
|
+
result.append(tokens[i])
|
|
152
|
+
i += 1
|
|
153
|
+
end
|
|
154
|
+
end
|
|
103
155
|
result
|
|
104
156
|
end
|
|
157
|
+
|
|
158
|
+
def next_backtick_index(tokens, start_idx)
|
|
159
|
+
idx = start_idx
|
|
160
|
+
while idx < tokens.length
|
|
161
|
+
return idx if tokens[idx].instance_of?(BacktickToken)
|
|
162
|
+
|
|
163
|
+
idx += 1
|
|
164
|
+
end
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def emphasis_token?(token)
|
|
169
|
+
token.is_a?(ItalicToken) || token.is_a?(BoldToken) || token.is_a?(BoldAndItalicToken)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def append_literal(result, text)
|
|
173
|
+
if !result.empty? && result[-1].instance_of?(TextLineToken)
|
|
174
|
+
result[-1].value += text
|
|
175
|
+
else
|
|
176
|
+
literal = TextLineToken.new
|
|
177
|
+
literal.value = text.dup
|
|
178
|
+
result.append(literal)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def can_flank?(str, start_idx, end_idx)
|
|
183
|
+
left_flanking?(str, start_idx, end_idx) || right_flanking?(str, start_idx, end_idx)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def left_flanking?(str, start_idx, end_idx)
|
|
187
|
+
after = char_at(str, end_idx + 1)
|
|
188
|
+
return false if after.nil? || whitespace?(after)
|
|
189
|
+
return true unless punctuation?(after)
|
|
190
|
+
|
|
191
|
+
before = char_at(str, start_idx - 1)
|
|
192
|
+
before.nil? || whitespace?(before)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def right_flanking?(str, start_idx, end_idx)
|
|
196
|
+
before = char_at(str, start_idx - 1)
|
|
197
|
+
return false if before.nil? || whitespace?(before)
|
|
198
|
+
return true unless punctuation?(before)
|
|
199
|
+
|
|
200
|
+
after = char_at(str, end_idx + 1)
|
|
201
|
+
after.nil? || whitespace?(after)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def char_at(str, idx)
|
|
205
|
+
return nil if idx.negative? || idx >= str.length
|
|
206
|
+
|
|
207
|
+
str[idx]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def whitespace?(char)
|
|
211
|
+
char.match?(/\s/)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def punctuation?(char)
|
|
215
|
+
char.match?(/[[:punct:]]/)
|
|
216
|
+
end
|
|
105
217
|
end
|
|
106
218
|
|
|
107
219
|
class TextLineBuilderContext
|
|
@@ -117,9 +229,17 @@ class TextLineBuilderContext
|
|
|
117
229
|
str
|
|
118
230
|
end
|
|
119
231
|
|
|
232
|
+
def inline_code(str)
|
|
233
|
+
str
|
|
234
|
+
end
|
|
235
|
+
|
|
120
236
|
def link(_link_text, link_url)
|
|
121
237
|
link_url
|
|
122
238
|
end
|
|
239
|
+
|
|
240
|
+
def wiki_link(inner)
|
|
241
|
+
"[[#{inner}]]"
|
|
242
|
+
end
|
|
123
243
|
end
|
|
124
244
|
|
|
125
245
|
class TextLineBuilder
|
|
@@ -129,7 +249,7 @@ class TextLineBuilder
|
|
|
129
249
|
@builder_context = builder_context
|
|
130
250
|
end
|
|
131
251
|
|
|
132
|
-
def restore(token_list)
|
|
252
|
+
def restore(token_list) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
133
253
|
result = ''
|
|
134
254
|
return '' if token_list.nil?
|
|
135
255
|
|
|
@@ -196,6 +316,25 @@ class TextLineBuilder
|
|
|
196
316
|
result += '***'
|
|
197
317
|
ti = ti_starting_position + 1
|
|
198
318
|
end
|
|
319
|
+
when 'DoubleSquareBracketLeft'
|
|
320
|
+
# wiki/Obsidian link: collect raw text up to the closing "]]"
|
|
321
|
+
is_found = false
|
|
322
|
+
ti_starting_position = ti
|
|
323
|
+
tii = ti + 1
|
|
324
|
+
while tii < tl
|
|
325
|
+
if token_list[tii].instance_of?(DoubleSquareBracketRight)
|
|
326
|
+
inner = token_list[(ti + 1)..(tii - 1)].map(&:value).join
|
|
327
|
+
result += @builder_context.wiki_link(inner)
|
|
328
|
+
ti = tii + 1
|
|
329
|
+
is_found = true
|
|
330
|
+
break
|
|
331
|
+
end
|
|
332
|
+
tii += 1
|
|
333
|
+
end
|
|
334
|
+
unless is_found
|
|
335
|
+
result += '[['
|
|
336
|
+
ti = ti_starting_position + 1
|
|
337
|
+
end
|
|
199
338
|
when 'SquareBracketLeft'
|
|
200
339
|
# try to find closing part
|
|
201
340
|
is_found = false
|
|
@@ -230,7 +369,10 @@ class TextLineBuilder
|
|
|
230
369
|
ti = ti_starting_position + 1
|
|
231
370
|
end
|
|
232
371
|
|
|
233
|
-
when '
|
|
372
|
+
when 'InlineCodeToken'
|
|
373
|
+
result += @builder_context.inline_code(token_list[ti].value)
|
|
374
|
+
ti += 1
|
|
375
|
+
when 'TextLineToken', 'ParentheseLeft', 'ParentheseRight', 'SquareBracketRight', 'DoubleSquareBracketRight'
|
|
234
376
|
result += token_list[ti].value
|
|
235
377
|
ti += 1
|
|
236
378
|
else
|
|
@@ -242,11 +384,37 @@ class TextLineBuilder
|
|
|
242
384
|
end
|
|
243
385
|
|
|
244
386
|
class TextLine < TextLineBuilderContext
|
|
245
|
-
@@
|
|
387
|
+
@@link_registry = nil # rubocop:disable Style/ClassVars
|
|
388
|
+
@@broken_links = [] # rubocop:disable Style/ClassVars
|
|
246
389
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
390
|
+
class << self
|
|
391
|
+
def link_registry=(registry)
|
|
392
|
+
@@link_registry = registry # rubocop:disable Style/ClassVars
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def link_registry
|
|
396
|
+
@@link_registry
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Cross-document links that could not be resolved to a managed document,
|
|
400
|
+
# collected during rendering for reporting (ADR-186, SRS-094).
|
|
401
|
+
def broken_links
|
|
402
|
+
@@broken_links
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def reset_broken_links
|
|
406
|
+
@@broken_links = [] # rubocop:disable Style/ClassVars
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def record_broken_link(document, target)
|
|
410
|
+
@@broken_links << { document: document&.id, target: target }
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# The document that owns this text line. Used to resolve cross-document links
|
|
415
|
+
# relative to the current page. nil for stand-alone text (e.g. unit tests).
|
|
416
|
+
def owner_document
|
|
417
|
+
nil
|
|
250
418
|
end
|
|
251
419
|
|
|
252
420
|
def format_string(str)
|
|
@@ -267,30 +435,64 @@ class TextLine < TextLineBuilderContext
|
|
|
267
435
|
"<b><i>#{str}</i></b>"
|
|
268
436
|
end
|
|
269
437
|
|
|
270
|
-
def
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
lazy_doc_id = nil
|
|
275
|
-
anchor = nil
|
|
276
|
-
|
|
277
|
-
if res = /(\w+)[.]md$/.match(link_url) # link
|
|
278
|
-
lazy_doc_id = res[1].to_s.downcase
|
|
438
|
+
def inline_code(str)
|
|
439
|
+
"<code class=\"inline\">#{CGI.escapeHTML(str)}</code>"
|
|
440
|
+
end
|
|
279
441
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
442
|
+
def link(link_text, link_url) # rubocop:disable Metrics/MethodLength
|
|
443
|
+
raw = link_url.to_s
|
|
444
|
+
kind, target, fragment = classify_markdown_link(raw)
|
|
445
|
+
case kind
|
|
446
|
+
when :internal
|
|
447
|
+
href = RelativeUrl.between(owner_document.output_rel_path, target.output_rel_path, fragment: fragment)
|
|
448
|
+
"<a href=\"#{href}\" class=\"external\">#{link_text}</a>"
|
|
449
|
+
when :broken
|
|
450
|
+
TextLine.record_broken_link(owner_document, raw)
|
|
451
|
+
"<a href=\"#{raw}\" class=\"broken_link\" title=\"Unresolved cross-document link\">#{link_text}</a>"
|
|
452
|
+
else
|
|
453
|
+
"<a target=\"_blank\" rel=\"noopener\" href=\"#{raw}\" class=\"external\">#{link_text}</a>"
|
|
285
454
|
end
|
|
455
|
+
end
|
|
286
456
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
457
|
+
# Resolves an Obsidian/wiki link "[[target#fragment|alias]]" to a managed
|
|
458
|
+
# document by its unique id/filename, independent of folder (ADR-186).
|
|
459
|
+
def wiki_link(inner) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize,Metrics/MethodLength
|
|
460
|
+
link_part, sep, alias_text = inner.partition('|')
|
|
461
|
+
target, _hash, fragment = link_part.partition('#')
|
|
462
|
+
display = (sep.empty? ? link_part : alias_text).strip
|
|
463
|
+
display = link_part.strip if display.empty?
|
|
464
|
+
|
|
465
|
+
doc = TextLine.link_registry&.find_by_id(target.strip)
|
|
466
|
+
if doc && owner_document&.output_rel_path
|
|
467
|
+
href = RelativeUrl.between(owner_document.output_rel_path, doc.output_rel_path,
|
|
468
|
+
fragment: fragment.strip.empty? ? nil : fragment.strip)
|
|
469
|
+
"<a href=\"#{href}\" class=\"external\">#{CGI.escapeHTML(display)}</a>"
|
|
470
|
+
elsif owner_document&.output_rel_path
|
|
471
|
+
TextLine.record_broken_link(owner_document, "[[#{inner}]]")
|
|
472
|
+
"<span class=\"broken_link\" title=\"Unresolved wiki link\">#{CGI.escapeHTML(display)}</span>"
|
|
473
|
+
else
|
|
474
|
+
"[[#{inner}]]"
|
|
293
475
|
end
|
|
294
|
-
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
private
|
|
479
|
+
|
|
480
|
+
# Classifies a Markdown link target as :internal (a managed document, resolved
|
|
481
|
+
# against the owning document's source directory), :broken (a local .md path
|
|
482
|
+
# that does not resolve), or :external (everything else). ADR-186.
|
|
483
|
+
def classify_markdown_link(raw) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
484
|
+
path_part, _sep, fragment = raw.partition('#')
|
|
485
|
+
path_part = URI::DEFAULT_PARSER.unescape(path_part) # decode %20 etc. to match the real file path
|
|
486
|
+
return [:external] unless local_markdown_path?(path_part)
|
|
487
|
+
|
|
488
|
+
doc = owner_document
|
|
489
|
+
return [:external] unless doc&.output_rel_path && doc.respond_to?(:path) && doc.path && TextLine.link_registry
|
|
490
|
+
|
|
491
|
+
target = TextLine.link_registry.find_by_source(File.expand_path(path_part, File.dirname(doc.path)))
|
|
492
|
+
target ? [:internal, target, fragment.empty? ? nil : fragment] : [:broken]
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def local_markdown_path?(path_part)
|
|
496
|
+
path_part.match?(/\.(md|markdown)\z/i) && !path_part.match?(%r{\A[a-z][a-z0-9+.\-]*://}i)
|
|
295
497
|
end
|
|
296
498
|
end
|
data/lib/almirah/doc_parser.rb
CHANGED
|
@@ -174,7 +174,7 @@ class DocParser # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
174
174
|
|
|
175
175
|
doc.items.append(item)
|
|
176
176
|
|
|
177
|
-
elsif res = /^(
|
|
177
|
+
elsif res = /^([*\-]\s+)(.*)/.match(s) # check if unordered list start
|
|
178
178
|
|
|
179
179
|
if doc.title == ''
|
|
180
180
|
# dummy section if root is not a Document Title (level 0)
|
|
@@ -248,8 +248,9 @@ class DocParser # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
248
248
|
|
|
249
249
|
if temp_md_table
|
|
250
250
|
if temp_md_table.is_separator_detected # if there is a separator
|
|
251
|
-
# check if parent doc is a Protocol
|
|
252
|
-
if doc.instance_of?
|
|
251
|
+
# check if parent doc is a Protocol, or a Decision Record inside an "Affected Documents" section
|
|
252
|
+
if doc.instance_of?(Protocol) ||
|
|
253
|
+
(doc.instance_of?(Decision) && in_section?(doc, 'Affected Documents'))
|
|
253
254
|
# check if it is a controlled table
|
|
254
255
|
tmp = /(.*)\s+>\[(\S*)\]/.match(row)
|
|
255
256
|
if tmp && (temp_md_table.instance_of? MarkdownTable)
|
|
@@ -379,4 +380,20 @@ class DocParser # rubocop:disable Metrics/ClassLength,Style/Documentation
|
|
|
379
380
|
end
|
|
380
381
|
temp_md_table
|
|
381
382
|
end
|
|
383
|
+
|
|
384
|
+
# Returns true when the most recently parsed heading is inside (or equal to) a section
|
|
385
|
+
# whose heading text matches `section_name`. Walks back through the heading list
|
|
386
|
+
# accumulating the ancestor chain (strictly decreasing levels).
|
|
387
|
+
def self.in_section?(doc, section_name)
|
|
388
|
+
return false if doc.headings.empty?
|
|
389
|
+
|
|
390
|
+
last_level = doc.headings[-1].level + 1
|
|
391
|
+
doc.headings.reverse_each do |h|
|
|
392
|
+
next if h.level >= last_level
|
|
393
|
+
|
|
394
|
+
last_level = h.level
|
|
395
|
+
return true if h.text.strip == section_name
|
|
396
|
+
end
|
|
397
|
+
false
|
|
398
|
+
end
|
|
382
399
|
end
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../relative_url'
|
|
4
|
+
|
|
3
5
|
class BaseDocument # rubocop:disable Style/Documentation
|
|
4
|
-
attr_accessor :title, :id, :dom, :headings
|
|
6
|
+
attr_accessor :title, :id, :dom, :headings, :output_rel_path
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
attr_accessor :show_decisions_link
|
|
10
|
+
end
|
|
5
11
|
|
|
6
12
|
def initialize
|
|
7
13
|
@items = []
|
|
@@ -21,9 +27,14 @@ class BaseDocument # rubocop:disable Style/Documentation
|
|
|
21
27
|
|
|
22
28
|
output_file_path += if @id == 'index'
|
|
23
29
|
"#{@id}.html"
|
|
30
|
+
elsif instance_of? DecisionsOverview
|
|
31
|
+
'overview.html'
|
|
32
|
+
elsif instance_of? Decision
|
|
33
|
+
"#{@id}.html"
|
|
24
34
|
else
|
|
25
35
|
"#{@id}/#{@id}.html"
|
|
26
36
|
end
|
|
37
|
+
@output_rel_path = output_file_path.split('/build/', 2).last
|
|
27
38
|
file = File.open(output_file_path, 'w')
|
|
28
39
|
file_data.each do |s| # rubocop:disable Metrics/BlockLength
|
|
29
40
|
if s.include?('{{CONTENT}}')
|
|
@@ -35,35 +46,21 @@ class BaseDocument # rubocop:disable Style/Documentation
|
|
|
35
46
|
elsif s.include?('{{DOCUMENT_TITLE}}')
|
|
36
47
|
file.puts s.gsub! '{{DOCUMENT_TITLE}}', @title
|
|
37
48
|
elsif s.include?('{{STYLES_AND_SCRIPTS}}')
|
|
49
|
+
file.puts "<link rel=\"stylesheet\" href=\"#{rel_to('css/main.css')}\">"
|
|
50
|
+
file.puts "<script src=\"#{rel_to('scripts/main.js')}\"></script>"
|
|
38
51
|
if @id == 'index'
|
|
39
|
-
file.puts
|
|
40
|
-
file.puts
|
|
41
|
-
|
|
42
|
-
file.puts '<script src="
|
|
43
|
-
elsif instance_of? Specification
|
|
44
|
-
file.puts '<link rel="stylesheet" href="../../css/main.css">'
|
|
45
|
-
file.puts '<script src="../../scripts/main.js"></script>'
|
|
46
|
-
elsif instance_of? Traceability
|
|
47
|
-
file.puts '<link rel="stylesheet" href="../../css/main.css">'
|
|
48
|
-
file.puts '<script src="../../scripts/main.js"></script>'
|
|
49
|
-
elsif instance_of? Coverage
|
|
50
|
-
file.puts '<link rel="stylesheet" href="../../css/main.css">'
|
|
51
|
-
file.puts '<script src="../../scripts/main.js"></script>'
|
|
52
|
-
elsif instance_of? Implementation
|
|
53
|
-
file.puts '<link rel="stylesheet" href="../../css/main.css">'
|
|
54
|
-
file.puts '<script src="../../scripts/main.js"></script>'
|
|
55
|
-
elsif instance_of? Protocol
|
|
56
|
-
file.puts '<link rel="stylesheet" href="../../../css/main.css">'
|
|
57
|
-
file.puts '<script src="../../../scripts/main.js"></script>'
|
|
52
|
+
file.puts "<script type=\"module\" src=\"#{rel_to('scripts/orama_search.js')}\"></script>"
|
|
53
|
+
file.puts "<link rel=\"stylesheet\" href=\"#{rel_to('css/search.css')}\">"
|
|
54
|
+
elsif instance_of? DecisionsOverview
|
|
55
|
+
file.puts '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>'
|
|
58
56
|
end
|
|
59
57
|
elsif s.include?('{{HOME_BUTTON}}')
|
|
60
58
|
if @id == 'index'
|
|
61
|
-
file.puts '
|
|
62
|
-
elsif instance_of? Protocol
|
|
63
|
-
file.puts '<a id="index_menu_item" href="./../../../index.html"><span><i class="fa fa-info" aria-hidden="true"></i></span> Index</a>'
|
|
59
|
+
file.puts home_link(rel_to('index.html'))
|
|
64
60
|
else
|
|
65
|
-
|
|
61
|
+
file.puts index_link(rel_to('index.html'))
|
|
66
62
|
end
|
|
63
|
+
file.puts decisions_link(rel_to('decisions/overview.html')) if BaseDocument.show_decisions_link
|
|
67
64
|
elsif s.include?('{{GEM_VERSION}}')
|
|
68
65
|
file.puts "(#{Gem.loaded_specs['Almirah'].version.version})"
|
|
69
66
|
else
|
|
@@ -72,4 +69,24 @@ class BaseDocument # rubocop:disable Style/Documentation
|
|
|
72
69
|
end
|
|
73
70
|
file.close
|
|
74
71
|
end
|
|
72
|
+
|
|
73
|
+
def decisions_link(href)
|
|
74
|
+
icon = '<span><i class="fa fa-gavel" aria-hidden="true"></i></span>'
|
|
75
|
+
%(<a id="decisions_menu_item" href="#{href}">#{icon} Decision Records</a>)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def index_link(href)
|
|
79
|
+
icon = '<span><i class="fa fa-info" aria-hidden="true"></i></span>'
|
|
80
|
+
%(<a id="index_menu_item" href="#{href}">#{icon} Index</a>)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def home_link(href)
|
|
84
|
+
icon = '<span><i class="fa fa-home" aria-hidden="true"></i></span>'
|
|
85
|
+
%(<a id="home_menu_item" href="#{href}">#{icon} Home</a>)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Relative URL from this page to a target path under the build root.
|
|
89
|
+
def rel_to(target)
|
|
90
|
+
RelativeUrl.between(@output_rel_path, target)
|
|
91
|
+
end
|
|
75
92
|
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require_relative 'persistent_document'
|
|
5
|
+
require_relative '../doc_items/heading'
|
|
6
|
+
require_relative '../doc_items/markdown_table'
|
|
7
|
+
|
|
8
|
+
class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metrics/ClassLength
|
|
9
|
+
attr_accessor :path, :sequence_number, :record_type, :html_rel_path, :root_prefix, :current_status,
|
|
10
|
+
:start_date, :target_release_version, :specifications_path, :wrong_links_hash
|
|
11
|
+
|
|
12
|
+
def initialize(file_path)
|
|
13
|
+
super
|
|
14
|
+
@path = file_path
|
|
15
|
+
stem = File.basename(file_path, File.extname(file_path))
|
|
16
|
+
assign_id_parts(stem)
|
|
17
|
+
@current_status = nil
|
|
18
|
+
@start_date = nil
|
|
19
|
+
@target_release_version = nil
|
|
20
|
+
@wrong_links_hash = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_console
|
|
24
|
+
puts "\e[36mDecision: #{@id}\e[0m"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_html(nav_pane, output_file_path)
|
|
28
|
+
html_rows = []
|
|
29
|
+
html_rows.append('')
|
|
30
|
+
|
|
31
|
+
@items.each do |item|
|
|
32
|
+
html_rows.append item.to_html
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
save_html_to_file(html_rows, nav_pane, output_file_path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def extract_current_status
|
|
39
|
+
status_table = find_section_table('Status')
|
|
40
|
+
return if status_table.nil?
|
|
41
|
+
|
|
42
|
+
status_table.is_decision_status_table = true
|
|
43
|
+
marker_rows = status_table.rows.select { |row| row[0].to_s.strip == '*' }
|
|
44
|
+
@current_status = marker_rows.length == 1 ? marker_rows[0][-1].to_s.strip : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def extract_start_date
|
|
48
|
+
dates = collect_dates('Status', 'Date') + collect_dates('Scope', 'Start Date')
|
|
49
|
+
@start_date = dates.min
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def extract_target_release_version
|
|
53
|
+
@target_release_version = lookup_cell(
|
|
54
|
+
section_name: 'Software Versions',
|
|
55
|
+
key_column: 'Software Version Category',
|
|
56
|
+
value_column: 'Software Version ID',
|
|
57
|
+
key: 'Target Release Version'
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def effective_status_on(date) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
62
|
+
table = find_section_table('Status')
|
|
63
|
+
return nil if table.nil?
|
|
64
|
+
|
|
65
|
+
date_idx = column_index(table, 'Date')
|
|
66
|
+
status_idx = column_index(table, 'Status')
|
|
67
|
+
return nil if date_idx.nil? || status_idx.nil?
|
|
68
|
+
|
|
69
|
+
best_idx = nil
|
|
70
|
+
best_date = nil
|
|
71
|
+
table.rows.each_with_index do |row, i|
|
|
72
|
+
parsed = parse_dd_mm_yyyy(row[date_idx])
|
|
73
|
+
next if parsed.nil? || parsed > date
|
|
74
|
+
|
|
75
|
+
if best_date.nil? || parsed > best_date || (parsed == best_date && i > best_idx)
|
|
76
|
+
best_date = parsed
|
|
77
|
+
best_idx = i
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
return nil if best_idx.nil?
|
|
81
|
+
|
|
82
|
+
status = table.rows[best_idx][status_idx].to_s.strip
|
|
83
|
+
status.empty? ? nil : status
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def lookup_cell(section_name:, key_column:, value_column:, key:) # rubocop:disable Metrics/AbcSize
|
|
89
|
+
table = find_section_table(section_name)
|
|
90
|
+
return nil if table.nil?
|
|
91
|
+
|
|
92
|
+
key_idx = column_index(table, key_column)
|
|
93
|
+
value_idx = column_index(table, value_column)
|
|
94
|
+
return nil if key_idx.nil? || value_idx.nil?
|
|
95
|
+
|
|
96
|
+
row = table.rows.find { |r| r[key_idx].to_s.strip == key }
|
|
97
|
+
return nil if row.nil?
|
|
98
|
+
|
|
99
|
+
cell = row[value_idx].to_s.strip
|
|
100
|
+
cell.empty? ? nil : cell
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def find_section_table(section_name) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
104
|
+
in_section = false
|
|
105
|
+
section_level = nil
|
|
106
|
+
@items.each do |item|
|
|
107
|
+
if item.is_a?(Heading)
|
|
108
|
+
if !in_section && item.text.strip == section_name
|
|
109
|
+
in_section = true
|
|
110
|
+
section_level = item.level
|
|
111
|
+
elsif in_section && item.level <= section_level
|
|
112
|
+
return nil
|
|
113
|
+
end
|
|
114
|
+
elsif in_section && item.is_a?(MarkdownTable)
|
|
115
|
+
return item
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def collect_dates(section_name, column_name)
|
|
122
|
+
table = find_section_table(section_name)
|
|
123
|
+
return [] if table.nil?
|
|
124
|
+
|
|
125
|
+
col_index = column_index(table, column_name)
|
|
126
|
+
return [] if col_index.nil?
|
|
127
|
+
|
|
128
|
+
table.rows.filter_map { |row| parse_dd_mm_yyyy(row[col_index]) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def column_index(table, column_name)
|
|
132
|
+
table.column_names.each_with_index do |name, idx|
|
|
133
|
+
return idx if name.to_s.strip == column_name
|
|
134
|
+
end
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_dd_mm_yyyy(value)
|
|
139
|
+
return nil if value.nil?
|
|
140
|
+
|
|
141
|
+
match = /\A(\d{2})-(\d{2})-(\d{4})\z/.match(value.to_s.strip)
|
|
142
|
+
return nil unless match
|
|
143
|
+
|
|
144
|
+
Date.new(match[3].to_i, match[2].to_i, match[1].to_i)
|
|
145
|
+
rescue ArgumentError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def assign_id_parts(stem)
|
|
150
|
+
match = stem.match(/\A([A-Za-z]+)-(\d+)/)
|
|
151
|
+
if match
|
|
152
|
+
@id = "#{match[1]}-#{match[2]}".downcase
|
|
153
|
+
@record_type = match[1].upcase
|
|
154
|
+
@sequence_number = match[2]
|
|
155
|
+
else
|
|
156
|
+
@id = stem.downcase
|
|
157
|
+
@record_type = nil
|
|
158
|
+
@sequence_number = nil
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|