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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3c6b1d68ee25c9b62690c4cdda45ec2d790960a6e75762576ddc60d6c3136e5
4
- data.tar.gz: 334ef7251595119cd27bf980eeb53a8f02d913cde7d2fc5d0844a1d863b6c825
3
+ metadata.gz: 5221af43605bcaab2815ac5c940d53188394a92f8f8fadbbd2def016d1d52328
4
+ data.tar.gz: dd1d271774fe527d93512134b70cca3471048ff2a0859327c813eb75ebd35b54
5
5
  SHA512:
6
- metadata.gz: 20383b85a3d59d35a3c6259095e7e8b6709055b6670e2b651fdecdd814eee54b71ebf7d2cc1958c85bc998a426361dfff470c1c10749044334ae7ec7f1a69616
7
- data.tar.gz: d1d066ecfc0cfe644eaa28f42d793342e328f42d3eefc0e1fdeb2290c8ff281009bd59b99d23893604f89927ab698a922ef844af1e702c45a3a0a5d51926ff84
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
@@ -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,7 +19,7 @@ class CodeBlock < DocItem
19
19
  end
20
20
  s += "<code>"
21
21
  @code_lines.each do |l|
22
- s += l + " </br>"
22
+ s += escape_text(l) + " </br>" # ADR-188/SRS-096: code content is inert text
23
23
  end
24
24
  s += "</code>\n"
25
25
  return s
@@ -151,7 +151,7 @@ class ControlledTable < DocItem # rubocop:disable Style/Documentation
151
151
  end
152
152
 
153
153
  def add_row(row)
154
- columns = row.split('|')
154
+ columns = split_table_cells(row)
155
155
 
156
156
  @rows.append(format_columns(columns))
157
157
 
@@ -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}-#{getTextWithoutSpaces}"
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 = get_section_info
93
+ heading_text = get_section_info_html
76
94
  if level.zero?
77
- heading_level = 1.to_s # Render Doc Title as a regular h1
78
- heading_text = @text # Doc Title does not have a section number
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
- s += "<p style=\"margin-top: 15px;\"><img src=\"#{@path}\" alt=\"#{@text}\" "
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].split('|')
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.split('|')
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
- result += @builder_context.link(restore(sub_list_url_text), restore(sub_list_url_address))
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
- @@lazy_doc_id_dict = {}
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
- def self.add_lazy_doc_id(id)
351
- doc_id = id.to_s.downcase
352
- @@lazy_doc_id_dict[doc_id] = doc_id
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
- # define default result first
379
- result = "<a target=\"_blank\" rel=\"noopener\" href=\"#{link_url}\" class=\"external\">#{link_text}</a>"
380
-
381
- lazy_doc_id = nil
382
- anchor = nil
383
-
384
- if res = /(\w+)[.]md$/.match(link_url) # link
385
- lazy_doc_id = res[1].to_s.downcase
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
- elsif res = /(\w*)[.]md(#.*)$/.match(link_url) # link with anchor
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
- if lazy_doc_id && @@lazy_doc_id_dict.key?(lazy_doc_id)
395
- result = if anchor
396
- "<a href=\".\\..\\#{lazy_doc_id}\\#{lazy_doc_id}.html#{anchor}\" class=\"external\">#{link_text}</a>"
397
- else
398
- "<a href=\".\\..\\#{lazy_doc_id}\\#{lazy_doc_id}.html\" class=\"external\">#{link_text}</a>"
399
- end
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
- result
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 '<script type="module" src="./scripts/orama_search.js"></script>'
48
- file.puts '<link rel="stylesheet" href="./css/search.css">'
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 '<a id="home_menu_item" href="./index.html"><span><i class="fa fa-home" aria-hidden="true"></i></span>&nbsp;Home</a>'
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>&nbsp;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 '<a id="index_menu_item" href="./../../index.html"><span><i class="fa fa-info" aria-hidden="true"></i></span>&nbsp;Index</a>'
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}&nbsp;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}&nbsp;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
- s += "\t\t<td class=\"item_meta\"></td>\n"
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"></div>
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})"