1# frozen_string_literal: true
2require 'rdoc'
3require 'erb'
4require 'time'
5require 'json'
6require 'webrick'
7
8##
9# This is a WEBrick servlet that allows you to browse ri documentation.
10#
11# You can show documentation through either `ri --server` or, with RubyGems
12# 2.0 or newer, `gem server`.  For ri, the server runs on port 8214 by
13# default.  For RubyGems the server runs on port 8808 by default.
14#
15# You can use this servlet in your own project by mounting it on a WEBrick
16# server:
17#
18#   require 'webrick'
19#
20#   server = WEBrick::HTTPServer.new Port: 8000
21#
22#   server.mount '/', RDoc::Servlet
23#
24# If you want to mount the servlet some other place than the root, provide the
25# base path when mounting:
26#
27#   server.mount '/rdoc', RDoc::Servlet, '/rdoc'
28
29class RDoc::Servlet < WEBrick::HTTPServlet::AbstractServlet
30
31  @server_stores = Hash.new { |hash, server| hash[server] = {} }
32  @cache         = Hash.new { |hash, store|  hash[store]  = {} }
33
34  ##
35  # Maps an asset type to its path on the filesystem
36
37  attr_reader :asset_dirs
38
39  ##
40  # An RDoc::Options instance used for rendering options
41
42  attr_reader :options
43
44  ##
45  # Creates an instance of this servlet that shares cached data between
46  # requests.
47
48  def self.get_instance server, *options # :nodoc:
49    stores = @server_stores[server]
50
51    new server, stores, @cache, *options
52  end
53
54  ##
55  # Creates a new WEBrick servlet.
56  #
57  # Use +mount_path+ when mounting the servlet somewhere other than /.
58  #
59  # Use +extra_doc_dirs+ for additional documentation directories.
60  #
61  # +server+ is provided automatically by WEBrick when mounting.  +stores+ and
62  # +cache+ are provided automatically by the servlet.
63
64  def initialize server, stores, cache, mount_path = nil, extra_doc_dirs = []
65    super server
66
67    @cache      = cache
68    @mount_path = mount_path
69    @extra_doc_dirs = extra_doc_dirs
70    @stores     = stores
71
72    @options = RDoc::Options.new
73    @options.op_dir = '.'
74
75    darkfish_dir = nil
76
77    # HACK dup
78    $LOAD_PATH.each do |path|
79      darkfish_dir = File.join path, 'rdoc/generator/template/darkfish/'
80      next unless File.directory? darkfish_dir
81      @options.template_dir = darkfish_dir
82      break
83    end
84
85    @asset_dirs = {
86      :darkfish   => darkfish_dir,
87      :json_index =>
88        File.expand_path('../generator/template/json_index/', __FILE__),
89    }
90  end
91
92  ##
93  # Serves the asset at the path in +req+ for +generator_name+ via +res+.
94
95  def asset generator_name, req, res
96    asset_dir = @asset_dirs[generator_name]
97
98    asset_path = File.join asset_dir, req.path
99
100    if_modified_since req, res, asset_path
101
102    res.body = File.read asset_path
103
104    res.content_type = case req.path
105                       when /css$/ then 'text/css'
106                       when /js$/  then 'application/javascript'
107                       else             'application/octet-stream'
108                       end
109  end
110
111  ##
112  # GET request entry point.  Fills in +res+ for the path, etc. in +req+.
113
114  def do_GET req, res
115    req.path.sub!(/^#{Regexp.escape @mount_path}/o, '') if @mount_path
116
117    case req.path
118    when '/' then
119      root req, res
120    when '/js/darkfish.js', '/js/jquery.js', '/js/search.js',
121         %r%^/css/%, %r%^/images/%, %r%^/fonts/% then
122      asset :darkfish, req, res
123    when '/js/navigation.js', '/js/searcher.js' then
124      asset :json_index, req, res
125    when '/js/search_index.js' then
126      root_search req, res
127    else
128      show_documentation req, res
129    end
130  rescue WEBrick::HTTPStatus::NotFound => e
131    generator = generator_for RDoc::Store.new
132
133    not_found generator, req, res, e.message
134  rescue WEBrick::HTTPStatus::Status
135    raise
136  rescue => e
137    error e, req, res
138  end
139
140  ##
141  # Fills in +res+ with the class, module or page for +req+ from +store+.
142  #
143  # +path+ is relative to the mount_path and is used to determine the class,
144  # module or page name (/RDoc/Servlet.html becomes RDoc::Servlet).
145  # +generator+ is used to create the page.
146
147  def documentation_page store, generator, path, req, res
148    name = path.sub(/.html$/, '').gsub '/', '::'
149
150    if klass = store.find_class_or_module(name) then
151      res.body = generator.generate_class klass
152    elsif page = store.find_text_page(name.sub(/_([^_]*)$/, '.\1')) then
153      res.body = generator.generate_page page
154    else
155      not_found generator, req, res
156    end
157  end
158
159  ##
160  # Creates the JSON search index on +res+ for the given +store+.  +generator+
161  # must respond to \#json_index to build.  +req+ is ignored.
162
163  def documentation_search store, generator, req, res
164    json_index = @cache[store].fetch :json_index do
165      @cache[store][:json_index] =
166        JSON.dump generator.json_index.build_index
167    end
168
169    res.content_type = 'application/javascript'
170    res.body = "var search_data = #{json_index}"
171  end
172
173  ##
174  # Returns the RDoc::Store and path relative to +mount_path+ for
175  # documentation at +path+.
176
177  def documentation_source path
178    _, source_name, path = path.split '/', 3
179
180    store = @stores[source_name]
181    return store, path if store
182
183    store = store_for source_name
184
185    store.load_all
186
187    @stores[source_name] = store
188
189    return store, path
190  end
191
192  ##
193  # Generates an error page for the +exception+ while handling +req+ on +res+.
194
195  def error exception, req, res
196    backtrace = exception.backtrace.join "\n"
197
198    res.content_type = 'text/html'
199    res.status = 500
200    res.body = <<-BODY
201<!DOCTYPE html>
202<html>
203<head>
204<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
205
206<title>Error - #{ERB::Util.html_escape exception.class}</title>
207
208<link type="text/css" media="screen" href="#{@mount_path}/css/rdoc.css" rel="stylesheet">
209</head>
210<body>
211<h1>Error</h1>
212
213<p>While processing <code>#{ERB::Util.html_escape req.request_uri}</code> the
214RDoc (#{ERB::Util.html_escape RDoc::VERSION}) server has encountered a
215<code>#{ERB::Util.html_escape exception.class}</code>
216exception:
217
218<pre>#{ERB::Util.html_escape exception.message}</pre>
219
220<p>Please report this to the
221<a href="https://github.com/ruby/rdoc/issues">RDoc issues tracker</a>.  Please
222include the RDoc version, the URI above and exception class, message and
223backtrace.  If you're viewing a gem's documentation, include the gem name and
224version.  If you're viewing Ruby's documentation, include the version of ruby.
225
226<p>Backtrace:
227
228<pre>#{ERB::Util.html_escape backtrace}</pre>
229
230</body>
231</html>
232    BODY
233  end
234
235  ##
236  # Instantiates a Darkfish generator for +store+
237
238  def generator_for store
239    generator = RDoc::Generator::Darkfish.new store, @options
240    generator.file_output = false
241    generator.asset_rel_path = '..'
242
243    rdoc = RDoc::RDoc.new
244    rdoc.store     = store
245    rdoc.generator = generator
246    rdoc.options   = @options
247
248    @options.main_page = store.main
249    @options.title     = store.title
250
251    generator
252  end
253
254  ##
255  # Handles the If-Modified-Since HTTP header on +req+ for +path+.  If the
256  # file has not been modified a Not Modified response is returned.  If the
257  # file has been modified a Last-Modified header is added to +res+.
258
259  def if_modified_since req, res, path = nil
260    last_modified = File.stat(path).mtime if path
261
262    res['last-modified'] = last_modified.httpdate
263
264    return unless ims = req['if-modified-since']
265
266    ims = Time.parse ims
267
268    unless ims < last_modified then
269      res.body = ''
270      raise WEBrick::HTTPStatus::NotModified
271    end
272  end
273
274  ##
275  # Returns an Array of installed documentation.
276  #
277  # Each entry contains the documentation name (gem name, 'Ruby
278  # Documentation', etc.), the path relative to the mount point, whether the
279  # documentation exists, the type of documentation (See RDoc::RI::Paths#each)
280  # and the filesystem to the RDoc::Store for the documentation.
281
282  def installed_docs
283    extra_counter = 0
284    ri_paths.map do |path, type|
285      store = RDoc::Store.new path, type
286      exists = File.exist? store.cache_path
287
288      case type
289      when :gem then
290        gem_path = path[%r%/([^/]*)/ri$%, 1]
291        [gem_path, "#{gem_path}/", exists, type, path]
292      when :system then
293        ['Ruby Documentation', 'ruby/', exists, type, path]
294      when :site then
295        ['Site Documentation', 'site/', exists, type, path]
296      when :home then
297        ['Home Documentation', 'home/', exists, type, path]
298      when :extra then
299        extra_counter += 1
300        store.load_cache if exists
301        title = store.title || "Extra Documentation"
302        [title, "extra-#{extra_counter}/", exists, type, path]
303      end
304    end
305  end
306
307  ##
308  # Returns a 404 page built by +generator+ for +req+ on +res+.
309
310  def not_found generator, req, res, message = nil
311    message ||= "The page <kbd>#{ERB::Util.h req.path}</kbd> was not found"
312    res.body = generator.generate_servlet_not_found message
313    res.status = 404
314  end
315
316  ##
317  # Enumerates the ri paths.  See RDoc::RI::Paths#each
318
319  def ri_paths &block
320    RDoc::RI::Paths.each true, true, true, :all, *@extra_doc_dirs, &block #TODO: pass extra_dirs
321  end
322
323  ##
324  # Generates the root page on +res+.  +req+ is ignored.
325
326  def root req, res
327    generator = RDoc::Generator::Darkfish.new nil, @options
328
329    res.body = generator.generate_servlet_root installed_docs
330
331    res.content_type = 'text/html'
332  end
333
334  ##
335  # Generates a search index for the root page on +res+.  +req+ is ignored.
336
337  def root_search req, res
338    search_index = []
339    info         = []
340
341    installed_docs.map do |name, href, exists, type, path|
342      next unless exists
343
344      search_index << name
345
346      case type
347      when :gem
348        gemspec = path.gsub(%r%/doc/([^/]*?)/ri$%,
349                            '/specifications/\1.gemspec')
350
351        spec = Gem::Specification.load gemspec
352
353        path    = spec.full_name
354        comment = spec.summary
355      when :system then
356        path    = 'ruby'
357        comment = 'Documentation for the Ruby standard library'
358      when :site then
359        path    = 'site'
360        comment = 'Documentation for non-gem libraries'
361      when :home then
362        path    = 'home'
363        comment = 'Documentation from your home directory'
364      when :extra
365        comment = name
366      end
367
368      info << [name, '', path, '', comment]
369    end
370
371    index = {
372      :index => {
373        :searchIndex     => search_index,
374        :longSearchIndex => search_index,
375        :info            => info,
376      }
377    }
378
379    res.body = "var search_data = #{JSON.dump index};"
380    res.content_type = 'application/javascript'
381  end
382
383  ##
384  # Displays documentation for +req+ on +res+, whether that be HTML or some
385  # asset.
386
387  def show_documentation req, res
388    store, path = documentation_source req.path
389
390    if_modified_since req, res, store.cache_path
391
392    generator = generator_for store
393
394    case path
395    when nil, '', 'index.html' then
396      res.body = generator.generate_index
397    when 'table_of_contents.html' then
398      res.body = generator.generate_table_of_contents
399    when 'js/search_index.js' then
400      documentation_search store, generator, req, res
401    else
402      documentation_page store, generator, path, req, res
403    end
404  ensure
405    res.content_type ||= 'text/html'
406  end
407
408  ##
409  # Returns an RDoc::Store for the given +source_name+ ('ruby' or a gem name).
410
411  def store_for source_name
412    case source_name
413    when 'home' then
414      RDoc::Store.new RDoc::RI::Paths.home_dir, :home
415    when 'ruby' then
416      RDoc::Store.new RDoc::RI::Paths.system_dir, :system
417    when 'site' then
418      RDoc::Store.new RDoc::RI::Paths.site_dir, :site
419    when /^extra-(\d+)$/ then
420      index = $1.to_i - 1
421      ri_dir = installed_docs[index][4]
422      RDoc::Store.new ri_dir, :extra
423    else
424      ri_dir, type = ri_paths.find do |dir, dir_type|
425        next unless dir_type == :gem
426
427        source_name == dir[%r%/([^/]*)/ri$%, 1]
428      end
429
430      raise WEBrick::HTTPStatus::NotFound,
431            "Could not find gem \"#{ERB::Util.html_escape(source_name)}\". Are you sure you installed it?" unless ri_dir
432
433      store = RDoc::Store.new ri_dir, type
434
435      return store if File.exist? store.cache_path
436
437      raise WEBrick::HTTPStatus::NotFound,
438            "Could not find documentation for \"#{ERB::Util.html_escape(source_name)}\". Please run `gem rdoc --ri gem_name`"
439
440    end
441  end
442
443end
444