jekyll-theme-zer0 0.22.19 → 0.22.21

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.
@@ -0,0 +1,117 @@
1
+ <!--
2
+ ===================================================================
3
+ NANOBAR - Config-Driven Page Loading Progress Bar
4
+ ===================================================================
5
+
6
+ File: nanobar.html
7
+ Path: _includes/components/nanobar.html
8
+ Purpose: Visual loading indicator shown on every page load,
9
+ fully configurable via site.nanobar in _config.yml
10
+
11
+ Template Logic:
12
+ - Conditionally renders only when site.nanobar.enabled != false
13
+ - Injects CSS custom properties from _config.yml values
14
+ - Loads nanobar.min.js (library) and nanobar-init.js (initializer)
15
+ - Passes config to JS via window.zer0Nanobar object
16
+
17
+ Dependencies:
18
+ - assets/js/nanobar.min.js — third-party Nanobar library
19
+ - assets/js/nanobar-init.js — theme initializer script
20
+ - _includes/core/header.html — #top-progress-target mount point
21
+ (rendered only when position == "navbar")
22
+
23
+ Configuration (_config.yml → nanobar):
24
+ enabled : true | false # master switch
25
+ color : CSS color value # bar fill colour
26
+ background : CSS color value # track background
27
+ height : CSS length # bar thickness
28
+ position : top | bottom | navbar # placement mode
29
+ z_index : integer # stacking order
30
+ steps : [int, …] # progress percentages
31
+ step_delay_ms : integer # ms between steps
32
+ classname : string # CSS class on wrapper
33
+ id : string # DOM id
34
+ target : CSS selector | "" # explicit mount target
35
+
36
+ Performance Notes:
37
+ - Both scripts loaded with `defer` to avoid blocking rendering
38
+ - CSS custom properties keep the inline <style> minimal
39
+ - Library injects its own baseline CSS; theme overrides via
40
+ specificity and !important where needed
41
+
42
+ Library: https://github.com/jacoborus/nanobar
43
+ ===================================================================
44
+ -->
45
+
46
+ {%- assign nb = site.nanobar | default: empty -%}
47
+ {%- if nb == empty or nb.enabled != false -%}
48
+
49
+ {%- comment -%} ── Resolve config with safe defaults ── {%- endcomment -%}
50
+ {%- assign nb_color = nb.color | default: "var(--bs-primary)" -%}
51
+ {%- assign nb_bg = nb.background | default: "transparent" -%}
52
+ {%- assign nb_height = nb.height | default: "3px" -%}
53
+ {%- assign nb_position = nb.position | default: "top" -%}
54
+ {%- assign nb_zindex = nb.z_index | default: 9999 -%}
55
+ {%- assign nb_classname = nb.classname | default: "nanobar" -%}
56
+ {%- assign nb_id = nb.id | default: "top-progress-bar" -%}
57
+ {%- assign nb_target = nb.target | default: "" -%}
58
+ {%- assign nb_step_delay = nb.step_delay_ms | default: 0 -%}
59
+ {%- assign nb_steps = nb.steps -%}
60
+ {%- if nb_steps == nil or nb_steps == empty -%}
61
+ {%- assign nb_steps = "30,76,100" | split: "," -%}
62
+ {%- endif -%}
63
+
64
+ <!-- ── Nanobar theme CSS (config-driven custom properties) ── -->
65
+ <style id="nanobar-theme">
66
+ :root {
67
+ --nanobar-color: {{ nb_color }};
68
+ --nanobar-bg: {{ nb_bg }};
69
+ --nanobar-height: {{ nb_height }};
70
+ --nanobar-z: {{ nb_zindex }};
71
+ }
72
+
73
+ /* Override library defaults with theme values */
74
+ .{{ nb_classname }} {
75
+ height: var(--nanobar-height) !important;
76
+ background: var(--nanobar-bg);
77
+ z-index: var(--nanobar-z);
78
+ }
79
+ .{{ nb_classname }} .bar {
80
+ background: var(--nanobar-color) !important;
81
+ transition: width .2s ease, height .3s ease;
82
+ }
83
+
84
+ /* Position modifiers */
85
+ .{{ nb_classname }}--bottom { top: auto !important; bottom: 0; }
86
+
87
+ /* Navbar mount: inline strip under the header */
88
+ .nanobar-mount {
89
+ position: relative;
90
+ width: 100%;
91
+ height: var(--nanobar-height);
92
+ overflow: hidden;
93
+ }
94
+ .nanobar-mount > .{{ nb_classname }}--navbar {
95
+ position: absolute !important;
96
+ top: 0; left: 0; right: 0;
97
+ width: 100%;
98
+ }
99
+ </style>
100
+
101
+ <!-- ── Nanobar scripts ── -->
102
+ <script defer src="{{ '/assets/js/nanobar.min.js' | relative_url }}"></script>
103
+ <script defer src="{{ '/assets/js/nanobar-init.js' | relative_url }}"></script>
104
+
105
+ <!-- ── Bridge _config.yml values to JS ── -->
106
+ <script>
107
+ window.zer0Nanobar = {
108
+ classname: {{ nb_classname | jsonify }},
109
+ id: {{ nb_id | jsonify }},
110
+ position: {{ nb_position | jsonify }},
111
+ target: {{ nb_target | jsonify }},
112
+ steps: [{% for s in nb_steps %}{{ s | strip | plus: 0 }}{% unless forloop.last %},{% endunless %}{% endfor %}],
113
+ stepDelay: {{ nb_step_delay | plus: 0 }}
114
+ };
115
+ </script>
116
+
117
+ {%- endif -%}
@@ -30,10 +30,10 @@
30
30
  ===================================================================
31
31
  -->
32
32
 
33
- <footer class="bd-footer container-xl border-top" role="contentinfo">
33
+ <footer class="bd-footer border-top" role="contentinfo">
34
34
  <!-- Powered by Row -->
35
- <div class="container row my-3">
36
- <ul class="nav col-sm justify-content-end list-unstyled d-flex align-items-center" aria-label="Powered by technologies">
35
+ <div class="container-xl my-3">
36
+ <ul class="nav justify-content-end list-unstyled d-flex align-items-center flex-wrap" aria-label="Powered by technologies">
37
37
  <span class="align-start">
38
38
  &copy; {{ site.time | date: "%Y" }} {{ site.name | default: site.title }} — Powered by:
39
39
  </span>
@@ -67,10 +67,9 @@
67
67
  </ul>
68
68
  </div>
69
69
 
70
- <!-- Branding and Navigation Block -->
71
- <div class="container row">
72
- <div class="container bg-dark text-light py-5 rounded-3">
73
- <div class="container">
70
+ <!-- Branding and Navigation Block — full-width dark background -->
71
+ <div class="bg-dark text-light py-5">
72
+ <div class="container-xl">
74
73
  <!-- Top: Site info + Quick Links + Social -->
75
74
  <div class="row mb-4">
76
75
  <!-- Site Info -->
@@ -163,10 +162,9 @@
163
162
  </div>
164
163
  </div>
165
164
 
166
- <!-- Bottom: Copyright -->
167
- <div class="text-center">
168
- <p class="mb-0">&copy; {{ site.time | date: "%Y" }} {{ site.title | default: site.name }}. All Rights Reserved.</p>
169
- </div>
165
+ <!-- Bottom: Copyright -->
166
+ <div class="text-center">
167
+ <p class="mb-0">&copy; {{ site.time | date: "%Y" }} {{ site.title | default: site.name }}. All Rights Reserved.</p>
170
168
  </div>
171
169
  </div>
172
170
  </div>
@@ -12,7 +12,7 @@
12
12
  - Loads critical JavaScript libraries before page render
13
13
  - Includes SEO optimization and social media meta tags
14
14
  - Configures third-party integrations (Analytics, MathJax)
15
- - Sets up progress bar and UI enhancement scripts
15
+ - Sets up progress bar via components/nanobar.html include
16
16
 
17
17
  Dependencies:
18
18
  - seo.html: SEO meta tags and Open Graph data
@@ -22,7 +22,7 @@
22
22
  Performance Notes:
23
23
  - Scripts loaded in head for immediate availability
24
24
  - MathJax loaded asynchronously to prevent render blocking
25
- - Nanobar provides visual loading feedback
25
+ - Nanobar provides visual loading feedback (see components/nanobar.html)
26
26
  ===================================================================
27
27
  -->
28
28
 
@@ -52,29 +52,8 @@
52
52
  {% include components/mermaid.html %}
53
53
  {% endif %}
54
54
 
55
- <!-- Nano Progress Bar - Visual loading indicator -->
56
- <script defer src="{{'/assets/js/nanobar.min.js' | relative_url }}"></script>
57
-
58
- <!-- Progress Bar Initialization - Creates visual loading feedback -->
59
- <script>
60
- // Wait for DOM to be ready before initializing Nanobar
61
- document.addEventListener('DOMContentLoaded', function() {
62
- // Check if the progress bar element exists before initializing
63
- var progressElement = document.getElementById('top-progress-bar');
64
- if (progressElement) {
65
- var options = {
66
- classname: 'nanobar',
67
- id: 'top-progress-bar'
68
- };
69
- var nanobar = new Nanobar(options);
70
- nanobar.go( 30 ); // Initial loading state
71
- nanobar.go( 76 ); // Partial completion
72
- nanobar.go(100); // Complete loading
73
- } else {
74
- console.warn('Progress bar element #top-progress-bar not found. Skipping Nanobar initialization.');
75
- }
76
- });
77
- </script>
55
+ <!-- Nano Progress Bar - Visual loading indicator (config-driven) -->
56
+ {% include components/nanobar.html %}
78
57
 
79
58
  <!-- MathJax - Mathematical notation rendering (async; bundled under assets/vendor) -->
80
59
  <script id="MathJax-script" async src="{{ '/assets/vendor/mathjax/es5/tex-mml-chtml.js' | relative_url }}"></script>
@@ -30,7 +30,10 @@
30
30
  - ARIA labels for screen readers
31
31
  - Focus management for offcanvas
32
32
 
33
- TODO: Fix Nanobar progress bar positioning and animation
33
+ Nanobar:
34
+ - Configured via `site.nanobar.*` in _config.yml
35
+ - When `position: navbar`, the bar mounts inside `#top-progress-target` below
36
+ - Otherwise it floats fixed to the viewport (top or bottom)
34
37
  ===================================================================
35
38
  -->
36
39
 
@@ -48,15 +51,7 @@
48
51
  <!-- TOP NAVIGATION BAR -->
49
52
  <!-- ================================ -->
50
53
  <div class="navbar navbar-expand-lg bg-body-tertiary flex-nowrap justify-content-center bottom-shadow">
51
-
52
- <!-- ========================== -->
53
- <!-- PROGRESS BAR INDICATOR -->
54
- <!-- ========================== -->
55
- <!-- Fixed position progress bar for page loading feedback -->
56
- <div class="nanobar" id="top-progress-bar" style="position: fixed;" role="progressbar" aria-label="Page loading progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
57
- <div class="bar"></div>
58
- </div>
59
-
54
+
60
55
  <!-- ========================== -->
61
56
  <!-- MAIN NAVIGATION CONTAINER -->
62
57
  <!-- ========================== -->
@@ -186,4 +181,15 @@
186
181
  </div>
187
182
  </nav>
188
183
  </div>
184
+
185
+ <!-- ========================== -->
186
+ <!-- PROGRESS BAR MOUNT POINT -->
187
+ <!-- ========================== -->
188
+ <!-- Nanobar mounts here when site.nanobar.position == "navbar" so the bar
189
+ renders as a thin strip directly under the header. For "top" / "bottom"
190
+ the bar floats fixed to the viewport and no mount is needed. -->
191
+ {%- assign _nb_pos = site.nanobar.position | default: "top" -%}
192
+ {%- if site.nanobar.enabled != false and _nb_pos == "navbar" -%}
193
+ <div id="top-progress-target" class="nanobar-mount" aria-hidden="true"></div>
194
+ {%- endif -%}
189
195
  </header>
@@ -38,6 +38,7 @@
38
38
 
39
39
  {%- for link in nav_main -%}
40
40
  {%- assign has_children = link.children and link.children.size > 0 -%}
41
+ {%- assign items_remaining = nav_main.size | minus: forloop.index -%}
41
42
 
42
43
  {%- if has_children -%}
43
44
  <li class="nav-item dropdown d-flex align-items-center nav-hover-dropdown" role="none">
@@ -69,7 +70,7 @@
69
70
  <span class="visually-hidden">Toggle {{ link.title }} submenu</span>
70
71
  </button>
71
72
 
72
- <ul class="dropdown-menu dropdown-menu-start" aria-labelledby="dropdown-{{ link.title | slugify }}" role="menu">
73
+ <ul class="dropdown-menu {% if items_remaining < 2 %}dropdown-menu-end{% else %}dropdown-menu-start{% endif %}" aria-labelledby="dropdown-{{ link.title | slugify }}" role="menu">
73
74
  {%- for child in link.children -%}
74
75
  <li role="none">
75
76
  <a
@@ -66,26 +66,32 @@ layout: root
66
66
  </div>
67
67
  </div>
68
68
  <div class="col-lg-6 text-center order-2">
69
+ {%- comment -%}
70
+ Inline `aspect-ratio` + `max-width` reserve the final box size BEFORE
71
+ external CSS parses, eliminating the post-load "jerk" caused by the
72
+ vendored Bootstrap `.ratio` class loading after first paint.
73
+ {%- endcomment -%}
69
74
  {% if page.hero_image %}
70
- <div class="landing-hero-media ratio ratio-4x3 mx-auto shadow-lg rounded overflow-hidden">
75
+ <div class="landing-hero-media mx-auto shadow-lg rounded overflow-hidden"
76
+ style="aspect-ratio: 4 / 3; max-width: min(100%, 28rem); width: 100%;">
71
77
  <img
72
78
  src="{{ page.hero_image | relative_url }}"
73
79
  alt="{{ page.title }}"
74
- class="w-100 h-100"
80
+ class="landing-hero-img w-100 h-100"
75
81
  width="800"
76
82
  height="600"
77
83
  loading="eager"
78
84
  decoding="async"
79
85
  fetchpriority="high"
80
- style="object-fit: contain;"
86
+ onload="this.classList.add('is-loaded')"
87
+ style="object-fit: contain; display: block;"
81
88
  >
82
89
  </div>
83
90
  {% else %}
84
- <div class="landing-hero-media ratio ratio-4x3 mx-auto shadow-lg rounded overflow-hidden">
85
- <div class="position-absolute top-0 start-0 w-100 h-100 d-flex flex-column align-items-center justify-content-center bg-secondary bg-opacity-25 text-body rounded">
86
- <i class="bi bi-code-square display-1"></i>
87
- <p class="mt-3 mb-0 px-3">Jekyll Theme</p>
88
- </div>
91
+ <div class="landing-hero-media mx-auto shadow-lg rounded overflow-hidden d-flex flex-column align-items-center justify-content-center bg-secondary bg-opacity-25 text-body"
92
+ style="aspect-ratio: 4 / 3; max-width: min(100%, 28rem); width: 100%;">
93
+ <i class="bi bi-code-square display-1"></i>
94
+ <p class="mt-3 mb-0 px-3">Jekyll Theme</p>
89
95
  </div>
90
96
  {% endif %}
91
97
  </div>
@@ -367,6 +367,12 @@
367
367
  box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.175);
368
368
  border: 1px solid var(--bs-border-color-translucent);
369
369
  }
370
+
371
+ // Right-align dropdowns near the end of the nav to prevent overflow clipping
372
+ .nav-hover-dropdown > .dropdown-menu.dropdown-menu-end {
373
+ left: auto;
374
+ right: 0;
375
+ }
370
376
 
371
377
  // Smooth hover reveal animation
372
378
  .nav-hover-dropdown .dropdown-menu {
data/_sass/custom.scss CHANGED
@@ -40,20 +40,33 @@ html, body {
40
40
  min-height: 50vh;
41
41
  }
42
42
 
43
- // Landing layout: stable hero media slot (ratio) + no reliance on scroll script for first paint
43
+ // Landing layout: stable hero media slot (CSS aspect-ratio) + smooth image fade-in.
44
+ // The inline `aspect-ratio` + `max-width` on .landing-hero-media in landing.html
45
+ // reserve the box size before this stylesheet loads — these rules are progressive
46
+ // enhancement on top of that.
44
47
  .landing-hero {
45
48
  .landing-hero-media {
46
49
  max-width: min(100%, 28rem);
50
+ // Subtle background while the image streams in so the box isn't a flash of
51
+ // empty colored space against the hero gradient.
52
+ background-color: rgba(255, 255, 255, 0.06);
47
53
  }
48
54
 
49
- .landing-hero-media.ratio > img {
55
+ .landing-hero-img {
50
56
  object-position: center;
57
+ opacity: 0;
58
+ transition: opacity 0.45s ease-out;
59
+
60
+ &.is-loaded {
61
+ opacity: 1;
62
+ }
51
63
  }
52
64
  }
53
65
 
54
66
  @media (prefers-reduced-motion: reduce) {
55
- .landing-hero .landing-hero-media {
67
+ .landing-hero .landing-hero-img {
56
68
  transition: none;
69
+ opacity: 1;
57
70
  }
58
71
  }
59
72
 
@@ -170,9 +183,51 @@ html, body {
170
183
  height: 56px;
171
184
  }
172
185
 
173
- // Cookie banner: above fixed navbar (1030), below offcanvas (1045) so drawers cover it
186
+ // Cookie banner: above fixed navbar (1030), below offcanvas (1045) so drawers cover it.
187
+ // Show/hide is driven by:
188
+ // - `[hidden]` attribute on initial render and after dismissal (no layout cost)
189
+ // - `.cookie-banner-visible` class to slide / fade into view
174
190
  .cookie-consent-banner {
175
191
  z-index: 1036;
192
+ background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
193
+ backdrop-filter: blur(10px);
194
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
195
+
196
+ // Off-screen + transparent until JS adds .cookie-banner-visible.
197
+ // Using `transform` + `opacity` (and NOT `display: none`) keeps the transition smooth.
198
+ opacity: 0;
199
+ transform: translateY(100%);
200
+ transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1),
201
+ opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1);
202
+ will-change: transform, opacity;
203
+
204
+ // The `hidden` attribute (set in markup + after dismissal) removes the banner
205
+ // from the layout entirely so it can't intercept clicks while invisible.
206
+ &[hidden] {
207
+ display: none !important;
208
+ }
209
+
210
+ &.cookie-banner-visible {
211
+ opacity: 1;
212
+ transform: translateY(0);
213
+ }
214
+
215
+ @media (max-width: 768px) {
216
+ .btn {
217
+ width: 100%;
218
+ margin-bottom: 0.5rem;
219
+
220
+ &:last-child {
221
+ margin-bottom: 0;
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ @media (prefers-reduced-motion: reduce) {
228
+ .cookie-consent-banner {
229
+ transition: none;
230
+ }
176
231
  }
177
232
 
178
233
  // Active TOC link highlighting
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Nanobar Initialization — config-driven page-load progress bar.
3
+ *
4
+ * Reads settings from `window.zer0Nanobar` (injected by the
5
+ * _includes/components/nanobar.html Liquid template) and instantiates the
6
+ * Nanobar library that was loaded via <script defer>.
7
+ *
8
+ * Placement modes (site.nanobar.position):
9
+ * "top" – fixed bar at the top of the viewport (default)
10
+ * "bottom" – fixed bar at the bottom of the viewport
11
+ * "navbar" – inline strip mounted inside #top-progress-target under header
12
+ *
13
+ * @see _config.yml → nanobar section
14
+ * @see _includes/components/nanobar.html
15
+ * @see _includes/core/header.html → #top-progress-target mount point
16
+ */
17
+ (function () {
18
+ 'use strict';
19
+
20
+ document.addEventListener('DOMContentLoaded', function () {
21
+ if (typeof Nanobar !== 'function') { return; }
22
+
23
+ var cfg = window.zer0Nanobar || {};
24
+ var classname = cfg.classname || 'nanobar';
25
+
26
+ // ----- Resolve mount target -----
27
+ // Priority: explicit selector → position "navbar" → none (fixed to viewport)
28
+ var targetEl = null;
29
+ if (cfg.target) {
30
+ targetEl = document.querySelector(cfg.target);
31
+ } else if (cfg.position === 'navbar') {
32
+ targetEl = document.getElementById('top-progress-target');
33
+ }
34
+
35
+ // ----- Position modifier class -----
36
+ var positionMod = '';
37
+ if (cfg.position === 'bottom') { positionMod = classname + '--bottom'; }
38
+ if (cfg.position === 'navbar') { positionMod = classname + '--navbar'; }
39
+
40
+ // ----- Create the Nanobar instance -----
41
+ var nanobar = new Nanobar({
42
+ classname: classname,
43
+ id: cfg.id,
44
+ target: targetEl || undefined
45
+ });
46
+
47
+ if (positionMod && nanobar.el && nanobar.el.classList) {
48
+ nanobar.el.classList.add(positionMod);
49
+ }
50
+
51
+ // ----- Animate progress steps -----
52
+ var steps = (cfg.steps && cfg.steps.length) ? cfg.steps : [30, 76, 100];
53
+ var delay = cfg.stepDelay || 0;
54
+
55
+ if (delay > 0) {
56
+ steps.forEach(function (pct, i) {
57
+ setTimeout(function () { nanobar.go(pct); }, i * delay);
58
+ });
59
+ } else {
60
+ steps.forEach(function (pct) { nanobar.go(pct); });
61
+ }
62
+ });
63
+ })();
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # =============================================================================
5
+ # generate-roadmap.rb
6
+ # =============================================================================
7
+ #
8
+ # Reads `_data/roadmap.yml` and updates the README.md roadmap section in-place.
9
+ #
10
+ # It rewrites two regions delimited by HTML comment markers:
11
+ #
12
+ # <!-- ROADMAP_MERMAID:START --> ... <!-- ROADMAP_MERMAID:END -->
13
+ # <!-- ROADMAP_TABLE:START --> ... <!-- ROADMAP_TABLE:END -->
14
+ #
15
+ # Usage:
16
+ # ruby scripts/generate-roadmap.rb # update README.md in place
17
+ # ruby scripts/generate-roadmap.rb --check # exit non-zero if README is stale
18
+ # ruby scripts/generate-roadmap.rb --stdout # print regenerated sections only
19
+ #
20
+ # This script has no gem dependencies beyond the Ruby stdlib.
21
+ # =============================================================================
22
+
23
+ require 'yaml'
24
+ require 'date'
25
+ require 'optparse'
26
+
27
+ ROOT = File.expand_path('..', __dir__)
28
+ DATA_FILE = File.join(ROOT, '_data', 'roadmap.yml')
29
+ README = File.join(ROOT, 'README.md')
30
+
31
+ MERMAID_START = '<!-- ROADMAP_MERMAID:START -->'
32
+ MERMAID_END = '<!-- ROADMAP_MERMAID:END -->'
33
+ TABLE_START = '<!-- ROADMAP_TABLE:START -->'
34
+ TABLE_END = '<!-- ROADMAP_TABLE:END -->'
35
+
36
+ # Width used to right-pad the gantt label column so the `:status, start, end`
37
+ # tail aligns vertically. Adjust if very long version/title combinations show up.
38
+ GANTT_LABEL_WIDTH = 28
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Rendering helpers
42
+ # ---------------------------------------------------------------------------
43
+
44
+ # Mermaid gantt task line for a single milestone.
45
+ #
46
+ # v0.22 AIEO Optimization :active, 2026-03, 2026-04
47
+ # v1.0 Stable Release :milestone, 2027-01, 1d
48
+ #
49
+ def gantt_task(milestone)
50
+ label = "v#{milestone['version']} #{milestone['title']}"
51
+ status = milestone['status'].to_s
52
+ start = milestone['start']
53
+ finish = milestone['end'] || milestone['start']
54
+
55
+ prefix =
56
+ case status
57
+ when 'completed' then 'done, '
58
+ when 'active' then 'active, '
59
+ when 'milestone' then 'milestone, '
60
+ else ''
61
+ end
62
+
63
+ range = status == 'milestone' ? "#{start}, 1d" : "#{start}, #{finish}"
64
+ " #{label.ljust(GANTT_LABEL_WIDTH)} :#{prefix}#{range}"
65
+ end
66
+
67
+ def render_mermaid(data)
68
+ title = data.dig('meta', 'title') || 'zer0-mistakes Roadmap'
69
+ milestones = data['milestones'] || []
70
+
71
+ # Group by section while preserving the order in which sections first appear.
72
+ sections = milestones.group_by { |m| m['section'] || 'Roadmap' }
73
+ ordered = milestones.map { |m| m['section'] }.uniq
74
+
75
+ lines = []
76
+ lines << '```mermaid'
77
+ lines << 'gantt'
78
+ lines << " title #{title}"
79
+ lines << ' dateFormat YYYY-MM'
80
+ ordered.each do |section|
81
+ lines << " section #{section}"
82
+ sections[section].each { |m| lines << gantt_task(m) }
83
+ end
84
+ lines << '```'
85
+ lines.join("\n")
86
+ end
87
+
88
+ # Status → human-readable target column for the summary table.
89
+ def target_label(milestone)
90
+ case milestone['status']
91
+ when 'completed'
92
+ if (released = milestone['released'])
93
+ Date.parse(released.to_s).strftime('%b %Y')
94
+ else
95
+ 'Completed'
96
+ end
97
+ when 'active'
98
+ milestone['target'] || 'In progress'
99
+ when 'milestone', 'planned'
100
+ milestone['target'] || milestone['start']
101
+ else
102
+ milestone['target'] || ''
103
+ end
104
+ end
105
+
106
+ def render_table(data)
107
+ rows = (data['milestones'] || []).map do |m|
108
+ version = "**v#{m['version']}**"
109
+ target = target_label(m)
110
+ summary = m['summary'] || ''
111
+ status_emoji =
112
+ case m['status']
113
+ when 'completed' then '✅ Completed'
114
+ when 'active' then '🚧 In Progress'
115
+ when 'milestone' then '🎯 Milestone'
116
+ else '🗓 Planned'
117
+ end
118
+ "| #{version} | #{status_emoji} | #{target} | #{summary} |"
119
+ end
120
+
121
+ header = [
122
+ '| Version | Status | Target | Highlights |',
123
+ '|---------|--------|--------|------------|'
124
+ ]
125
+
126
+ (header + rows).join("\n")
127
+ end
128
+
129
+ def replace_block(content, marker_start, marker_end, replacement)
130
+ pattern = /(#{Regexp.escape(marker_start)})(.*?)(#{Regexp.escape(marker_end)})/m
131
+ unless content.match?(pattern)
132
+ raise "Markers not found in README: #{marker_start} ... #{marker_end}"
133
+ end
134
+
135
+ # Surround the replacement with blank lines so kramdown parses the following
136
+ # markdown (especially tables) instead of treating it as part of the HTML
137
+ # comment block. Without the blank line after `<!-- ... -->`, GFM tables
138
+ # collapse into a single paragraph.
139
+ content.sub(pattern, "\\1\n\n#{replacement}\n\n\\3")
140
+ end
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Main
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def main
147
+ options = { mode: :write }
148
+ OptionParser.new do |opts|
149
+ opts.banner = 'Usage: generate-roadmap.rb [--check|--stdout]'
150
+ opts.on('--check', 'Exit non-zero if README would change') { options[:mode] = :check }
151
+ opts.on('--stdout', 'Print regenerated sections to stdout') { options[:mode] = :stdout }
152
+ end.parse!
153
+
154
+ # The roadmap data only contains scalars and Date values; Symbol is not used,
155
+ # but Date/Time must be permitted because YAML's safe loader rejects them by default.
156
+ # Ruby >= 3.1 supports `permitted_classes:` on `YAML.load_file`. On older Rubies
157
+ # (e.g. macOS system Ruby 2.6), fall back to `safe_load` which accepted the
158
+ # keyword earlier, so the generator works for contributors without rbenv/rvm.
159
+ data =
160
+ begin
161
+ YAML.load_file(DATA_FILE, permitted_classes: [Date, Time])
162
+ rescue ArgumentError
163
+ YAML.safe_load(File.read(DATA_FILE), permitted_classes: [Date, Time], aliases: false)
164
+ end
165
+ mermaid = render_mermaid(data)
166
+ table = render_table(data)
167
+
168
+ if options[:mode] == :stdout
169
+ puts mermaid
170
+ puts
171
+ puts table
172
+ return 0
173
+ end
174
+
175
+ original = File.read(README)
176
+ updated = original.dup
177
+ updated = replace_block(updated, MERMAID_START, MERMAID_END, mermaid)
178
+ updated = replace_block(updated, TABLE_START, TABLE_END, table)
179
+
180
+ if options[:mode] == :check
181
+ if original == updated
182
+ puts '✓ README.md roadmap section is up to date with _data/roadmap.yml'
183
+ return 0
184
+ else
185
+ warn '✗ README.md roadmap section is out of date with _data/roadmap.yml'
186
+ warn ' Run: ./scripts/generate-roadmap.sh'
187
+ return 1
188
+ end
189
+ end
190
+
191
+ if original == updated
192
+ puts 'README.md roadmap section already up to date.'
193
+ else
194
+ File.write(README, updated)
195
+ puts "Updated README.md roadmap section from #{File.basename(DATA_FILE)}."
196
+ end
197
+ 0
198
+ end
199
+
200
+ exit main if $PROGRAM_NAME == __FILE__