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