1 #!/usr/bin/env ruby
2
3 # Copyright (C) 2023 Dave Gauer <dave@ratfactor.com>
4 # This program is free software. Please see the LICENSE file.
5
6 # Required modules from Ruby Std-Lib (not gems)
7 require 'fileutils' # For mkdir_p, cp, mv
8 require 'cgi' # For CGI.escapeHTML
9
10 # .git must exist (we must already be at root of repo)
11 if !Dir.exist?('.git')
12 puts "ERROR: Could not access .git directory."
13 puts "Please run this program from the root of a repo."
14 exit 3
15 end
16
17 # This program must be run from the root of a repo.
18 repo_dir = Dir.pwd
19 name = File.basename(repo_dir)
20
21 # Load and check config file
22 config_file = "#{ENV['HOME']}/.config/reporat.conf.rb"
23 if !File.exist?(config_file)
24 puts "ERROR: Could not find config file '#{config_file}'."
25 puts "See README.md for example."
26 exit 1
27 end
28 require config_file
29 [:my_output_dir, :my_header, :my_footer].each do |m|
30 if !self.respond_to?(m, :include_private)
31 puts "Config file must define method '#{m}'."
32 exit 2
33 end
34 end
35
36 # Call the first config method to get output directory
37 root_output_dir = my_output_dir()
38
39 # Get the repo description (almost certainly exists)
40 description = ''
41 if File.exist?('.git/description')
42 description = File.read('.git/description')
43 end
44
45 puts "RepoRat generating site for:"
46 puts " #{name}"
47 puts " #{description}"
48
49 # Now assemble output paths based on name
50 output_dir = "#{root_output_dir}/#{name}"
51 bare_output_dir = "#{output_dir}/#{name}.git"
52
53 # Get list of files in repo HEAD
54 # (Method cribbed from repo2html by m455.)
55 file_list_str = `git ls-tree -r --name-only HEAD`
56 file_list = file_list_str.split("\n").map(&:chomp)
57
58 # Detect README (very strict naming) and type of formatting
59 readme_type = 'text'
60 readme_file = file_list.find { |f| f.match?(/\bREADME(\.md)?$/) }
61 if !readme_file
62 puts "ERROR: Couldn't find README or README.md"
63 puts "(It must exist and be comitted to the Git repo.)"
64 exit 7
65 end
66 if readme_file.match?(/\.md$/)
67 readme_type = 'markdown'
68 end
69
70 # Prompt to create repo output directory if it doesn't exist yet
71 if !Dir.exist?(output_dir)
72 puts "Output directory '#{output_dir}' does not yet exist."
73 puts "Let's create it and populate it with a \"bare\" Git repo."
74 answer = nil
75 until answer == 'y' or answer == 'n'
76 print "Proceed? (y/n) "
77 answer = $stdin.gets.chomp
78 end
79 if answer == 'y'
80 # Create dir(s)! mkdir_p creates subdirs as needed
81 puts "Creating '#{output_dir}'..."
82 FileUtils.mkdir_p(output_dir)
83 # Create bare repo suitable for "dumb http" git cloning.
84 # (No need to puts here since Git says what it's doing)
85 `git clone --bare . #{bare_output_dir}`
86 else
87 puts "Okay, exiting!"
88 exit 6
89 end
90 end
91
92 # Make raw source files dir
93 raw_dir = "#{output_dir}/raw"
94 FileUtils.mkdir_p(raw_dir)
95
96 # Make html source files dir
97 html_dir = "#{output_dir}/html"
98 FileUtils.mkdir_p(html_dir)
99
100 # Is this file readable by humans?
101 # Returns empty string if readable, otherwise a reason it's not.
102 def is_readable(fname)
103 max_readable_avg = 100 # As determined by me :-)
104 lens = []
105 total_lens = 0
106 longest_line = 0
107 control_chars = false
108
109 File.open(fname) do |f|
110 line_len = 0
111 f.each_char do |c|
112
113 # If found newline, count
114 line_len += 1
115 if c == "\n"
116 lens.push line_len
117 total_lens += line_len
118 line_len = 0
119 end
120
121 # First byte of encoded char
122 b = c.bytes[0]
123 if b < 9
124 control_chars = true
125 end
126 end
127
128 avg = total_lens / (lens.length+1)
129
130 if control_chars
131 return "it contains one or more control characters"
132 end
133
134 if avg > 100
135 return "the average line length, <b>#{avg}</b> chars, is too long"
136 end
137
138 return ""
139 end
140 end
141
142 file_page_count = 0
143 file_binary_count = 0
144
145 # Each file in repo...
146 file_list.each do |fname|
147 file_page_count += 1
148
149 # Make raw file subdir as needed
150 file_dir = File.dirname("#{raw_dir}/#{fname}")
151 if !Dir.exist?(file_dir)
152 puts "Creating directory '#{file_dir}'..."
153 FileUtils.mkdir_p(file_dir)
154 end
155
156 # copy source file to output
157 FileUtils.cp(fname, "#{raw_dir}/#{fname}")
158
159 # Make HTML output subdir as needed
160 file_html_dir = File.dirname("#{html_dir}/#{fname}")
161 if !Dir.exist?(file_html_dir)
162 puts "Creating directory '#{file_html_dir}'..."
163 FileUtils.mkdir_p(file_html_dir)
164 end
165
166 # make an html file for this file
167 html_out = "#{html_dir}/#{fname}.html"
168
169 # Figure out a path relative to the mini-site's root
170 rrp = '../' * (html_out.count('/') - raw_dir.count('/'))
171
172 File.open(html_out, 'w') do |f|
173 f.puts my_header({
174 name: name,
175 description: description,
176 page_type: :file,
177 root_rel_prefix: rrp,
178 file_fname: fname,
179 })
180
181 f.puts "<h2>#{name}/#{fname}</h2>"
182
183 # link to raw source file
184 f.puts "<p>Download raw file: <a href=\"#{rrp}raw/#{fname}\">#{fname}</a></p>"
185
186 # if image, display in page
187 if fname.end_with?(".jpg", ".gif", ".png", ".svg")
188
189 f.puts "<img src=\"#{rrp}raw/#{fname}\" alt=\"\" style=\"margin: 2em auto; display: block;\">"
190 end
191
192 # Human-readable file? ("" or string contains reason it isn't):
193 readability = is_readable(fname)
194
195 # if source, display with line nums
196 if readability == ""
197 f.puts "<div class=\"source-file\">"
198 ln = 0
199 src_txt = File.read(fname) rescue fail("Couldn't read #{fname}")
200 src_txt.each_line do |l|
201 ln += 1
202 hl = CGI.escapeHTML(l)
203 lns = " " * (6 - ln.to_s.length)
204 f.print "<a id=\"L#{ln}\" href=\"#L#{ln}\">#{lns}#{ln} </a>#{hl}"
205 end
206 f.puts "</div>"
207 else
208 file_binary_count += 1
209 f.puts "<div>(This file was determined to not be human-readable because #{readability}.)</div>"
210 end
211
212 f.puts my_footer()
213 end
214 end
215
216 # Create file list page.
217 files_page_out = "#{output_dir}/files.html"
218
219 File.open(files_page_out, 'w') do |f|
220 f.puts my_header({
221 name: name,
222 description: description,
223 page_type: :files_list,
224 root_rel_prefix: '',
225 })
226
227 f.puts "<h2>Files</h2>"
228 f.puts "<p>This repo contains #{file_list.length} file(s):</p>"
229 f.puts "<ul class=\"file-list\">"
230 file_list.each do |fname|
231 f.puts " <li><a href=\"html/#{fname}.html\">#{fname}</a></li>"
232 end
233 f.puts "</ul>"
234
235 f.puts my_footer()
236 end
237
238 # Create commit history page.
239 commits_page_out = "#{output_dir}/commits.html"
240 File.open(commits_page_out, 'w') do |f|
241 f.puts my_header({
242 name: name,
243 description: description,
244 page_type: :commits,
245 root_rel_prefix: '',
246 })
247
248 f.puts "<h2>Commit history</h2>"
249 f.puts "<pre class=\"commits\">"
250 f.puts `git log`
251 f.puts "</pre>"
252
253 f.puts my_footer()
254 end
255
256 # Create project landing page.
257 index_out = "#{output_dir}/index.html"
258 File.open(index_out, 'w') do |f|
259 f.puts my_header({
260 name: name,
261 description: description,
262 page_type: :main,
263 root_rel_prefix: '',
264 })
265
266 file_show_max = 20
267
268 f.puts "<h2>Files</h2>"
269 f.puts "<ul>"
270 file_list.take(file_show_max).each do |fname|
271 f.puts " <li><a href=\"html/#{fname}.html\">#{fname}</a></li>"
272 end
273 if file_list.length > file_show_max
274 f.puts "<li>...<br><a href=\"files.html\">View all #{file_list.length} files</a></li>"
275 end
276 f.puts "</ul>"
277
278 # start div.readme:
279 f.puts "<div class=\"readme\"><b class=\"filename\">#{readme_file}</b><br>"
280
281 if readme_type == 'text'
282 f.puts "<pre>"
283 f.puts File.read(readme_file)
284 f.puts "</pre>"
285 end
286
287 if readme_type == 'markdown'
288 #require 'rdoc'
289 #data = File.read(readme_file)
290 #fmt = RDoc::Markup::ToHtml.new(RDoc::Options.new, nil)
291 #html = RDoc::Markdown.parse(data).accept(fmt)
292 #f.puts html
293 readme_html = `markdown #{readme_file}`
294 f.puts readme_html
295 end
296
297 # end div.readme:
298 f.puts "</div>"
299
300 f.puts my_footer()
301 end
302
303 # Lastly, sync and update the output's bare repo for "dumb http" Git cloning.
304 update_bare = <<CMD
305 cd #{bare_output_dir}
306 git fetch #{repo_dir} '*:*'
307 git update-server-info
308 CMD
309 `#{update_bare}`
310
311 puts "Output complete at '#{output_dir}'"