Multi-version support

Ref #25.
pull/308/merge
Thibaut Courouble 9 years ago
parent bd6e27eca2
commit b2d2066d96

@ -75,6 +75,7 @@
docs = @settings.getDocs()
for doc in @DOCS
(if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc)
@migrateDocs()
@docs.sort()
@disabledDocs.sort()
@docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true
@ -99,6 +100,15 @@
@entries.add doc.entries.all()
return
migrateDocs: ->
for slug in @settings.getDocs() when not @docs.findBy('slug', slug)
needsSaving = true
if doc = @disabledDocs.findBy('slug_without_version', slug)
@disabledDocs.remove(doc)
@docs.add(doc)
@saveDocs() if needsSaving
enableDoc: (doc, _onSuccess, onError) ->
return if @docs.contains(doc)
onSuccess = =>
@ -106,14 +116,17 @@
@docs.add(doc)
@docs.sort()
@initDoc(doc)
@settings.setDocs(doc.slug for doc in @docs.all())
@saveDocs()
_onSuccess()
@appCache?.updateInBackground()
return
doc.load onSuccess, onError, writeCache: true
return
saveDocs: ->
@settings.setDocs(doc.slug for doc in @docs.all())
@appCache?.updateInBackground()
welcomeBack: ->
visitCount = @settings.get('count')
@settings.set 'count', ++visitCount

@ -39,7 +39,7 @@ class app.Router
return
doc: (context, next) ->
if doc = app.docs.findBy('slug', context.params.doc) or app.disabledDocs.findBy('slug', context.params.doc)
if doc = app.docs.findBySlug(context.params.doc) or app.disabledDocs.findBySlug(context.params.doc)
context.doc = doc
context.entry = doc.toEntry()
@triggerRoute 'entry'
@ -48,7 +48,7 @@ class app.Router
return
type: (context, next) ->
doc = app.docs.findBy 'slug', context.params.doc
doc = app.docs.findBySlug(context.params.doc)
if type = doc?.types.findBy 'slug', context.params.type
context.doc = doc
@ -59,7 +59,7 @@ class app.Router
return
entry: (context, next) ->
doc = app.docs.findBy 'slug', context.params.doc
doc = app.docs.findBySlug(context.params.doc)
if entry = doc?.findEntryByPathAndHash(context.params.path, context.hash)
context.doc = doc

@ -1,6 +1,9 @@
class app.collections.Docs extends app.Collection
@model: 'Doc'
findBySlug: (slug) ->
@findBy('slug', slug) or @findBy('slug_without_version', slug)
sort: ->
@models.sort (a, b) ->
a = a.name.toLowerCase()

@ -4,6 +4,8 @@ class app.models.Doc extends app.Model
constructor: ->
super
@reset @
[@slug_without_version, @version] = @slug.split('~v')
@icon = @slug_without_version
@text = @toEntry().text
reset: (data) ->

@ -51,10 +51,11 @@ canICloseTheTab = ->
app.templates.offlineDoc = (doc, status) ->
outdated = doc.isOutdated(status)
version = if doc.version then " (#{doc.version})" else ''
html = """
<tr data-slug="#{doc.slug}"#{if outdated then ' class="_highlight"' else ''}>
<td class="_docs-name _icon-#{doc.slug}">#{doc.name}</td>
<td class="_docs-name _icon-#{doc.icon}">#{doc.name}#{version}</td>
<td class="_docs-size">#{Math.ceil(doc.db_size / 100000) / 10} MB</td>
"""

@ -1,5 +1,5 @@
app.templates.path = (doc, type, entry) ->
html = """<a href="#{doc.fullPath()}" class="_path-item _icon-#{doc.slug}">#{doc.name}</a>"""
html = """<a href="#{doc.fullPath()}" class="_path-item _icon-#{doc.icon}">#{doc.name}</a>"""
html += """<a href="#{type.fullPath()}" class="_path-item">#{type.name}</a>""" if type
html += """<span class="_path-item">#{$.escape entry.name}</span>""" if entry
html

@ -1,7 +1,7 @@
templates = app.templates
templates.sidebarDoc = (doc, options = {}) ->
link = """<a href="#{doc.fullPath()}" class="_list-item _icon-#{doc.slug} """
link = """<a href="#{doc.fullPath()}" class="_list-item _icon-#{doc.icon} """
link += if options.disabled then '_list-disabled' else '_list-dir'
link += """" data-slug="#{doc.slug}" title="#{doc.name}">"""
if options.disabled
@ -22,7 +22,7 @@ templates.sidebarResult = (entry) ->
"""<span class="_list-enable" data-enable="#{entry.doc.slug}">Enable</span>"""
else
"""<span class="_list-reveal" data-reset-list title="Reveal in list"></span>"""
"""<a href="#{entry.fullPath()}" class="_list-item _list-hover _list-result _icon-#{entry.doc.slug}">#{addon}#{$.escape entry.name}</a>"""
"""<a href="#{entry.fullPath()}" class="_list-item _list-hover _list-result _icon-#{entry.doc.icon}">#{addon}#{$.escape entry.name}</a>"""
templates.sidebarNoResults = ->
html = """ <div class="_list-note">No results.</div> """
@ -35,11 +35,13 @@ templates.sidebarPageLink = (count) ->
"""<span class="_list-item _list-pagelink">Show more\u2026 (#{count})</span>"""
templates.sidebarLabel = (doc, options = {}) ->
label = """<label class="_list-item _list-label _icon-#{doc.slug}"""
label = """<label class="_list-item _list-label _icon-#{doc.icon}"""
label += ' _list-label-off' unless options.checked
label += """"><input type="checkbox" name="#{doc.slug}" class="_list-checkbox" """
label += 'checked' if options.checked
label + ">#{doc.name}</label>"
label += ">#{doc.name}"
label += " (#{doc.version})" if doc.version
label + "</label>"
templates.sidebarDisabledList = (options) ->
"""<div class="_disabled-list">#{templates.render 'sidebarDoc', options.docs, disabled: true}</div>"""

@ -115,6 +115,23 @@ class App < Sinatra::Application
end
end
def find_doc(slug)
settings.docs[slug] || begin
slug = "#{slug}~v"
settings.docs.each do |_slug, _doc|
return _doc if _slug.start_with?(slug)
end
nil
end
end
def user_has_docs?(slug)
docs.include?(slug) || begin
slug = "#{slug}~v"
docs.any? { |_slug| _slug.start_with?(slug) }
end
end
def doc_index_urls
docs.each_with_object [] do |slug, result|
if doc = settings.docs[slug]
@ -247,14 +264,14 @@ class App < Sinatra::Application
settings.news_feed
end
get %r{\A/(\w+)(\-[\w\-]+)?(/.*)?\z} do |doc, type, rest|
return 404 unless @doc = settings.docs[doc]
get %r{\A/([\w~\.]+)(\-[\w\-]+)?(/.*)?\z} do |doc, type, rest|
return 404 unless @doc = find_doc(doc)
if rest.nil?
redirect "/#{doc}#{type}/#{query_string_for_redirection}"
elsif rest.length > 1 && rest.end_with?('/')
redirect "/#{doc}#{type}#{rest[0...-1]}#{query_string_for_redirection}"
elsif docs.include?(doc) && supports_js_redirection?
elsif user_has_docs?(doc) && supports_js_redirection?
redirect_via_js(request.path)
else
erb :other

@ -31,31 +31,44 @@ module Docs
Dir["#{root_path}/docs/scrapers/**/*.rb"].
map { |file| File.basename(file, '.rb') }.
sort!.
map(&method(:find)).
map { |name| const_get(name.camelize) }.
reject(&:abstract)
end
def self.find(name)
def self.all_versions
all.flat_map(&:versions)
end
def self.find(name, version)
const = name.camelize
const_get(const)
doc = const_get(const)
if version.present?
doc = doc.versions.find { |klass| klass.version == version }
raise DocNotFound.new(%(could not find version "#{version}" for doc "#{name}"), name) unless doc
else
doc = doc.versions.first
end
doc
rescue NameError => error
if error.name.to_s == const
raise DocNotFound.new("failed to locate doc class '#{name}'", name)
raise DocNotFound.new(%(could not find doc "#{name}"), name)
else
raise error
end
end
def self.generate_page(name, page_id)
find(name).store_page(store, page_id)
def self.generate_page(name, version, page_id)
find(name, version).store_page(store, page_id)
end
def self.generate(name)
find(name).store_pages(store)
def self.generate(name, version)
find(name, version).store_pages(store)
end
def self.generate_manifest
Manifest.new(store, all).store
Manifest.new(store, all_versions).store
end
def self.store

@ -12,12 +12,39 @@ module Docs
subclass.type = type
end
def version(version = nil, &block)
return @version if version.nil?
klass = Class.new(self)
klass.class_exec(&block)
klass.name = name
klass.slug = slug
klass.version = version
klass.links = links
@versions ||= []
@versions << klass
klass
end
def version=(value)
@version = value.to_s
end
def versions
@versions.presence || [self]
end
def version?
version.present?
end
def name
@name || super.try(:demodulize)
end
def slug
@slug || name.try(:downcase)
slug = @slug || name.try(:downcase)
version? ? "#{slug}~v#{version}" : slug
end
def path

@ -1,6 +1,6 @@
module Docs
class Python2
class EntriesFilter < Docs::EntriesFilter
class Python
class EntriesV2Filter < Docs::EntriesFilter
REPLACE_TYPES = {
'compiler package' => 'Compiler',
'Cryptographic' => 'Cryptography',

@ -1,6 +1,6 @@
module Docs
class Python
class EntriesFilter < Docs::EntriesFilter
class EntriesV3Filter < Docs::EntriesFilter
REPLACE_TYPES = {
'Cryptographic' => 'Cryptography',
'Custom Interpreters' => 'Interpreters',

@ -1,13 +1,8 @@
module Docs
class Python < FileScraper
self.release = '3.5.1'
self.type = 'sphinx'
self.dir = '/Users/Thibaut/DevDocs/Docs/Python' # downloaded from docs.python.org/3/download.html
self.base_url = 'http://docs.python.org/3/'
self.root_path = 'library/index.html'
html_filters.push 'python/entries', 'python/clean_html'
options[:only_patterns] = [/\Alibrary\//]
options[:skip] = %w(
@ -23,5 +18,21 @@ module Docs
&copy; 1990&ndash;2015 Python Software Foundation<br>
Licensed under the PSF License.
HTML
version '3.5' do
self.release = '3.5.1'
self.dir = '/Users/Thibaut/DevDocs/Docs/Python35' # docs.python.org/3.5/download.html
self.base_url = 'https://docs.python.org/3.5/'
html_filters.push 'python/entries_v3', 'python/clean_html'
end
version '2.7' do
self.release = '2.7.10'
self.dir = '/Users/Thibaut/DevDocs/Docs/Python27' # docs.python.org/2.7/download.html
self.base_url = 'https://docs.python.org/2.7/'
html_filters.push 'python/entries_v2', 'python/clean_html'
end
end
end

@ -1,29 +0,0 @@
module Docs
class Python2 < FileScraper
self.name = 'Python 2'
self.slug = 'python2'
self.release = '2.7.10'
self.type = 'sphinx'
self.dir = '/Users/Thibaut/DevDocs/Docs/Python2' # downloaded from docs.python.org/2.7/download.html
self.base_url = 'http://docs.python.org/2.7/'
self.root_path = 'library/index.html'
html_filters.push 'python2/entries', 'python/clean_html'
options[:only_patterns] = [/\Alibrary\//]
options[:skip] = %w(
library/2to3.html
library/formatter.html
library/index.html
library/intro.html
library/undoc.html
library/unittest.mock-examples.html
library/sunau.html)
options[:attribution] = <<-HTML
&copy; 1990&ndash;2015 Python Software Foundation<br>
Licensed under the PSF License.
HTML
end
end

@ -16,11 +16,13 @@ class DocsCLI < Thor
max_length = 0
Docs.all.
map { |doc| [doc.to_s.demodulize.underscore, doc] }.
each { |pair| max_length = pair.first.length if pair.first.length > max_length }.
each { |pair| puts "#{pair.first.rjust max_length + 1}: #{pair.second.base_url.remove %r{\Ahttps?://}}" }
to_h.
each { |name, doc| max_length = name.length if name.length > max_length }.
each { |name, doc| puts "#{name.rjust max_length + 1}: #{doc.versions.map { |v| v.release || '-' }.join(', ')}" }
end
desc 'page <doc> [path] [--verbose] [--debug]', 'Generate a page (no indexing)'
desc 'page <doc> [path] [--version] [--verbose] [--debug]', 'Generate a page (no indexing)'
option :version, type: :string
option :verbose, type: :boolean
option :debug, type: :boolean
def page(name, path = '')
@ -34,16 +36,17 @@ class DocsCLI < Thor
Docs.install_report :filter, :request
end
if Docs.generate_page(name, path)
if Docs.generate_page(name, options[:version], path)
puts 'Done'
else
puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}"
end
rescue Docs::DocNotFound
invalid_doc(name)
rescue Docs::DocNotFound => error
handle_doc_not_found_error(error)
end
desc 'generate <doc> [--verbose] [--debug] [--force] [--package]', 'Generate a documentation'
desc 'generate <doc> [--version] [--verbose] [--debug] [--force] [--package]', 'Generate a documentation'
option :version, type: :string
option :verbose, type: :boolean
option :debug, type: :boolean
option :force, type: :boolean
@ -66,18 +69,18 @@ class DocsCLI < Thor
return unless yes? 'Proceed? (y/n)'
end
if Docs.generate(name)
if Docs.generate(name, options[:version])
generate_manifest
if options[:package]
require 'unix_utils'
package_doc(Docs.find(name))
package_doc(Docs.find(name, options[:version]))
end
puts 'Done'
else
puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}"
end
rescue Docs::DocNotFound
invalid_doc(name)
rescue Docs::DocNotFound => error
handle_doc_not_found_error(error)
end
desc 'manifest', 'Create the manifest'
@ -86,7 +89,7 @@ class DocsCLI < Thor
puts 'Done'
end
desc 'download (<doc> <doc>... | --all)', 'Download documentations'
desc 'download (<doc> <doc@version>... | --all)', 'Download documentations'
option :all, type: :boolean
def download(*names)
require 'unix_utils'
@ -96,10 +99,10 @@ class DocsCLI < Thor
generate_manifest
puts 'Done'
rescue Docs::DocNotFound => error
invalid_doc(error.name)
handle_doc_not_found_error(error)
end
desc 'package (<doc> <doc>... | --all)', 'Package documentations'
desc 'package (<doc> <doc@version>... | --all)', 'Package documentations'
option :all, type: :boolean
def package(*names)
require 'unix_utils'
@ -108,7 +111,7 @@ class DocsCLI < Thor
docs.each(&method(:package_doc))
puts 'Done'
rescue Docs::DocNotFound => error
invalid_doc(error.name)
handle_doc_not_found_error(error)
end
desc 'clean', 'Delete documentation packages'
@ -121,7 +124,8 @@ class DocsCLI < Thor
def find_docs(names)
names.map do |name|
Docs.find(name)
name, version = name.split('@')
Docs.find(name, version)
end
end
@ -133,9 +137,9 @@ class DocsCLI < Thor
end
end
def invalid_doc(name)
puts %(ERROR: invalid doc "#{name}".)
puts 'Run "thor docs:list" to see the list of docs.'
def handle_doc_not_found_error(error)
puts %(ERROR: #{error}.)
puts 'Run "thor docs:list" to see the list of docs and versions.'
end
def download_docs(docs)

@ -80,11 +80,18 @@ class AppTest < MiniTest::Spec
end
it "works with cookie" do
set_cookie('docs=css/html')
set_cookie('docs=css/html~v5')
get '/manifest.appcache'
assert last_response.ok?
assert_includes last_response.body, '/css/index.json'
assert_includes last_response.body, '/html/index.json'
assert_includes last_response.body, '/css/index.json?1420139788'
assert_includes last_response.body, '/html~v5/index.json?1420139791'
end
it "ignores invalid docs in the cookie" do
set_cookie('docs=foo')
get '/manifest.appcache'
assert last_response.ok?
refute_includes last_response.body, 'foo'
end
it "has the word 'default' when no 'dark' cookie is set" do
@ -120,13 +127,26 @@ class AppTest < MiniTest::Spec
describe "/[doc]" do
it "renders when the doc exists and isn't enabled" do
set_cookie('docs=css')
get '/html/', {}, 'HTTP_USER_AGENT' => MODERN_BROWSER
set_cookie('docs=html~v5')
get '/html~v4/', {}, 'HTTP_USER_AGENT' => MODERN_BROWSER
assert last_response.ok?
end
it "redirects via JS cookie when the doc exists and is enabled" do
set_cookie('docs=html')
set_cookie('docs=html~v5')
get '/html~v5/', {}, 'HTTP_USER_AGENT' => MODERN_BROWSER
assert last_response.redirect?
assert_equal 'http://example.org/', last_response['Location']
assert last_response['Set-Cookie'].start_with?("initial_path=%2Fhtml%7Ev5%2F; path=/; expires=")
end
it "renders when the doc exists, has no version in the path, and isn't enabled" do
get '/html/', {}, 'HTTP_USER_AGENT' => MODERN_BROWSER
assert last_response.ok?
end
it "redirects via JS cookie when the doc exists, has no version in the path, and a version is enabled" do
set_cookie('docs=html~v5')
get '/html/', {}, 'HTTP_USER_AGENT' => MODERN_BROWSER
assert last_response.redirect?
assert_equal 'http://example.org/', last_response['Location']
@ -140,7 +160,7 @@ class AppTest < MiniTest::Spec
end
it "returns 404 when the doc doesn't exist" do
get '/foo/'
get '/html~v6/'
assert last_response.not_found?
end
@ -157,42 +177,49 @@ class AppTest < MiniTest::Spec
describe "/[doc]-[type]" do
it "works when the doc exists" do
get '/html~v4-foo-bar_42/'
assert last_response.ok?
assert_includes last_response.body, 'app.DOC = {"name":"HTML","slug":"html~v4"'
end
it "works when the doc has no version in the path and a version exists" do
get '/html-foo-bar_42/'
assert last_response.ok?
assert_includes last_response.body, 'app.DOC = {"name":"HTML","slug":"html~v5"'
end
it "returns 404 when the type is blank" do
get '/html-/'
get '/css-/'
assert last_response.not_found?
end
it "returns 404 when the type is not alpha-numeric" do
get '/html-foo:bar/'
get '/css-foo:bar/'
assert last_response.not_found?
end
it "returns 404 when the doc doesn't exist" do
get '/foo-bar/'
get '/html~v6-bar/'
assert last_response.not_found?
end
it "redirects with trailing slash" do
get '/html-foo'
get '/css-foo'
assert last_response.redirect?
assert_equal 'http://example.org/html-foo/', last_response['Location']
assert_equal 'http://example.org/css-foo/', last_response['Location']
get '/html-foo', bar: 'baz'
get '/css-foo', bar: 'baz'
assert last_response.redirect?
assert_equal 'http://example.org/html-foo/?bar=baz', last_response['Location']
assert_equal 'http://example.org/css-foo/?bar=baz', last_response['Location']
end
end
describe "/[doc+type]/[path]" do
it "works when the doc exists" do
get '/html/foo'
get '/css/foo'
assert last_response.ok?
get '/html-bar/foo'
get '/css-bar/foo'
assert last_response.ok?
end
@ -202,13 +229,13 @@ class AppTest < MiniTest::Spec
end
it "redirects without trailing slash" do
get '/html/foo/'
get '/css/foo/'
assert last_response.redirect?
assert_equal 'http://example.org/html/foo', last_response['Location']
assert_equal 'http://example.org/css/foo', last_response['Location']
get '/html/foo/', bar: 'baz'
get '/css/foo/', bar: 'baz'
assert last_response.redirect?
assert_equal 'http://example.org/html/foo?bar=baz', last_response['Location']
assert_equal 'http://example.org/css/foo?bar=baz', last_response['Location']
end
end

@ -1 +1 @@
[{"name":"CSS","slug":"css","type":"mdn","release":null,"mtime":1420139788,"db_size":3460507},{"name":"DOM","slug":"dom","type":"mdn","release":null,"mtime":1420139789,"db_size":11399128},{"name":"DOM Events","slug":"dom_events","type":"mdn","release":null,"mtime":1420139790,"db_size":889020},{"name":"HTML","slug":"html","type":"mdn","release":null,"mtime":1420139790,"db_size":1835646},{"name":"HTTP","slug":"http","type":"rfc","release":null,"mtime":1420139790,"db_size":183083},{"name":"JavaScript","slug":"javascript","type":"mdn","release":null,"mtime":1420139791,"db_size":4125477}]
[{"name":"CSS","slug":"css","type":"mdn","release":null,"mtime":1420139788,"db_size":3460507},{"name":"DOM","slug":"dom","type":"mdn","release":null,"mtime":1420139789,"db_size":11399128},{"name":"DOM Events","slug":"dom_events","type":"mdn","release":null,"mtime":1420139790,"db_size":889020},{"name":"HTML","slug":"html~v5","type":"mdn","version":"5","mtime":1420139791,"db_size":1835647},{"name":"HTML","slug":"html~v4","type":"mdn","version":"4","mtime":1420139790,"db_size":1835646},{"name":"HTTP","slug":"http","type":"rfc","release":null,"mtime":1420139790,"db_size":183083},{"name":"JavaScript","slug":"javascript","type":"mdn","release":null,"mtime":1420139791,"db_size":4125477}]

@ -44,6 +44,17 @@ class DocsDocTest < MiniTest::Spec
it "returns 'doc' when the class is Docs::Doc" do
assert_equal 'doc', Docs::Doc.slug
end
it "returns 'doc~v42' when the class is Docs::Doc and its #version is '42'" do
stub(Docs::Doc).version { '42' }
assert_equal 'doc~v42', Docs::Doc.slug
end
it "returns 'foo~v42' when #slug has been set to 'foo' and #version to '42'" do
doc.slug = 'foo'
doc.version = '42'
assert_equal 'foo~v42', doc.slug
end
end
describe ".slug=" do
@ -53,6 +64,13 @@ class DocsDocTest < MiniTest::Spec
end
end
describe ".version=" do
it "stores .version as a string" do
doc.version = 4815162342
assert_equal '4815162342', doc.version
end
end
describe ".release=" do
it "stores .release" do
doc.release = '1'
@ -297,4 +315,37 @@ class DocsDocTest < MiniTest::Spec
end
end
end
describe ".versions" do
it "returns [self] if no versions have been created" do
assert_equal [doc], doc.versions
end
end
describe ".version" do
context "with no args" do
it "returns @version by default" do
doc.version = 'v'
assert_equal 'v', doc.version
end
end
context "with args" do
it "creates a version subclass" do
version = doc.version('4') { self.release = '8'}
assert_equal [version], doc.versions
assert_nil doc.version
assert_nil doc.release
refute doc.version?
assert version.version?
assert_equal '4', version.version
assert_equal '8', version.release
assert_equal 'name', version.name
assert_equal 'type', version.type
end
end
end
end

@ -21,7 +21,7 @@
<div class="_list">
<% unless @doc %>
<% App.docs.each do |slug, doc| %>
<a href="/<%= slug %>/" class="_list-item _icon-<%= slug %> _list-dir"><span class="_list-arrow"></span><%= doc['name'] %></a>
<a href="/<%= slug %>/" class="_list-item"><span class="_list-arrow"></span><%= doc['name'] %></a>
<% end %>
<% end %>
</div>

Loading…
Cancel
Save