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}'"