1# frozen_string_literal: true
2# -*- mode: ruby; ruby-indent-level: 2; tab-width: 2 -*-
3
4require 'erb'
5require 'fileutils'
6require 'pathname'
7require 'rdoc/generator/markup'
8
9##
10# Darkfish RDoc HTML Generator
11#
12# $Id: darkfish.rb 52 2009-01-07 02:08:11Z deveiant $
13#
14# == Author/s
15# * Michael Granger (ged@FaerieMUD.org)
16#
17# == Contributors
18# * Mahlon E. Smith (mahlon@martini.nu)
19# * Eric Hodel (drbrain@segment7.net)
20#
21# == License
22#
23# Copyright (c) 2007, 2008, Michael Granger. All rights reserved.
24#
25# Redistribution and use in source and binary forms, with or without
26# modification, are permitted provided that the following conditions are met:
27#
28# * Redistributions of source code must retain the above copyright notice,
29#   this list of conditions and the following disclaimer.
30#
31# * Redistributions in binary form must reproduce the above copyright notice,
32#   this list of conditions and the following disclaimer in the documentation
33#   and/or other materials provided with the distribution.
34#
35# * Neither the name of the author/s, nor the names of the project's
36#   contributors may be used to endorse or promote products derived from this
37#   software without specific prior written permission.
38#
39# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
40# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
41# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
42# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
43# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
44# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
45# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
46# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
47# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
48# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
49#
50# == Attributions
51#
52# Darkfish uses the {Silk Icons}[http://www.famfamfam.com/lab/icons/silk/] set
53# by Mark James.
54
55class RDoc::Generator::Darkfish
56
57  RDoc::RDoc.add_generator self
58
59  include ERB::Util
60
61  ##
62  # Stylesheets, fonts, etc. that are included in RDoc.
63
64  BUILTIN_STYLE_ITEMS = # :nodoc:
65    %w[
66      css/fonts.css
67      fonts/Lato-Light.ttf
68      fonts/Lato-LightItalic.ttf
69      fonts/Lato-Regular.ttf
70      fonts/Lato-RegularItalic.ttf
71      fonts/SourceCodePro-Bold.ttf
72      fonts/SourceCodePro-Regular.ttf
73      css/rdoc.css
74  ]
75
76  ##
77  # Path to this file's parent directory. Used to find templates and other
78  # resources.
79
80  GENERATOR_DIR = File.join 'rdoc', 'generator'
81
82  ##
83  # Release Version
84
85  VERSION = '3'
86
87  ##
88  # Description of this generator
89
90  DESCRIPTION = 'HTML generator, written by Michael Granger'
91
92  ##
93  # The relative path to style sheets and javascript.  By default this is set
94  # the same as the rel_prefix.
95
96  attr_accessor :asset_rel_path
97
98  ##
99  # The path to generate files into, combined with <tt>--op</tt> from the
100  # options for a full path.
101
102  attr_reader :base_dir
103
104  ##
105  # Classes and modules to be used by this generator, not necessarily
106  # displayed.  See also #modsort
107
108  attr_reader :classes
109
110  ##
111  # No files will be written when dry_run is true.
112
113  attr_accessor :dry_run
114
115  ##
116  # When false the generate methods return a String instead of writing to a
117  # file.  The default is true.
118
119  attr_accessor :file_output
120
121  ##
122  # Files to be displayed by this generator
123
124  attr_reader :files
125
126  ##
127  # The JSON index generator for this Darkfish generator
128
129  attr_reader :json_index
130
131  ##
132  # Methods to be displayed by this generator
133
134  attr_reader :methods
135
136  ##
137  # Sorted list of classes and modules to be displayed by this generator
138
139  attr_reader :modsort
140
141  ##
142  # The RDoc::Store that is the source of the generated content
143
144  attr_reader :store
145
146  ##
147  # The directory where the template files live
148
149  attr_reader :template_dir # :nodoc:
150
151  ##
152  # The output directory
153
154  attr_reader :outputdir
155
156  ##
157  # Initialize a few instance variables before we start
158
159  def initialize store, options
160    @store   = store
161    @options = options
162
163    @asset_rel_path = ''
164    @base_dir       = Pathname.pwd.expand_path
165    @dry_run        = @options.dry_run
166    @file_output    = true
167    @template_dir   = Pathname.new options.template_dir
168    @template_cache = {}
169
170    @classes = nil
171    @context = nil
172    @files   = nil
173    @methods = nil
174    @modsort = nil
175
176    @json_index = RDoc::Generator::JsonIndex.new self, options
177  end
178
179  ##
180  # Output progress information if debugging is enabled
181
182  def debug_msg *msg
183    return unless $DEBUG_RDOC
184    $stderr.puts(*msg)
185  end
186
187  ##
188  # Directory where generated class HTML files live relative to the output
189  # dir.
190
191  def class_dir
192    nil
193  end
194
195  ##
196  # Directory where generated class HTML files live relative to the output
197  # dir.
198
199  def file_dir
200    nil
201  end
202
203  ##
204  # Create the directories the generated docs will live in if they don't
205  # already exist.
206
207  def gen_sub_directories
208    @outputdir.mkpath
209  end
210
211  ##
212  # Copy over the stylesheet into the appropriate place in the output
213  # directory.
214
215  def write_style_sheet
216    debug_msg "Copying static files"
217    options = { :verbose => $DEBUG_RDOC, :noop => @dry_run }
218
219    BUILTIN_STYLE_ITEMS.each do |item|
220      install_rdoc_static_file @template_dir + item, "./#{item}", options
221    end
222
223    @options.template_stylesheets.each do |stylesheet|
224      FileUtils.cp stylesheet, '.', options
225    end
226
227    Dir[(@template_dir + "{js,images}/**/*").to_s].each do |path|
228      next if File.directory? path
229      next if File.basename(path) =~ /^\./
230
231      dst = Pathname.new(path).relative_path_from @template_dir
232
233      install_rdoc_static_file @template_dir + path, dst, options
234    end
235  end
236
237  ##
238  # Build the initial indices and output objects based on an array of TopLevel
239  # objects containing the extracted information.
240
241  def generate
242    setup
243
244    write_style_sheet
245    generate_index
246    generate_class_files
247    generate_file_files
248    generate_table_of_contents
249    @json_index.generate
250    @json_index.generate_gzipped
251
252    copy_static
253
254  rescue => e
255    debug_msg "%s: %s\n  %s" % [
256      e.class.name, e.message, e.backtrace.join("\n  ")
257    ]
258
259    raise
260  end
261
262  ##
263  # Copies static files from the static_path into the output directory
264
265  def copy_static
266    return if @options.static_path.empty?
267
268    fu_options = { :verbose => $DEBUG_RDOC, :noop => @dry_run }
269
270    @options.static_path.each do |path|
271      unless File.directory? path then
272        FileUtils.install path, @outputdir, fu_options.merge(:mode => 0644)
273        next
274      end
275
276      Dir.chdir path do
277        Dir[File.join('**', '*')].each do |entry|
278          dest_file = @outputdir + entry
279
280          if File.directory? entry then
281            FileUtils.mkdir_p entry, fu_options
282          else
283            FileUtils.install entry, dest_file, fu_options.merge(:mode => 0644)
284          end
285        end
286      end
287    end
288  end
289
290  ##
291  # Return a list of the documented modules sorted by salience first, then
292  # by name.
293
294  def get_sorted_module_list classes
295    classes.select do |klass|
296      klass.display?
297    end.sort
298  end
299
300  ##
301  # Generate an index page which lists all the classes which are documented.
302
303  def generate_index
304    setup
305
306    template_file = @template_dir + 'index.rhtml'
307    return unless template_file.exist?
308
309    debug_msg "Rendering the index page..."
310
311    out_file = @base_dir + @options.op_dir + 'index.html'
312    rel_prefix = @outputdir.relative_path_from out_file.dirname
313    search_index_rel_prefix = rel_prefix
314    search_index_rel_prefix += @asset_rel_path if @file_output
315
316    asset_rel_prefix = rel_prefix + @asset_rel_path
317
318    @title = @options.title
319
320    render_template template_file, out_file do |io|
321      here = binding
322      # suppress 1.9.3 warning
323      here.local_variable_set(:asset_rel_prefix, asset_rel_prefix)
324      here
325    end
326  rescue => e
327    error = RDoc::Error.new \
328      "error generating index.html: #{e.message} (#{e.class})"
329    error.set_backtrace e.backtrace
330
331    raise error
332  end
333
334  ##
335  # Generates a class file for +klass+
336
337  def generate_class klass, template_file = nil
338    setup
339
340    current = klass
341
342    template_file ||= @template_dir + 'class.rhtml'
343
344    debug_msg "  working on %s (%s)" % [klass.full_name, klass.path]
345    out_file   = @outputdir + klass.path
346    rel_prefix = @outputdir.relative_path_from out_file.dirname
347    search_index_rel_prefix = rel_prefix
348    search_index_rel_prefix += @asset_rel_path if @file_output
349
350    asset_rel_prefix = rel_prefix + @asset_rel_path
351    svninfo          = get_svninfo(current)
352
353    @title = "#{klass.type} #{klass.full_name} - #{@options.title}"
354
355    debug_msg "  rendering #{out_file}"
356    render_template template_file, out_file do |io|
357      here = binding
358      # suppress 1.9.3 warning
359      here.local_variable_set(:asset_rel_prefix, asset_rel_prefix)
360      here.local_variable_set(:svninfo, svninfo)
361      here
362    end
363  end
364
365  ##
366  # Generate a documentation file for each class and module
367
368  def generate_class_files
369    setup
370
371    template_file = @template_dir + 'class.rhtml'
372    template_file = @template_dir + 'classpage.rhtml' unless
373      template_file.exist?
374    return unless template_file.exist?
375    debug_msg "Generating class documentation in #{@outputdir}"
376
377    current = nil
378
379    @classes.each do |klass|
380      current = klass
381
382      generate_class klass, template_file
383    end
384  rescue => e
385    error = RDoc::Error.new \
386      "error generating #{current.path}: #{e.message} (#{e.class})"
387    error.set_backtrace e.backtrace
388
389    raise error
390  end
391
392  ##
393  # Generate a documentation file for each file
394
395  def generate_file_files
396    setup
397
398    page_file     = @template_dir + 'page.rhtml'
399    fileinfo_file = @template_dir + 'fileinfo.rhtml'
400
401    # for legacy templates
402    filepage_file = @template_dir + 'filepage.rhtml' unless
403      page_file.exist? or fileinfo_file.exist?
404
405    return unless
406      page_file.exist? or fileinfo_file.exist? or filepage_file.exist?
407
408    debug_msg "Generating file documentation in #{@outputdir}"
409
410    out_file = nil
411    current = nil
412
413    @files.each do |file|
414      current = file
415
416      if file.text? and page_file.exist? then
417        generate_page file
418        next
419      end
420
421      template_file = nil
422      out_file = @outputdir + file.path
423      debug_msg "  working on %s (%s)" % [file.full_name, out_file]
424      rel_prefix = @outputdir.relative_path_from out_file.dirname
425      search_index_rel_prefix = rel_prefix
426      search_index_rel_prefix += @asset_rel_path if @file_output
427
428      asset_rel_prefix = rel_prefix + @asset_rel_path
429
430      unless filepage_file then
431        if file.text? then
432          next unless page_file.exist?
433          template_file = page_file
434          @title = file.page_name
435        else
436          next unless fileinfo_file.exist?
437          template_file = fileinfo_file
438          @title = "File: #{file.base_name}"
439        end
440      end
441
442      @title += " - #{@options.title}"
443      template_file ||= filepage_file
444
445      render_template template_file, out_file do |io|
446        here = binding
447        # suppress 1.9.3 warning
448        here.local_variable_set(:asset_rel_prefix, asset_rel_prefix)
449        here.local_variable_set(:current, current)
450        here
451      end
452    end
453  rescue => e
454    error =
455      RDoc::Error.new "error generating #{out_file}: #{e.message} (#{e.class})"
456    error.set_backtrace e.backtrace
457
458    raise error
459  end
460
461  ##
462  # Generate a page file for +file+
463
464  def generate_page file
465    setup
466
467    template_file = @template_dir + 'page.rhtml'
468
469    out_file = @outputdir + file.path
470    debug_msg "  working on %s (%s)" % [file.full_name, out_file]
471    rel_prefix = @outputdir.relative_path_from out_file.dirname
472    search_index_rel_prefix = rel_prefix
473    search_index_rel_prefix += @asset_rel_path if @file_output
474
475    current          = file
476    asset_rel_prefix = rel_prefix + @asset_rel_path
477
478    @title = "#{file.page_name} - #{@options.title}"
479
480    debug_msg "  rendering #{out_file}"
481    render_template template_file, out_file do |io|
482      here = binding
483      # suppress 1.9.3 warning
484      here.local_variable_set(:current, current)
485      here.local_variable_set(:asset_rel_prefix, asset_rel_prefix)
486      here
487    end
488  end
489
490  ##
491  # Generates the 404 page for the RDoc servlet
492
493  def generate_servlet_not_found message
494    setup
495
496    template_file = @template_dir + 'servlet_not_found.rhtml'
497    return unless template_file.exist?
498
499    debug_msg "Rendering the servlet 404 Not Found page..."
500
501    rel_prefix = rel_prefix = ''
502    search_index_rel_prefix = rel_prefix
503    search_index_rel_prefix += @asset_rel_path if @file_output
504
505    asset_rel_prefix = ''
506
507    @title = 'Not Found'
508
509    render_template template_file do |io|
510      here = binding
511      # suppress 1.9.3 warning
512      here.local_variable_set(:asset_rel_prefix, asset_rel_prefix)
513      here
514    end
515  rescue => e
516    error = RDoc::Error.new \
517      "error generating servlet_not_found: #{e.message} (#{e.class})"
518    error.set_backtrace e.backtrace
519
520    raise error
521  end
522
523  ##
524  # Generates the servlet root page for the RDoc servlet
525
526  def generate_servlet_root installed
527    setup
528
529    template_file = @template_dir + 'servlet_root.rhtml'
530    return unless template_file.exist?
531
532    debug_msg 'Rendering the servlet root page...'
533
534    rel_prefix = '.'
535    asset_rel_prefix = rel_prefix
536    search_index_rel_prefix = asset_rel_prefix
537    search_index_rel_prefix += @asset_rel_path if @file_output
538
539    @title = 'Local RDoc Documentation'
540
541    render_template template_file do |io| binding end
542  rescue => e
543    error = RDoc::Error.new \
544      "error generating servlet_root: #{e.message} (#{e.class})"
545    error.set_backtrace e.backtrace
546
547    raise error
548  end
549
550  ##
551  # Generate an index page which lists all the classes which are documented.
552
553  def generate_table_of_contents
554    setup
555
556    template_file = @template_dir + 'table_of_contents.rhtml'
557    return unless template_file.exist?
558
559    debug_msg "Rendering the Table of Contents..."
560
561    out_file = @outputdir + 'table_of_contents.html'
562    rel_prefix = @outputdir.relative_path_from out_file.dirname
563    search_index_rel_prefix = rel_prefix
564    search_index_rel_prefix += @asset_rel_path if @file_output
565
566    asset_rel_prefix = rel_prefix + @asset_rel_path
567
568    @title = "Table of Contents - #{@options.title}"
569
570    render_template template_file, out_file do |io|
571      here = binding
572      # suppress 1.9.3 warning
573      here.local_variable_set(:asset_rel_prefix, asset_rel_prefix)
574      here
575    end
576  rescue => e
577    error = RDoc::Error.new \
578      "error generating table_of_contents.html: #{e.message} (#{e.class})"
579    error.set_backtrace e.backtrace
580
581    raise error
582  end
583
584  def install_rdoc_static_file source, destination, options # :nodoc:
585    return unless source.exist?
586
587    begin
588      FileUtils.mkdir_p File.dirname(destination), options
589
590      begin
591        FileUtils.ln source, destination, options
592      rescue Errno::EEXIST
593        FileUtils.rm destination
594        retry
595      end
596    rescue
597      FileUtils.cp source, destination, options
598    end
599  end
600
601  ##
602  # Prepares for generation of output from the current directory
603
604  def setup
605    return if instance_variable_defined? :@outputdir
606
607    @outputdir = Pathname.new(@options.op_dir).expand_path @base_dir
608
609    return unless @store
610
611    @classes = @store.all_classes_and_modules.sort
612    @files   = @store.all_files.sort
613    @methods = @classes.map { |m| m.method_list }.flatten.sort
614    @modsort = get_sorted_module_list @classes
615  end
616
617  ##
618  # Return a string describing the amount of time in the given number of
619  # seconds in terms a human can understand easily.
620
621  def time_delta_string seconds
622    return 'less than a minute'          if seconds < 60
623    return "#{seconds / 60} minute#{seconds / 60 == 1 ? '' : 's'}" if
624                                            seconds < 3000     # 50 minutes
625    return 'about one hour'              if seconds < 5400     # 90 minutes
626    return "#{seconds / 3600} hours"     if seconds < 64800    # 18 hours
627    return 'one day'                     if seconds < 86400    #  1 day
628    return 'about one day'               if seconds < 172800   #  2 days
629    return "#{seconds / 86400} days"     if seconds < 604800   #  1 week
630    return 'about one week'              if seconds < 1209600  #  2 week
631    return "#{seconds / 604800} weeks"   if seconds < 7257600  #  3 months
632    return "#{seconds / 2419200} months" if seconds < 31536000 #  1 year
633    return "#{seconds / 31536000} years"
634  end
635
636  # %q$Id: darkfish.rb 52 2009-01-07 02:08:11Z deveiant $"
637  SVNID_PATTERN = /
638    \$Id:\s
639    (\S+)\s                # filename
640    (\d+)\s                # rev
641    (\d{4}-\d{2}-\d{2})\s  # Date (YYYY-MM-DD)
642    (\d{2}:\d{2}:\d{2}Z)\s # Time (HH:MM:SSZ)
643    (\w+)\s                # committer
644    \$$
645  /x
646
647  ##
648  # Try to extract Subversion information out of the first constant whose
649  # value looks like a subversion Id tag. If no matching constant is found,
650  # and empty hash is returned.
651
652  def get_svninfo klass
653    constants = klass.constants or return {}
654
655    constants.find { |c| c.value =~ SVNID_PATTERN } or return {}
656
657    filename, rev, date, time, committer = $~.captures
658    commitdate = Time.parse "#{date} #{time}"
659
660    return {
661      :filename    => filename,
662      :rev         => Integer(rev),
663      :commitdate  => commitdate,
664      :commitdelta => time_delta_string(Time.now - commitdate),
665      :committer   => committer,
666    }
667  end
668
669  ##
670  # Creates a template from its components and the +body_file+.
671  #
672  # For backwards compatibility, if +body_file+ contains "<html" the body is
673  # used directly.
674
675  def assemble_template body_file
676    body = body_file.read
677    return body if body =~ /<html/
678
679    head_file = @template_dir + '_head.rhtml'
680    footer_file = @template_dir + '_footer.rhtml'
681
682    <<-TEMPLATE
683<!DOCTYPE html>
684
685<html>
686<head>
687#{head_file.read}
688
689#{body}
690
691#{footer_file.read}
692    TEMPLATE
693  end
694
695  ##
696  # Renders the ERb contained in +file_name+ relative to the template
697  # directory and returns the result based on the current context.
698
699  def render file_name
700    template_file = @template_dir + file_name
701
702    template = template_for template_file, false, RDoc::ERBPartial
703
704    template.filename = template_file.to_s
705
706    template.result @context
707  end
708
709  ##
710  # Load and render the erb template in the given +template_file+ and write
711  # it out to +out_file+.
712  #
713  # Both +template_file+ and +out_file+ should be Pathname-like objects.
714  #
715  # An io will be yielded which must be captured by binding in the caller.
716
717  def render_template template_file, out_file = nil # :yield: io
718    io_output = out_file && !@dry_run && @file_output
719    erb_klass = io_output ? RDoc::ERBIO : ERB
720
721    template = template_for template_file, true, erb_klass
722
723    if io_output then
724      debug_msg "Outputting to %s" % [out_file.expand_path]
725
726      out_file.dirname.mkpath
727      out_file.open 'w', 0644 do |io|
728        io.set_encoding @options.encoding
729
730        @context = yield io
731
732        template_result template, @context, template_file
733      end
734    else
735      @context = yield nil
736
737      output = template_result template, @context, template_file
738
739      debug_msg "  would have written %d characters to %s" % [
740        output.length, out_file.expand_path
741      ] if @dry_run
742
743      output
744    end
745  end
746
747  ##
748  # Creates the result for +template+ with +context+.  If an error is raised a
749  # Pathname +template_file+ will indicate the file where the error occurred.
750
751  def template_result template, context, template_file
752    template.filename = template_file.to_s
753    template.result context
754  rescue NoMethodError => e
755    raise RDoc::Error, "Error while evaluating %s: %s" % [
756      template_file.expand_path,
757      e.message,
758    ], e.backtrace
759  end
760
761  ##
762  # Retrieves a cache template for +file+, if present, or fills the cache.
763
764  def template_for file, page = true, klass = ERB
765    template = @template_cache[file]
766
767    return template if template
768
769    if page then
770      template = assemble_template file
771      erbout = 'io'
772    else
773      template = file.read
774      template = template.encode @options.encoding
775
776      file_var = File.basename(file).sub(/\..*/, '')
777
778      erbout = "_erbout_#{file_var}"
779    end
780
781    if RUBY_VERSION >= '2.6'
782      template = klass.new template, trim_mode: '<>', eoutvar: erbout
783    else
784      template = klass.new template, nil, '<>', erbout
785    end
786    @template_cache[file] = template
787    template
788  end
789
790end
791