Almirah 0.4.0 → 0.4.2
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 +1 -6
- data/lib/almirah/doc_items/code_block.rb +1 -1
- data/lib/almirah/doc_items/controlled_table.rb +1 -1
- data/lib/almirah/doc_items/doc_item.rb +12 -0
- data/lib/almirah/doc_items/heading.rb +22 -4
- data/lib/almirah/doc_items/image.rb +8 -1
- data/lib/almirah/doc_items/markdown_table.rb +3 -3
- data/lib/almirah/doc_items/text_line.rb +145 -29
- data/lib/almirah/doc_types/base_document.rb +21 -38
- data/lib/almirah/doc_types/decision.rb +7 -1
- data/lib/almirah/doc_types/decisions_overview.rb +47 -2
- data/lib/almirah/doc_types/source_file.rb +4 -8
- data/lib/almirah/doc_types/traceability.rb +2 -2
- data/lib/almirah/dom/doc_section.rb +1 -1
- data/lib/almirah/dom/document.rb +1 -1
- data/lib/almirah/html_safe.rb +39 -0
- data/lib/almirah/link_registry.rb +53 -0
- data/lib/almirah/project/project_data.rb +6 -2
- data/lib/almirah/project.rb +60 -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 +13 -1
- data/lib/almirah/templates/scripts/main.js +4 -1
- data/lib/almirah/templates/scripts/orama_search.js +20 -7
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5221af43605bcaab2815ac5c940d53188394a92f8f8fadbbd2def016d1d52328
|
|
4
|
+
data.tar.gz: dd1d271774fe527d93512134b70cca3471048ff2a0859327c813eb75ebd35b54
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cf6a8d5892855c7b8752a2a3d447fec79dcf89a6a796b931709b2207ceeaddaede5cf7ac262ce13ab8b47aa081ead25fd225f353fff3afb84bc01dfaf2dfbdd
|
|
7
|
+
data.tar.gz: 9c0c6cffe8a41a19fc6778bcb0a3210f5a0ca3402b058d6b5f011b0434805fd11035be76a9ec6a742485839fe7523480d043923e582a2f0367c4fadb93922473
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Reporter prints concise, aligned progress lines for the processing pipeline:
|
|
4
|
+
#
|
|
5
|
+
# parsing specifications ..... 4 ok
|
|
6
|
+
# parsing test protocols ..... 2 ok
|
|
7
|
+
# parsing decisions .......... 14 ok
|
|
8
|
+
# traceability matrices ...... 4 ok
|
|
9
|
+
# coverage matrices .......... 1 ok
|
|
10
|
+
# implementation matrices .... 1 ok
|
|
11
|
+
# decision links ............. 5 ok
|
|
12
|
+
# rendering HTML ............. ./build/index.html
|
|
13
|
+
#
|
|
14
|
+
# ANSI colour is applied only when writing to an interactive terminal, so piped
|
|
15
|
+
# or captured output stays clean.
|
|
16
|
+
module ConsoleReporter
|
|
17
|
+
COLUMN = 28 # label is dot-padded out to this column, then the value follows
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def count(label, number)
|
|
22
|
+
emit(label, "#{number} ok", 92) # green highlight
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def result(label, value)
|
|
26
|
+
emit(label, value, 96) # cyan highlight
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def warn(label, value)
|
|
30
|
+
emit(label, value.to_s, 91) # red highlight
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Colourises a detail line with the same red as warn (TTY-gated).
|
|
34
|
+
def warn_detail(text)
|
|
35
|
+
colorize(text, 91)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def emit(label, value, color_code)
|
|
39
|
+
dotted = "#{label} ".ljust(COLUMN, '.')
|
|
40
|
+
puts "#{dotted} #{colorize(value, color_code)}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def colorize(text, color_code)
|
|
44
|
+
$stdout.tty? ? "\e[#{color_code}m#{text}\e[0m" : text
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/almirah/doc_fabric.rb
CHANGED
|
@@ -17,12 +17,6 @@ class DocFabric
|
|
|
17
17
|
@@spec_colors = %w[cff4d2 fbeee6 ffcad4 bce6ff e4dfd9 f9e07f cbe54e d3e7ee eab595 86e3c3
|
|
18
18
|
ffdca2 ffffff ffdd94 d0e6a5 ffb284 f3dbcf c9bbc8 c6c09c]
|
|
19
19
|
|
|
20
|
-
def self.add_lazy_doc_id(path)
|
|
21
|
-
if res = /(\w+)[.]md$/.match(path)
|
|
22
|
-
TextLine.add_lazy_doc_id(res[1])
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
20
|
def self.create_specification(path)
|
|
27
21
|
color = @@spec_colors[@@color_index]
|
|
28
22
|
@@color_index += 1
|
|
@@ -71,6 +65,7 @@ class DocFabric
|
|
|
71
65
|
DocFabric.parse_document doc
|
|
72
66
|
doc.extract_current_status
|
|
73
67
|
doc.extract_start_date
|
|
68
|
+
doc.extract_target_date
|
|
74
69
|
doc.extract_target_release_version
|
|
75
70
|
doc
|
|
76
71
|
end
|
|
@@ -19,4 +19,16 @@ class DocItem < TextLine # rubocop:disable Style/Documentation
|
|
|
19
19
|
def get_url
|
|
20
20
|
''
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
# Splits a Markdown table row into its cell strings, treating a backslash-escaped
|
|
24
|
+
# pipe (\|) as a literal character within a cell rather than a column separator
|
|
25
|
+
# (e.g. a "|" inside an inline code span such as `[[target\|alias]]`). The escaping
|
|
26
|
+
# backslash is dropped so the cell renders the intended literal pipe.
|
|
27
|
+
def split_table_cells(row)
|
|
28
|
+
row.split(/(?<!\\)\|/).map { |cell| cell.gsub('\\|', '|') }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def owner_document
|
|
32
|
+
@parent_doc
|
|
33
|
+
end
|
|
22
34
|
end
|
|
@@ -57,8 +57,26 @@ class Heading < Paragraph
|
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# As get_section_info but with the author-supplied text HTML-escaped for safe
|
|
61
|
+
# interpolation into rendered HTML (headings, TOC, traceability). ADR-188/SRS-096.
|
|
62
|
+
def get_section_info_html
|
|
63
|
+
if level.zero? # Doc Title
|
|
64
|
+
escape_text(@text)
|
|
65
|
+
else
|
|
66
|
+
"#{@section_number} #{escape_text(@text)}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
60
70
|
def get_anchor_text
|
|
61
|
-
"#{@section_number}-#{
|
|
71
|
+
"#{@section_number}-#{anchor_slug}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The anchor is a generated identifier emitted into name/href attributes, so it
|
|
75
|
+
# must not carry the HTML-significant characters that author heading text may
|
|
76
|
+
# contain (ADR-188). Stripping them keeps the anchor inert and self-consistent
|
|
77
|
+
# across the heading, its self-link, and the table of contents.
|
|
78
|
+
def anchor_slug
|
|
79
|
+
getTextWithoutSpaces.gsub(/[<>"'&]/, '')
|
|
62
80
|
end
|
|
63
81
|
|
|
64
82
|
def get_markdown_anchor_text
|
|
@@ -72,10 +90,10 @@ class Heading < Paragraph
|
|
|
72
90
|
@@html_table_render_in_progress = false
|
|
73
91
|
end
|
|
74
92
|
heading_level = level.to_s
|
|
75
|
-
heading_text =
|
|
93
|
+
heading_text = get_section_info_html
|
|
76
94
|
if level.zero?
|
|
77
|
-
heading_level = 1.to_s
|
|
78
|
-
heading_text = @text
|
|
95
|
+
heading_level = 1.to_s # Render Doc Title as a regular h1
|
|
96
|
+
heading_text = escape_text(@text) # Doc Title does not have a section number
|
|
79
97
|
end
|
|
80
98
|
s += "<a name=\"#{@anchor_id}\"></a>\n"
|
|
81
99
|
s += "<h#{heading_level}> #{heading_text} <a href=\"\##{@anchor_id}\" class=\"heading_anchor\">"
|
|
@@ -21,7 +21,14 @@ class Image < DocItem
|
|
|
21
21
|
@@html_table_render_in_progress = false
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
alt = escape_attr(@text)
|
|
25
|
+
src = safe_url(@path)
|
|
26
|
+
if src.nil? # disallowed scheme: render inert rather than emitting it (ADR-188, SRS-098)
|
|
27
|
+
s += "<p style=\"margin-top: 15px;\">[image: #{alt}]"
|
|
28
|
+
return s
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
s += "<p style=\"margin-top: 15px;\"><img src=\"#{escape_attr(src)}\" alt=\"#{alt}\" "
|
|
25
32
|
s += "href=\"javascript:void(0)\" onclick=\"image_OnClick(this)\">"
|
|
26
33
|
return s
|
|
27
34
|
end
|
|
@@ -12,7 +12,7 @@ class MarkdownTable < DocItem
|
|
|
12
12
|
|
|
13
13
|
res = /^[|](.*[|])/.match(heading_row)
|
|
14
14
|
@column_names = if res
|
|
15
|
-
res[1]
|
|
15
|
+
split_table_cells(res[1])
|
|
16
16
|
else
|
|
17
17
|
['# ERROR# ']
|
|
18
18
|
end
|
|
@@ -49,7 +49,7 @@ class MarkdownTable < DocItem
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def add_row(row)
|
|
52
|
-
columns = row
|
|
52
|
+
columns = split_table_cells(row)
|
|
53
53
|
@rows.append(columns.map!(&:strip))
|
|
54
54
|
true
|
|
55
55
|
end
|
|
@@ -65,7 +65,7 @@ class MarkdownTable < DocItem
|
|
|
65
65
|
s += "\t<thead>"
|
|
66
66
|
|
|
67
67
|
@column_names.each do |h|
|
|
68
|
-
s += " <th>#{h}</th>"
|
|
68
|
+
s += " <th>#{format_string(h.strip)}</th>"
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
s += " </thead>\n"
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
require 'cgi'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require_relative '../relative_url'
|
|
4
|
+
require_relative '../html_safe'
|
|
2
5
|
|
|
3
6
|
class TextLineToken
|
|
4
7
|
attr_accessor :value
|
|
@@ -50,6 +53,18 @@ class SquareBracketRight < TextLineToken
|
|
|
50
53
|
end
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
class DoubleSquareBracketLeft < TextLineToken
|
|
57
|
+
def initialize # rubocop:disable Lint/MissingSuper
|
|
58
|
+
@value = '[['
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class DoubleSquareBracketRight < TextLineToken
|
|
63
|
+
def initialize # rubocop:disable Lint/MissingSuper
|
|
64
|
+
@value = ']]'
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
53
68
|
class SquareBracketRightAndParentheseLeft < TextLineToken
|
|
54
69
|
def initialize
|
|
55
70
|
@value = ']('
|
|
@@ -77,6 +92,8 @@ class TextLineParser
|
|
|
77
92
|
@supported_tokens.append(BoldToken.new)
|
|
78
93
|
@supported_tokens.append(ItalicToken.new)
|
|
79
94
|
@supported_tokens.append(BacktickToken.new)
|
|
95
|
+
@supported_tokens.append(DoubleSquareBracketLeft.new)
|
|
96
|
+
@supported_tokens.append(DoubleSquareBracketRight.new)
|
|
80
97
|
@supported_tokens.append(SquareBracketRightAndParentheseLeft.new)
|
|
81
98
|
@supported_tokens.append(ParentheseLeft.new)
|
|
82
99
|
@supported_tokens.append(ParentheseRight.new)
|
|
@@ -201,6 +218,12 @@ class TextLineParser
|
|
|
201
218
|
end
|
|
202
219
|
|
|
203
220
|
class TextLineBuilderContext
|
|
221
|
+
# Literal (non-markup) text run. Subclasses encode it for the HTML context;
|
|
222
|
+
# the base context leaves it untouched for plain reconstruction/unit tests.
|
|
223
|
+
def literal_text(str)
|
|
224
|
+
str
|
|
225
|
+
end
|
|
226
|
+
|
|
204
227
|
def italic(str)
|
|
205
228
|
str
|
|
206
229
|
end
|
|
@@ -220,6 +243,10 @@ class TextLineBuilderContext
|
|
|
220
243
|
def link(_link_text, link_url)
|
|
221
244
|
link_url
|
|
222
245
|
end
|
|
246
|
+
|
|
247
|
+
def wiki_link(inner)
|
|
248
|
+
"[[#{inner}]]"
|
|
249
|
+
end
|
|
223
250
|
end
|
|
224
251
|
|
|
225
252
|
class TextLineBuilder
|
|
@@ -229,7 +256,7 @@ class TextLineBuilder
|
|
|
229
256
|
@builder_context = builder_context
|
|
230
257
|
end
|
|
231
258
|
|
|
232
|
-
def restore(token_list)
|
|
259
|
+
def restore(token_list) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
233
260
|
result = ''
|
|
234
261
|
return '' if token_list.nil?
|
|
235
262
|
|
|
@@ -296,6 +323,25 @@ class TextLineBuilder
|
|
|
296
323
|
result += '***'
|
|
297
324
|
ti = ti_starting_position + 1
|
|
298
325
|
end
|
|
326
|
+
when 'DoubleSquareBracketLeft'
|
|
327
|
+
# wiki/Obsidian link: collect raw text up to the closing "]]"
|
|
328
|
+
is_found = false
|
|
329
|
+
ti_starting_position = ti
|
|
330
|
+
tii = ti + 1
|
|
331
|
+
while tii < tl
|
|
332
|
+
if token_list[tii].instance_of?(DoubleSquareBracketRight)
|
|
333
|
+
inner = token_list[(ti + 1)..(tii - 1)].map(&:value).join
|
|
334
|
+
result += @builder_context.wiki_link(inner)
|
|
335
|
+
ti = tii + 1
|
|
336
|
+
is_found = true
|
|
337
|
+
break
|
|
338
|
+
end
|
|
339
|
+
tii += 1
|
|
340
|
+
end
|
|
341
|
+
unless is_found
|
|
342
|
+
result += '[['
|
|
343
|
+
ti = ti_starting_position + 1
|
|
344
|
+
end
|
|
299
345
|
when 'SquareBracketLeft'
|
|
300
346
|
# try to find closing part
|
|
301
347
|
is_found = false
|
|
@@ -324,7 +370,11 @@ class TextLineBuilder
|
|
|
324
370
|
tii += 1
|
|
325
371
|
end
|
|
326
372
|
if is_found
|
|
327
|
-
|
|
373
|
+
# URL is reconstructed raw (not via restore) so scheme classification
|
|
374
|
+
# and file-path resolution see the original characters; link() applies
|
|
375
|
+
# attribute escaping and the scheme allow-list (ADR-188).
|
|
376
|
+
raw_url = (sub_list_url_address || []).map(&:value).join
|
|
377
|
+
result += @builder_context.link(restore(sub_list_url_text), raw_url)
|
|
328
378
|
else
|
|
329
379
|
result += '['
|
|
330
380
|
ti = ti_starting_position + 1
|
|
@@ -333,8 +383,8 @@ class TextLineBuilder
|
|
|
333
383
|
when 'InlineCodeToken'
|
|
334
384
|
result += @builder_context.inline_code(token_list[ti].value)
|
|
335
385
|
ti += 1
|
|
336
|
-
when 'TextLineToken', 'ParentheseLeft', 'ParentheseRight', 'SquareBracketRight'
|
|
337
|
-
result += token_list[ti].value
|
|
386
|
+
when 'TextLineToken', 'ParentheseLeft', 'ParentheseRight', 'SquareBracketRight', 'DoubleSquareBracketRight'
|
|
387
|
+
result += @builder_context.literal_text(token_list[ti].value)
|
|
338
388
|
ti += 1
|
|
339
389
|
else
|
|
340
390
|
ti += 1
|
|
@@ -345,11 +395,39 @@ class TextLineBuilder
|
|
|
345
395
|
end
|
|
346
396
|
|
|
347
397
|
class TextLine < TextLineBuilderContext
|
|
348
|
-
|
|
398
|
+
include HtmlSafe
|
|
399
|
+
|
|
400
|
+
@@link_registry = nil # rubocop:disable Style/ClassVars
|
|
401
|
+
@@broken_links = [] # rubocop:disable Style/ClassVars
|
|
402
|
+
|
|
403
|
+
class << self
|
|
404
|
+
def link_registry=(registry)
|
|
405
|
+
@@link_registry = registry # rubocop:disable Style/ClassVars
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def link_registry
|
|
409
|
+
@@link_registry
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Cross-document links that could not be resolved to a managed document,
|
|
413
|
+
# collected during rendering for reporting (ADR-186, SRS-094).
|
|
414
|
+
def broken_links
|
|
415
|
+
@@broken_links
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def reset_broken_links
|
|
419
|
+
@@broken_links = [] # rubocop:disable Style/ClassVars
|
|
420
|
+
end
|
|
349
421
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
422
|
+
def record_broken_link(document, target)
|
|
423
|
+
@@broken_links << { document: document&.id, target: target }
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# The document that owns this text line. Used to resolve cross-document links
|
|
428
|
+
# relative to the current page. nil for stand-alone text (e.g. unit tests).
|
|
429
|
+
def owner_document
|
|
430
|
+
nil
|
|
353
431
|
end
|
|
354
432
|
|
|
355
433
|
def format_string(str)
|
|
@@ -358,6 +436,11 @@ class TextLine < TextLineBuilderContext
|
|
|
358
436
|
tlb.restore(tlp.tokenize(str))
|
|
359
437
|
end
|
|
360
438
|
|
|
439
|
+
# Literal text run, HTML-escaped for element content (ADR-188, SRS-096).
|
|
440
|
+
def literal_text(str)
|
|
441
|
+
escape_text(str)
|
|
442
|
+
end
|
|
443
|
+
|
|
361
444
|
def italic(str)
|
|
362
445
|
"<i>#{str}</i>"
|
|
363
446
|
end
|
|
@@ -374,30 +457,63 @@ class TextLine < TextLineBuilderContext
|
|
|
374
457
|
"<code class=\"inline\">#{CGI.escapeHTML(str)}</code>"
|
|
375
458
|
end
|
|
376
459
|
|
|
377
|
-
def link(link_text, link_url)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
460
|
+
def link(link_text, link_url) # rubocop:disable Metrics/MethodLength
|
|
461
|
+
raw = link_url.to_s
|
|
462
|
+
kind, target, fragment = classify_markdown_link(raw)
|
|
463
|
+
case kind
|
|
464
|
+
when :internal
|
|
465
|
+
href = RelativeUrl.between(owner_document.output_rel_path, target.output_rel_path, fragment: fragment)
|
|
466
|
+
"<a href=\"#{escape_attr(href)}\" class=\"external\">#{link_text}</a>"
|
|
467
|
+
when :broken
|
|
468
|
+
TextLine.record_broken_link(owner_document, raw)
|
|
469
|
+
"<a href=\"#{escape_attr(raw)}\" class=\"broken_link\" title=\"Unresolved cross-document link\">#{link_text}</a>"
|
|
470
|
+
else
|
|
471
|
+
url = safe_url(raw)
|
|
472
|
+
return link_text if url.nil? # disallowed scheme: render inert (ADR-188, SRS-098)
|
|
386
473
|
|
|
387
|
-
|
|
388
|
-
if res && res.length > 2
|
|
389
|
-
lazy_doc_id = res[1]
|
|
390
|
-
anchor = res[2]
|
|
391
|
-
end
|
|
474
|
+
"<a target=\"_blank\" rel=\"noopener\" href=\"#{escape_attr(url)}\" class=\"external\">#{link_text}</a>"
|
|
392
475
|
end
|
|
476
|
+
end
|
|
393
477
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
478
|
+
# Resolves an Obsidian/wiki link "[[target#fragment|alias]]" to a managed
|
|
479
|
+
# document by its unique id/filename, independent of folder (ADR-186).
|
|
480
|
+
def wiki_link(inner) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize,Metrics/MethodLength
|
|
481
|
+
link_part, sep, alias_text = inner.partition('|')
|
|
482
|
+
target, _hash, fragment = link_part.partition('#')
|
|
483
|
+
display = (sep.empty? ? link_part : alias_text).strip
|
|
484
|
+
display = link_part.strip if display.empty?
|
|
485
|
+
|
|
486
|
+
doc = TextLine.link_registry&.find_by_id(target.strip)
|
|
487
|
+
if doc && owner_document&.output_rel_path
|
|
488
|
+
href = RelativeUrl.between(owner_document.output_rel_path, doc.output_rel_path,
|
|
489
|
+
fragment: fragment.strip.empty? ? nil : fragment.strip)
|
|
490
|
+
"<a href=\"#{href}\" class=\"external\">#{CGI.escapeHTML(display)}</a>"
|
|
491
|
+
elsif owner_document&.output_rel_path
|
|
492
|
+
TextLine.record_broken_link(owner_document, "[[#{inner}]]")
|
|
493
|
+
"<span class=\"broken_link\" title=\"Unresolved wiki link\">#{CGI.escapeHTML(display)}</span>"
|
|
494
|
+
else
|
|
495
|
+
"[[#{inner}]]"
|
|
400
496
|
end
|
|
401
|
-
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
private
|
|
500
|
+
|
|
501
|
+
# Classifies a Markdown link target as :internal (a managed document, resolved
|
|
502
|
+
# against the owning document's source directory), :broken (a local .md path
|
|
503
|
+
# that does not resolve), or :external (everything else). ADR-186.
|
|
504
|
+
def classify_markdown_link(raw) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
505
|
+
path_part, _sep, fragment = raw.partition('#')
|
|
506
|
+
path_part = URI::DEFAULT_PARSER.unescape(path_part) # decode %20 etc. to match the real file path
|
|
507
|
+
return [:external] unless local_markdown_path?(path_part)
|
|
508
|
+
|
|
509
|
+
doc = owner_document
|
|
510
|
+
return [:external] unless doc&.output_rel_path && doc.respond_to?(:path) && doc.path && TextLine.link_registry
|
|
511
|
+
|
|
512
|
+
target = TextLine.link_registry.find_by_source(File.expand_path(path_part, File.dirname(doc.path)))
|
|
513
|
+
target ? [:internal, target, fragment.empty? ? nil : fragment] : [:broken]
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def local_markdown_path?(path_part)
|
|
517
|
+
path_part.match?(/\.(md|markdown)\z/i) && !path_part.match?(%r{\A[a-z][a-z0-9+.\-]*://}i)
|
|
402
518
|
end
|
|
403
519
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
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
|
|
5
7
|
|
|
6
8
|
class << self
|
|
7
9
|
attr_accessor :show_decisions_link
|
|
@@ -32,6 +34,7 @@ class BaseDocument # rubocop:disable Style/Documentation
|
|
|
32
34
|
else
|
|
33
35
|
"#{@id}/#{@id}.html"
|
|
34
36
|
end
|
|
37
|
+
@output_rel_path = output_file_path.split('/build/', 2).last
|
|
35
38
|
file = File.open(output_file_path, 'w')
|
|
36
39
|
file_data.each do |s| # rubocop:disable Metrics/BlockLength
|
|
37
40
|
if s.include?('{{CONTENT}}')
|
|
@@ -43,51 +46,21 @@ class BaseDocument # rubocop:disable Style/Documentation
|
|
|
43
46
|
elsif s.include?('{{DOCUMENT_TITLE}}')
|
|
44
47
|
file.puts s.gsub! '{{DOCUMENT_TITLE}}', @title
|
|
45
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>"
|
|
46
51
|
if @id == 'index'
|
|
47
|
-
file.puts
|
|
48
|
-
file.puts
|
|
49
|
-
file.puts '<link rel="stylesheet" href="./css/main.css">'
|
|
50
|
-
file.puts '<script src="./scripts/main.js"></script>'
|
|
51
|
-
elsif instance_of? Specification
|
|
52
|
-
file.puts '<link rel="stylesheet" href="../../css/main.css">'
|
|
53
|
-
file.puts '<script src="../../scripts/main.js"></script>'
|
|
54
|
-
elsif instance_of? Traceability
|
|
55
|
-
file.puts '<link rel="stylesheet" href="../../css/main.css">'
|
|
56
|
-
file.puts '<script src="../../scripts/main.js"></script>'
|
|
57
|
-
elsif instance_of? Coverage
|
|
58
|
-
file.puts '<link rel="stylesheet" href="../../css/main.css">'
|
|
59
|
-
file.puts '<script src="../../scripts/main.js"></script>'
|
|
60
|
-
elsif instance_of? Implementation
|
|
61
|
-
file.puts '<link rel="stylesheet" href="../../css/main.css">'
|
|
62
|
-
file.puts '<script src="../../scripts/main.js"></script>'
|
|
63
|
-
elsif instance_of? Protocol
|
|
64
|
-
file.puts '<link rel="stylesheet" href="../../../css/main.css">'
|
|
65
|
-
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')}\">"
|
|
66
54
|
elsif instance_of? DecisionsOverview
|
|
67
|
-
file.puts '<link rel="stylesheet" href="../css/main.css">'
|
|
68
|
-
file.puts '<script src="../scripts/main.js"></script>'
|
|
69
55
|
file.puts '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>'
|
|
70
|
-
elsif instance_of? Decision
|
|
71
|
-
file.puts "<link rel=\"stylesheet\" href=\"#{root_prefix}css/main.css\">"
|
|
72
|
-
file.puts "<script src=\"#{root_prefix}scripts/main.js\"></script>"
|
|
73
56
|
end
|
|
74
57
|
elsif s.include?('{{HOME_BUTTON}}')
|
|
75
58
|
if @id == 'index'
|
|
76
|
-
file.puts '
|
|
77
|
-
file.puts decisions_link('./decisions/overview.html') if BaseDocument.show_decisions_link
|
|
78
|
-
elsif instance_of? Protocol
|
|
79
|
-
file.puts '<a id="index_menu_item" href="./../../../index.html"><span><i class="fa fa-info" aria-hidden="true"></i></span> Index</a>'
|
|
80
|
-
file.puts decisions_link('./../../../decisions/overview.html') if BaseDocument.show_decisions_link
|
|
81
|
-
elsif instance_of? DecisionsOverview
|
|
82
|
-
file.puts index_link('./../index.html')
|
|
83
|
-
file.puts decisions_link('./overview.html')
|
|
84
|
-
elsif instance_of? Decision
|
|
85
|
-
file.puts index_link("#{root_prefix}index.html")
|
|
86
|
-
file.puts decisions_link("#{root_prefix}decisions/overview.html")
|
|
59
|
+
file.puts home_link(rel_to('index.html'))
|
|
87
60
|
else
|
|
88
|
-
file.puts '
|
|
89
|
-
file.puts decisions_link('./../../decisions/overview.html') if BaseDocument.show_decisions_link
|
|
61
|
+
file.puts index_link(rel_to('index.html'))
|
|
90
62
|
end
|
|
63
|
+
file.puts decisions_link(rel_to('decisions/overview.html')) if BaseDocument.show_decisions_link
|
|
91
64
|
elsif s.include?('{{GEM_VERSION}}')
|
|
92
65
|
file.puts "(#{Gem.loaded_specs['Almirah'].version.version})"
|
|
93
66
|
else
|
|
@@ -106,4 +79,14 @@ class BaseDocument # rubocop:disable Style/Documentation
|
|
|
106
79
|
icon = '<span><i class="fa fa-info" aria-hidden="true"></i></span>'
|
|
107
80
|
%(<a id="index_menu_item" href="#{href}">#{icon} Index</a>)
|
|
108
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
|
|
109
92
|
end
|
|
@@ -7,7 +7,7 @@ require_relative '../doc_items/markdown_table'
|
|
|
7
7
|
|
|
8
8
|
class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metrics/ClassLength
|
|
9
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
|
|
10
|
+
:start_date, :target_date, :target_release_version, :specifications_path, :wrong_links_hash
|
|
11
11
|
|
|
12
12
|
def initialize(file_path)
|
|
13
13
|
super
|
|
@@ -16,6 +16,7 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
|
|
|
16
16
|
assign_id_parts(stem)
|
|
17
17
|
@current_status = nil
|
|
18
18
|
@start_date = nil
|
|
19
|
+
@target_date = nil
|
|
19
20
|
@target_release_version = nil
|
|
20
21
|
@wrong_links_hash = {}
|
|
21
22
|
end
|
|
@@ -49,6 +50,11 @@ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metric
|
|
|
49
50
|
@start_date = dates.min
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
def extract_target_date
|
|
54
|
+
dates = collect_dates('Status', 'Date') + collect_dates('Scope', 'Target Date')
|
|
55
|
+
@target_date = dates.max
|
|
56
|
+
end
|
|
57
|
+
|
|
52
58
|
def extract_target_release_version
|
|
53
59
|
@target_release_version = lookup_cell(
|
|
54
60
|
section_name: 'Software Versions',
|
|
@@ -54,7 +54,8 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
|
|
|
54
54
|
s += "\t\t<td class=\"item_text\" style='padding: 5px;'>#{title_html}</td>\n"
|
|
55
55
|
start_date_html = doc.start_date ? doc.start_date.strftime('%d-%m-%Y') : ''
|
|
56
56
|
s += "\t\t<td class=\"item_meta\">#{start_date_html}</td>\n"
|
|
57
|
-
|
|
57
|
+
target_date_html = doc.target_date ? doc.target_date.strftime('%d-%m-%Y') : ''
|
|
58
|
+
s += "\t\t<td class=\"item_meta\">#{target_date_html}</td>\n"
|
|
58
59
|
s += "\t\t<td class=\"item_meta\">#{doc.target_release_version}</td>\n"
|
|
59
60
|
s += "\t\t<td class=\"item_meta\"></td>\n"
|
|
60
61
|
s += "</tr>\n"
|
|
@@ -72,6 +73,10 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
|
|
|
72
73
|
[75, 192, 192], [153, 102, 255], [201, 203, 207]
|
|
73
74
|
].freeze
|
|
74
75
|
|
|
76
|
+
# Records with a missing or ambiguous current-status marker are surfaced under
|
|
77
|
+
# this category as a data-quality indicator rather than being silently dropped.
|
|
78
|
+
UNDEFINED_STATUS_LABEL = 'Undefined'
|
|
79
|
+
|
|
75
80
|
def render_charts_grid # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
76
81
|
counts = @project.project_data.decisions.each_with_object(Hash.new(0)) do |item, cntr|
|
|
77
82
|
cntr[item.record_type] += 1 if item.record_type
|
|
@@ -81,6 +86,7 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
|
|
|
81
86
|
pie_colors = labels.each_with_index.map { |_, i| palette_rgba(i, 0.5) }
|
|
82
87
|
|
|
83
88
|
velocity = velocity_chart_data
|
|
89
|
+
status_dist = status_distribution_chart_data
|
|
84
90
|
|
|
85
91
|
<<~HTML
|
|
86
92
|
<div class="decisions_overview_charts">
|
|
@@ -115,7 +121,20 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
|
|
|
115
121
|
\t\t\t});
|
|
116
122
|
\t\t</script>
|
|
117
123
|
\t</div>
|
|
118
|
-
\t<div class="chart_cell"
|
|
124
|
+
\t<div class="chart_cell">
|
|
125
|
+
\t\t<canvas id="decisions_status_bar"></canvas>
|
|
126
|
+
\t\t<script>
|
|
127
|
+
\t\t\tnew Chart(document.getElementById('decisions_status_bar'), {
|
|
128
|
+
\t\t\t\ttype: 'bar',
|
|
129
|
+
\t\t\t\tdata: #{status_dist.to_json},
|
|
130
|
+
\t\t\t\toptions: {
|
|
131
|
+
\t\t\t\t\tindexAxis: 'y',
|
|
132
|
+
\t\t\t\t\tplugins: { title: { display: true, text: 'Decision Records by Current Status' }, legend: { display: false } },
|
|
133
|
+
\t\t\t\t\tscales: { x: { beginAtZero: true, ticks: { precision: 0 } } }
|
|
134
|
+
\t\t\t\t}
|
|
135
|
+
\t\t\t});
|
|
136
|
+
\t\t</script>
|
|
137
|
+
\t</div>
|
|
119
138
|
</div>
|
|
120
139
|
HTML
|
|
121
140
|
end
|
|
@@ -144,6 +163,32 @@ class DecisionsOverview < BaseDocument # rubocop:disable Style/Documentation,Met
|
|
|
144
163
|
}
|
|
145
164
|
end
|
|
146
165
|
|
|
166
|
+
# Tally each decision record under its current status (the "*"-marked Status
|
|
167
|
+
# row). Records whose current status is undefined fall under "Undefined", which
|
|
168
|
+
# is ordered last; real statuses keep first-seen order. The count is baked into
|
|
169
|
+
# each label so small categories stay legible on a linear axis next to a
|
|
170
|
+
# dominant one.
|
|
171
|
+
def status_distribution_chart_data # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
172
|
+
counts = Hash.new(0)
|
|
173
|
+
keys = []
|
|
174
|
+
@project.project_data.decisions.each do |doc|
|
|
175
|
+
status = doc.current_status
|
|
176
|
+
key = status.nil? || status.strip.empty? ? UNDEFINED_STATUS_LABEL : status
|
|
177
|
+
keys << key unless counts.key?(key)
|
|
178
|
+
counts[key] += 1
|
|
179
|
+
end
|
|
180
|
+
keys << UNDEFINED_STATUS_LABEL if keys.delete(UNDEFINED_STATUS_LABEL)
|
|
181
|
+
|
|
182
|
+
colors = keys.each_with_index.map do |k, i|
|
|
183
|
+
k == UNDEFINED_STATUS_LABEL ? palette_rgba(CHART_PALETTE.length - 1, 0.5) : palette_rgba(i, 0.5)
|
|
184
|
+
end
|
|
185
|
+
{
|
|
186
|
+
labels: keys.map { |k| "#{k} (#{counts[k]})" },
|
|
187
|
+
datasets: [{ label: 'Decision records', data: keys.map { |k| counts[k] },
|
|
188
|
+
backgroundColor: colors, borderWidth: 0 }]
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
147
192
|
def palette_rgba(index, alpha)
|
|
148
193
|
r, g, b = CHART_PALETTE[index % CHART_PALETTE.length]
|
|
149
194
|
"rgba(#{r}, #{g}, #{b}, #{alpha})"
|