diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..64b8f28c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +test +Dockerfile* +.gitignore +.dockerignore +.travis.yml +*.md diff --git a/.image_optim.yml b/.image_optim.yml new file mode 100644 index 00000000..f846feb4 --- /dev/null +++ b/.image_optim.yml @@ -0,0 +1,22 @@ +verbose: false +skip_missing_workers: true +allow_lossy: true +threads: 1 +advpng: false +gifsicle: + interlace: false + level: 3 + careful: true +jhead: false +jpegoptim: + strip: all + max_quality: 100 +jpegrecompress: false +jpegtran: false +optipng: false +pngcrush: false +pngout: false +pngquant: + quality: !ruby/range 80..99 + speed: 3 +svgo: false diff --git a/.ruby-version b/.ruby-version index cc6612c3..58073ef8 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.3.0 \ No newline at end of file +2.4.1 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eaed038e..fd4bd534 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ Use the [Trello board](https://trello.com/b/6BmTulfx/devdocs-documentation) wher See the [wiki](https://github.com/Thibaut/devdocs/wiki) to learn how to add new documentations. -**Important:** the documentation's license must permit alteration, redistribution, and commercial use. +**Important:** the documentation's license must permit alteration, redistribution and commercial use, and the documented software must be released under an open source license. Feel free to get in touch if you're not sure if a documentation meets those requirements. In addition to the [guidelines for contributing code](#contributing-code-and-features), the following guidelines apply to pull requests that add a new documentation: diff --git a/COPYRIGHT b/COPYRIGHT index c9f04ecb..e194cf37 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,4 +1,4 @@ -Copyright 2013-2016 Thibaut Courouble and other contributors +Copyright 2013-2017 Thibaut Courouble and other contributors This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this diff --git a/Dockerfile b/Dockerfile index e7c09228..314f6253 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,22 @@ +FROM ruby:2.4.1 -FROM ruby:2.3.0 -MAINTAINER Conor Heine +WORKDIR /devdocs -RUN apt-get update -RUN apt-get -y install git nodejs -RUN git clone https://github.com/Thibaut/devdocs.git /devdocs -RUN gem install bundler +RUN apt-get update && \ + apt-get -y install git nodejs && \ + gem install bundler && \ + rm -rf /var/lib/apt/lists/* -WORKDIR /devdocs +COPY Gemfile Gemfile.lock Rakefile /devdocs/ + +RUN bundle install --system && \ + rm -rf ~/.gem /root/.bundle/cache /usr/local/bundle/cache -RUN bundle install --system -RUN thor docs:download --all +COPY . /devdocs + +RUN thor docs:download --all && \ + thor assets:compile && \ + rm -rf /tmp EXPOSE 9292 CMD rackup -o 0.0.0.0 - diff --git a/Dockerfile-alpine b/Dockerfile-alpine new file mode 100644 index 00000000..ecbe5828 --- /dev/null +++ b/Dockerfile-alpine @@ -0,0 +1,17 @@ +FROM ruby:2.4.1-alpine + +WORKDIR /devdocs + +COPY . /devdocs + +RUN apk --update add nodejs build-base libstdc++ gzip git zlib-dev && \ + gem install bundler && \ + bundle install --system --without test && \ + thor docs:download --all && \ + thor assets:compile && \ + apk del gzip build-base git zlib-dev && \ + rm -rf /var/cache/apk/* /tmp ~/.gem /root/.bundle/cache \ + /usr/local/bundle/cache /usr/lib/node_modules + +EXPOSE 9292 +CMD rackup -o 0.0.0.0 diff --git a/Gemfile b/Gemfile index f2958d3d..931014b4 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,10 @@ source 'https://rubygems.org' -ruby '2.3.0' +ruby '2.4.1' gem 'rake' gem 'thor' gem 'pry', '~> 0.10.0' -gem 'activesupport', '~> 4.2', require: false +gem 'activesupport', '~> 5.0', require: false gem 'yajl-ruby', require: false group :app do @@ -14,7 +14,7 @@ group :app do gem 'thin' gem 'sprockets' gem 'sprockets-helpers' - gem 'erubis' + gem 'erubi' gem 'browser' gem 'sass' gem 'coffee-script' @@ -32,6 +32,8 @@ group :docs do gem 'typhoeus' gem 'nokogiri' gem 'html-pipeline' + gem 'image_optim' + gem 'image_optim_pack', platforms: :ruby gem 'progress_bar', require: false gem 'unix_utils', require: false gem 'tty-pager', require: false diff --git a/Gemfile.lock b/Gemfile.lock index e7169051..a7ad9ac2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,111 +1,136 @@ GEM remote: https://rubygems.org/ specs: - activesupport (4.2.6) + activesupport (5.1.3) + concurrent-ruby (~> 1.0, >= 1.0.2) i18n (~> 0.7) - json (~> 1.7, >= 1.7.7) minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - backports (3.6.8) - better_errors (2.1.1) + backports (3.8.0) + better_errors (2.3.0) coderay (>= 1.0.0) - erubis (>= 2.6.6) + erubi (>= 1.0.0) rack (>= 0.9.0) - browser (2.1.0) + browser (2.4.0) coderay (1.1.1) coffee-script (2.4.1) coffee-script-source execjs - coffee-script-source (1.10.0) - concurrent-ruby (1.0.2) - daemons (1.2.3) - erubis (2.7.0) - ethon (0.9.0) + coffee-script-source (1.12.2) + concurrent-ruby (1.0.5) + daemons (1.2.4) + erubi (1.6.1) + ethon (0.10.1) ffi (>= 1.3.0) - eventmachine (1.2.0.1) - execjs (2.6.0) - ffi (1.9.10) + eventmachine (1.2.5) + execjs (2.7.0) + exifr (1.3.1) + ffi (1.9.18) + fspath (3.1.0) highline (1.7.8) - html-pipeline (2.4.1) - activesupport (>= 2, < 5) + html-pipeline (2.6.0) + activesupport (>= 2) nokogiri (>= 1.4) - i18n (0.7.0) - json (1.8.3) + i18n (0.8.6) + image_optim (0.25.0) + exifr (~> 1.2, >= 1.2.2) + fspath (~> 3.0) + image_size (~> 1.5) + in_threads (~> 1.3) + progress (~> 3.0, >= 3.0.1) + image_optim_pack (0.5.0.20170803) + fspath (>= 2.1, < 4) + image_optim (~> 0.19) + image_size (1.5.0) + in_threads (1.4.0) method_source (0.8.2) - mini_portile2 (2.0.0) - minitest (5.8.4) - multi_json (1.12.0) - nokogiri (1.6.7.2) - mini_portile2 (~> 2.0.0.rc2) + mini_portile2 (2.2.0) + minitest (5.10.3) + multi_json (1.12.1) + mustermann (1.0.0) + nokogiri (1.8.0) + mini_portile2 (~> 2.2.0) options (2.3.2) - progress_bar (1.0.5) + progress (3.3.1) + progress_bar (1.1.0) highline (~> 1.6) options (~> 2.3.0) - pry (0.10.3) + pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) slop (~> 3.4) - rack (1.6.4) - rack-protection (1.5.3) + rack (2.0.3) + rack-protection (2.0.0) rack - rack-test (0.6.3) - rack (>= 1.0) - rake (11.1.2) - rr (1.1.2) - sass (3.4.22) - sinatra (1.4.7) - rack (~> 1.5) - rack-protection (~> 1.4) - tilt (>= 1.3, < 3) - sinatra-contrib (1.4.7) + rack-test (0.7.0) + rack (>= 1.0, < 3) + rake (12.0.0) + rb-fsevent (0.10.2) + rb-inotify (0.9.10) + ffi (>= 0.5.0, < 2) + rr (1.2.1) + sass (3.5.1) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + sinatra (2.0.0) + mustermann (~> 1.0) + rack (~> 2.0) + rack-protection (= 2.0.0) + tilt (~> 2.0) + sinatra-contrib (2.0.0) backports (>= 2.0) multi_json - rack-protection - rack-test - sinatra (~> 1.4.0) + mustermann (~> 1.0) + rack-protection (= 2.0.0) + sinatra (= 2.0.0) tilt (>= 1.3, < 3) slop (3.6.0) - sprockets (3.6.0) + sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-helpers (1.2.1) sprockets (>= 2.2) - thin (1.6.4) + thin (1.7.2) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) - rack (~> 1.0) - thor (0.19.1) - thread_safe (0.3.5) - tilt (2.0.3) - tty-pager (0.4.0) + rack (>= 1, < 3) + thor (0.19.4) + thread_safe (0.3.6) + tilt (2.0.8) + tty-pager (0.8.0) tty-screen (~> 0.5.0) - tty-which (~> 0.1.0) - verse (~> 0.4.0) + tty-which (~> 0.3.0) + verse (~> 0.5.0) tty-screen (0.5.0) - tty-which (0.1.0) - typhoeus (1.0.2) + tty-which (0.3.0) + typhoeus (1.1.2) ethon (>= 0.9.0) - tzinfo (1.2.2) + tzinfo (1.2.3) thread_safe (~> 0.1) - uglifier (3.0.0) + uglifier (3.2.0) execjs (>= 0.3.0, < 3) + unicode-display_width (1.1.3) unicode_utils (1.4.0) unix_utils (0.0.15) - verse (0.4.0) + verse (0.5.0) + unicode-display_width (~> 1.1.0) unicode_utils (~> 1.4.0) - yajl-ruby (1.2.1) + yajl-ruby (1.3.0) PLATFORMS ruby DEPENDENCIES - activesupport (~> 4.2) + activesupport (~> 5.0) better_errors browser coffee-script - erubis + erubi html-pipeline + image_optim + image_optim_pack minitest nokogiri progress_bar @@ -127,5 +152,8 @@ DEPENDENCIES unix_utils yajl-ruby +RUBY VERSION + ruby 2.4.1p111 + BUNDLED WITH - 1.11.2 + 1.14.1 diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..90890e07 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,7 @@ + diff --git a/README.md b/README.md index 8d449a4a..9a49da5f 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,14 @@ Unless you wish to contribute to the project, I recommend using the hosted versi DevDocs is made of two pieces: a Ruby scraper that generates the documentation and metadata, and a JavaScript app powered by a small Sinatra app. -DevDocs requires Ruby 2.3.0, libcurl, and a JavaScript runtime supported by [ExecJS](https://github.com/rails/execjs#readme) (included in OS X and Windows; [Node.js](https://nodejs.org/en/) on Linux). Once you have these installed, run the following commands: +DevDocs requires Ruby 2.4.1, libcurl, and a JavaScript runtime supported by [ExecJS](https://github.com/rails/execjs#readme) (included in OS X and Windows; [Node.js](https://nodejs.org/en/) on Linux). Once you have these installed, run the following commands: ``` git clone https://github.com/Thibaut/devdocs.git && cd devdocs gem install bundler bundle install -thor docs:download --default -rackup +bundle exec thor docs:download --default +bundle exec rackup ``` Finally, point your browser at [localhost:9292](http://localhost:9292) (the first request will take a few seconds to compile the assets). You're all set. @@ -130,6 +130,8 @@ thor assets:compile # Compile assets (not required in development mode) thor assets:clean # Clean old assets ``` +If multiple versions of Ruby are installed on your system, commands must be run through `bundle exec`. + ## Contributing Contributions are welcome. Please read the [contributing guidelines](https://github.com/Thibaut/devdocs/blob/master/CONTRIBUTING.md). @@ -138,7 +140,7 @@ DevDocs's own documentation is available on the [wiki](https://github.com/Thibau ## Copyright / License -Copyright 2013-2016 Thibaut Courouble and [other contributors](https://github.com/Thibaut/devdocs/graphs/contributors) +Copyright 2013-2017 Thibaut Courouble and [other contributors](https://github.com/Thibaut/devdocs/graphs/contributors) This software is licensed under the terms of the Mozilla Public License v2.0. See the [COPYRIGHT](https://github.com/Thibaut/devdocs/blob/master/COPYRIGHT) and [LICENSE](https://github.com/Thibaut/devdocs/blob/master/LICENSE) files. diff --git a/assets/images/docs-1.png b/assets/images/docs-1.png new file mode 100644 index 00000000..e1c9ce11 Binary files /dev/null and b/assets/images/docs-1.png differ diff --git a/assets/images/docs-1@2x.png b/assets/images/docs-1@2x.png new file mode 100644 index 00000000..33eeaa4f Binary files /dev/null and b/assets/images/docs-1@2x.png differ diff --git a/assets/images/docs-2.png b/assets/images/docs-2.png new file mode 100644 index 00000000..fcfb9f25 Binary files /dev/null and b/assets/images/docs-2.png differ diff --git a/assets/images/docs-2@2x.png b/assets/images/docs-2@2x.png new file mode 100644 index 00000000..1b88fe32 Binary files /dev/null and b/assets/images/docs-2@2x.png differ diff --git a/assets/images/icons.png b/assets/images/icons.png index 1720cac0..8aba11bb 100644 Binary files a/assets/images/icons.png and b/assets/images/icons.png differ diff --git a/assets/images/icons@2x.png b/assets/images/icons@2x.png index 94feef55..a2766399 100644 Binary files a/assets/images/icons@2x.png and b/assets/images/icons@2x.png differ diff --git a/assets/javascripts/app/app.coffee b/assets/javascripts/app/app.coffee index e8f083a2..6b627751 100644 --- a/assets/javascripts/app/app.coffee +++ b/assets/javascripts/app/app.coffee @@ -13,9 +13,9 @@ @showLoading() @el = $('._app') - @store = new Store + @localStorage = new LocalStorageStore @appCache = new app.AppCache if app.AppCache.isEnabled() - @settings = new app.Settings @store + @settings = new app.Settings @db = new app.DB() @docs = new app.collections.Docs @@ -27,7 +27,8 @@ @document = new app.views.Document @mobile = new app.views.Mobile if @isMobile() - if @DOC + if document.body.hasAttribute('data-doc') + @DOC = JSON.parse(document.body.getAttribute('data-doc')) @bootOne() else if @DOCS @bootAll() @@ -50,26 +51,33 @@ else if @config.sentry_dsn Raven.config @config.sentry_dsn, + release: @config.release whitelistUrls: [/devdocs/] includePaths: [/devdocs/] - ignoreErrors: [/dpQuery/, /NPObject/, /NS_ERROR/, /^null$/] + ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/] tags: - mode: if @DOC then 'single' else 'full' + mode: if @isSingleDoc() then 'single' else 'full' iframe: (window.top isnt window).toString() + electron: (!!window.process?.versions?.electron).toString() shouldSendCallback: => try if @isInjectionError() @onInjectionError() return false + if @isAndroidWebview() + return false true dataCallback: (data) -> try - $.extend(data.user ||= {}, app.settings.settings) + $.extend(data.user ||= {}, app.settings.dump()) + data.user.docs = data.user.docs.split('/') if data.user.docs data.user.lastIDBTransaction = app.lastIDBTransaction if app.lastIDBTransaction + data.tags.scriptCount = document.scripts.length data .install() @previousErrorHandler = onerror window.onerror = @onWindowError.bind(@) + CookieStore.onBlocked = @onCookieBlocked return bootOne: -> @@ -85,8 +93,6 @@ 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 delete @DOCS return @@ -98,21 +104,23 @@ @trigger 'ready' @router.start() @hideLoading() - @welcomeBack() unless @doc - @removeEvent 'ready bootError' - try navigator.mozApps?.getSelf().onsuccess = -> app.mozApp = true catch + setTimeout => + @welcomeBack() unless @doc + @removeEvent 'ready bootError' + , 50 return initDoc: (doc) -> - @entries.add type.toEntry() for type in doc.types.all() + doc.entries.add type.toEntry() for type in doc.types.all() @entries.add doc.entries.all() return migrateDocs: -> for slug in @settings.getDocs() when not @docs.findBy('slug', slug) needsSaving = true - doc = @disabledDocs.findBy('slug', 'node~4_lts') if slug == 'node~4.2_lts' - doc = @disabledDocs.findBy('slug', 'xslt_xpath') if slug == 'xpath' + doc = @disabledDocs.findBy('slug', 'webpack') if slug == 'webpack~2' + doc = @disabledDocs.findBy('slug', 'angular') if slug == 'angular~4_typescript' + doc = @disabledDocs.findBy('slug', 'angular~2') if slug == 'angular~2_typescript' doc ||= @disabledDocs.findBy('slug_without_version', slug) if doc @disabledDocs.remove(doc) @@ -157,7 +165,7 @@ return reset: -> - @store.clear() + @localStorage.reset() @settings.reset() @db?.reset() @appCache?.update() @@ -166,10 +174,10 @@ showTip: (tip) -> return if @isSingleDoc() - tips = @settings.get('tips') || [] + tips = @settings.getTips() if tips.indexOf(tip) is -1 tips.push(tip) - @settings.set('tips', tips) + @settings.setTips(tips) new app.views.Tip(tip) return @@ -179,6 +187,7 @@ return hideLoading: -> + document.body.classList.add '_overlay-scrollbars' if $.overlayScrollbarsEnabled() document.body.classList.remove '_booting' document.body.classList.remove '_loading' return @@ -186,7 +195,7 @@ indexHost: -> # Can't load the index files from the host/CDN when applicationCache is # enabled because it doesn't support caching URLs that use CORS. - @config[if @appCache and @settings.hasDocs() then 'index_path' else 'docs_host'] + @config[if @appCache and @settings.hasDocs() then 'index_path' else 'docs_origin'] onBootError: (args...) -> @trigger 'bootError' @@ -197,9 +206,17 @@ return if @quotaExceeded @quotaExceeded = true new app.views.Notif 'QuotaExceeded', autoHide: null - Raven.captureMessage 'QuotaExceededError' + return + + onCookieBlocked: (key, value, actual) -> + return if @cookieBlocked + @cookieBlocked = true + new app.views.Notif 'CookieBlocked', autoHide: null + Raven.captureMessage "CookieBlocked/#{key}", level: 'warning', extra: {value, actual} + return onWindowError: (args...) -> + return if @cookieBlocked if @isInjectionError args... @onInjectionError() else if @isAppError args... @@ -215,7 +232,7 @@ alert """ JavaScript code has been injected in the page which prevents DevDocs from running correctly. Please check your browser extensions/addons. """ - Raven.captureMessage 'injection error' + Raven.captureMessage 'injection error', level: 'info' return isInjectionError: -> @@ -239,16 +256,16 @@ cssGradients: supportsCssGradients() for key, value of features when not value - Raven.captureMessage "unsupported/#{key}" + Raven.captureMessage "unsupported/#{key}", level: 'info' return false true catch error - Raven.captureMessage 'unsupported/exception', extra: { error: error } + Raven.captureMessage 'unsupported/exception', level: 'info', extra: { error: error } false isSingleDoc: -> - !!(@DOC or @doc) + document.body.hasAttribute('data-doc') isMobile: -> @_isMobile ?= app.views.Mobile.detect() diff --git a/assets/javascripts/app/appcache.coffee b/assets/javascripts/app/appcache.coffee index d79af1ea..d2606ab1 100644 --- a/assets/javascripts/app/appcache.coffee +++ b/assets/javascripts/app/appcache.coffee @@ -16,21 +16,25 @@ class app.AppCache update: -> @notifyUpdate = true + @notifyProgress = true try @cache.update() catch return updateInBackground: -> @notifyUpdate = false + @notifyProgress = false try @cache.update() catch return reload: -> $.on @cache, 'updateready noupdate error', -> window.location = '/' - @updateInBackground() + @notifyUpdate = false + @notifyProgress = true + try @cache.update() catch return onProgress: (event) => - @trigger 'progress', event + @trigger 'progress', event if @notifyProgress return onUpdateReady: => diff --git a/assets/javascripts/app/config.coffee.erb b/assets/javascripts/app/config.coffee.erb index bc7d1260..ec26b697 100644 --- a/assets/javascripts/app/config.coffee.erb +++ b/assets/javascripts/app/config.coffee.erb @@ -1,7 +1,7 @@ app.config = db_filename: 'db.json' default_docs: <%= App.default_docs.to_json %> - docs_host: '<%= App.docs_host %>' + docs_origin: '<%= App.docs_origin %>' env: '<%= App.environment %>' history_cache_size: 10 index_filename: 'index.json' @@ -11,3 +11,5 @@ app.config = search_param: 'q' sentry_dsn: '<%= App.sentry_dsn %>' version: <%= Time.now.to_i %> + release: <%= Time.now.utc.httpdate.to_json %> + mathml_stylesheet: '<%= App.cdn_origin %>/mathml.css' diff --git a/assets/javascripts/app/db.coffee b/assets/javascripts/app/db.coffee index 7eda08e9..28e4b0ea 100644 --- a/assets/javascripts/app/db.coffee +++ b/assets/javascripts/app/db.coffee @@ -1,9 +1,10 @@ class app.DB NAME = 'docs' + VERSION = 15 constructor: -> + @versionMultipler = if $.isIE() then 1e5 else 1e9 @useIndexedDB = @useIndexedDB() - @appVersion = @appVersion() @callbacks = [] db: (fn) -> @@ -13,45 +14,91 @@ class app.DB try @open = true - req = indexedDB.open(NAME, @schemaVersion()) + req = indexedDB.open(NAME, VERSION * @versionMultipler + @userVersion()) req.onsuccess = @onOpenSuccess req.onerror = @onOpenError req.onupgradeneeded = @onUpgradeNeeded - catch - @onOpenError() + catch error + @fail 'exception', error return onOpenSuccess: (event) => - try - db = event.target.result - unless @checkedBuggyIDB - @idbTransaction(db, stores: ['docs', app.docs.all()[0].slug], mode: 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937 - @checkedBuggyIDB = true - catch - try db.close() - @reason = 'apple' - @onOpenError() - return + db = event.target.result - @runCallbacks(db) - @open = false - db.close() + if db.objectStoreNames.length is 0 + try db.close() + @open = false + @fail 'empty' + else if error = @buggyIDB(db) + try db.close() + @open = false + @fail 'buggy', error + else + @runCallbacks(db) + @open = false + db.close() return onOpenError: (event) => - event?.preventDefault() + event.preventDefault() @open = false + error = event.target.error + + switch error.name + when 'QuotaExceededError' + @onQuotaExceededError() + when 'VersionError' + @onVersionError() + when 'InvalidStateError' + @fail 'private_mode' + else + @fail 'cant_open', error + return - if event?.target?.error?.name is 'QuotaExceededError' - @reset() - @db() - app.onQuotaExceeded() + fail: (reason, error) -> + @cachedDocs = null + @useIndexedDB = false + @reason or= reason + @error or= error + console.error? 'IDB error', error if error + @runCallbacks() + if error and reason is 'cant_open' + Raven.captureMessage "#{error.name}: #{error.message}", level: 'warning', fingerprint: [error.name] + return + + onQuotaExceededError: -> + @reset() + @db() + app.onQuotaExceeded() + Raven.captureMessage 'QuotaExceededError', level: 'warning' + return + + onVersionError: -> + req = indexedDB.open(NAME) + req.onsuccess = (event) => + @handleVersionMismatch event.target.result.version + req.onerror = (event) -> + event.preventDefault() + @fail 'cant_open', error + return + + handleVersionMismatch: (actualVersion) -> + if Math.floor(actualVersion / @versionMultipler) isnt VERSION + @fail 'version' else - @useIndexedDB = false - @reason or= 'cant_open' - @runCallbacks() + @setUserVersion actualVersion - VERSION * @versionMultipler + @db() return + buggyIDB: (db) -> + return if @checkedBuggyIDB + @checkedBuggyIDB = true + try + @idbTransaction(db, stores: $.makeArray(db.objectStoreNames)[0..1], mode: 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937 + return + catch error + return error + runCallbacks: (db) -> fn(db) while fn = @callbacks.shift() return @@ -62,7 +109,7 @@ class app.DB objectStoreNames = $.makeArray(db.objectStoreNames) unless $.arrayDelete(objectStoreNames, 'docs') - db.createObjectStore('docs') + try db.createObjectStore('docs') for doc in app.docs.all() when not $.arrayDelete(objectStoreNames, doc.slug) try db.createObjectStore(doc.slug) @@ -71,7 +118,7 @@ class app.DB try db.deleteObjectStore(name) return - store: (doc, data, onSuccess, onError) -> + store: (doc, data, onSuccess, onError, _retry = true) -> @db (db) => unless db onError() @@ -82,9 +129,15 @@ class app.DB @cachedDocs?[doc.slug] = doc.mtime onSuccess() return - txn.onerror = (event) -> + txn.onerror = (event) => event.preventDefault() - onError(event) + if txn.error?.name is 'NotFoundError' and _retry + @migrate() + setTimeout => + @store(doc, data, onSuccess, onError, false) + , 0 + else + onError(event) return store = txn.objectStore(doc.slug) @@ -96,7 +149,7 @@ class app.DB return return - unstore: (doc, onSuccess, onError) -> + unstore: (doc, onSuccess, onError, _retry = true) -> @db (db) => unless db onError() @@ -109,14 +162,20 @@ class app.DB return txn.onerror = (event) -> event.preventDefault() - onError(event) + if txn.error?.name is 'NotFoundError' and _retry + @migrate() + setTimeout => + @unstore(doc, onSuccess, onError, false) + , 0 + else + onError(event) return - store = txn.objectStore(doc.slug) - store.clear() - store = txn.objectStore('docs') store.delete(doc.slug) + + store = txn.objectStore(doc.slug) + store.clear() return return @@ -227,9 +286,11 @@ class app.DB @cachedDocs = {} txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - store = txn.objectStore('docs') + txn.oncomplete = => + setTimeout(@checkForCorruptedDocs, 50) + return - req = store.openCursor() + req = txn.objectStore('docs').openCursor() req.onsuccess = (event) => return unless cursor = event.target.result @cachedDocs[cursor.key] = cursor.value @@ -240,6 +301,45 @@ class app.DB return return + checkForCorruptedDocs: => + @db (db) => + @corruptedDocs = [] + docs = (key for key, value of @cachedDocs when value) + return if docs.length is 0 + + for slug in docs when not app.docs.findBy('slug', slug) + @corruptedDocs.push(slug) + + for slug in @corruptedDocs + $.arrayDelete(docs, slug) + + if docs.length is 0 + setTimeout(@deleteCorruptedDocs, 0) + return + + txn = @idbTransaction(db, stores: docs, mode: 'readonly', ignoreError: false) + txn.oncomplete = => + setTimeout(@deleteCorruptedDocs, 0) if @corruptedDocs.length > 0 + return + + for doc in docs + txn.objectStore(doc).get('index').onsuccess = (event) => + @corruptedDocs.push(event.target.source.name) unless event.target.result + return + return + return + + deleteCorruptedDocs: => + @db (db) => + txn = @idbTransaction(db, stores: ['docs'], mode: 'readwrite', ignoreError: false) + store = txn.objectStore('docs') + while doc = @corruptedDocs.pop() + @cachedDocs[doc] = false + store.delete(doc) + return + Raven.captureMessage 'corruptedDocs', level: 'info', extra: { docs: @corruptedDocs.join(',') } + return + shouldLoadWithIDB: (entry) -> @useIndexedDB and (not @cachedDocs or @cachedDocs[entry.doc.slug]) @@ -274,11 +374,9 @@ class app.DB app.settings.set('schema', @userVersion() + 1) return - schemaVersion: -> - @appVersion * 10 + @userVersion() + setUserVersion: (version) -> + app.settings.set('schema', version) + return userVersion: -> app.settings.get('schema') - - appVersion: -> - if app.config.env is 'production' then app.config.version else Math.floor(Date.now() / 1000) diff --git a/assets/javascripts/app/router.coffee b/assets/javascripts/app/router.coffee index aa1fc6b7..41870424 100644 --- a/assets/javascripts/app/router.coffee +++ b/assets/javascripts/app/router.coffee @@ -2,16 +2,17 @@ class app.Router $.extend @prototype, Events @routes: [ - ['*', 'before' ] - ['/', 'root' ] - ['/offline', 'offline' ] - ['/about', 'about' ] - ['/news', 'news' ] - ['/help', 'help' ] - ['/:doc-:type/', 'type' ] - ['/:doc/', 'doc' ] - ['/:doc/:path(*)', 'entry' ] - ['*', 'notFound'] + ['*', 'before' ] + ['/', 'root' ] + ['/settings', 'settings' ] + ['/offline', 'offline' ] + ['/about', 'about' ] + ['/news', 'news' ] + ['/help', 'help' ] + ['/:doc-:type/', 'type' ] + ['/:doc/', 'doc' ] + ['/:doc/:path(*)', 'entry' ] + ['*', 'notFound' ] ] constructor: -> @@ -33,19 +34,24 @@ class app.Router return before: (context, next) -> + previousContext = @context @context = context @trigger 'before', context - next() - return + + if res = next() + @context = previousContext + return res + else + return doc: (context, next) -> if doc = app.docs.findBySlug(context.params.doc) or app.disabledDocs.findBySlug(context.params.doc) context.doc = doc context.entry = doc.toEntry() @triggerRoute 'entry' + return else - next() - return + return next() type: (context, next) -> doc = app.docs.findBySlug(context.params.doc) @@ -54,9 +60,9 @@ class app.Router context.doc = doc context.type = type @triggerRoute 'type' + return else - next() - return + return next() entry: (context, next) -> doc = app.docs.findBySlug(context.params.doc) @@ -65,32 +71,39 @@ class app.Router context.doc = doc context.entry = entry @triggerRoute 'entry' + return else - next() - return + return next() root: -> - if app.isSingleDoc() - setTimeout (-> window.location = '/'), 0 - else - @triggerRoute 'root' + return '/' if app.isSingleDoc() + @triggerRoute 'root' + return + + settings: (context) -> + return "/#/#{context.path}" if app.isSingleDoc() + @triggerRoute 'settings' return - offline: -> + offline: (context)-> + return "/#/#{context.path}" if app.isSingleDoc() @triggerRoute 'offline' return about: (context) -> + return "/#/#{context.path}" if app.isSingleDoc() context.page = 'about' @triggerRoute 'page' return news: (context) -> + return "/#/#{context.path}" if app.isSingleDoc() context.page = 'news' @triggerRoute 'page' return help: (context) -> + return "/#/#{context.path}" if app.isSingleDoc() context.page = 'help' @triggerRoute 'page' return @@ -99,15 +112,15 @@ class app.Router @triggerRoute 'notFound' return - isRoot: -> - location.pathname is '/' + isIndex: -> + @context?.path is '/' or (app.isSingleDoc() and @context?.entry?.isIndex()) setInitialPath: -> # Remove superfluous forward slashes at the beginning of the path if (path = location.pathname.replace /^\/{2,}/g, '/') isnt location.pathname page.replace path + location.search + location.hash, null, true - if @isRoot() + if location.pathname is '/' if path = @getInitialPathFromHash() page.replace path + location.search, null, true else if path = @getInitialPathFromCookie() diff --git a/assets/javascripts/app/searcher.coffee b/assets/javascripts/app/searcher.coffee index 932c580f..79f6a304 100644 --- a/assets/javascripts/app/searcher.coffee +++ b/assets/javascripts/app/searcher.coffee @@ -26,7 +26,7 @@ return unless index >= 0 lastIndex = value.lastIndexOf(query) if index isnt lastIndex - return Math.max(scoreExactMatch(), (index = lastIndex) and scoreExactMatch()) + return Math.max(scoreExactMatch(), ((index = lastIndex) and scoreExactMatch()) or 0) else return scoreExactMatch() `}` @@ -112,7 +112,8 @@ class app.Searcher max_results: app.config.max_results fuzzy_min_length: 3 - SEPARATORS_REGEXP = /\:?\ |#|::|->|\$(?=\w)|\-(?=\w)/g + SEPARATORS_REGEXP = /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g + EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/ INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/ EMPTY_PARANTHESES_REGEXP = /\(\)/ EVENT_REGEXP = /\ event$/ @@ -134,6 +135,10 @@ class app.Searcher .replace EMPTY_PARANTHESES_REGEXP, EMPTY_STRING .replace WHITESPACE_REGEXP, EMPTY_STRING + @normalizeQuery: (string) -> + string = @normalizeString(string) + string.replace EOS_SEPARATORS_REGEXP, '$1.' + constructor: (options = {}) -> @options = $.extend {}, DEFAULTS, options @@ -149,7 +154,7 @@ class app.Searcher return setup: -> - query = @query = @constructor.normalizeString(@query) + query = @query = @constructor.normalizeQuery(@query) queryLength = query.length @dataLength = @data.length @matchers = [exactMatch] @@ -166,7 +171,7 @@ class app.Searcher return isValid: -> - queryLength > 0 + queryLength > 0 and query isnt SEPARATOR end: -> @triggerResults [] unless @totalResults diff --git a/assets/javascripts/app/settings.coffee b/assets/javascripts/app/settings.coffee index 23c74a2a..5fd47e5c 100644 --- a/assets/javascripts/app/settings.coffee +++ b/assets/javascripts/app/settings.coffee @@ -1,101 +1,80 @@ class app.Settings - SETTINGS_KEY = 'settings' DOCS_KEY = 'docs' DARK_KEY = 'dark' LAYOUT_KEY = 'layout' SIZE_KEY = 'size' + TIPS_KEY = 'tips' @defaults: count: 0 hideDisabled: false hideIntro: false news: 0 - autoUpdate: true + manualUpdate: false schema: 1 - constructor: (@store) -> - @create() unless @settings = @store.get(SETTINGS_KEY) + constructor: -> + @store = new CookieStore + @cache = {} - create: -> - @settings = $.extend({}, @constructor.defaults) - @applyLegacyValues @settings - @save() - return - - applyLegacyValues: (settings) -> - for key, v of settings when (value = @store.get(key))? - settings[key] = value - @store.del(key) - return - - save: -> - @store.set SETTINGS_KEY, @settings + get: (key) -> + return @cache[key] if @cache.hasOwnProperty(key) + @cache[key] = @store.get(key) ? @constructor.defaults[key] set: (key, value) -> - @settings[key] = value - @save() + @store.set(key, value) + delete @cache[key] + return - get: (key) -> - @settings[key] ? @constructor.defaults[key] + del: (key) -> + @store.del(key) + delete @cache[key] + return hasDocs: -> - try !!Cookies.get DOCS_KEY + try !!@store.get(DOCS_KEY) getDocs: -> - try - Cookies.get(DOCS_KEY)?.split('/') or app.config.default_docs - catch - app.config.default_docs + @store.get(DOCS_KEY)?.split('/') or app.config.default_docs setDocs: (docs) -> - try - Cookies.set DOCS_KEY, docs.join('/'), path: '/', expires: 1e8 - catch + @set DOCS_KEY, docs.join('/') return - setDark: (value) -> - try - if value - Cookies.set DARK_KEY, '1', path: '/', expires: 1e8 - else - Cookies.expire DARK_KEY - catch + getTips: -> + @store.get(TIPS_KEY)?.split('/') or [] + + setTips: (tips) -> + @set TIPS_KEY, tips.join('/') return setLayout: (name, enable) -> - try - layout = (Cookies.get(LAYOUT_KEY) || '').split(' ') - $.arrayDelete(layout, '') - - if enable - layout.push(name) if layout.indexOf(name) is -1 - else - $.arrayDelete(layout, name) - - if layout.length > 0 - Cookies.set LAYOUT_KEY, layout.join(' '), path: '/', expires: 1e8 - else - Cookies.expire LAYOUT_KEY - catch + layout = (@store.get(LAYOUT_KEY) || '').split(' ') + $.arrayDelete(layout, '') + + if enable + layout.push(name) if layout.indexOf(name) is -1 + else + $.arrayDelete(layout, name) + + if layout.length > 0 + @set LAYOUT_KEY, layout.join(' ') + else + @del LAYOUT_KEY return hasLayout: (name) -> - try - layout = (Cookies.get(LAYOUT_KEY) || '').split(' ') - layout.indexOf(name) isnt -1 - catch - false + layout = (@store.get(LAYOUT_KEY) || '').split(' ') + layout.indexOf(name) isnt -1 setSize: (value) -> - try - Cookies.set SIZE_KEY, value, path: '/', expires: 1e8 - catch + @set SIZE_KEY, value return + dump: -> + @store.dump() + reset: -> - try Cookies.expire DOCS_KEY - try Cookies.expire DARK_KEY - try Cookies.expire LAYOUT_KEY - try Cookies.expire SIZE_KEY - try @store.del(SETTINGS_KEY) + @store.reset() + @cache = {} return diff --git a/assets/javascripts/app/shortcuts.coffee b/assets/javascripts/app/shortcuts.coffee index 12656620..5bc24806 100644 --- a/assets/javascripts/app/shortcuts.coffee +++ b/assets/javascripts/app/shortcuts.coffee @@ -2,7 +2,7 @@ class app.Shortcuts $.extend @prototype, Events constructor: -> - @isWindows = $.isWindows() + @isMac = $.isMac() @start() start: -> @@ -15,11 +15,15 @@ class app.Shortcuts $.off document, 'keypress', @onKeypress return + swapArrowKeysBehavior: -> + app.settings.get('arrowScroll') + showTip: -> app.showTip('KeyNav') @showTip = null onKeydown: (event) => + return if @buggyEvent(event) result = if event.ctrlKey or event.metaKey @handleKeydownSuperEvent event unless event.altKey or event.shiftKey else if event.shiftKey @@ -33,12 +37,15 @@ class app.Shortcuts return onKeypress: (event) => + return if @buggyEvent(event) unless event.ctrlKey or event.metaKey result = @handleKeypressEvent event event.preventDefault() if result is false return - handleKeydownEvent: (event) -> + handleKeydownEvent: (event, _force) -> + return @handleKeydownAltEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior() + if not event.target.form and (48 <= event.which <= 57 or 65 <= event.which <= 90) @trigger 'typing' return @@ -50,8 +57,9 @@ class app.Shortcuts @trigger 'enter' when 27 @trigger 'escape' + false when 32 - if not @lastKeypress or @lastKeypress < Date.now() - 500 + if event.target.type is 'search' and (not @lastKeypress or @lastKeypress < Date.now() - 500) @trigger 'pageDown' false when 33 @@ -59,9 +67,9 @@ class app.Shortcuts when 34 @trigger 'pageDown' when 35 - @trigger 'end' + @trigger 'pageBottom' unless event.target.form when 36 - @trigger 'home' + @trigger 'pageTop' unless event.target.form when 37 @trigger 'left' unless event.target.value when 38 @@ -74,27 +82,36 @@ class app.Shortcuts @trigger 'down' @showTip?() false + when 191 + unless event.target.form + @trigger 'typing' + false handleKeydownSuperEvent: (event) -> switch event.which when 13 @trigger 'superEnter' when 37 - unless @isWindows + if @isMac @trigger 'superLeft' false when 38 - @trigger 'home' + @trigger 'pageTop' false when 39 - unless @isWindows + if @isMac @trigger 'superRight' false when 40 - @trigger 'end' + @trigger 'pageBottom' + false + when 188 + @trigger 'preferences' false - handleKeydownShiftEvent: (event) -> + handleKeydownShiftEvent: (event, _force) -> + return @handleKeydownEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior() + if not event.target.form and 65 <= event.which <= 90 @trigger 'typing' return @@ -112,19 +129,21 @@ class app.Shortcuts @trigger 'altDown' false - handleKeydownAltEvent: (event) -> + handleKeydownAltEvent: (event, _force) -> + return @handleKeydownEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior() + switch event.which when 9 @trigger 'altRight', event when 37 - if @isWindows + unless @isMac @trigger 'superLeft' false when 38 @trigger 'altUp' false when 39 - if @isWindows + unless @isMac @trigger 'superRight' false when 40 @@ -135,6 +154,9 @@ class app.Shortcuts when 71 @trigger 'altG' false + when 79 + @trigger 'altO' + false when 82 @trigger 'altR' false @@ -148,3 +170,12 @@ class app.Shortcuts false else @lastKeypress = Date.now() + + buggyEvent: (event) -> + try + event.target + event.ctrlKey + event.which + return false + catch + return true diff --git a/assets/javascripts/app/update_checker.coffee b/assets/javascripts/app/update_checker.coffee index e7af516e..5630b488 100644 --- a/assets/javascripts/app/update_checker.coffee +++ b/assets/javascripts/app/update_checker.coffee @@ -2,10 +2,10 @@ class app.UpdateChecker constructor: -> @lastCheck = Date.now() - $.on window, 'focus', @checkForUpdate + $.on window, 'focus', @onFocus app.appCache.on 'updateready', @onUpdateReady if app.appCache - @checkDocs() + setTimeout @checkDocs, 0 check: -> if app.appCache @@ -21,8 +21,8 @@ class app.UpdateChecker new app.views.Notif 'UpdateReady', autoHide: null return - checkDocs: -> - if app.settings.get('autoUpdate') + checkDocs: => + unless app.settings.get('manualUpdate') app.docs.updateInBackground() else app.docs.checkForUpdates (i) => @onDocsUpdateReady() if i > 0 diff --git a/assets/javascripts/application.js.coffee b/assets/javascripts/application.js.coffee index ae85f852..6bf87f1c 100644 --- a/assets/javascripts/application.js.coffee +++ b/assets/javascripts/application.js.coffee @@ -18,6 +18,8 @@ #= require_tree ./templates +#= require tracking + init = -> document.removeEventListener 'DOMContentLoaded', init, false diff --git a/assets/javascripts/collections/collection.coffee b/assets/javascripts/collections/collection.coffee index a5628d8a..b902a498 100644 --- a/assets/javascripts/collections/collection.coffee +++ b/assets/javascripts/collections/collection.coffee @@ -48,3 +48,8 @@ class app.Collection findAllBy: (attr, value) -> model for model in @models when model[attr] is value + + countAllBy: (attr, value) -> + i = 0 + i += 1 for model in @models when model[attr] is value + i diff --git a/assets/javascripts/collections/docs.coffee b/assets/javascripts/collections/docs.coffee index c4b2e368..d76e0f07 100644 --- a/assets/javascripts/collections/docs.coffee +++ b/assets/javascripts/collections/docs.coffee @@ -4,11 +4,19 @@ class app.collections.Docs extends app.Collection findBySlug: (slug) -> @findBy('slug', slug) or @findBy('slug_without_version', slug) + NORMALIZE_VERSION_RGX = /\.(\d)$/ + NORMALIZE_VERSION_SUB = '.0$1' sort: -> @models.sort (a, b) -> - a = a.name.toLowerCase() - b = b.name.toLowerCase() - if a < b then -1 else if a > b then 1 else 0 + if a.name is b.name + if not a.version or a.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB) > b.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB) + -1 + else + 1 + else if a.name.toLowerCase() > b.name.toLowerCase() + 1 + else + -1 # Load models concurrently. # It's not pretty but I didn't want to import a promise library only for this. diff --git a/assets/javascripts/collections/types.coffee b/assets/javascripts/collections/types.coffee index 851ecaff..8e76eeab 100644 --- a/assets/javascripts/collections/types.coffee +++ b/assets/javascripts/collections/types.coffee @@ -7,10 +7,13 @@ class app.collections.Types extends app.Collection (result[@_groupFor(type)] ||= []).push(type) result.filter (e) -> e.length > 0 - GUIDES_RGX = /(^|[\s\(])(guide|guides|tutorial|reference|playbooks|getting\ started|manual)($|[\s\):])/i + GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i + APPENDIX_RGX = /appendix/i _groupFor: (type) -> if GUIDES_RGX.test(type.name) 0 + else if APPENDIX_RGX.test(type.name) + 2 else 1 diff --git a/assets/javascripts/debug.js.coffee b/assets/javascripts/debug.js.coffee index 255293f0..032d93ac 100644 --- a/assets/javascripts/debug.js.coffee +++ b/assets/javascripts/debug.js.coffee @@ -70,13 +70,16 @@ app.Searcher.prototype = _proto # View tree # -@viewTree = (view = app.document, level = 0) -> - console.log "%c #{Array(level + 1).join(' ')}#{view.constructor.name}: #{view.activated}", +@viewTree = (view = app.document, level = 0, visited = []) -> + return if visited.indexOf(view) >= 0 + visited.push(view) + + console.log "%c #{Array(level + 1).join(' ')}#{view.constructor.name}: #{!!view.activated}", 'color:' + (view.activated and 'green' or 'red') - for key, value of view when key isnt 'view' and value + for own key, value of view when key isnt 'view' and value if typeof value is 'object' and value.setupElement - @viewTree(value, level + 1) + @viewTree(value, level + 1, visited) else if value.constructor.toString().match(/Object\(\)/) - @viewTree(v, level + 1) for k, v of value when value and typeof value is 'object' and value.setupElement + @viewTree(v, level + 1, visited) for own k, v of value when v and typeof v is 'object' and v.setupElement return diff --git a/assets/javascripts/lib/ajax.coffee b/assets/javascripts/lib/ajax.coffee index 6433de0d..33ab2d9b 100644 --- a/assets/javascripts/lib/ajax.coffee +++ b/assets/javascripts/lib/ajax.coffee @@ -29,6 +29,7 @@ ajax.defaults = # data # error # headers + # progress # success # url @@ -54,6 +55,7 @@ applyCallbacks = (xhr, options) -> return unless options.async xhr.timer = setTimeout onTimeout.bind(undefined, xhr, options), options.timeout * 1000 + xhr.onprogress = options.progress if options.progress xhr.onreadystatechange = -> if xhr.readyState is 4 clearTimeout(xhr.timer) diff --git a/assets/javascripts/lib/cookie_store.coffee b/assets/javascripts/lib/cookie_store.coffee new file mode 100644 index 00000000..9cfb4099 --- /dev/null +++ b/assets/javascripts/lib/cookie_store.coffee @@ -0,0 +1,37 @@ +class @CookieStore + INT = /^\d+$/ + + @onBlocked: -> + + get: (key) -> + value = Cookies.get(key) + value = parseInt(value, 10) if value? and INT.test(value) + value + + set: (key, value) -> + if value == false + @del(key) + return + + value = 1 if value == true + Cookies.set(key, '' + value, path: '/', expires: 1e8) + @constructor.onBlocked(key, value, @get(key)) if @get(key) != value + return + + del: (key) -> + Cookies.expire(key) + return + + reset: -> + try + for cookie in document.cookie.split(/;\s?/) + Cookies.expire(cookie.split('=')[0]) + return + catch + + dump: -> + result = {} + for cookie in document.cookie.split(/;\s?/) when cookie[0] isnt '_' + cookie = cookie.split('=') + result[cookie[0]] = cookie[1] + result diff --git a/assets/javascripts/lib/events.coffee b/assets/javascripts/lib/events.coffee index feeb5498..05936076 100644 --- a/assets/javascripts/lib/events.coffee +++ b/assets/javascripts/lib/events.coffee @@ -15,8 +15,10 @@ @ trigger: (event, args...) -> + @eventInProgress = { name: event, args: args } if callbacks = @_callbacks?[event] callback? args... for callback in callbacks.slice(0) + @eventInProgress = null @trigger 'all', event, args... unless event is 'all' @ diff --git a/assets/javascripts/lib/license.coffee b/assets/javascripts/lib/license.coffee index 18061d0c..484d865c 100644 --- a/assets/javascripts/lib/license.coffee +++ b/assets/javascripts/lib/license.coffee @@ -1,5 +1,5 @@ ### - * Copyright 2013-2016 Thibaut Courouble and other contributors + * Copyright 2013-2017 Thibaut Courouble and other contributors * * This source code is licensed under the terms of the Mozilla * Public License, v. 2.0, a copy of which may be obtained at: diff --git a/assets/javascripts/lib/store.coffee b/assets/javascripts/lib/local_storage_store.coffee similarity index 89% rename from assets/javascripts/lib/store.coffee rename to assets/javascripts/lib/local_storage_store.coffee index c76d6f28..f4438c86 100644 --- a/assets/javascripts/lib/store.coffee +++ b/assets/javascripts/lib/local_storage_store.coffee @@ -1,4 +1,4 @@ -class @Store +class @LocalStorageStore get: (key) -> try JSON.parse localStorage.getItem(key) @@ -16,7 +16,7 @@ class @Store true catch - clear: -> + reset: -> try localStorage.clear() true diff --git a/assets/javascripts/lib/page.coffee b/assets/javascripts/lib/page.coffee index b311e51f..3f40be38 100644 --- a/assets/javascripts/lib/page.coffee +++ b/assets/javascripts/lib/page.coffee @@ -38,10 +38,15 @@ page.stop = -> page.show = (path, state) -> return if path is currentState?.path context = new Context(path, state) + previousState = currentState currentState = context.state - page.dispatch(context) - context.pushState() - track() + if res = page.dispatch(context) + currentState = previousState + location.assign(res) + else + context.pushState() + updateCanonicalLink() + track() context page.replace = (path, state, skipDispatch, init) -> @@ -50,16 +55,22 @@ page.replace = (path, state, skipDispatch, init) -> currentState = context.state page.dispatch(context) unless skipDispatch context.replaceState() - track() unless init or skipDispatch + updateCanonicalLink() + track() unless skipDispatch context page.dispatch = (context) -> i = 0 next = -> - fn(context, next) if fn = callbacks[i++] - return - next() - return + res = fn(context, next) if fn = callbacks[i++] + return res + return next() + +page.canGoBack = -> + not Context.isIntialState(currentState) + +page.canGoForward = -> + not Context.isLastState(currentState) currentPath = -> location.pathname + location.search + location.hash @@ -69,6 +80,12 @@ class Context @sessionId: Date.now() @stateId: 0 + @isIntialState: (state) -> + state.id == 0 + + @isLastState: (state) -> + state.id == @stateId - 1 + @isInitialPopState: (state) -> state.path is @initialPath and @stateId is 1 @@ -102,10 +119,9 @@ class Route (context, next) => if @match context.pathname, params = [] context.params = params - fn(context, next) + return fn(context, next) else - next() - return + return next() match: (path, params) -> return unless matchData = @regexp.exec(path) @@ -159,13 +175,24 @@ onclick = (event) -> if link and not link.target and isSameOrigin(link.href) event.preventDefault() - page.show link.pathname + link.search + link.hash + path = link.pathname + link.search + link.hash + path = path.replace /^\/\/+/, '/' # IE11 bug + page.show(path) return isSameOrigin = (url) -> url.indexOf("#{location.protocol}//#{location.hostname}") is 0 +updateCanonicalLink = -> + @canonicalLink ||= document.head.querySelector('link[rel="canonical"]') + @canonicalLink.setAttribute('href', "http://#{location.host}#{location.pathname}") + +trackers = [] + +page.track = (fn) -> + trackers.push(fn) + return + track = -> - ga?('send', 'pageview', location.pathname + location.search + location.hash) - _gauges?.push(['track']) + tracker.call() for tracker in trackers return diff --git a/assets/javascripts/lib/util.coffee b/assets/javascripts/lib/util.coffee index de7f7077..c2e8e7f1 100644 --- a/assets/javascripts/lib/util.coffee +++ b/assets/javascripts/lib/util.coffee @@ -167,27 +167,32 @@ $.scrollTo = (el, parent, position = 'center', options = {}) -> return unless parent parentHeight = parent.clientHeight - return unless parent.scrollHeight > parentHeight + parentScrollHeight = parent.scrollHeight + return unless parentScrollHeight > parentHeight top = $.offset(el, parent).top + offsetTop = parent.firstElementChild.offsetTop switch position when 'top' - parent.scrollTop = top - (if options.margin? then options.margin else 20) + parent.scrollTop = top - offsetTop - (if options.margin? then options.margin else 0) when 'center' parent.scrollTop = top - Math.round(parentHeight / 2 - el.offsetHeight / 2) when 'continuous' scrollTop = parent.scrollTop height = el.offsetHeight + lastElementOffset = parent.lastElementChild.offsetTop + parent.lastElementChild.offsetHeight + offsetBottom = if lastElementOffset > 0 then parentScrollHeight - lastElementOffset else 0 + # If the target element is above the visible portion of its scrollable # ancestor, move it near the top with a gap = options.topGap * target's height. - if top <= scrollTop + height * (options.topGap or 1) - parent.scrollTop = top - height * (options.topGap or 1) + if top - offsetTop <= scrollTop + height * (options.topGap or 1) + parent.scrollTop = top - offsetTop - height * (options.topGap or 1) # If the target element is below the visible portion of its scrollable # ancestor, move it near the bottom with a gap = options.bottomGap * target's height. - else if top >= scrollTop + parentHeight - height * ((options.bottomGap or 1) + 1) - parent.scrollTop = top - parentHeight + height * ((options.bottomGap or 1) + 1) + else if top + offsetBottom >= scrollTop + parentHeight - height * ((options.bottomGap or 1) + 1) + parent.scrollTop = top + offsetBottom - parentHeight + height * ((options.bottomGap or 1) + 1) return $.scrollToWithImageLock = (el, parent, args...) -> @@ -223,6 +228,36 @@ $.lockScroll = (el, fn) -> fn() return +smoothScroll = smoothStart = smoothEnd = smoothDistance = smoothDuration = null + +$.smoothScroll = (el, end) -> + unless window.requestAnimationFrame + el.scrollTop = end + return + + smoothEnd = end + + if smoothScroll + newDistance = smoothEnd - smoothStart + smoothDuration += Math.min 300, Math.abs(smoothDistance - newDistance) + smoothDistance = newDistance + return + + smoothStart = el.scrollTop + smoothDistance = smoothEnd - smoothStart + smoothDuration = Math.min 300, Math.abs(smoothDistance) + startTime = Date.now() + + smoothScroll = -> + p = Math.min 1, (Date.now() - startTime) / smoothDuration + y = Math.max 0, Math.floor(smoothStart + smoothDistance * (if p < 0.5 then 2 * p * p else p * (4 - p * 2) - 1)) + el.scrollTop = y + if p is 1 + smoothScroll = null + else + requestAnimationFrame(smoothScroll) + requestAnimationFrame(smoothScroll) + # # Utilities # @@ -278,6 +313,19 @@ $.classify = (string) -> string[i] = substr[0].toUpperCase() + substr[1..] string.join('') +$.framify = (fn, obj) -> + if window.requestAnimationFrame + (args...) -> requestAnimationFrame(fn.bind(obj, args...)) + else + fn + +$.requestAnimationFrame = (fn) -> + if window.requestAnimationFrame + requestAnimationFrame(fn) + else + setTimeout(fn, 0) + return + # # Miscellaneous # @@ -285,17 +333,38 @@ $.classify = (string) -> $.noop = -> $.popup = (value) -> - open value.href or value, '_blank' + try + win = window.open() + win.opener = null if win.opener + win.location = value.href or value + catch + window.open value.href or value, '_blank' return -$.isTouchScreen = -> - typeof ontouchstart isnt 'undefined' - -$.isWindows = -> - navigator.platform?.indexOf('Win') >= 0 - +isMac = null $.isMac = -> - navigator.userAgent?.indexOf('Mac') >= 0 + isMac ?= navigator.userAgent?.indexOf('Mac') >= 0 + +isIE = null +$.isIE = -> + isIE ?= navigator.userAgent?.indexOf('MSIE') >= 0 || navigator.userAgent?.indexOf('rv:11.0') >= 0 + +isAndroid = null +$.isAndroid = -> + isAndroid ?= navigator.userAgent?.indexOf('Android') >= 0 + +isIOS = null +$.isIOS = -> + isIOS ?= navigator.userAgent?.indexOf('iPhone') >= 0 || navigator.userAgent?.indexOf('iPad') >= 0 + +$.overlayScrollbarsEnabled = -> + return false unless $.isMac() + div = document.createElement('div') + div.setAttribute('style', 'width: 100px; height: 100px; overflow: scroll; position: absolute') + document.body.appendChild(div) + result = div.offsetWidth is div.clientWidth + document.body.removeChild(div) + result HIGHLIGHT_DEFAULTS = className: 'highlight' diff --git a/assets/javascripts/models/doc.coffee b/assets/javascripts/models/doc.coffee index 5a7961ad..2d5f7e4a 100644 --- a/assets/javascripts/models/doc.coffee +++ b/assets/javascripts/models/doc.coffee @@ -7,6 +7,7 @@ class app.models.Doc extends app.Model @slug_without_version = @slug.split('~')[0] @fullName = "#{@name}" + if @version then " #{@version}" else '' @icon = @slug_without_version + @short_version = @version.split(' ')[0] if @version @text = @toEntry().text reset: (data) -> @@ -29,19 +30,22 @@ class app.models.Doc extends app.Model "/#{@slug}#{path}" fileUrl: (path) -> - "#{app.config.docs_host}#{@fullPath(path)}?#{@mtime}" + "#{app.config.docs_origin}#{@fullPath(path)}?#{@mtime}" dbUrl: -> - "#{app.config.docs_host}/#{@slug}/#{app.config.db_filename}?#{@mtime}" + "#{app.config.docs_origin}/#{@slug}/#{app.config.db_filename}?#{@mtime}" indexUrl: -> "#{app.indexHost()}/#{@slug}/#{app.config.index_filename}?#{@mtime}" toEntry: -> - @entry ||= new app.models.Entry + return @entry if @entry + @entry = new app.models.Entry doc: @ name: @fullName path: 'index' + @entry.addAlias(@name) if @version + @entry findEntryByPathAndHash: (path, hash) -> if hash and entry = @entries.findBy 'path', "#{path}##{hash}" @@ -66,7 +70,7 @@ class app.models.Doc extends app.Model error: onError clearCache: -> - app.store.del @slug + app.localStorage.del @slug return _loadFromCache: (onSuccess) -> @@ -81,7 +85,7 @@ class app.models.Doc extends app.Model true _getCache: -> - return unless data = app.store.get @slug + return unless data = app.localStorage.get @slug if data[0] is @mtime return data[1] @@ -90,10 +94,10 @@ class app.models.Doc extends app.Model return _setCache: (data) -> - app.store.set @slug, [@mtime, data] + app.localStorage.set @slug, [@mtime, data] return - install: (onSuccess, onError) -> + install: (onSuccess, onError, onProgress) -> return if @installing @installing = true @@ -111,6 +115,7 @@ class app.models.Doc extends app.Model url: @dbUrl() success: success error: error + progress: onProgress timeout: 3600 return diff --git a/assets/javascripts/models/entry.coffee b/assets/javascripts/models/entry.coffee index 9d4d40e7..5ab41695 100644 --- a/assets/javascripts/models/entry.coffee +++ b/assets/javascripts/models/entry.coffee @@ -5,8 +5,13 @@ class app.models.Entry extends app.Model constructor: -> super - @text = app.Searcher.normalizeString(@name) - @text = applyAliases(@text) + @text = applyAliases(app.Searcher.normalizeString(@name)) + + addAlias: (name) -> + text = applyAliases(app.Searcher.normalizeString(name)) + @text = [@text] unless Array.isArray(@text) + @text.push(if Array.isArray(text) then text[1] else text) + return fullPath: -> @doc.fullPath if @isIndex() then '' else @path @@ -45,8 +50,9 @@ class app.models.Entry extends app.Model return string @ALIASES = ALIASES = + 'angular': 'ng' 'angular.js': 'ng' - 'backbone': 'bb' + 'backbone.js': 'bb' 'c++': 'cpp' 'coffeescript': 'cs' 'elixir': 'ex' @@ -59,10 +65,16 @@ class app.models.Entry extends app.Model 'markdown': 'md' 'modernizr': 'mdr' 'moment.js': 'mt' + 'openjdk': 'java' 'nginx': 'ngx' + 'numpy': 'np' + 'pandas': 'pd' 'postgresql': 'pg' 'python': 'py' 'ruby.on.rails': 'ror' 'ruby': 'rb' + 'rust': 'rs' 'sass': 'scss' + 'tensorflow': 'tf' + 'typescript': 'ts' 'underscore.js': '_' diff --git a/assets/javascripts/news.json b/assets/javascripts/news.json index cd2eeb78..d18ec141 100644 --- a/assets/javascripts/news.json +++ b/assets/javascripts/news.json @@ -1,5 +1,60 @@ [ [ + "2017-07-23", + "New documentation: Godot" + ], [ + "2017-06-04", + "New documentations: Electron, Pug, and Falcon" + ], [ + "2017-05-14", + "New documentations: Jest, Jasmine and Liquid" + ], [ + "2017-04-30", + "New documentation: OpenJDK" + ], [ + "2017-02-26", + "Refreshed design.", + "Added Preferences." + ], [ + "2017-01-22", + "New HTTP documentation (thanks Mozilla)" + ], [ + "2016-12-04", + "New documentations: SQLite, Codeception and CodeceptJS" + ], [ + "2016-11-20", + "New documentations: Yarn, Immutable.js and Async" + ], [ + "2016-10-10", + "New documentations: scikit-learn and Statsmodels" + ], [ + "2016-09-18", + "New documentations: pandas and Twig" + ], [ + "2016-09-05", + "New documentations: Fish, Bottle and scikit-image" + ], [ + "2016-08-07", + "New documentation: Docker" + ], [ + "2016-07-31", + "New documentations: Bootstrap 3 and Bootstrap 4" + ], [ + "2016-07-24", + "New documentations: Julia, Crystal and Redux" + ], [ + "2016-07-03", + "New documentations: CMake and Matplotlib" + ], [ + "2016-06-19", + "New documentation: LÖVE" + ], [ + "2016-06-12", + "New documentation: Angular 2" + ], [ + "2016-06-05", + "New documentations: Kotlin and Padrino" + ], [ "2016-04-24", "New documentations: NumPy and Apache Pig" ], [ @@ -25,7 +80,7 @@ "New documentations: Erlang and Tcl/Tk" ], [ "2016-01-24", - "“Multi-version support” has landed!\nClick Select documentation to pick which versions to use. More versions will be added in the coming weeks.\nIf you notice any bugs, please report them on GitHub." + "“Multi-version support” has landed!" ], [ "2015-11-22", "New documentations: Phoenix, Dojo, Relay and Flow" @@ -49,11 +104,8 @@ "New documentations: Q and OpenTSDB" ], [ "2015-07-26", - "Added search abbreviations (e.g. $ is an alias for jQuery).\nClick here to see the full list. Feel free to suggest more on GitHub.", + "Added search aliases (e.g. $ is an alias for jQuery).\nClick here to see the full list. Feel free to suggest more on GitHub.", "Added shift + ↓/↑ shortcut for scrolling (same as alt + ↓/↑)." - ], [ - "2015-07-12", - "New sponsors: JetBrains and Code School\nIf you like DevDocs, please take a moment to check out their products — they're awesome!" ], [ "2015-07-05", "New documentations: Drupal, Vue.js, Phaser and webpack" @@ -78,10 +130,10 @@ "New io.js, Symfony, Clojure, Lua and Yii 1.1 documentations" ], [ "2015-02-08", - "New dark theme\nClick the icon in the bottom left corner to activate.\nFeedback welcome :)" + "New dark theme" ], [ "2015-01-13", - "Offline mode has landed!\nIf you notice any bugs, please report them on GitHub." + "Offline mode has landed!" ], [ "2014-12-21", "New React, RethinkDB, Socket.IO, Modernizr and Bower documentations" @@ -93,7 +145,7 @@ "New Python 2 documentation" ], [ "2014-11-09", - "New design\nFeedback welcome on Twitter and GitHub." + "New design\nFeedback welcome on Twitter and GitHub." ], [ "2014-10-19", "New SVG, Marionette.js, and Mongoose documentations" @@ -151,9 +203,6 @@ ], [ "2014-02-12", "The root/category pages are now included in the search index (e.g. CSS)" - ], [ - "2014-01-26", - "Updated Angular.js documentation" ], [ "2014-01-19", "New D3.js and Knockout.js documentations" @@ -207,7 +256,7 @@ "URL search now automatically opens the first result." ], [ "2013-08-13", - "New Angular.js documentation" + "New Angular.js documentation" ], [ "2013-08-11", "New Sass and Less documentations" diff --git a/assets/javascripts/templates/error_tmpl.coffee b/assets/javascripts/templates/error_tmpl.coffee index e0f15847..9dc311d9 100644 --- a/assets/javascripts/templates/error_tmpl.coffee +++ b/assets/javascripts/templates/error_tmpl.coffee @@ -3,7 +3,7 @@ error = (title, text = '', links = '') -> links = """""" if links """

#{title}

#{text}#{links}
""" -back = 'Go back' +back = 'Go back' app.templates.notFoundPage = -> error """ Page not found. """, @@ -19,22 +19,37 @@ app.templates.pageLoadError = -> app.templates.bootError = -> error """ The app failed to load. """, - """ Check your Internet connection and try reloading.
+ """ Check your Internet connection and try reloading.
If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """ -app.templates.offlineError = (reason) -> +app.templates.offlineError = (reason, exception) -> + if reason is 'cookie_blocked' + return error """ Cookies must be enabled to use offline mode. """ + reason = switch reason when 'not_supported' - """ Unfortunately your browser either doesn't support it or does not make it available. """ + """ DevDocs requires IndexedDB to cache documentations for offline access.
+ Unfortunately your browser either doesn't support IndexedDB or doesn't make it available. """ + when 'buggy' + """ DevDocs requires IndexedDB to cache documentations for offline access.
+ Unfortunately your browser's implementation of IndexedDB contains bugs that prevent DevDocs from using it. """ + when 'private_mode' + """ Your browser appears to be running in private mode.
+ This prevents DevDocs from caching documentations for offline access.""" + when 'exception' + """ An error occured when trying to open the IndexedDB database:
+ #{exception.name}: #{exception.message} """ when 'cant_open' - """ Although your browser appears to support it, DevDocs couldn't open the database.
- This could be because you're browsing in private mode and have disallowed offline storage on the domain. """ - when 'apple' - """ Unfortunately Safari's implementation of IndexedDB is badly broken.
- This message will automatically go away when Apple fix their code. """ - - error """ Offline mode is unavailable. """, - """ DevDocs requires IndexedDB to cache documentations for offline access.
#{reason} """ + """ An error occured when trying to open the IndexedDB database:
+ #{exception.name}: #{exception.message}
+ This could be because you're browsing in private mode or have disallowed offline storage on the domain. """ + when 'version' + """ The IndexedDB database was modified with a newer version of the app.
+ Reload the page to use offline mode. """ + when 'empty' + """ The IndexedDB database appears to be corrupted. Try resetting the app. """ + + error 'Offline mode is unavailable.', reason app.templates.unsupportedBrowser = """
diff --git a/assets/javascripts/templates/notice_tmpl.coffee b/assets/javascripts/templates/notice_tmpl.coffee index 3441a0f6..75818967 100644 --- a/assets/javascripts/templates/notice_tmpl.coffee +++ b/assets/javascripts/templates/notice_tmpl.coffee @@ -1,9 +1,9 @@ notice = (text) -> """

#{text}

""" app.templates.singleDocNotice = (doc) -> - notice """ You're currently browsing the #{doc.fullName} documentation. To browse all docs, go to + notice """ You're browsing the #{doc.fullName} documentation. To browse all docs, go to #{app.config.production_host} (or press esc). """ app.templates.disabledDocNotice = -> - notice """ This documentation is currently disabled. - To enable it, click Select documentation. """ + notice """ This documentation is disabled. + To enable it, go to Preferences. """ diff --git a/assets/javascripts/templates/notif_tmpl.coffee b/assets/javascripts/templates/notif_tmpl.coffee index c307100b..45882e87 100644 --- a/assets/javascripts/templates/notif_tmpl.coffee +++ b/assets/javascripts/templates/notif_tmpl.coffee @@ -1,24 +1,28 @@ notif = (title, html) -> html = html.replace /#{title}#{html}""" + """
#{title}
#{html}
' notif 'Updates', "#{html}" app.templates.notifShare = -> textNotif """ Hi there! """, - """ Like DevDocs? Help us reach more developers by sharing the link with your friends, on - Twitter, Facebook, - Reddit, etc.
Thanks :) """ + """ Like DevDocs? Help us reach more developers by sharing the link with your friends on + Twitter, Facebook, + Reddit, etc.
Thanks :) """ app.templates.notifUpdateDocs = -> textNotif """ Documentation updates available. """, diff --git a/assets/javascripts/templates/pages/about_tmpl.coffee b/assets/javascripts/templates/pages/about_tmpl.coffee index 3bbe87a9..ed092c5d 100644 --- a/assets/javascripts/templates/pages/about_tmpl.coffee +++ b/assets/javascripts/templates/pages/about_tmpl.coffee @@ -1,21 +1,21 @@ app.templates.aboutPage = -> """ -
+
+ -

API Documentation Browser

+

DevDocs: API Documentation Browser

DevDocs combines multiple API documentations in a fast, organized, and searchable interface.

To keep up-to-date with the latest news: