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