ratatui_ruby-devtools 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c53c42478cc7f26dd91cca19b18ae8335e36b4a9fd2ed2a3c3a023023f6643ff
4
- data.tar.gz: 66c09dcf4036b8188fd848d1cee92a2ff9da3e2b385511956af6ec58e09cb657
3
+ metadata.gz: 13d0c21c772a1be88c23b0345200140bc7d8befcbaf1c20d25a88230847188be
4
+ data.tar.gz: 54de2f06a6849dbe99f182967834c5f3ca6a24963fc98ee929cd5fc59529fbb2
5
5
  SHA512:
6
- metadata.gz: 9f731aac290da1c200cd116d846d2b019dfa2bd496abb79ce90f3a826d49a57536b70db930249967eeacf7e94a5c824b955022d0b85ad585087a0d6e0af51585
7
- data.tar.gz: 665b96fdaa99996adb47b9d95af710472cf364159d3b3268c3e76f2260f398cba61ba974143b18b1ad61ce82f36343e629ce6c35f5dcbfa9da8ddd183f292e80
6
+ metadata.gz: 28ce8dacd601f0fc068c34665d5e455523ff78fb5d43db7601341304d35c825d21fdb528ff1af09db13e35137894a3d9c0742f45b728672dce44e6da084f359f
7
+ data.tar.gz: 8dbf94f55d3259e14735e7b55bb57d3d4ad57d7d0423699a012b73ce820393622220a15f9fab02eb0fea47fb1db2d3a290ceda2a51cce5a696aab6bd5e7ca9b5
data/CHANGELOG.md CHANGED
@@ -15,9 +15,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
 
16
16
  ### Added
17
17
 
18
+ ### Changed
19
+
20
+ ### Fixed
21
+
22
+ ### Removed
23
+
24
+ ## [0.1.1] - 2026-01-26
25
+
26
+ ### Added
27
+
28
+ - Multi-version documentation website builder: `install_website_tasks!` generates
29
+ documentation portals with version dropdown menus. Discovers versions from git
30
+ tags, builds RDoc for each in isolation, and creates a landing page with
31
+ branding support.
32
+
33
+ ### Changed
34
+
35
+ ### Fixed
36
+
37
+ ### Removed
38
+
39
+ ## [0.1.0] - 2026-01-08
40
+
41
+ ### Added
42
+
18
43
  - Initial release of `ratatui_ruby-devtools`
19
44
  - Rake tasks: `lint`, `lint:fix`, `reuse`, `license`, `bump`
20
45
  - Executables: `agent_rake`, `consolidate_md`, `hbs`, `announce`
21
46
  - Templates for scaffolding new ecosystem gems
22
47
  - Auto-discovery of gem configuration from `*.gemspec`
23
- - Conditional Cargo/Rust task support for gems with native extensions
48
+ - Conditional Cargo/Rust task support for gems with native extensions
49
+
50
+ ### Added
51
+
52
+ ### Changed
53
+
54
+ ### Fixed
55
+
56
+ ### Removed
57
+
58
+ [Unreleased]: https://git.sr.ht/~kerrick/ratatui_ruby-devtools/refs/HEAD
59
+ [0.1.1]: https://git.sr.ht/~kerrick/ratatui_ruby-devtools/refs/v0.1.1
60
+ [0.1.0]: https://git.sr.ht/~kerrick/ratatui_ruby-devtools/refs/v0.1.0
data/README.md CHANGED
@@ -17,6 +17,23 @@ Mailing List: Development](https://img.shields.io/badge/mailing_list-development
17
17
 
18
18
  This is a **non-runtime** gem. It provides build tooling—not application features. Add it to your `:development` group only.
19
19
 
20
+ ---
21
+
22
+ ## Quick Links
23
+
24
+ ### The Ecosystem
25
+
26
+ **RatatuiRuby:** [Core engine](https://git.sr.ht/~kerrick/ratatui_ruby) • **Tea:** [MVU architecture](https://git.sr.ht/~kerrick/ratatui_ruby-tea) • **Kit:** [Component architecture](https://git.sr.ht/~kerrick/ratatui_ruby-kit) (Planned) • **DSL:** [Glimmer syntax](https://sr.ht/~kerrick/ratatui_ruby/#chapter-4-the-syntax) (Planned) • **Framework:** [Omakase framework](https://git.sr.ht/~kerrick/ratatui_ruby-framework) (Planned) • **UI:** [Polished widgets](https://git.sr.ht/~kerrick/ratatui_ruby-ui) (Planned) • **UI Pro:** [More polished widgets](https://sr.ht/~kerrick/ratatui_ruby#chapter-6-licensing) (Planned)
27
+
28
+ ### For App Developers
29
+
30
+ **Get Started:** [Quickstart](https://git.sr.ht/~kerrick/ratatui_ruby/tree/stable/item/doc/getting_started/quickstart.md) • [Examples](https://git.sr.ht/~kerrick/ratatui_ruby/tree/stable/item/examples) ⸺ **Stay Informed:** [Announce List](https://lists.sr.ht/~kerrick/ratatui_ruby-announce) • [FAQ](https://man.sr.ht/~kerrick/ratatui_ruby/troubleshooting.md) ⸺ **Reach Out:** [Discuss List](https://lists.sr.ht/~kerrick/ratatui_ruby-discuss) • [Bug Tracker](https://todo.sr.ht/~kerrick/ratatui_ruby)
31
+
32
+ ### For Contributors
33
+
34
+ **Get Started:** [Contributing Guide](https://man.sr.ht/~kerrick/ratatui_ruby/contributing.md) • [Code of Conduct](https://man.sr.ht/~kerrick/ratatui_ruby/code_of_conduct.md) ⸺ **Stay Informed:** [Announce List](https://lists.sr.ht/~kerrick/ratatui_ruby-announce) • [Project History](https://man.sr.ht/~kerrick/ratatui_ruby/history/index.md) ⸺ **Reach Out:** [Development List](https://lists.sr.ht/~kerrick/ratatui_ruby-devel) • [Bug Tracker](https://todo.sr.ht/~kerrick/ratatui_ruby)
35
+
36
+ ---
20
37
 
21
38
  ## Installation
22
39
 
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "fileutils"
9
+ require "digest/md5"
10
+
11
+ # Website build tasks for ecosystem documentation sites.
12
+ #
13
+ # These tasks sync documentation from a source gem repository, update
14
+ # HTML metadata, and manage deployments.
15
+
16
+ SOURCE_GEM_DIR = RatatuiRuby::Devtools.source_gem_dir
17
+ PUBLIC_DOCS_DIR = File.expand_path("public/docs", Dir.pwd)
18
+ INDEX_HTML = File.expand_path("public/index.html", Dir.pwd)
19
+ BUILT_SHA_FILE = File.join(PUBLIC_DOCS_DIR, ".built_sha")
20
+
21
+ # Shared helper to load version metadata from source gem
22
+ def load_source_gem_metadata
23
+ # Find the version.rb file dynamically
24
+ version_files = Dir.glob(File.join(SOURCE_GEM_DIR, "lib/**/version.rb"))
25
+ raise "No version.rb found in #{SOURCE_GEM_DIR}" if version_files.empty?
26
+
27
+ version_file = version_files.first
28
+ load version_file
29
+
30
+ # Find the module that defines VERSION
31
+ # Convention: the namespace matches the gem name
32
+ gemspec_files = Dir.glob(File.join(SOURCE_GEM_DIR, "*.gemspec"))
33
+ raise "No gemspec found in #{SOURCE_GEM_DIR}" if gemspec_files.empty?
34
+
35
+ gem_name = File.basename(gemspec_files.first, ".gemspec")
36
+
37
+ # Convert gem_name to module name (e.g., ratatui_ruby -> RatatuiRuby)
38
+ module_name = gem_name.split("_").map(&:capitalize).join
39
+ .split("-").map(&:capitalize).join("::")
40
+
41
+ full_version = Object.const_get(module_name)::VERSION
42
+ raise "Could not load VERSION from #{version_file}" unless full_version
43
+
44
+ # Compute docs version (major.minor only) using Gem::Version
45
+ segments = Gem::Version.new(full_version).segments
46
+ docs_version = segments.first(2).join(".")
47
+
48
+ # Read summary from gemspec
49
+ gemspec_file = gemspec_files.first
50
+ gemspec_content = File.read(gemspec_file)
51
+ summary = gemspec_content[/spec\.summary\s*=\s*"([^"]+)"/, 1]
52
+ raise "Could not parse summary from #{gemspec_file}" unless summary
53
+
54
+ # Get datePublished from git tag's tagger date
55
+ tag_date = Dir.chdir(SOURCE_GEM_DIR) do
56
+ `git tag -l --format='%(creatordate:short)' v#{full_version} 2>/dev/null`.strip
57
+ end
58
+ tag_date = nil if tag_date.empty?
59
+
60
+ # Get copyrightYear from this version's release (each version is its own work)
61
+ copyright_year = tag_date&.split("-")&.first
62
+
63
+ { full_version:, docs_version:, summary:, tag_date:, copyright_year:, gem_name: }
64
+ end
65
+
66
+ def current_source_sha
67
+ Dir.chdir(SOURCE_GEM_DIR) { `git rev-parse trunk`.strip }
68
+ end
69
+
70
+ def docs_content_hash
71
+ return nil unless File.directory?(PUBLIC_DOCS_DIR)
72
+
73
+ trunk_dir = File.join(PUBLIC_DOCS_DIR, "trunk")
74
+ return nil unless File.directory?(trunk_dir)
75
+
76
+ digest = Digest::MD5.new
77
+ Dir.glob("#{PUBLIC_DOCS_DIR}/**/*", File::FNM_DOTMATCH).sort.each do |path|
78
+ next if File.directory?(path)
79
+ next if path == BUILT_SHA_FILE
80
+ digest.update(path)
81
+ digest.update(File.binread(path))
82
+ end
83
+ digest.hexdigest
84
+ end
85
+
86
+ def docs_up_to_date?
87
+ return false unless File.exist?(BUILT_SHA_FILE)
88
+
89
+ stored = File.read(BUILT_SHA_FILE).strip.split("\n")
90
+ stored_sha = stored[0]
91
+ stored_hash = stored[1]
92
+
93
+ return false unless stored_sha == current_source_sha
94
+ return false unless stored_hash == docs_content_hash
95
+
96
+ true
97
+ end
98
+
99
+ def write_build_stamp
100
+ File.write(BUILT_SHA_FILE, "#{current_source_sha}\n#{docs_content_hash}")
101
+ end
102
+
103
+ namespace :build do
104
+ desc "Build RDoc from source gem and copy to public/docs/ (FORCE=1 to rebuild)"
105
+ task :docs do
106
+ if docs_up_to_date? && !ENV["FORCE"]
107
+ puts "Docs already built for #{current_source_sha[0, 8]}, skipping (use FORCE=1 to rebuild)"
108
+ next
109
+ end
110
+
111
+ Bundler.with_unbundled_env do
112
+ Dir.chdir(SOURCE_GEM_DIR) do
113
+ sh "bundle install --quiet"
114
+ sh "bundle exec rake website:build"
115
+ end
116
+ end
117
+
118
+ www_source = File.join(SOURCE_GEM_DIR, "www")
119
+
120
+ FileUtils.rm_rf(PUBLIC_DOCS_DIR)
121
+ FileUtils.mkdir_p(PUBLIC_DOCS_DIR)
122
+
123
+ # Copy contents, not the directory itself, to avoid cp_r's quirky behavior
124
+ Dir.glob("#{www_source}/*", File::FNM_DOTMATCH).each do |entry|
125
+ next if entry.end_with?("/.", "/..")
126
+ FileUtils.cp_r(entry, PUBLIC_DOCS_DIR)
127
+ end
128
+
129
+ # Failsafe: if files ended up in public/docs/www/, move them up
130
+ nested_www = File.join(PUBLIC_DOCS_DIR, "www")
131
+ if File.directory?(nested_www)
132
+ warn "WARNING: Detected nested www/ directory, moving contents up..."
133
+ Dir.glob("#{nested_www}/*", File::FNM_DOTMATCH).each do |entry|
134
+ next if entry.end_with?("/.", "/..")
135
+ FileUtils.mv(entry, PUBLIC_DOCS_DIR)
136
+ end
137
+ FileUtils.rmdir(nested_www)
138
+ end
139
+
140
+ # Write SHA + content hash after successful build
141
+ write_build_stamp
142
+
143
+ puts "Copied docs to #{PUBLIC_DOCS_DIR} (#{current_source_sha[0, 8]})"
144
+ end
145
+
146
+ desc "Update index.html with version metadata from source gem"
147
+ task :html do
148
+ meta = load_source_gem_metadata
149
+
150
+ html = File.read(INDEX_HTML)
151
+
152
+ if html =~ /"softwareVersion":\s*"([^"]+)"/
153
+ old_version = $1
154
+ old_docs = old_version.split(".").first(2).join(".")
155
+
156
+ # Plain string replacement for all occurrences
157
+ # Bare version catches both "v1.1.0" and "gem_name-1.1.0.gem"
158
+ html.gsub!(old_version, meta[:full_version])
159
+ html.gsub!("v#{old_docs}/", "v#{meta[:docs_version]}/")
160
+
161
+ # Update description from gemspec summary
162
+ html.gsub!(/"description":\s*"[^"]+"/, %{"description": "#{meta[:summary]}"})
163
+
164
+ # Update datePublished from git tag date
165
+ if meta[:tag_date]
166
+ html.gsub!(/"datePublished":\s*"[^"]+"/, %{"datePublished": "#{meta[:tag_date]}"})
167
+ end
168
+
169
+ # Update copyrightYear from first release
170
+ if meta[:copyright_year]
171
+ html.gsub!(/"copyrightYear":\s*\d+/, %{"copyrightYear": #{meta[:copyright_year]}})
172
+ end
173
+
174
+ File.write(INDEX_HTML, html)
175
+ else
176
+ warn "WARNING: No softwareVersion found in index.html JSON-LD, skipping version update"
177
+ end
178
+
179
+ puts "Updated index.html to version v#{meta[:full_version]} (docs: v#{meta[:docs_version]})"
180
+ end
181
+ end
182
+
183
+ desc "Build docs and update index.html"
184
+ task build: ["build:docs", "build:html"]
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # Uses PUBLIC_DOCS_DIR from build.rake
9
+
10
+ desc "Deploy to production (builds first, validates docs)"
11
+ task deploy: :build do
12
+ trunk_dir = File.join(PUBLIC_DOCS_DIR, "trunk")
13
+ unless File.directory?(trunk_dir)
14
+ abort "ERROR: #{trunk_dir} does not exist. Run `rake build:docs` first."
15
+ end
16
+
17
+ sh "kamal deploy"
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ desc "Serve public/ locally on http://localhost:8000"
9
+ task :serve do
10
+ require "webrick"
11
+ server = WEBrick::HTTPServer.new(Port: 8000, DocumentRoot: File.expand_path("public", Dir.pwd))
12
+ trap("INT") { server.shutdown }
13
+ server.start
14
+ end
@@ -0,0 +1,129 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-or-later
5
+ -->
6
+ <%
7
+ # Branding configuration with sensible defaults
8
+ logo_small = branding[:logo_small] || "../logo-small.png"
9
+ wordmark = branding[:wordmark] || project_name
10
+ tagline = branding[:tagline] || "API documentation and guides"
11
+
12
+ # Navigation links (array of {text:, href:} hashes)
13
+ nav_links = branding[:nav_links] || []
14
+
15
+ # Footer columns (array of {heading:, links: [{text:, href:, tag:, tag_muted:}]} hashes)
16
+ footer_columns = branding[:footer_columns] || []
17
+
18
+ # Footer bottom bar
19
+ footer_meta_links = branding[:footer_meta_links] || []
20
+ copyright = branding[:copyright] || "© #{Time.now.year}"
21
+ license_text = branding[:license_text] || ""
22
+
23
+ # CSS/font customization
24
+ stylesheet = branding[:stylesheet] || "../styles.css"
25
+ google_fonts_url = branding[:google_fonts_url]
26
+
27
+ # Find latest version for default links
28
+ latest = versions.find { |v| v.respond_to?(:latest?) && v.latest? }
29
+ latest_slug = latest&.slug || "trunk"
30
+ %>
31
+ <!DOCTYPE html>
32
+ <html lang="en">
33
+ <head>
34
+ <meta charset="UTF-8">
35
+ <title><%= project_name %> documentation</title>
36
+ <meta name="viewport" content="width=device-width, initial-scale=1">
37
+ <meta name="description" content="<%= tagline %> for <%= project_name %>, organized by version.">
38
+ <link rel="stylesheet" href="<%= stylesheet %>">
39
+ <% if google_fonts_url %>
40
+ <link rel="preconnect" href="https://fonts.googleapis.com">
41
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
42
+ <link href="<%= google_fonts_url %>" rel="stylesheet">
43
+ <% end %>
44
+ </head>
45
+ <body>
46
+ <header class="header">
47
+ <div class="header__inner">
48
+ <a href="/" class="header__brand">
49
+ <img src="<%= logo_small %>" alt="<%= project_name %> Logo" class="header__logo" width="48" height="48">
50
+ <span class="header__wordmark"><%= wordmark %></span>
51
+ </a>
52
+ <% unless nav_links.empty? %>
53
+ <nav class="header__nav" aria-label="Primary">
54
+ <% nav_links.each do |link| %>
55
+ <a href="<%= link[:href] %>"<%= ' class="header__nav-external"' if link[:external] %>><%= link[:text] %></a>
56
+ <% end %>
57
+ </nav>
58
+ <% end %>
59
+ </div>
60
+ </header>
61
+
62
+ <main>
63
+ <section class="section">
64
+ <div class="section__inner">
65
+ <header class="section__header">
66
+ <span class="section__eyebrow">Reference</span>
67
+ <h1 class="section__title">Documentation</h1>
68
+ <p class="section__intro"><%= tagline %> for current and past versions of <%= project_name %>.</p>
69
+ </header>
70
+
71
+ <ul class="version-list">
72
+ <% versions.each do |version| %>
73
+ <li class="version-list__item<%= ' version-list__item--latest' if version.latest? %>">
74
+ <a href="<%= version.slug %>/index.html" class="version-list__link">
75
+ <span class="version-list__name"><%= version.name %></span>
76
+ <span class="version-list__meta">
77
+ <% if version.latest? %>
78
+ Latest release via RubyGems
79
+ <% elsif version.edge? %>
80
+ Pre-release via git
81
+ <% else %>
82
+ Historical
83
+ <% end %>
84
+ </span>
85
+ </a>
86
+ </li>
87
+ <% end %>
88
+ </ul>
89
+ </div>
90
+ </section>
91
+ </main>
92
+
93
+ <footer class="footer">
94
+ <div class="footer__inner">
95
+ <% unless footer_columns.empty? %>
96
+ <!-- Megafooter columns -->
97
+ <div class="footer__columns">
98
+ <% footer_columns.each do |column| %>
99
+ <div class="footer__column">
100
+ <h3 class="footer__heading"><%= column[:heading] %></h3>
101
+ <ul class="footer__list">
102
+ <% column[:links].each do |link| %>
103
+ <li><a href="<%= link[:href] %>"><%= link[:text] %><% if link[:tag] %> <span class="footer__tag<%= ' footer__tag--muted' if link[:tag_muted] %>"><%= link[:tag] %></span><% end %></a></li>
104
+ <% end %>
105
+ </ul>
106
+ </div>
107
+ <% end %>
108
+ </div>
109
+ <% end %>
110
+
111
+ <!-- Bottom bar -->
112
+ <div class="footer__bottom">
113
+ <div class="footer__brand">
114
+ <img src="<%= logo_small %>" alt="" class="footer__logo" width="32" height="32" aria-hidden="true">
115
+ <span class="footer__wordmark"><%= wordmark %></span>
116
+ </div>
117
+ <% unless footer_meta_links.empty? %>
118
+ <nav class="footer__meta" aria-label="Meta links">
119
+ <% footer_meta_links.each do |link| %>
120
+ <a href="<%= link[:href] %>"><%= link[:text] %></a>
121
+ <% end %>
122
+ </nav>
123
+ <% end %>
124
+ <p class="footer__copy"><%= copyright %><% if license_text && !license_text.empty? %> · <%= license_text %><% end %></p>
125
+ </div>
126
+ </div>
127
+ </footer>
128
+ </body>
129
+ </html>
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "erb"
9
+
10
+ # Landing page for multi-version documentation portals.
11
+ #
12
+ # Documentation websites need a version picker. Users land on the portal
13
+ # and choose their version. Without a landing page, they'd need to guess
14
+ # the URL.
15
+ #
16
+ # This class generates an index page with version links and optional
17
+ # branding. It marks the newest tagged release as "(latest)".
18
+ #
19
+ # Use it to build the root <tt>index.html</tt> for documentation portals.
20
+ class IndexPage
21
+ # Creates an IndexPage.
22
+ #
23
+ # Marks the newest Tagged version as latest for display.
24
+ #
25
+ # [versions] Array of Version objects.
26
+ # [branding] Optional hash of branding overrides for the template.
27
+ def initialize(versions, branding: {})
28
+ @versions = versions
29
+ @branding = branding
30
+
31
+ latest_version = @versions.find { |v| v.is_a?(Tagged) }
32
+ latest_version.is_latest = true if latest_version
33
+ end
34
+
35
+ # Renders the landing page HTML to a file.
36
+ #
37
+ # Uses an ERB template with version links and branding.
38
+ #
39
+ # [path] Output file path.
40
+ # [project_name] Project name for page title.
41
+ def publish_to(path, project_name:)
42
+ puts "Generating index page..."
43
+
44
+ template_path = File.expand_path("../resources/index.html.erb", __dir__)
45
+ template = File.read(template_path)
46
+
47
+ versions = @versions
48
+ branding = @branding
49
+ # project_name is used in the ERB
50
+ html_content = ERB.new(template).result(binding)
51
+
52
+ File.write(path, html_content)
53
+ end
54
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "rubygems"
9
+ require "fileutils"
10
+
11
+ # A documentation version for multi-version websites.
12
+ #
13
+ # Documentation websites need to serve multiple versions. Users browse docs
14
+ # for their installed release. Maintainers preview trunk changes. Manually
15
+ # tracking git tags and branches is tedious.
16
+ #
17
+ # This class discovers versions from git tags. It extracts source files at
18
+ # each ref. It provides metadata for version menus.
19
+ #
20
+ # Use it to build multi-version documentation portals.
21
+ class Version
22
+ # Discovers all available versions.
23
+ #
24
+ # Returns Edge (trunk) plus the latest patch for each minor version,
25
+ # sorted newest first.
26
+ def self.all
27
+ tags = `git tag`.split.grep(/^v\d/)
28
+ sorted_versions = tags.map { |t| Tagged.new(t) }
29
+ .sort_by(&:semver)
30
+ .reverse
31
+
32
+ # Keep only the latest patch for each minor version
33
+ # e.g., if we have v0.6.0, v0.6.1, v0.6.2, only keep v0.6.2
34
+ latest_per_minor = sorted_versions
35
+ .group_by { |v| v.semver.segments[0..1] } # group by [major, minor]
36
+ .values
37
+ .map(&:first) # take the first (highest patch) from each group
38
+
39
+ [Edge.new] + latest_per_minor
40
+ end
41
+
42
+ # URL-safe identifier for this version.
43
+ def slug
44
+ raise NotImplementedError
45
+ end
46
+
47
+ # Human-readable name for version menus.
48
+ def name
49
+ raise NotImplementedError
50
+ end
51
+
52
+ # Version type: <tt>:edge</tt> or <tt>:version</tt>.
53
+ def type
54
+ raise NotImplementedError
55
+ end
56
+
57
+ # Git ref (branch or tag) for checkout.
58
+ def ref
59
+ raise NotImplementedError
60
+ end
61
+
62
+ # Yields a temporary directory containing this version's source.
63
+ #
64
+ # Exports the git archive at the specified ref. Removes the native
65
+ # extension directory to avoid compilation issues.
66
+ #
67
+ # [globs] File patterns (unused, for API compatibility).
68
+ def checkout(globs:, &block)
69
+ Dir.mktmpdir do |path|
70
+ # Use git archive to export the version at the specified ref
71
+ # Pipe to tar to extract into the temporary directory
72
+ system("git archive #{ref} | tar -x -C #{path}")
73
+
74
+ # Remove the native extension directory as we don't need it for builds
75
+ # and it can cause issues if not meant to be compiled in this context
76
+ FileUtils.rm_rf("#{path}/ext")
77
+
78
+ yield path
79
+ end
80
+ end
81
+
82
+ # Returns <tt>true</tt> if this is the latest stable release.
83
+ def latest?
84
+ false
85
+ end
86
+
87
+ # Returns <tt>true</tt> if this is the edge (trunk) version.
88
+ def edge?
89
+ false
90
+ end
91
+ end
92
+
93
+ # The trunk branch version for unreleased changes.
94
+ #
95
+ # Developers preview upcoming features before release. The edge version
96
+ # builds documentation from trunk.
97
+ class Edge < Version
98
+ # Stable URL path for bookmarks. Trunk docs live at <tt>/trunk/</tt>.
99
+ def slug
100
+ "trunk"
101
+ end
102
+
103
+ # Appears in version menus as-is, without version numbering.
104
+ def name
105
+ "trunk"
106
+ end
107
+
108
+ # Distinguishes unreleased docs from tagged releases in conditionals.
109
+ def type
110
+ :edge
111
+ end
112
+
113
+ # Git branch name for archive extraction.
114
+ def ref
115
+ "trunk"
116
+ end
117
+
118
+ # Identifies this as unreleased for "(dev)" labels in menus.
119
+ def edge?
120
+ true
121
+ end
122
+ end
123
+
124
+ # A Git release tag version.
125
+ #
126
+ # Each release gets a tag like <tt>v0.6.0</tt>. The documentation website
127
+ # shows only the latest patch for each minor version.
128
+ class Tagged < Version
129
+ # The raw Git tag for archive extraction.
130
+ attr_reader :tag
131
+
132
+ # Creates a Tagged version.
133
+ #
134
+ # [tag] Git tag string.
135
+ def initialize(tag)
136
+ @tag = tag
137
+ end
138
+
139
+ # Groups patch releases under one URL. Docs for <tt>v0.6.0</tt> and
140
+ # <tt>v0.6.1</tt> both live at <tt>/v0.6/</tt>.
141
+ def slug
142
+ segments = semver.segments
143
+ "v#{segments[0]}.#{segments[1]}"
144
+ end
145
+
146
+ # Shows exact version in menus. Users know which patch they're viewing.
147
+ def name
148
+ @tag
149
+ end
150
+
151
+ # Distinguishes released docs from trunk in conditionals.
152
+ def type
153
+ :version
154
+ end
155
+
156
+ # Git tag for archive extraction.
157
+ def ref
158
+ @tag
159
+ end
160
+
161
+ # Enables sorting and grouping by semantic version rules.
162
+ def semver
163
+ Gem::Version.new(@tag.sub(/^v/, ""))
164
+ end
165
+
166
+ # Set by IndexPage to enable "(latest)" label in menus.
167
+ attr_accessor :is_latest
168
+
169
+ # Identifies this for "(latest)" labels in menus.
170
+ def latest?
171
+ @is_latest
172
+ end
173
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # Version switcher for generated documentation pages.
9
+ #
10
+ # Multi-version docs need navigation between versions. Users viewing
11
+ # <tt>v0.5</tt> docs want to switch to <tt>v0.6</tt>. Without a switcher,
12
+ # they'd need to navigate back to the portal.
13
+ #
14
+ # This class injects a dropdown menu into every generated HTML page.
15
+ # It calculates relative paths so links work from any nesting depth.
16
+ #
17
+ # Use it after generating all version documentation.
18
+ class VersionMenu
19
+ # Creates a VersionMenu.
20
+ #
21
+ # [root] Root directory of the generated site.
22
+ # [versions] Array of Version objects for the dropdown.
23
+ def initialize(root:, versions:)
24
+ @root = root
25
+ @versions = versions
26
+ end
27
+
28
+ # Injects the version dropdown into all HTML files.
29
+ #
30
+ # Finds the theme toggle button and inserts the menu before it.
31
+ def run
32
+ puts "Injecting version menu into generated HTML..."
33
+
34
+ # Process all HTML files in the output directory
35
+ Dir.glob(File.join(@root, "**/*.html")).each do |file|
36
+ inject_menu(file)
37
+ end
38
+ end
39
+
40
+ private def inject_menu(file)
41
+ content = File.read(file)
42
+
43
+ # Find the injection point (before the theme toggle button)
44
+ pattern = /(<button[^>]*id="theme-toggle"[^>]*>)/mi
45
+
46
+ unless content.match?(pattern)
47
+ # warn "Could not find theme-toggle in #{file}"
48
+ return
49
+ end
50
+
51
+ # Calculate relative path to root from this file
52
+ file_dir = File.dirname(file)
53
+ relative_path_to_root = Pathname.new(@root).relative_path_from(Pathname.new(file_dir)).to_s
54
+ relative_path_to_root += "/" unless relative_path_to_root.end_with?("/")
55
+
56
+ # Determine current version from file path
57
+ relative_path_from_root = Pathname.new(file).relative_path_from(Pathname.new(@root)).to_s
58
+ current_version_slug = relative_path_from_root.split("/").first
59
+
60
+ # Build options
61
+ options = @versions.map do |version|
62
+ value = "#{relative_path_to_root}#{version.slug}/index.html"
63
+ selected = (version.slug == current_version_slug) ? "selected" : ""
64
+ display_name = version.name
65
+ display_name += " (latest)" if version.respond_to?(:latest?) && version.latest?
66
+ display_name += " (dev)" if version.edge?
67
+
68
+ %Q{<option value="#{value}" #{selected}>#{display_name}</option>}
69
+ end.join("\n")
70
+
71
+ # margin-left: auto pushes it to the right
72
+ # margin-right: 1rem spacing from the theme toggle
73
+ switcher_html = <<~HTML
74
+ <select class="version-menu" onchange="window.location.href=this.value" style="margin-left: auto; padding: 0.25rem; border-radius: 4px; border: 1px solid #ccc; margin-right: 1rem;">
75
+ #{options}
76
+ <option value="#{relative_path_to_root}index.html">All Versions</option>
77
+ </select>
78
+ HTML
79
+
80
+ # Inject before the button
81
+ new_content = content.sub(pattern, "#{switcher_html}\n\\1")
82
+
83
+ File.write(file, new_content)
84
+ end
85
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "../rdoc_config"
9
+
10
+ # Builds documentation for a single version.
11
+ #
12
+ # Multi-version sites need docs generated from each git ref. Building
13
+ # locally would pollute the working directory. Using git checkout would
14
+ # disrupt work in progress.
15
+ #
16
+ # This class extracts source files to a temp directory and runs RDoc.
17
+ # It copies the output to the target path. It preserves the current
18
+ # working directory.
19
+ #
20
+ # Use it to build one version's documentation within a multi-version site.
21
+ class VersionedDocumentation
22
+ # Creates a VersionedDocumentation builder.
23
+ #
24
+ # [version] Version object to build docs for.
25
+ def initialize(version)
26
+ @version = version
27
+ end
28
+
29
+ # Generates and publishes documentation for this version.
30
+ #
31
+ # Extracts source to a temp directory, runs RDoc, and copies output.
32
+ # Falls back to direct RDoc if rake fails.
33
+ #
34
+ # [path] Output directory for generated docs.
35
+ # [project_name] Project name for RDoc title.
36
+ # [globs] File patterns to document.
37
+ # [assets] Optional directories to copy into output.
38
+ def publish_to(path, project_name:, globs:, assets: [])
39
+ puts "Building documentation for #{@version.name}..."
40
+
41
+ absolute_path = File.absolute_path(path)
42
+ gemfile_path = File.absolute_path("Gemfile")
43
+ custom_css_path = File.absolute_path("doc/custom.css")
44
+ rakefile_path = File.absolute_path("Rakefile")
45
+ tasks_dir_path = File.absolute_path("tasks")
46
+
47
+ @version.checkout(globs:) do |source_path|
48
+ # Copy current Rakefile and tasks into the temp directory
49
+ # This ensures all versions use the latest example generation logic
50
+ FileUtils.cp(rakefile_path, File.join(source_path, "Rakefile"))
51
+ FileUtils.cp_r(tasks_dir_path, File.join(source_path, "tasks"))
52
+
53
+ Dir.chdir(source_path) do
54
+ title = "#{project_name} #{@version.name}"
55
+ title = "#{project_name} (trunk)" if @version.edge?
56
+
57
+ # Use rake rerdoc to ensure copy_examples runs
58
+ # Set environment variables to override rdoc settings
59
+ success = system(
60
+ {
61
+ "BUNDLE_GEMFILE" => gemfile_path,
62
+ "RDOC_OUTPUT" => absolute_path,
63
+ "RDOC_TITLE" => title,
64
+ "RDOC_CUSTOM_CSS" => custom_css_path,
65
+ },
66
+ "bundle exec rake rerdoc"
67
+ )
68
+
69
+ # Fall back to direct rdoc call if rake fails for any reason
70
+ unless success
71
+ puts " Rake task failed, falling back to direct rdoc call..."
72
+ files = globs.flat_map { |glob| Dir[glob] }.uniq
73
+ system(
74
+ { "BUNDLE_GEMFILE" => gemfile_path },
75
+ "bundle exec rdoc -o #{absolute_path} --main #{RDocConfig::MAIN} --title '#{title}' --template-stylesheets \"#{custom_css_path}\" #{files.join(' ')}"
76
+ )
77
+ end
78
+
79
+ # Copy generated documentation to target path if it was generated elsewhere
80
+ # This handles cases where RDOC_OUTPUT wasn't respected (evaluated at load time)
81
+ temp_output_paths = ["tmp/rdoc", "doc"]
82
+ temp_output_paths.each do |temp_path|
83
+ # Check if this looks like generated rdoc (has index.html)
84
+ if Dir.exist?(temp_path) && !Dir.empty?(temp_path) && File.exist?(File.join(temp_path, "index.html"))
85
+ puts " Copying generated docs from #{temp_path} to #{absolute_path}..."
86
+ FileUtils.mkdir_p(absolute_path)
87
+ FileUtils.cp_r Dir["#{temp_path}/*"], absolute_path
88
+ break
89
+ end
90
+ end
91
+
92
+ copy_assets_to(absolute_path, assets)
93
+ end
94
+ end
95
+ end
96
+
97
+ private def copy_assets_to(path, assets)
98
+ assets.each do |asset_dir|
99
+ if Dir.exist?(asset_dir)
100
+ destination = File.join(path, asset_dir)
101
+ FileUtils.mkdir_p(destination)
102
+ FileUtils.cp_r Dir["#{asset_dir}/*"], destination
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "version"
9
+ require_relative "versioned_documentation"
10
+ require_relative "index_page"
11
+ require_relative "version_menu"
12
+ require "fileutils"
13
+
14
+ # Multi-version documentation website builder.
15
+ #
16
+ # Projects need documentation for multiple versions. Users on v0.5 read
17
+ # v0.5 docs. Users on trunk preview upcoming changes. Building this
18
+ # manually is tedious and error-prone.
19
+ #
20
+ # This class orchestrates the entire build: discovers versions, generates
21
+ # docs for each, creates the landing page, and injects version menus.
22
+ #
23
+ # Use it to build a complete documentation portal.
24
+ class Website
25
+ # Creates a Website builder.
26
+ #
27
+ # [at] Output directory (default: <tt>www</tt>).
28
+ # [project_name] Project name for page titles.
29
+ # [globs] File patterns to document.
30
+ # [assets] Optional directories to copy into each version's output.
31
+ # [branding] Optional hash of branding overrides for the index template.
32
+ def initialize(at: "www", project_name:, globs:, assets: [], branding: {})
33
+ @destination = at
34
+ @project_name = project_name
35
+ @globs = globs
36
+ @assets = assets
37
+ @branding = branding
38
+ end
39
+
40
+ # Builds the complete documentation website.
41
+ #
42
+ # Cleans the output directory, generates docs for all versions,
43
+ # creates the landing page, and injects version menus.
44
+ def build
45
+ clean
46
+
47
+ versions.each do |version|
48
+ VersionedDocumentation.new(version).publish_to(
49
+ join(version.slug),
50
+ project_name: @project_name,
51
+ globs: @globs,
52
+ assets: @assets
53
+ )
54
+ end
55
+
56
+ IndexPage.new(versions, branding: @branding).publish_to(join("index.html"), project_name: @project_name)
57
+
58
+ VersionMenu.new(root: @destination, versions:).run
59
+
60
+ puts "Website built in #{@destination}/"
61
+ end
62
+
63
+ # Discovered versions for this build.
64
+ #
65
+ # Cached after first call. Includes Edge plus latest patch per minor.
66
+ def versions
67
+ @versions ||= Version.all
68
+ end
69
+
70
+ private def join(path)
71
+ File.join(@destination, path)
72
+ end
73
+
74
+ private def clean
75
+ FileUtils.rm_rf(@destination)
76
+ FileUtils.mkdir_p(@destination)
77
+ end
78
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "erb"
9
+ require "fileutils"
10
+ require "tmpdir"
11
+ require_relative "rdoc_config"
12
+
13
+ namespace :website do
14
+ desc "Build documentation for trunk (current dir) and all git tags"
15
+ task :build do
16
+ require_relative "website/website"
17
+
18
+ spec = Gem::Specification.load(Dir["*.gemspec"].first)
19
+ globs = RDocConfig::RDOC_FILES + ["*.gemspec", "doc/images/**/*", "examples/**/*"]
20
+
21
+ # Allow projects to customize via website_config.rb in their tasks/ directory
22
+ config = if File.exist?("tasks/website_config.rb")
23
+ load File.expand_path("tasks/website_config.rb", Dir.pwd)
24
+ WebsiteConfig::CONFIG
25
+ else
26
+ {}
27
+ end
28
+
29
+ Website.new(
30
+ at: config[:output_dir] || "www",
31
+ project_name: spec.name,
32
+ globs:,
33
+ assets: config[:assets] || ["doc/images"],
34
+ branding: config[:branding] || {}
35
+ ).build
36
+ end
37
+ end
@@ -8,6 +8,6 @@
8
8
  module RatatuiRuby
9
9
  module Devtools
10
10
  # Current version of the ratatui_ruby-devtools gem.
11
- VERSION = "0.1.0"
11
+ VERSION = "0.1.1"
12
12
  end
13
13
  end
@@ -40,19 +40,48 @@ module RatatuiRuby
40
40
  # Configuration accessors - auto-discovered if not set
41
41
  attr_writer :gem_name, :gemspec_file, :version_file
42
42
 
43
- # Loads all devtools Rake tasks into the current application.
43
+ # Loads gem development Rake tasks (test, lint, bump, license, etc.)
44
44
  #
45
- # Consumer gems need shared tasks for linting, testing, and licensing.
46
- # Manually copying rake files is tedious and leads to drift. Call this
47
- # once in your Rakefile to import all devtools tasks.
45
+ # Use this in gem/library Rakefiles. These tasks help with version
46
+ # management, testing, linting, and releasing gems.
48
47
  #
49
48
  # === Example
50
49
  #
51
50
  # require "ratatui_ruby/devtools"
52
- # RatatuiRuby::Devtools.install!
51
+ # RatatuiRuby::Devtools.install_gem_tasks!
53
52
  #
54
- def install!
55
- tasks_dir = File.expand_path("devtools/tasks", __dir__)
53
+ def install_gem_tasks!
54
+ load_tasks_from("tasks")
55
+ end
56
+
57
+ # Loads website hosting Rake tasks (build, serve, deploy).
58
+ #
59
+ # Use this in website repository Rakefiles. These tasks help sync docs
60
+ # from a source gem, update HTML metadata, and deploy.
61
+ #
62
+ # @param source_gem_dir [String] Path to the source gem directory
63
+ # (e.g., "~/Developer/ratatui_ruby")
64
+ #
65
+ # === Example
66
+ #
67
+ # require "ratatui_ruby/devtools"
68
+ # RatatuiRuby::Devtools.install_website_tasks!(
69
+ # source_gem_dir: "~/Developer/ratatui_ruby"
70
+ # )
71
+ #
72
+ def install_website_tasks!(source_gem_dir:)
73
+ @source_gem_dir = File.expand_path(source_gem_dir)
74
+ load_tasks_from("site_tasks")
75
+ end
76
+
77
+ # Returns the source gem directory for website tasks.
78
+ attr_reader :source_gem_dir
79
+
80
+ # Backwards-compatible alias for install_gem_tasks!
81
+ alias install! install_gem_tasks!
82
+
83
+ private def load_tasks_from(subdir)
84
+ tasks_dir = File.expand_path("devtools/#{subdir}", __dir__)
56
85
  Dir.glob("#{tasks_dir}/*.rake").each do |task_file|
57
86
  Rake.application.add_import(task_file)
58
87
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ratatui_ruby-devtools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerrick Long
@@ -162,6 +162,9 @@ files:
162
162
  - exe/hbs
163
163
  - exe/scaffold
164
164
  - lib/ratatui_ruby/devtools.rb
165
+ - lib/ratatui_ruby/devtools/site_tasks/build.rake
166
+ - lib/ratatui_ruby/devtools/site_tasks/deploy.rake
167
+ - lib/ratatui_ruby/devtools/site_tasks/serve.rake
165
168
  - lib/ratatui_ruby/devtools/tasks/autodoc.rake
166
169
  - lib/ratatui_ruby/devtools/tasks/autodoc/examples.rb
167
170
  - lib/ratatui_ruby/devtools/tasks/autodoc/member.rb
@@ -188,10 +191,17 @@ files:
188
191
  - lib/ratatui_ruby/devtools/tasks/lint.rake
189
192
  - lib/ratatui_ruby/devtools/tasks/rdoc_config.rb
190
193
  - lib/ratatui_ruby/devtools/tasks/resources/build.yml.erb
194
+ - lib/ratatui_ruby/devtools/tasks/resources/index.html.erb
191
195
  - lib/ratatui_ruby/devtools/tasks/resources/rubies.yml
192
196
  - lib/ratatui_ruby/devtools/tasks/reuse.rake
193
197
  - lib/ratatui_ruby/devtools/tasks/sourcehut.rake
194
198
  - lib/ratatui_ruby/devtools/tasks/test.rake
199
+ - lib/ratatui_ruby/devtools/tasks/website.rake
200
+ - lib/ratatui_ruby/devtools/tasks/website/index_page.rb
201
+ - lib/ratatui_ruby/devtools/tasks/website/version.rb
202
+ - lib/ratatui_ruby/devtools/tasks/website/version_menu.rb
203
+ - lib/ratatui_ruby/devtools/tasks/website/versioned_documentation.rb
204
+ - lib/ratatui_ruby/devtools/tasks/website/website.rb
195
205
  - lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb
196
206
  - lib/ratatui_ruby/devtools/templates/.gitignore.erb
197
207
  - lib/ratatui_ruby/devtools/templates/.pre-commit-config.yaml.erb