ratatui_ruby-devtools 0.1.0

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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/.builds/ruby-4.0.yml +38 -0
  3. data/.pre-commit-config.yaml +16 -0
  4. data/.rubocop.yml +8 -0
  5. data/AGENTS.md +72 -0
  6. data/CHANGELOG.md +23 -0
  7. data/LICENSE +661 -0
  8. data/LICENSES/AGPL-3.0-or-later.txt +661 -0
  9. data/LICENSES/CC-BY-SA-4.0.txt +427 -0
  10. data/LICENSES/CC0-1.0.txt +121 -0
  11. data/LICENSES/MIT-0.txt +16 -0
  12. data/LICENSES/MIT.txt +18 -0
  13. data/README.md +199 -0
  14. data/REUSE.toml +18 -0
  15. data/Rakefile +13 -0
  16. data/bin/agent_rake +13 -0
  17. data/bin/announce +13 -0
  18. data/bin/console +14 -0
  19. data/bin/consolidate_md +13 -0
  20. data/bin/hbs +13 -0
  21. data/bin/setup +17 -0
  22. data/doc/contributors/documentation_style.md +121 -0
  23. data/doc/custom.css +22 -0
  24. data/exe/agent_rake +96 -0
  25. data/exe/announce +1120 -0
  26. data/exe/consolidate_md +246 -0
  27. data/exe/hbs +670 -0
  28. data/exe/scaffold +662 -0
  29. data/lib/ratatui_ruby/devtools/tasks/autodoc/examples.rb +133 -0
  30. data/lib/ratatui_ruby/devtools/tasks/autodoc/member.rb +116 -0
  31. data/lib/ratatui_ruby/devtools/tasks/autodoc/name.rb +33 -0
  32. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +21 -0
  33. data/lib/ratatui_ruby/devtools/tasks/bump/cargo_lockfile.rb +38 -0
  34. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +67 -0
  35. data/lib/ratatui_ruby/devtools/tasks/bump/header.rb +43 -0
  36. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +50 -0
  37. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +78 -0
  38. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +63 -0
  39. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +77 -0
  40. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +63 -0
  41. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +75 -0
  42. data/lib/ratatui_ruby/devtools/tasks/bump.rake +80 -0
  43. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +47 -0
  44. data/lib/ratatui_ruby/devtools/tasks/doc.rake +887 -0
  45. data/lib/ratatui_ruby/devtools/tasks/example_viewer.html.erb +172 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +276 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +236 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +143 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +353 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +186 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license.rake +91 -0
  52. data/lib/ratatui_ruby/devtools/tasks/lint.rake +84 -0
  53. data/lib/ratatui_ruby/devtools/tasks/rdoc_config.rb +45 -0
  54. data/lib/ratatui_ruby/devtools/tasks/resources/build.yml.erb +54 -0
  55. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +7 -0
  56. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +104 -0
  57. data/lib/ratatui_ruby/devtools/tasks/sourcehut.rake +94 -0
  58. data/lib/ratatui_ruby/devtools/tasks/test.rake +18 -0
  59. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +47 -0
  60. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +18 -0
  61. data/lib/ratatui_ruby/devtools/templates/.pre-commit-config.yaml.erb +16 -0
  62. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +8 -0
  63. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +65 -0
  64. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +18 -0
  65. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +32 -0
  66. data/lib/ratatui_ruby/devtools/templates/README.md.erb +127 -0
  67. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +33 -0
  68. data/lib/ratatui_ruby/devtools/templates/Rakefile.erb +29 -0
  69. data/lib/ratatui_ruby/devtools/templates/bin/console.erb +18 -0
  70. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +24 -0
  71. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +16 -0
  72. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +49 -0
  73. data/lib/ratatui_ruby/devtools/templates/doc/custom.css.erb +24 -0
  74. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +56 -0
  75. data/lib/ratatui_ruby/devtools/templates/doc/images/.gitkeep +0 -0
  76. data/lib/ratatui_ruby/devtools/templates/doc/index.md.erb +25 -0
  77. data/lib/ratatui_ruby/devtools/templates/exe/.gitkeep +0 -0
  78. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +58 -0
  79. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +12 -0
  80. data/lib/ratatui_ruby/devtools/templates/tasks/example_viewer.html.erb +174 -0
  81. data/lib/ratatui_ruby/devtools/templates/tasks/resources/build.yml.erb +62 -0
  82. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +46 -0
  83. data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +9 -0
  84. data/lib/ratatui_ruby/devtools/templates/vendor/goodcop/base.yml +1047 -0
  85. data/lib/ratatui_ruby/devtools/version.rb +13 -0
  86. data/lib/ratatui_ruby/devtools.rb +137 -0
  87. data/mise.toml +7 -0
  88. data/sig/ratatui_ruby/devtools.rbs +15 -0
  89. data/vendor/goodcop/base.yml +1047 -0
  90. metadata +252 -0
@@ -0,0 +1,887 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "rdoc/task"
9
+
10
+ require_relative "rdoc_config"
11
+
12
+ RDoc::Task.new do |rdoc|
13
+ rdoc.rdoc_dir = ENV["RDOC_OUTPUT"] || "tmp/rdoc"
14
+ rdoc.main = RDocConfig::MAIN
15
+ rdoc.title = ENV["RDOC_TITLE"] if ENV["RDOC_TITLE"]
16
+ rdoc.rdoc_files.include(RDocConfig::RDOC_FILES)
17
+ custom_css = ENV["RDOC_CUSTOM_CSS"] || "doc/custom.css"
18
+ rdoc.options << "--template-stylesheets=#{custom_css}"
19
+ end
20
+
21
+ # Custom RDoc HTML generator that captures headings for TOC
22
+ class CapturingToHtml < RDoc::Markup::ToHtml
23
+ attr_reader :captured_headings
24
+
25
+ def initialize(options, markup = nil)
26
+ super
27
+ @captured_headings = []
28
+ end
29
+
30
+ def accept_heading(heading)
31
+ start_pos = @res.length
32
+ super
33
+ added = @res[start_pos..-1].join
34
+ if added =~ /id="([^"]+)"/
35
+ @captured_headings << { level: heading.level, text: heading.text, id: $1 }
36
+ end
37
+ end
38
+ end
39
+
40
+ task :copy_doc_images do
41
+ rdoc_dir = ENV["RDOC_OUTPUT"] || "tmp/rdoc"
42
+ if Dir.exist?("doc/images")
43
+ FileUtils.mkdir_p "#{rdoc_dir}/doc/images"
44
+ FileUtils.cp_r Dir["doc/images/*.png"], "#{rdoc_dir}/doc/images"
45
+ FileUtils.cp_r Dir["doc/images/*.gif"], "#{rdoc_dir}/doc/images"
46
+ end
47
+ end
48
+
49
+ def build_tree(all_files, root_dir, max_depth = nil, current_depth = 1)
50
+ return {} if max_depth && current_depth > max_depth
51
+
52
+ files_by_dir = all_files.group_by { |f| File.dirname(f) }
53
+ dirs_at_level = files_by_dir.keys.select { |d| d.start_with?(root_dir) && d.count("/") == current_depth }
54
+
55
+ tree = {}
56
+
57
+ dirs_at_level.each do |dir|
58
+ dir_name = dir.split("/").last
59
+ files = files_by_dir[dir] || []
60
+ subdirs = files_by_dir.keys.select { |d| d.start_with?("#{dir}/") && d.count("/") == current_depth + 1 }
61
+
62
+ tree[dir_name] = {
63
+ path: dir.sub("examples/", ""),
64
+ files: files.map { |f|
65
+ {
66
+ name: File.basename(f),
67
+ path: "#{File.basename(f).gsub('.', '_')}.html",
68
+ full_path: f,
69
+ }
70
+ }.sort_by { |f| f[:name] },
71
+ subdirs: build_tree(all_files, dir, max_depth, current_depth + 1),
72
+ }
73
+ end
74
+
75
+ tree
76
+ end
77
+
78
+ def extract_rdoc_info(content, filename)
79
+ require "rdoc/comment"
80
+ require "rdoc/markup/to_html"
81
+
82
+ options = RDoc::Options.new
83
+ store = RDoc::Store.new(options)
84
+ top_level = store.add_file filename
85
+ stats = RDoc::Stats.new(store, 1)
86
+
87
+ parser = RDoc::Parser::Ruby.new(top_level, content, options, stats)
88
+ parser.scan
89
+
90
+ lines = content.lines
91
+
92
+ # Find the first class/module defined in the file
93
+ # We want the one that appears earliest in the file
94
+ # Filter out items with nil lines
95
+ target_class = top_level.classes_or_modules.select(&:line).min_by(&:line)
96
+
97
+ if target_class
98
+ # Use the class definition line as the anchor
99
+ # RDoc line numbers are 1-based
100
+ anchor_index = target_class.line - 1
101
+ title = target_class.name
102
+ search_snippet = target_class.respond_to?(:search_snippet) ? target_class.search_snippet : ""
103
+ else
104
+ # Fallback to first line of code if no class defined
105
+ first_code_index = lines.find_index { |l| !l.strip.empty? && !l.strip.start_with?("#") }
106
+ anchor_index = first_code_index
107
+ title = nil
108
+ search_snippet = ""
109
+ end
110
+
111
+ # Walk upwards from the line before the anchor to find immediate comments
112
+ comment_lines = []
113
+ if anchor_index && anchor_index > 0
114
+ idx = anchor_index - 1
115
+ while idx >= 0
116
+ line = lines[idx].strip
117
+
118
+ # Stop at blank lines
119
+ break if line.empty?
120
+
121
+ # Stop if we hit something that isn't a comment (shouldn't happen if we are above code, but safety check)
122
+ break unless line.start_with?("#")
123
+
124
+ comment_lines.unshift(lines[idx])
125
+ idx -= 1
126
+ end
127
+ end
128
+
129
+ raw_comment = nil
130
+ unless comment_lines.empty?
131
+ # Create RDoc comment from extracted lines
132
+ # Strip leading # and optional space
133
+ cleaned_lines = comment_lines.map do |line|
134
+ line.strip.sub(/^#\s?/, "")
135
+ end
136
+ raw_comment = cleaned_lines.join("\n")
137
+ # Use first line of comment as snippet if RDoc didn't provide one
138
+ search_snippet = cleaned_lines.first if search_snippet.empty?
139
+ end
140
+
141
+ {
142
+ title:,
143
+ raw_comment:,
144
+ search_snippet:,
145
+ }
146
+ rescue => e
147
+ puts "Warning: Failed to extract RDoc info for #{filename}: #{e.message}"
148
+ { title: nil, raw_comment: nil, search_snippet: "" }
149
+ end
150
+
151
+ def render_tree_html(tree_data, current_path, current_file_html, depth = 0)
152
+ html_parts = []
153
+
154
+ # Calculate prefix to return to examples root from current file
155
+ current_depth = current_path.split("/").size - 1
156
+ root_prefix = "../" * current_depth
157
+
158
+ tree_data.keys.sort.each do |dir_name|
159
+ item = tree_data[dir_name]
160
+ has_children = item[:files].any? || item[:subdirs].any?
161
+
162
+ if has_children
163
+ # Check if this directory is in the path of the current file
164
+ # item[:path] is relative to examples root (e.g., "app_all_events/model")
165
+ # current_path is also relative to examples root (e.g., "app_all_events/model/event_color_cycle")
166
+ # We want to open if current_path starts with this directory's path
167
+ is_in_path = current_path == item[:path] || current_path.start_with?("#{item[:path]}/")
168
+ open_attr = is_in_path ? "open" : ""
169
+
170
+ html_parts << "<li>"
171
+ html_parts << " <details #{open_attr}>"
172
+ html_parts << " <summary>#{ERB::Util.html_escape(dir_name)}</summary>"
173
+ html_parts << " <ul class=\"link-list nav-list\">"
174
+
175
+ # Add files
176
+ item[:files].each do |file|
177
+ # Check specific file match
178
+ # file[:path] is the HTML filename (e.g., "event_color_cycle.html")
179
+ # current_file_html is the current file's HTML name
180
+ is_active = is_in_path && file[:path] == current_file_html
181
+
182
+ active_class = is_active ? ' class="active"' : ""
183
+ file_name_display = ERB::Util.html_escape(file[:name])
184
+ file_name_display = "<strong>#{file_name_display}</strong>" if is_active
185
+
186
+ # Link relative to examples root
187
+ file_path = "#{root_prefix}#{item[:path]}/#{file[:path]}"
188
+ html_parts << " <li><a href=\"#{file_path}\"#{active_class}><span class=\"file\"></span>#{file_name_display}</a></li>"
189
+ end
190
+
191
+ # Add subdirectories recursively
192
+ if item[:subdirs].any?
193
+ html_parts << render_tree_html(item[:subdirs], current_path, current_file_html, depth + 1)
194
+ end
195
+
196
+ html_parts << " </ul>"
197
+ html_parts << " </details>"
198
+ html_parts << "</li>"
199
+ end
200
+ end
201
+
202
+ html_parts.join("\n")
203
+ end
204
+
205
+ task :copy_examples do
206
+ puts "Copying examples..."
207
+ require "erb"
208
+
209
+ require "rdoc"
210
+ require "rdoc/markdown"
211
+ require "rdoc/markup/to_html"
212
+
213
+ rdoc_dir = ENV["RDOC_OUTPUT"] || "tmp/rdoc"
214
+
215
+ if Dir.exist?("examples")
216
+ FileUtils.rm_rf "#{rdoc_dir}/examples"
217
+ FileUtils.mkdir_p "#{rdoc_dir}/examples"
218
+
219
+ all_files = Dir.glob("examples/**/*.{rb,md}")
220
+
221
+ template = File.read("tasks/example_viewer.html.erb")
222
+ erb = ERB.new(template)
223
+
224
+ # Find the RDoc icons template
225
+ icons_path = Gem.find_files("rdoc/generator/template/aliki/_icons.rhtml").first
226
+ icons_svg = icons_path ? File.read(icons_path) : ""
227
+
228
+ # Group files by directory
229
+ files_by_dir = all_files.group_by { |f| File.dirname(f) }
230
+
231
+ # Create a binding context for ERB
232
+ class ExampleViewerContext
233
+ attr_reader :breadcrumb_path, :page_title, :file_content_html, :file_header_html
234
+ attr_reader :current_file_html, :tree_data, :doc_root_link, :icons_svg, :relative_path
235
+ attr_reader :toc_items
236
+ attr_accessor :render_tree_helper
237
+
238
+ def initialize(breadcrumb_path, page_title, file_content_html, file_header_html,
239
+ current_file_html, tree_data, doc_root_link, icons_svg, relative_path, toc_items
240
+ )
241
+ @breadcrumb_path = breadcrumb_path
242
+ @page_title = page_title
243
+ @file_content_html = file_content_html
244
+ @file_header_html = file_header_html
245
+
246
+ @current_file_html = current_file_html
247
+ @tree_data = tree_data
248
+ @doc_root_link = doc_root_link
249
+ @icons_svg = icons_svg
250
+ @relative_path = relative_path
251
+ @toc_items = toc_items
252
+ end
253
+
254
+ def render_tree(tree_data, current_path, current_file_html)
255
+ render_tree_helper.call(tree_data, current_path, current_file_html)
256
+ # Output directly to preserve HTML tags
257
+ end
258
+
259
+ def render_toc(items)
260
+ return "" if items.empty?
261
+
262
+ html = []
263
+ html << "<ul>"
264
+ base_level = items.first[:level]
265
+
266
+ items.each_with_index do |item, i|
267
+ level = item[:level]
268
+ text = item[:text]
269
+ id = item[:id]
270
+
271
+ html << "<li><a href=\"##{id}\">#{text}</a>"
272
+
273
+ next_item = items[i + 1]
274
+
275
+ if next_item
276
+ next_level = next_item[:level]
277
+ if next_level > level
278
+ (next_level - level).times { html << "<ul>" }
279
+ elsif next_level < level
280
+ html << "</li>"
281
+ (level - next_level).times { html << "</ul></li>" }
282
+ else # same level
283
+ html << "</li>"
284
+ end
285
+ else
286
+ # Last item. Close everything back to start.
287
+ html << "</li>"
288
+ (level - base_level).times { html << "</ul></li>" }
289
+ end
290
+ end
291
+
292
+ html << "</ul>"
293
+ html.join("\n")
294
+ end
295
+
296
+ def get_binding
297
+ binding
298
+ end
299
+ end
300
+
301
+ # Collect search index entries
302
+ search_entries = []
303
+
304
+ # Generate HTML files for each file
305
+ all_files.each do |file_path|
306
+ relative_path = file_path.sub("examples/", "")
307
+ target_dir = "#{rdoc_dir}/examples/#{File.dirname(relative_path)}"
308
+ FileUtils.mkdir_p target_dir
309
+
310
+ content = File.read(file_path)
311
+ ext = File.extname(file_path)
312
+ filename = File.basename(file_path)
313
+ toc_items = []
314
+
315
+ if ext == ".md"
316
+ # Markdown files usually have their own H1, so no header needed
317
+ page_title = filename
318
+ breadcrumb_path = relative_path
319
+ file_header_html = ""
320
+
321
+ # Parse markdown
322
+ doc = RDoc::Markdown.parse(content)
323
+
324
+ # Render and capture headings
325
+ options = RDoc::Options.new
326
+ renderer = CapturingToHtml.new(options)
327
+ file_content_html = doc.accept(renderer)
328
+ toc_items = renderer.captured_headings
329
+
330
+ # For Markdown, if we assume the first header is the title and is already captured,
331
+ # we might not need to prepend anything.
332
+ # But if file_header_html is empty, the H1 is in file_content_html.
333
+ # CapturingToHtml should have captured it.
334
+ else
335
+ info = extract_rdoc_info(content, filename)
336
+
337
+ if info[:title]
338
+ page_title = info[:title]
339
+ breadcrumb_path = relative_path
340
+ else
341
+ page_title = filename
342
+ breadcrumb_path = "#{File.dirname(relative_path)}/"
343
+ end
344
+
345
+ # Add to search index
346
+ html_path = "#{File.dirname(relative_path)}/#{File.basename(file_path).gsub('.', '_')}.html"
347
+ search_entries << {
348
+ name: page_title,
349
+ full_name: relative_path,
350
+ type: info[:title] ? "class" : "file",
351
+ path: html_path,
352
+ snippet: info[:search_snippet],
353
+ }
354
+
355
+ file_header_html = "<h1 id=\"top\">#{ERB::Util.html_escape(page_title)}</h1>"
356
+
357
+ # Concatenate comment and Source Code section
358
+ parts = []
359
+ if info[:raw_comment] && !info[:raw_comment].strip.empty?
360
+ parts << info[:raw_comment]
361
+ end
362
+
363
+ indented_code = content.gsub(/^/, " ")
364
+ parts << "= Source Code\n\n#{indented_code}"
365
+
366
+ combined_doc = parts.join("\n\n")
367
+
368
+ # Parse and render
369
+ doc = RDoc::Markup.parse(combined_doc)
370
+ options = RDoc::Options.new
371
+ renderer = CapturingToHtml.new(options)
372
+ file_content_html = doc.accept(renderer)
373
+ toc_items = renderer.captured_headings
374
+
375
+ # Add Page Title to TOC
376
+ toc_items.unshift({ level: 1, text: page_title, id: "top" })
377
+ end
378
+
379
+ # Calculate link to doc root
380
+
381
+ # Calculate link to doc root
382
+ depth = relative_path.split("/").size - 1
383
+ doc_root_link = "#{'../' * (depth + 1)}index.html"
384
+
385
+ # Build tree structure for sidebar
386
+ current_file_html = "#{File.basename(file_path).gsub('.', '_')}.html"
387
+ tree_data = build_tree(all_files, "examples", nil)
388
+
389
+ context = ExampleViewerContext.new(breadcrumb_path, page_title, file_content_html, file_header_html,
390
+ current_file_html, tree_data, doc_root_link, icons_svg, relative_path, toc_items)
391
+ context.render_tree_helper = lambda { |tree, path, file|
392
+ render_tree_html(tree, path, file)
393
+ }
394
+ html = erb.result(context.get_binding)
395
+
396
+ html_file = "#{target_dir}/#{File.basename(file_path).gsub('.', '_')}.html"
397
+ File.write(html_file, html)
398
+ end
399
+
400
+ # Write search index for examples
401
+ FileUtils.mkdir_p "#{rdoc_dir}/examples/js"
402
+ search_data = { index: search_entries }
403
+ File.write("#{rdoc_dir}/examples/js/search_data.js", "var search_data = #{JSON.generate(search_data)};")
404
+
405
+ # Copy RDoc search JS files to examples
406
+ rdoc_js_dir = Gem.find_files("rdoc/generator/template/aliki/js").first
407
+ if rdoc_js_dir && Dir.exist?(rdoc_js_dir)
408
+ %w[search_navigation.js search_ranker.js search_controller.js aliki.js].each do |js_file|
409
+ src = File.join(rdoc_js_dir, js_file)
410
+ FileUtils.cp(src, "#{rdoc_dir}/examples/js/#{js_file}") if File.exist?(src)
411
+ end
412
+ end
413
+
414
+ # Generate index.html files for each directory
415
+ files_by_dir.each do |dir, files|
416
+ target_dir = "#{rdoc_dir}/examples/#{dir}".sub("examples/", "")
417
+ FileUtils.mkdir_p target_dir
418
+
419
+ # Get parent directory
420
+ if dir == "examples"
421
+ parent_link = nil
422
+ doc_root_link = "../index.html"
423
+ else
424
+ parent_dir = File.dirname(dir).sub("examples/", "")
425
+ parent_link = (parent_dir == ".") ? "../index.html" : "../index.html"
426
+ depth = dir.sub("examples/", "").split("/").size
427
+ doc_root_link = "#{'../' * (depth + 1)}index.html"
428
+ end
429
+
430
+ # Find subdirectories
431
+ subdirs = files_by_dir.keys.select { |d| File.dirname(d) == dir && d != dir }
432
+
433
+ # Build combined list of folders and files with icons
434
+ items = []
435
+ subdirs.each { |d| items << { type: :dir, name: File.basename(d), path: "#{File.basename(d)}/index.html", icon: "📁" } }
436
+ files.each { |f| items << { type: :file, name: File.basename(f), path: "#{File.basename(f).gsub('.', '_')}.html", icon: "📄" } }
437
+
438
+ # Sort alphabetically
439
+ sorted_items = items.sort_by { |i| i[:name].downcase }
440
+
441
+ index_html = <<~HTML
442
+ <!DOCTYPE html>
443
+ <html lang="en">
444
+ <head>
445
+ <meta charset="UTF-8">
446
+ <meta name="viewport" content="width=device-width, initial-scale=1">
447
+ <title>Examples</title>
448
+ <link href="#{doc_root_link.sub('index.html', '')}css/rdoc.css" rel="stylesheet">
449
+ <link href="#{doc_root_link.sub('index.html', '')}custom.css" rel="stylesheet">
450
+ <script>
451
+ var rdoc_rel_prefix = "#{doc_root_link.sub('index.html', '')}";
452
+ </script>
453
+ </head>
454
+ <body class="file">
455
+ <header class="top-navbar">
456
+ <div class="navbar-brand">Examples</div>
457
+ <div class="navbar-search navbar-search-desktop"></div>
458
+ <button id="theme-toggle" class="theme-toggle" aria-label="Switch to dark mode" type="button" onclick="cycleColorMode()">
459
+ <span class="theme-toggle-icon" aria-hidden="true">🌙</span>
460
+ </button>
461
+ </header>
462
+
463
+ <nav id="navigation" role="navigation">
464
+ <div id="fileindex-section" class="nav-section">
465
+ <h3>Navigation</h3>
466
+ <ul class="nav-list">
467
+ <li><a href="#{doc_root_link}">← Back to Docs</a></li>
468
+ #{parent_link ? "<li><a href=\"#{parent_link}\">↑ Up to parent directory</a></li>" : ''}
469
+ </ul>
470
+ </div>
471
+ </nav>
472
+
473
+ <main role="main">
474
+ <div class="content">
475
+ <ul class="file-list">
476
+ #{sorted_items.map { |item| "<li><a href=\"#{item[:path]}\"><span class=\"icon\">#{item[:icon]}</span>#{item[:name]}#{(item[:type] == :dir) ? '/' : ''}</a></li>" }.join("\n ")}
477
+ </ul>
478
+ </div>
479
+ <div class="footer"><a href="#{doc_root_link}">← Back to docs</a></div>
480
+ </main>
481
+
482
+ <script>
483
+ const modes = ['auto', 'light', 'dark'];
484
+ const icons = { auto: '🌓', light: '☀️', dark: '🌙' };
485
+
486
+ function setColorMode(mode) {
487
+ if (mode === 'auto') {
488
+ document.documentElement.removeAttribute('data-theme');
489
+ const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
490
+ document.documentElement.setAttribute('data-theme', systemTheme);
491
+ } else {
492
+ document.documentElement.setAttribute('data-theme', mode);
493
+ }
494
+
495
+ const icon = icons[mode];
496
+ const toggle = document.getElementById('theme-toggle');
497
+ if (toggle) {
498
+ toggle.querySelector('.theme-toggle-icon').textContent = icon;
499
+ }
500
+
501
+ localStorage.setItem('rdoc-theme', mode);
502
+ }
503
+
504
+ function cycleColorMode() {
505
+ const current = localStorage.getItem('rdoc-theme') || 'auto';
506
+ const currentIndex = modes.indexOf(current);
507
+ const nextMode = modes[(currentIndex + 1) % modes.length];
508
+ setColorMode(nextMode);
509
+ }
510
+
511
+ const savedMode = localStorage.getItem('rdoc-theme') || 'auto';
512
+ setColorMode(savedMode);
513
+ </script>
514
+ </body>
515
+ </html>
516
+ HTML
517
+
518
+ File.write("#{target_dir}/index.html", index_html)
519
+ end
520
+
521
+ # Generate root index.html
522
+ root_files = all_files.select { |f| File.dirname(f) == "examples" }
523
+ root_subdirs = files_by_dir.keys.select { |d| File.dirname(d) == "examples" && d != "examples" }
524
+
525
+ # Build combined list of root folders and files with icons
526
+ root_items = []
527
+ root_subdirs.each { |d| root_items << { type: :dir, name: File.basename(d), path: "#{File.basename(d)}/index.html", icon: "📁" } }
528
+ root_files.each { |f| root_items << { type: :file, name: File.basename(f), path: "#{File.basename(f).gsub('.', '_')}.html", icon: "📄" } }
529
+
530
+ # Sort alphabetically
531
+ sorted_root_items = root_items.sort_by { |i| i[:name].downcase }
532
+
533
+ root_index_html = <<~HTML
534
+ <!DOCTYPE html>
535
+ <html lang="en">
536
+ <head>
537
+ <meta charset="UTF-8">
538
+ <meta name="viewport" content="width=device-width, initial-scale=1">
539
+ <title>Examples</title>
540
+ <link href="../css/rdoc.css" rel="stylesheet">
541
+ <link href="../custom.css" rel="stylesheet">
542
+ <script>
543
+ var rdoc_rel_prefix = "../";
544
+ </script>
545
+ </head>
546
+ <body class="file">
547
+ <header class="top-navbar">
548
+ <div class="navbar-brand">Examples</div>
549
+ <div class="navbar-search navbar-search-desktop"></div>
550
+ <button id="theme-toggle" class="theme-toggle" aria-label="Switch to dark mode" type="button" onclick="cycleColorMode()">
551
+ <span class="theme-toggle-icon" aria-hidden="true">🌙</span>
552
+ </button>
553
+ </header>
554
+
555
+ <nav id="navigation" role="navigation">
556
+ <div id="fileindex-section" class="nav-section">
557
+ <h3>Navigation</h3>
558
+ <ul class="nav-list">
559
+ <li><a href="../index.html">← Back to Docs</a></li>
560
+ </ul>
561
+ </div>
562
+ </nav>
563
+
564
+ <main role="main">
565
+ <div class="content">
566
+ <ul class="file-list">
567
+ #{sorted_root_items.map { |item| "<li><a href=\"#{item[:path]}\"><span class=\"icon\">#{item[:icon]}</span>#{item[:name]}#{(item[:type] == :dir) ? '/' : ''}</a></li>" }.join("\n ")}
568
+ </ul>
569
+ </div>
570
+ <div class="footer"><a href="../index.html">← Back to docs</a></div>
571
+ </main>
572
+
573
+ <script>
574
+ const modes = ['auto', 'light', 'dark'];
575
+ const icons = { auto: '🌓', light: '☀️', dark: '🌙' };
576
+
577
+ function setColorMode(mode) {
578
+ if (mode === 'auto') {
579
+ document.documentElement.removeAttribute('data-theme');
580
+ const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
581
+ document.documentElement.setAttribute('data-theme', systemTheme);
582
+ } else {
583
+ document.documentElement.setAttribute('data-theme', mode);
584
+ }
585
+
586
+ const icon = icons[mode];
587
+ const toggle = document.getElementById('theme-toggle');
588
+ if (toggle) {
589
+ toggle.querySelector('.theme-toggle-icon').textContent = icon;
590
+ }
591
+
592
+ localStorage.setItem('rdoc-theme', mode);
593
+ }
594
+
595
+ function cycleColorMode() {
596
+ const current = localStorage.getItem('rdoc-theme') || 'auto';
597
+ const currentIndex = modes.indexOf(current);
598
+ const nextMode = modes[(currentIndex + 1) % modes.length];
599
+ setColorMode(nextMode);
600
+ }
601
+
602
+ const savedMode = localStorage.getItem('rdoc-theme') || 'auto';
603
+ setColorMode(savedMode);
604
+ </script>
605
+ </body>
606
+ </html>
607
+ HTML
608
+
609
+ File.write("#{rdoc_dir}/examples/index.html", root_index_html)
610
+ end
611
+ end
612
+
613
+ Rake::Task[:rdoc].enhance do
614
+ Rake::Task[:copy_doc_images].invoke
615
+ Rake::Task[:copy_examples].invoke
616
+ Rake::Task[:rewrite_examples_link].invoke
617
+ end
618
+
619
+ Rake::Task[:rerdoc].enhance do
620
+ Rake::Task[:copy_doc_images].invoke
621
+ Rake::Task[:copy_examples].invoke
622
+ Rake::Task[:rewrite_examples_link].invoke
623
+ end
624
+
625
+ task :rewrite_examples_link do
626
+ require "nokogiri"
627
+
628
+ rdoc_dir = ENV["RDOC_OUTPUT"] || "tmp/rdoc"
629
+
630
+ # Build a mapping of example READMEs to their H1 titles and categories
631
+ examples_by_category = { "Apps" => [], "Widgets" => [] }
632
+
633
+ Dir.glob("examples/*/README.md").each do |readme_path|
634
+ dir_name = File.dirname(readme_path).sub("examples/", "")
635
+
636
+ # Skip verify examples entirely
637
+ next if dir_name.start_with?("verify_")
638
+
639
+ content = File.read(readme_path)
640
+ if content =~ /^#\s+(.+)$/
641
+ title = $1.strip.sub(/ Example$/, "") # Remove trailing " Example"
642
+ rdoc_path = "examples/#{dir_name}/README_md.html"
643
+
644
+ # Categorize by prefix
645
+ category = if dir_name.start_with?("app_")
646
+ "Apps"
647
+ elsif dir_name.start_with?("widget_")
648
+ title = title.sub(/ Widget$/, "") # Also strip trailing " Widget" for widgets
649
+ "Widgets"
650
+ else
651
+ nil
652
+ end
653
+
654
+ if category
655
+ examples_by_category[category] << { title:, rdoc_path:, dir_name: }
656
+ end
657
+ end
658
+ end
659
+
660
+ # Sort each category alphabetically by title
661
+ examples_by_category.each_value { |list| list.sort_by! { |e| e[:title] } }
662
+
663
+ # Process all HTML files
664
+ Dir.glob("#{rdoc_dir}/**/*.html").each do |file|
665
+ content = File.read(file)
666
+ modified = false
667
+
668
+ doc = Nokogiri::HTML(content)
669
+
670
+ # Find the examples details section to remove from Pages
671
+ examples_detail = doc.css("details summary").find { |s| s.text.strip.downcase == "examples" }&.parent
672
+
673
+ # Find the classindex-section to insert Examples section before it
674
+ classindex_section = doc.at_css("#classindex-section")
675
+
676
+ if examples_detail && classindex_section
677
+ # Remove examples from Pages section
678
+ examples_detail.remove
679
+
680
+ # Build the new Examples section as a top-level nav-section
681
+ current_depth = file.sub("#{rdoc_dir}/", "").count("/")
682
+ prefix = "../" * current_depth
683
+
684
+ examples_section = Nokogiri::XML::Node.new("div", doc)
685
+ examples_section["id"] = "exampleindex-section"
686
+ examples_section["class"] = "nav-section"
687
+
688
+ examples_section.inner_html = <<~HTML
689
+ <details class="nav-section-collapsible" open>
690
+ <summary class="nav-section-header">
691
+ <span class="nav-section-icon">
692
+ <svg><use href="#icon-layers"></use></svg>
693
+ </span>
694
+ <span class="nav-section-title">Examples</span>
695
+ <span class="nav-section-chevron">
696
+ <svg><use href="#icon-chevron"></use></svg>
697
+ </span>
698
+ </summary>
699
+ <ul class="link-list nav-list">
700
+ </ul>
701
+ </details>
702
+ HTML
703
+
704
+ # Build the category structure
705
+ examples_ul = examples_section.at_css("ul.link-list")
706
+
707
+ examples_by_category.each do |category_name, examples|
708
+ next if examples.empty?
709
+
710
+ cat_li = Nokogiri::XML::Node.new("li", doc)
711
+ cat_details = Nokogiri::XML::Node.new("details", doc)
712
+ # Subcategories closed by default
713
+ cat_summary = Nokogiri::XML::Node.new("summary", doc)
714
+ cat_summary.content = category_name
715
+ cat_details.add_child(cat_summary)
716
+
717
+ cat_ul = Nokogiri::XML::Node.new("ul", doc)
718
+ cat_ul["class"] = "link-list nav-list"
719
+
720
+ examples.each do |example|
721
+ li = Nokogiri::XML::Node.new("li", doc)
722
+ a = Nokogiri::XML::Node.new("a", doc)
723
+ a["href"] = "#{prefix}#{example[:rdoc_path]}"
724
+ a.content = example[:title]
725
+ li.add_child(a)
726
+ cat_ul.add_child(li)
727
+ end
728
+
729
+ cat_details.add_child(cat_ul)
730
+ cat_li.add_child(cat_details)
731
+ examples_ul.add_child(cat_li)
732
+ end
733
+
734
+ # Insert Examples section before Classes and Modules
735
+ classindex_section.add_previous_sibling(examples_section)
736
+
737
+ # --- GUIDES SECTION ---
738
+ # Build dynamic hierarchical tree from doc/ folder structure
739
+ guides_tree = build_guides_tree
740
+
741
+ # Find and remove the doc details section from Pages
742
+ doc_detail = doc.css("details summary").find { |s| s.text.strip.downcase == "doc" }&.parent
743
+ doc_detail&.remove
744
+
745
+ # Create the Guides section
746
+ guides_section = Nokogiri::XML::Node.new("div", doc)
747
+ guides_section["id"] = "guidesindex-section"
748
+ guides_section["class"] = "nav-section"
749
+
750
+ guides_section.inner_html = <<~HTML
751
+ <details class="nav-section-collapsible" open>
752
+ <summary class="nav-section-header">
753
+ <span class="nav-section-icon">
754
+ <svg><use href="#icon-file"></use></svg>
755
+ </span>
756
+ <span class="nav-section-title">Guides</span>
757
+ <span class="nav-section-chevron">
758
+ <svg><use href="#icon-chevron"></use></svg>
759
+ </span>
760
+ </summary>
761
+ <ul class="link-list nav-list">
762
+ </ul>
763
+ </details>
764
+ HTML
765
+
766
+ # Get current file path relative to rdoc_dir (e.g. "doc/getting_started/quickstart_md.html")
767
+ current_file_rel = file.sub("#{rdoc_dir}/", "")
768
+
769
+ guides_ul = guides_section.at_css("ul.link-list")
770
+ build_guides_nav(guides_ul, guides_tree, doc, prefix, current_file_rel, "doc")
771
+
772
+ # Insert Guides section before Examples
773
+ examples_section.add_previous_sibling(guides_section)
774
+
775
+ content = doc.to_html
776
+ modified = true
777
+ end
778
+
779
+ # Also rewrite examples_md.html to examples/index.html
780
+ if content.include?("examples_md.html")
781
+ content = content.gsub(/href="([^"]*?)examples_md\.html"/, 'href="\1examples/index.html"')
782
+ modified = true
783
+ end
784
+
785
+ File.write(file, content) if modified
786
+ end
787
+
788
+ # Delete the now-unused examples_md.html
789
+ examples_page = "#{rdoc_dir}/examples_md.html"
790
+ FileUtils.rm_f(examples_page)
791
+
792
+ puts "Created Examples and Guides sections in sidebar"
793
+ end
794
+
795
+ # Build a hierarchical tree structure from doc/**/*.md files
796
+ def build_guides_tree
797
+ tree = { files: [], subdirs: {} }
798
+
799
+ Dir.glob("doc/**/*.md").each do |md_path|
800
+ # Skip images folder
801
+ next if md_path.include?("/images/")
802
+
803
+ relative = md_path.sub("doc/", "")
804
+ parts = relative.split("/")
805
+ filename = parts.pop
806
+
807
+ # Get title from H1
808
+ content = File.read(md_path)
809
+ title = if content =~ /^#\s+(.+)$/
810
+ $1.strip
811
+ else
812
+ filename.sub(/\.md$/, "").tr("_-", " ").split.map(&:capitalize).join(" ")
813
+ end
814
+
815
+ # Convert to RDoc path
816
+ rdoc_path = "doc/#{relative.gsub('.', '_')}.html"
817
+
818
+ # Navigate to correct position in tree
819
+ current = tree
820
+ parts.each do |dir|
821
+ current[:subdirs][dir] ||= { files: [], subdirs: {} }
822
+ current = current[:subdirs][dir]
823
+ end
824
+
825
+ current[:files] << { title:, rdoc_path:, filename: }
826
+ end
827
+
828
+ # Sort files in each level alphabetically by title
829
+ sort_guides_tree(tree)
830
+ tree
831
+ end
832
+
833
+ def sort_guides_tree(node)
834
+ node[:files].sort_by! { |f| f[:title] }
835
+ node[:subdirs].each_value { |subdir| sort_guides_tree(subdir) }
836
+ end
837
+
838
+ # Recursively build navigation elements from the tree
839
+ # current_file_rel: path of current HTML file relative to rdoc_dir (e.g. "doc/getting_started/quickstart_md.html")
840
+ # current_tree_path: path in the tree we're building (e.g. "doc", "doc/getting_started")
841
+ def build_guides_nav(parent_ul, tree, doc, prefix, current_file_rel, current_tree_path)
842
+ # Add files at this level first
843
+ tree[:files].each do |file|
844
+ # Check if this file is the current page
845
+ is_current = (file[:rdoc_path] == current_file_rel)
846
+
847
+ li = Nokogiri::XML::Node.new("li", doc)
848
+ a = Nokogiri::XML::Node.new("a", doc)
849
+ a["href"] = "#{prefix}#{file[:rdoc_path]}"
850
+ if is_current
851
+ a["class"] = "active"
852
+ strong = Nokogiri::XML::Node.new("strong", doc)
853
+ strong.content = file[:title]
854
+ a.add_child(strong)
855
+ else
856
+ a.content = file[:title]
857
+ end
858
+ li.add_child(a)
859
+ parent_ul.add_child(li)
860
+ end
861
+
862
+ # Add subdirectories as collapsible details
863
+ tree[:subdirs].each do |dir_name, subtree|
864
+ subdir_path = "#{current_tree_path}/#{dir_name}"
865
+
866
+ # Check if current file is inside this subdirectory
867
+ # current_file_rel might be "doc/getting_started/quickstart_md.html"
868
+ # subdir_path would be "doc/getting_started"
869
+ is_current_in_subdir = current_file_rel.start_with?("#{subdir_path}/")
870
+
871
+ li = Nokogiri::XML::Node.new("li", doc)
872
+ details = Nokogiri::XML::Node.new("details", doc)
873
+ # Open if current file is inside this subdir
874
+ details["open"] = "open" if is_current_in_subdir
875
+ summary = Nokogiri::XML::Node.new("summary", doc)
876
+ summary.content = dir_name.tr("_-", " ").split.map(&:capitalize).join(" ")
877
+ details.add_child(summary)
878
+
879
+ subdir_ul = Nokogiri::XML::Node.new("ul", doc)
880
+ subdir_ul["class"] = "link-list nav-list"
881
+ build_guides_nav(subdir_ul, subtree, doc, prefix, current_file_rel, subdir_path)
882
+
883
+ details.add_child(subdir_ul)
884
+ li.add_child(details)
885
+ parent_ul.add_child(li)
886
+ end
887
+ end