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