History

Merge pull request #1022 from jmerle/service-worker

Replace the App Cache with a Service Worker

Jasper van Merle committed 7 years ago

Commit 6138f052 parents 22d78b5 876d9a428 files · +217 −231Browse files

Unified · Split

28 files
  1. .gitignore +1 −0
  2. Dockerfile +1 −0
  3. Dockerfile-alpine +1 −0
  4. README.md +5 −5
  5. assets/javascripts/app/app.coffee +8 −6
  6. assets/javascripts/app/appcache.coffee +0 −42
  7. assets/javascripts/app/config.coffee.erb +2 −0
  8. assets/javascripts/app/serviceworker.coffee +49 −0
  9. assets/javascripts/app/settings.coffee +30 −0
  10. assets/javascripts/app/update_checker.coffee +3 −3
  11. assets/javascripts/templates/error_tmpl.coffee +5 −5
  12. assets/javascripts/templates/pages/offline_tmpl.coffee +9 −4
  13. assets/javascripts/templates/pages/root_tmpl.coffee.erb +1 −1
  14. assets/javascripts/views/content/entry_page.coffee +1 −1
  15. assets/javascripts/views/content/settings_page.coffee +1 −11
  16. assets/javascripts/views/layout/resizer.coffee +2 −9
  17. assets/javascripts/views/layout/settings.coffee +1 −9
  18. docs/maintainers.md +1 −1
  19. lib/app.rb +32 −20
  20. public/images/webapp-icon-192.png +0 −0
  21. public/manifest.json +6 −0
  22. test/app_test.rb +0 −81
  23. views/app.erb +1 −13
  24. views/index.erb +3 −2
  25. views/manifest.erb +0 −14
  26. views/other.erb +3 −2
  27. views/service-worker.js.erb +49 −0
  28. views/unsupported.erb +3 −3
.gitignore+1−0
@@ -1,5 +1,6 @@
1.DS_Store1.DS_Store
2.bundle2.bundle
3log
3tmp4tmp
4public/assets5public/assets
5public/fonts6public/fonts
Dockerfile+1−0
@@ -1,6 +1,7 @@
1FROM ruby:2.6.01FROM ruby:2.6.0
2 2
3ENV LANG=C.UTF-83ENV LANG=C.UTF-8
4ENV ENABLE_SERVICE_WORKER=true
4 5
5WORKDIR /devdocs6WORKDIR /devdocs
6 7
Dockerfile-alpine+1−0
@@ -1,6 +1,7 @@
1FROM ruby:2.6.0-alpine1FROM ruby:2.6.0-alpine
2 2
3ENV LANG=C.UTF-83ENV LANG=C.UTF-8
4ENV ENABLE_SERVICE_WORKER=true
4 5
5WORKDIR /devdocs6WORKDIR /devdocs
6 7
README.md+5−5
@@ -59,14 +59,14 @@ The web app is all client-side JavaScript, written in [CoffeeScript](http://coff · 58 unmodified lines
59 59
60Many of the code's design decisions were driven by the fact that the app uses XHR to load content directly into the main frame. This includes stripping the original documents of most of their HTML markup (e.g. scripts and stylesheets) to avoid polluting the main frame, and prefixing all CSS class names with an underscore to prevent conflicts.60Many of the code's design decisions were driven by the fact that the app uses XHR to load content directly into the main frame. This includes stripping the original documents of most of their HTML markup (e.g. scripts and stylesheets) to avoid polluting the main frame, and prefixing all CSS class names with an underscore to prevent conflicts.
61 61
62Another driving factor is performance and the fact that everything happens in the browser. `applicationCache` (which comes with its own set of constraints) and `localStorage` are used to speed up the boot time, while memory consumption is kept in check by allowing the user to pick his/her own set of documentations. The search algorithm is kept simple because it needs to be fast even searching through 100,000 strings.62Another driving factor is performance and the fact that everything happens in the browser. A service worker (which comes with its own set of constraints) and `localStorage` are used to speed up the boot time, while memory consumption is kept in check by allowing the user to pick his/her own set of documentations. The search algorithm is kept simple because it needs to be fast even searching through 100,000 strings.
63 63
64DevDocs being a developer tool, the browser requirements are high:64DevDocs being a developer tool, the browser requirements are high:
65 65
66* Recent versions of Firefox, Chrome, or Opera66* Recent versions of Firefox, Chrome, or Opera
67* Safari 9.1+67* Safari 11.1+
68* Edge 16+68* Edge 17+
69* iOS 10+69* iOS 11.3+
70 70
71This allows the code to take advantage of the latest DOM and HTML5 APIs and make developing DevDocs a lot more fun!71This allows the code to take advantage of the latest DOM and HTML5 APIs and make developing DevDocs a lot more fun!
72 72
@@ -156,7 +156,7 @@ Contributions are welcome. Please read the [contributing guidelines](./.github/C · 83 unmodified lines
156* [Doc Browser](https://github.com/qwfy/doc-browser) is a native Linux app that supports DevDocs docsets156* [Doc Browser](https://github.com/qwfy/doc-browser) is a native Linux app that supports DevDocs docsets
157* [GNOME Application](https://github.com/hardpixel/devdocs-desktop) GTK3 application with search integrated in headerbar157* [GNOME Application](https://github.com/hardpixel/devdocs-desktop) GTK3 application with search integrated in headerbar
158* [macOS Application](https://github.com/dteoh/devdocs-macos)158* [macOS Application](https://github.com/dteoh/devdocs-macos)
159* [Android Application](https://github.com/Merith-TK/devdocs_webapp_kotlin) is a fully working, advanced WebView with AppCache enabled159* [Android Application](https://github.com/Merith-TK/devdocs_webapp_kotlin) is a fully working, advanced WebView
160 160
161## Copyright / License161## Copyright / License
162 162
assets/javascripts/app/app.coffee+8−6
@@ -13,10 +13,12 @@ · 12 unmodified lines
13 13
14 @el = $('._app')14 @el = $('._app')
15 @localStorage = new LocalStorageStore15 @localStorage = new LocalStorageStore
16 @appCache = new app.AppCache if app.AppCache.isEnabled()16 @serviceWorker = new app.ServiceWorker if app.ServiceWorker.isEnabled()
17 @settings = new app.Settings17 @settings = new app.Settings
18 @db = new app.DB()18 @db = new app.DB()
19 19
20 @settings.initLayout()
21
20 @docs = new app.collections.Docs22 @docs = new app.collections.Docs
21 @disabledDocs = new app.collections.Docs23 @disabledDocs = new app.collections.Docs
22 @entries = new app.collections.Entries24 @entries = new app.collections.Entries
@@ -147,7 +149,7 @@ · 124 unmodified lines
147 saveDocs: ->149 saveDocs: ->
148 @settings.setDocs(doc.slug for doc in @docs.all())150 @settings.setDocs(doc.slug for doc in @docs.all())
149 @db.migrate()151 @db.migrate()
150 @appCache?.updateInBackground()152 @serviceWorker?.updateInBackground()
151 153
152 welcomeBack: ->154 welcomeBack: ->
153 visitCount = @settings.get('count')155 visitCount = @settings.get('count')
@@ -167,14 +169,14 @@ · 13 unmodified lines
167 reload: ->169 reload: ->
168 @docs.clearCache()170 @docs.clearCache()
169 @disabledDocs.clearCache()171 @disabledDocs.clearCache()
170 if @appCache then @appCache.reload() else @reboot()172 if @serviceWorker then @serviceWorker.reload() else @reboot()
171 return173 return
172 174
173 reset: ->175 reset: ->
174 @localStorage.reset()176 @localStorage.reset()
175 @settings.reset()177 @settings.reset()
176 @db?.reset()178 @db?.reset()
177 @appCache?.update()179 @serviceWorker?.update()
178 window.location = '/'180 window.location = '/'
179 return181 return
180 182
@@ -193,9 +195,9 @@ · 12 unmodified lines
193 return195 return
194 196
195 indexHost: ->197 indexHost: ->
196 # Can't load the index files from the host/CDN when applicationCache is198 # Can't load the index files from the host/CDN when service worker is
197 # enabled because it doesn't support caching URLs that use CORS.199 # enabled because it doesn't support caching URLs that use CORS.
198 @config[if @appCache and @settings.hasDocs() then 'index_path' else 'docs_origin']200 @config[if @serviceWorker and @settings.hasDocs() then 'index_path' else 'docs_origin']
199 201
200 onBootError: (args...) ->202 onBootError: (args...) ->
201 @trigger 'bootError'203 @trigger 'bootError'
assets/javascripts/app/appcache.coffee+0−42
@@ -1,42 +0,0 @@
1class app.AppCache
2 $.extend @prototype, Events
3
4 @isEnabled: ->
5 try
6 applicationCache and applicationCache.status isnt applicationCache.UNCACHED
7 catch
8
9 constructor: ->
10 @cache = applicationCache
11 @notifyUpdate = true
12 @onUpdateReady() if @cache.status is @cache.UPDATEREADY
13
14 $.on @cache, 'progress', @onProgress
15 $.on @cache, 'updateready', @onUpdateReady
16
17 update: ->
18 @notifyUpdate = true
19 @notifyProgress = true
20 try @cache.update() catch
21 return
22
23 updateInBackground: ->
24 @notifyUpdate = false
25 @notifyProgress = false
26 try @cache.update() catch
27 return
28
29 reload: ->
30 $.on @cache, 'updateready noupdate error', -> app.reboot()
31 @notifyUpdate = false
32 @notifyProgress = true
33 try @cache.update() catch
34 return
35
36 onProgress: (event) =>
37 @trigger 'progress', event if @notifyProgress
38 return
39
40 onUpdateReady: =>
41 @trigger 'updateready' if @notifyUpdate
42 return
assets/javascripts/app/config.coffee.erb+2−0
@@ -13,3 +13,5 @@ app.config = · 12 unmodified lines
13 version: <%= Time.now.to_i %>13 version: <%= Time.now.to_i %>
14 release: <%= Time.now.utc.httpdate.to_json %>14 release: <%= Time.now.utc.httpdate.to_json %>
15 mathml_stylesheet: '<%= App.cdn_origin %>/mathml.css'15 mathml_stylesheet: '<%= App.cdn_origin %>/mathml.css'
16 service_worker_path: '/service-worker.js'
17 service_worker_enabled: <%= App.environment == 'production' || ENV['ENABLE_SERVICE_WORKER'] == 'true' %>
assets/javascripts/app/serviceworker.coffee+49−0
@@ -0,0 +1,49 @@
1class app.ServiceWorker
2 $.extend @prototype, Events
3
4 @isEnabled: ->
5 !!navigator.serviceWorker and app.config.service_worker_enabled
6
7 constructor: ->
8 @registration = null
9 @notifyUpdate = true
10
11 navigator.serviceWorker.register(app.config.service_worker_path, {scope: '/'})
12 .then(
13 (registration) => @updateRegistration(registration),
14 (error) -> console.error('Could not register service worker:', error)
15 )
16
17 update: ->
18 return unless @registration
19 @notifyUpdate = true
20 return @registration.update().catch(->)
21
22 updateInBackground: ->
23 return unless @registration
24 @notifyUpdate = false
25 return @registration.update().catch(->)
26
27 reload: ->
28 return @updateInBackground().then(() -> app.reboot())
29
30 updateRegistration: (registration) ->
31 @registration = registration
32 $.on @registration, 'updatefound', @onUpdateFound
33 return
34
35 onUpdateFound: =>
36 $.off @installingRegistration, 'statechange', @onStateChange() if @installingRegistration
37 @installingRegistration = @registration.installing
38 $.on @installingRegistration, 'statechange', @onStateChange
39 return
40
41 onStateChange: =>
42 if @installingRegistration and @installingRegistration.state == 'installed' and navigator.serviceWorker.controller
43 @installingRegistration = null
44 @onUpdateReady()
45 return
46
47 onUpdateReady: ->
48 @trigger 'updateready' if @notifyUpdate
49 return
assets/javascripts/app/settings.coffee+30−0
@@ -19,6 +19,8 @@ class app.Settings · 18 unmodified lines
19 'news'19 'news'
20 ]20 ]
21 21
22 LAYOUTS: ['_max-width', '_sidebar-hidden', '_native-scrollbars']
23
22 @defaults:24 @defaults:
23 count: 025 count: 0
24 hideDisabled: false26 hideDisabled: false
@@ -38,6 +40,7 @@ class app.Settings · 13 unmodified lines
38 set: (key, value) ->40 set: (key, value) ->
39 @store.set(key, value)41 @store.set(key, value)
40 delete @cache[key]42 delete @cache[key]
43 @toggleDark(value) if key == 'dark'
41 return44 return
42 45
43 del: (key) ->46 del: (key) ->
@@ -63,6 +66,8 @@ class app.Settings · 19 unmodified lines
63 return66 return
64 67
65 setLayout: (name, enable) ->68 setLayout: (name, enable) ->
69 @toggleLayout(name, enable)
70
66 layout = (@store.get('layout') || '').split(' ')71 layout = (@store.get('layout') || '').split(' ')
67 $.arrayDelete(layout, '')72 $.arrayDelete(layout, '')
68 73
@@ -104,3 +109,28 @@ class app.Settings · 35 unmodified lines
104 @store.reset()109 @store.reset()
105 @cache = {}110 @cache = {}
106 return111 return
112
113 initLayout: ->
114 @toggleDark(@get('dark') is 1)
115 @toggleLayout(layout, @hasLayout(layout)) for layout in @LAYOUTS
116 @initSidebarWidth()
117 return
118
119 toggleDark: (enable) ->
120 classList = document.documentElement.classList
121 classList.toggle('_theme-default', !enable)
122 classList.toggle('_theme-dark', enable)
123 color = getComputedStyle(document.documentElement).getPropertyValue('--headerBackground').trim()
124 $('meta[name=theme-color]').setAttribute('content', color)
125 return
126
127 toggleLayout: (layout, enable) ->
128 classList = document.body.classList
129 classList.toggle(layout, enable) unless layout is '_sidebar-hidden'
130 classList.toggle('_overlay-scrollbars', $.overlayScrollbarsEnabled())
131 return
132
133 initSidebarWidth: ->
134 size = @get('size')
135 document.documentElement.style.setProperty('--sidebarWidth', size + 'px') if size
136 return
assets/javascripts/app/update_checker.coffee+3−3
@@ -3,13 +3,13 @@ class app.UpdateChecker · 2 unmodified lines
3 @lastCheck = Date.now()3 @lastCheck = Date.now()
4 4
5 $.on window, 'focus', @onFocus5 $.on window, 'focus', @onFocus
6 app.appCache.on 'updateready', @onUpdateReady if app.appCache6 app.serviceWorker?.on 'updateready', @onUpdateReady
7 7
8 setTimeout @checkDocs, 08 setTimeout @checkDocs, 0
9 9
10 check: ->10 check: ->
11 if app.appCache11 if app.serviceWorker
12 app.appCache.update()12 app.serviceWorker.update()
13 else13 else
14 ajax14 ajax
15 url: $('script[src*="application"]').getAttribute('src')15 url: $('script[src*="application"]').getAttribute('src')
assets/javascripts/templates/error_tmpl.coffee+5−5
@@ -12,8 +12,8 @@ app.templates.notFoundPage = -> · 11 unmodified lines
12 12
13app.templates.pageLoadError = ->13app.templates.pageLoadError = ->
14 error """ The page failed to load. """,14 error """ The page failed to load. """,
15 """ It may be missing from the server (try reloading the app) or you could be offline.<br>15 """ It may be missing from the server (try reloading the app) or you could be offline (try <a href="/offline">installing the documentation for offline usage</a> when online again).<br>
16 If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """,16 If you're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """,
17 """ #{back} &middot; <a href="/##{location.pathname}" target="_top" class="_error-link">Reload</a>17 """ #{back} &middot; <a href="/##{location.pathname}" target="_top" class="_error-link">Reload</a>
18 &middot; <a href="#" class="_error-link" data-retry>Retry</a> """18 &middot; <a href="#" class="_error-link" data-retry>Retry</a> """
19 19
@@ -57,9 +57,9 @@ app.templates.unsupportedBrowser = """ · 37 unmodified lines
57 <p class="_fail-text">DevDocs is an API documentation browser which supports the following browsers:57 <p class="_fail-text">DevDocs is an API documentation browser which supports the following browsers:
58 <ul class="_fail-list">58 <ul class="_fail-list">
59 <li>Recent versions of Firefox, Chrome, or Opera59 <li>Recent versions of Firefox, Chrome, or Opera
60 <li>Safari 9.1+60 <li>Safari 11.1+
61 <li>Edge 16+61 <li>Edge 17+
62 <li>iOS 10+62 <li>iOS 11.3+
63 </ul>63 </ul>
64 <p class="_fail-text">64 <p class="_fail-text">
65 If you're unable to upgrade, we apologize.65 If you're unable to upgrade, we apologize.
assets/javascripts/templates/pages/offline_tmpl.coffee+9−4
@@ -25,8 +25,8 @@ app.templates.offlinePage = (docs) -> """ · 24 unmodified lines
25 <h2 class="_block-heading">Questions & Answers</h2>25 <h2 class="_block-heading">Questions & Answers</h2>
26 <dl>26 <dl>
27 <dt>How does this work?27 <dt>How does this work?
28 <dd>Each page is cached as a key-value pair in <a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">IndexedDB</a> (downloaded from a single file).<br>28 <dd>Each page is cached as a key-value pair in <a href="https://devdocs.io/dom/indexeddb_api">IndexedDB</a> (downloaded from a single file).<br>
29 The app also uses <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Using_the_application_cache">AppCache</a> and <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API">localStorage</a> to cache the assets and index files.29 The app also uses <a href="https://devdocs.io/dom/service_worker_api/using_service_workers">Service Workers</a> and <a href="https://devdocs.io/dom/web_storage_api">localStorage</a> to cache the assets and index files.
30 <dt>Can I close the tab/browser?30 <dt>Can I close the tab/browser?
31 <dd>#{canICloseTheTab()}31 <dd>#{canICloseTheTab()}
32 <dt>What if I don't update a documentation?32 <dt>What if I don't update a documentation?
@@ -41,10 +41,15 @@ app.templates.offlinePage = (docs) -> """ · 8 unmodified lines
41"""41"""
42 42
43canICloseTheTab = ->43canICloseTheTab = ->
44 if app.AppCache.isEnabled()44 if app.ServiceWorker.isEnabled()
45 """ Yes! Even offline, you can open a new tab, go to <a href="//devdocs.io">devdocs.io</a>, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). """45 """ Yes! Even offline, you can open a new tab, go to <a href="//devdocs.io">devdocs.io</a>, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). """
46 else46 else
47 """ No. AppCache isn't available in your browser (or is disabled), so loading <a href="//devdocs.io">devdocs.io</a> offline won't work.<br>47 reason = "aren't available in your browser (or are disabled)"
48
49 if app.config.env != 'production'
50 reason = "are disabled in your development instance of DevDocs (enable them by setting the <code>ENABLE_SERVICE_WORKER</code> environment variable to <code>true</code>)"
51
52 """ No. Service Workers #{reason}, so loading <a href="//devdocs.io">devdocs.io</a> offline won't work.<br>
48 The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). """53 The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). """
49 54
50app.templates.offlineDoc = (doc, status) ->55app.templates.offlineDoc = (doc, status) ->
assets/javascripts/templates/pages/root_tmpl.coffee.erb+1−1
@@ -8,7 +8,7 @@ app.templates.intro = """ · 7 unmodified lines
8 <p>Thanks for downloading DevDocs. Here are a few things you should know:8 <p>Thanks for downloading DevDocs. Here are a few things you should know:
9 <ol class="_intro-list">9 <ol class="_intro-list">
10 <li>Your local version of DevDocs won't self-update. Unless you're modifying the code,10 <li>Your local version of DevDocs won't self-update. Unless you're modifying the code,
11 I&nbsp;recommend using the hosted version at <a href="https://devdocs.io">devdocs.io</a>.11 we&nbsp;recommend using the hosted version at <a href="https://devdocs.io">devdocs.io</a>.
12 <li>Run <code>thor docs:list</code> to see all available documentations.12 <li>Run <code>thor docs:list</code> to see all available documentations.
13 <li>Run <code>thor docs:download &lt;name&gt;</code> to download documentations.13 <li>Run <code>thor docs:download &lt;name&gt;</code> to download documentations.
14 <li>Run <code>thor docs:download --installed</code> to update all downloaded documentations.14 <li>Run <code>thor docs:download --installed</code> to update all downloaded documentations.
assets/javascripts/views/content/entry_page.coffee+1−1
@@ -123,7 +123,7 @@ class app.views.EntryPage extends app.View · 122 unmodified lines
123 @render @tmpl('pageLoadError')123 @render @tmpl('pageLoadError')
124 @resetClass()124 @resetClass()
125 @addClass @constructor.errorClass125 @addClass @constructor.errorClass
126 app.appCache?.update()126 app.serviceWorker?.update()
127 return127 return
128 128
129 cache: ->129 cache: ->
assets/javascripts/views/content/settings_page.coffee+1−11
@@ -1,7 +1,4 @@
1class app.views.SettingsPage extends app.View1class app.views.SettingsPage extends app.View
2 LAYOUTS = ['_max-width', '_sidebar-hidden', '_native-scrollbars']
3 SIDEBAR_HIDDEN_LAYOUT = '_sidebar-hidden'
4
5 @className: '_static'2 @className: '_static'
6 3
7 @events:4 @events:
@@ -17,25 +14,18 @@ class app.views.SettingsPage extends app.View · 9 unmodified lines
17 settings.dark = app.settings.get('dark')14 settings.dark = app.settings.get('dark')
18 settings.smoothScroll = !app.settings.get('fastScroll')15 settings.smoothScroll = !app.settings.get('fastScroll')
19 settings.arrowScroll = app.settings.get('arrowScroll')16 settings.arrowScroll = app.settings.get('arrowScroll')
20 settings[layout] = app.settings.hasLayout(layout) for layout in LAYOUTS17 settings[layout] = app.settings.hasLayout(layout) for layout in app.settings.LAYOUTS
21 settings18 settings
22 19
23 getTitle: ->20 getTitle: ->
24 'Preferences'21 'Preferences'
25 22
26 toggleDark: (enable) ->23 toggleDark: (enable) ->
27 html = document.documentElement
28 html.classList.toggle('_theme-default')
29 html.classList.toggle('_theme-dark')
30 app.settings.set('dark', !!enable)24 app.settings.set('dark', !!enable)
31 app.appCache?.updateInBackground()
32 return25 return
33 26
34 toggleLayout: (layout, enable) ->27 toggleLayout: (layout, enable) ->
35 document.body.classList[if enable then 'add' else 'remove'](layout) unless layout is SIDEBAR_HIDDEN_LAYOUT
36 document.body.classList[if $.overlayScrollbarsEnabled() then 'add' else 'remove']('_overlay-scrollbars')
37 app.settings.setLayout(layout, enable)28 app.settings.setLayout(layout, enable)
38 app.appCache?.updateInBackground()
39 return29 return
40 30
41 toggleSmoothScroll: (enable) ->31 toggleSmoothScroll: (enable) ->
assets/javascripts/views/layout/resizer.coffee+2−9
@@ -11,9 +11,6 @@ class app.views.Resizer extends app.View · 10 unmodified lines
11 init: ->11 init: ->
12 @el.setAttribute('draggable', 'true')12 @el.setAttribute('draggable', 'true')
13 @appendTo $('._app')13 @appendTo $('._app')
14
15 @style = $('style[data-resizer]')
16 @size = @style.getAttribute('data-size')
17 return14 return
18 15
19 MIN = 26016 MIN = 260
@@ -24,15 +21,11 @@ class app.views.Resizer extends app.View · 4 unmodified lines
24 return unless value > 021 return unless value > 0
25 value = Math.min(Math.max(Math.round(value), MIN), MAX)22 value = Math.min(Math.max(Math.round(value), MIN), MAX)
26 newSize = "#{value}px"23 newSize = "#{value}px"
27 @style.innerHTML = @style.innerHTML.replace(new RegExp(@size, 'g'), newSize)24 document.documentElement.style.setProperty('--sidebarWidth', newSize)
28 @size = newSize25 app.settings.setSize(value) if save
29 if save
30 app.settings.setSize(value)
31 app.appCache?.updateInBackground()
32 return26 return
33 27
34 onDragStart: (event) =>28 onDragStart: (event) =>
35 @style.removeAttribute('disabled')
36 event.dataTransfer.effectAllowed = 'link'29 event.dataTransfer.effectAllowed = 'link'
37 event.dataTransfer.setData('Text', '')30 event.dataTransfer.setData('Text', '')
38 $.on(window, 'dragover', @onDrag)31 $.on(window, 'dragover', @onDrag)
assets/javascripts/views/layout/settings.coffee+1−9
@@ -25,7 +25,6 @@ class app.views.Settings extends app.View · 24 unmodified lines
25 if super25 if super
26 @render()26 @render()
27 document.body.classList.remove(SIDEBAR_HIDDEN_LAYOUT)27 document.body.classList.remove(SIDEBAR_HIDDEN_LAYOUT)
28 app.appCache?.on 'progress', @onAppCacheProgress
29 return28 return
30 29
31 deactivate: ->30 deactivate: ->
@@ -33,7 +32,6 @@ class app.views.Settings extends app.View · 1 unmodified line
33 @resetClass()32 @resetClass()
34 @docPicker.detach()33 @docPicker.detach()
35 document.body.classList.add(SIDEBAR_HIDDEN_LAYOUT) if app.settings.hasLayout(SIDEBAR_HIDDEN_LAYOUT)34 document.body.classList.add(SIDEBAR_HIDDEN_LAYOUT) if app.settings.hasLayout(SIDEBAR_HIDDEN_LAYOUT)
36 app.appCache?.off 'progress', @onAppCacheProgress
37 return35 return
38 36
39 render: ->37 render: ->
@@ -52,7 +50,7 @@ class app.views.Settings extends app.View · 12 unmodified lines
52 docs = @docPicker.getSelectedDocs()50 docs = @docPicker.getSelectedDocs()
53 app.settings.setDocs(docs)51 app.settings.setDocs(docs)
54 52
55 @saveBtn.textContent = if app.appCache then 'Downloading\u2026' else 'Saving\u2026'53 @saveBtn.textContent = 'Saving\u2026'
56 disabledDocs = new app.collections.Docs(doc for doc in app.docs.all() when docs.indexOf(doc.slug) is -1)54 disabledDocs = new app.collections.Docs(doc for doc in app.docs.all() when docs.indexOf(doc.slug) is -1)
57 disabledDocs.uninstall ->55 disabledDocs.uninstall ->
58 app.db.migrate()56 app.db.migrate()
@@ -83,9 +81,3 @@ class app.views.Settings extends app.View · 24 unmodified lines
83 $.stopEvent(event)81 $.stopEvent(event)
84 app.router.show '/'82 app.router.show '/'
85 return83 return
86
87 onAppCacheProgress: (event) =>
88 if event.lengthComputable
89 percentage = Math.round event.loaded * 100 / event.total
90 @saveBtn.textContent = "Downloading\u2026 (#{percentage}%)"
91 return
docs/maintainers.md+1−1
@@ -83,7 +83,7 @@ In addition to the [publicly-documented commands](https://github.com/freeCodeCam · 82 unmodified lines
83 83
84Once docs have been uploaded via `thor docs:upload` (if applicable), you can push to the DevDocs master branch (or merge the PR containing the updates). If the Travis build succeeds, the Heroku application will be deployed automatically.84Once docs have been uploaded via `thor docs:upload` (if applicable), you can push to the DevDocs master branch (or merge the PR containing the updates). If the Travis build succeeds, the Heroku application will be deployed automatically.
85 85
86- If you're deploying documentation updates, verify that the documentations work properly once the deploy is done (you will need to reload [devdocs.io](https://devdocs.io/) a couple times for the application cache to update and the new version to load).86- If you're deploying documentation updates, verify that the documentations work properly once the deploy is done. Keep in mind that you'll need to wait a few seconds for the service worker to finish caching the new assets. You should see a "DevDocs has been updated" notification appear when the caching is done, after which you need to refresh the page to see the changes.
87- If you're deploying frontend changes, monitor [Sentry](https://sentry.io/devdocs/devdocs-js/) for new JS errors once the deploy is done.87- If you're deploying frontend changes, monitor [Sentry](https://sentry.io/devdocs/devdocs-js/) for new JS errors once the deploy is done.
88- If you're deploying server changes, monitor New Relic (accessible through [the Heroku dashboard](https://dashboard.heroku.com/apps/devdocs)) for Ruby exceptions and throughput or response time changes once the deploy is done.88- If you're deploying server changes, monitor New Relic (accessible through [the Heroku dashboard](https://dashboard.heroku.com/apps/devdocs)) for Ruby exceptions and throughput or response time changes once the deploy is done.
89 89
lib/app.rb+32−20
@@ -192,35 +192,47 @@ class App < Sinatra::Application · 191 unmodified lines
192 request.query_string.empty? ? nil : "?#{request.query_string}"192 request.query_string.empty? ? nil : "?#{request.query_string}"
193 end193 end
194 194
195 def manifest_asset_urls195 def service_worker_asset_urls
196 @@manifest_asset_urls ||= [196 @@service_worker_asset_urls ||= [
197 javascript_path('application', asset_host: false),197 javascript_path('application', asset_host: false),
198 stylesheet_path('application'),198 stylesheet_path('application'),
199 image_path('docs-1.png'),199 image_path('docs-1.png'),
200 image_path('docs-1@2x.png'),200 image_path('docs-1@2x.png'),
201 image_path('docs-2.png'),201 image_path('docs-2.png'),
202 image_path('docs-2@2x.png'),202 image_path('docs-2@2x.png'),
203 asset_path('docs.js')203 asset_path('docs.js'),
204 ]204 App.production? ? nil : javascript_path('debug'),
205 ].compact
205 end206 end
206 207
207 def app_size208 # Returns a cache name for the service worker to use which changes if any of the assets changes
208 @app_size ||= memoized_cookies['size'].nil? ? '20rem' : "#{memoized_cookies['size']}px"209 # When a manifest exist, this name is only created once based on the asset manifest because it never changes without a server restart
209 end210 # If a manifest does not exist, it is created every time this method is called because the assets can change while the server is running
211 def service_worker_cache_name
212 if File.exist?(App.assets_manifest_path)
213 if defined?(@@service_worker_cache_name)
214 return @@service_worker_cache_name
215 end
210 216
211 def app_layout217 digest = Sprockets::Manifest
212 memoized_cookies['layout']218 .new(nil, App.assets_manifest_path)
213 end219 .files
220 .values
221 .map {|file| file["digest"]}
222 .join
214 223
215 def app_theme224 return @@service_worker_cache_name ||= Digest::MD5.hexdigest(digest)
216 @app_theme ||= memoized_cookies['dark'].nil? ? 'default' : 'dark'225 else
217 end226 paths = App.sprockets
227 .each_file
228 .to_a
229 .reject {|file| file.start_with?(App.docs_path)}
218 230
219 def dark_theme?231 return App.sprockets.pack_hexdigest(App.sprockets.files_digest(paths))
220 app_theme == 'dark'232 end
221 end233 end
222 234
223 def redirect_via_js(path) # courtesy of HTML5 App Cache235 def redirect_via_js(path)
224 response.set_cookie :initial_path, value: path, expires: Time.now + 15, path: '/'236 response.set_cookie :initial_path, value: path, expires: Time.now + 15, path: '/'
225 redirect '/', 302237 redirect '/', 302
226 end238 end
@@ -243,15 +255,15 @@ class App < Sinatra::Application · 16 unmodified lines
243 end255 end
244 end256 end
245 257
246 get '/manifest.appcache' do258 get '/service-worker.js' do
247 content_type 'text/cache-manifest'259 content_type 'application/javascript'
248 expires 0, :'no-cache'260 expires 0, :'no-cache'
249 erb :manifest261 erb :'service-worker.js'
250 end262 end
251 263
252 get '/' do264 get '/' do
253 return redirect "/#q=#{params[:q]}" if params[:q]265 return redirect "/#q=#{params[:q]}" if params[:q]
254 return redirect '/' unless request.query_string.empty? # courtesy of HTML5 App Cache266 return redirect '/' unless request.query_string.empty?
255 response.headers['Content-Security-Policy'] = settings.csp if settings.csp267 response.headers['Content-Security-Policy'] = settings.csp if settings.csp
256 erb :index268 erb :index
257 end269 end
public/images/webapp-icon-192.png+0−0

Binary file not shown.

public/manifest.json+6−0
@@ -4,6 +4,7 @@ · 3 unmodified lines
4 "description": "API Documentation Browser",4 "description": "API Documentation Browser",
5 "start_url": "/",5 "start_url": "/",
6 "display": "standalone",6 "display": "standalone",
7 "background_color": "#EEEEEE",
7 "icons": [8 "icons": [
8 {9 {
9 "src": "/images/webapp-icon-32.png",10 "src": "/images/webapp-icon-32.png",
@@ -25,6 +26,11 @@ · 15 unmodified lines
25 "sizes": "128x128",26 "sizes": "128x128",
26 "type": "image/png"27 "type": "image/png"
27 },28 },
29 {
30 "src": "/images/webapp-icon-192.png",
31 "sizes": "192x192",
32 "type": "image/png"
33 },
28 {34 {
29 "src": "/images/webapp-icon-256.png",35 "src": "/images/webapp-icon-256.png",
30 "sizes": "256x256",36 "sizes": "256x256",
test/app_test.rb+0−81
@@ -43,35 +43,6 @@ class AppTest < MiniTest::Spec · 42 unmodified lines
43 assert last_response.redirect?43 assert last_response.redirect?
44 assert_equal 'https://example.org/', last_response['Location']44 assert_equal 'https://example.org/', last_response['Location']
45 end45 end
46
47 it "sets default size" do
48 get '/'
49 assert_includes last_response.body, 'data-size="20rem"'
50 end
51
52 it "sets size from cookie" do
53 set_cookie('size=42')
54 get '/'
55 assert_includes last_response.body, 'data-size="42px"'
56 end
57
58 it "sets layout from cookie" do
59 set_cookie('layout=foo')
60 get '/'
61 assert_includes last_response.body, '<body class="foo">'
62 end
63
64 it "sets the <html> theme from cookie" do
65 get '/'
66 assert_match %r{<html [^>]*class="[^\"]*_theme-default}, last_response.body
67 refute_includes last_response.body, '_theme-dark'
68
69 set_cookie('dark=1')
70
71 get '/'
72 assert_match %r{<html [^>]*class="[^\"]*_theme-dark}, last_response.body
73 refute_includes last_response.body, '_theme-default'
74 end
75 end46 end
76 47
77 describe "/[static-page]" do48 describe "/[static-page]" do
@@ -106,58 +77,6 @@ class AppTest < MiniTest::Spec · 28 unmodified lines
106 end77 end
107 end78 end
108 79
109 describe "/manifest.appcache" do
110 it "works" do
111 get '/manifest.appcache'
112 assert last_response.ok?
113 end
114
115 it "works with cookie" do
116 set_cookie('docs=css/html~5')
117 get '/manifest.appcache'
118 assert last_response.ok?
119 assert_includes last_response.body, '/css/index.json?1420139788'
120 assert_includes last_response.body, '/html~5/index.json?1420139791'
121 end
122
123 it "ignores invalid docs in the cookie" do
124 set_cookie('docs=foo')
125 get '/manifest.appcache'
126 assert last_response.ok?
127 refute_includes last_response.body, 'foo'
128 end
129
130 it "has the word 'default' when no 'dark' cookie is set" do
131 get '/manifest.appcache'
132 assert_includes last_response.body, '# default'
133 refute_includes last_response.body, '# dark'
134 end
135
136 it "has the word 'dark' when the cookie is set" do
137 set_cookie('dark=1')
138 get '/manifest.appcache'
139 assert_includes last_response.body, '# dark'
140 refute_includes last_response.body, '# default'
141 end
142
143 it "sets default size" do
144 get '/manifest.appcache'
145 assert_includes last_response.body, '20rem'
146 end
147
148 it "sets size from cookie" do
149 set_cookie('size=42')
150 get '/manifest.appcache'
151 assert_includes last_response.body, '42px'
152 end
153
154 it "sets layout from cookie" do
155 set_cookie('layout=foo_layout')
156 get '/manifest.appcache'
157 assert_includes last_response.body, 'foo_layout'
158 end
159 end
160
161 describe "/[doc]" do80 describe "/[doc]" do
162 it "renders when the doc exists and isn't enabled" do81 it "renders when the doc exists and isn't enabled" do
163 set_cookie('docs=html~5')82 set_cookie('docs=html~5')
views/app.erb+1−13
@@ -28,13 +28,7 @@ · 27 unmodified lines
28 </nav>28 </nav>
29 </header>29 </header>
30 <section class="_sidebar" tabindex="-1">30 <section class="_sidebar" tabindex="-1">
31 <div class="_list" role="navigation">31 <div class="_list" role="navigation"></div>
32 <% unless @doc %>
33 <% App.docs.each do |slug, doc| %>
34 <a href="/<%= slug %>/" class="_list-item"><%= doc['full_name'] %></a>
35 <% end %>
36 <% end %>
37 </div>
38 </section>32 </section>
39 <div class="_container" role="document">33 <div class="_container" role="document">
40 <main class="_content _content-loading" role="main"></main>34 <main class="_content _content-loading" role="main"></main>
@@ -63,9 +57,3 @@ · 22 unmodified lines
63 <symbol id="icon-external-link" viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></symbol>57 <symbol id="icon-external-link" viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></symbol>
64 </defs>58 </defs>
65</svg>59</svg>
66<style data-size="<%= app_size %>" data-resizer>
67 ._container { margin-left: <%= app_size %>; }
68 ._header, ._list { width: <%= app_size %>; }
69 ._list-hover.clone { min-width: <%= app_size %>; }
70 ._notice, ._path, ._resizer { left: <%= app_size %>; }
71</style>
views/index.erb+3−2
@@ -1,5 +1,5 @@
1<!DOCTYPE html>1<!DOCTYPE html>
2<html<%= ' manifest="/manifest.appcache"' if App.production? %> prefix="og: http://ogp.me/ns#" lang="en" class="_booting _theme-<%= app_theme %>">2<html prefix="og: http://ogp.me/ns#" lang="en" class="_booting">
3<head>3<head>
4 <meta charset="utf-8">4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,shrink-to-fit=no">5 <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,shrink-to-fit=no">
@@ -14,6 +14,7 @@ · 8 unmodified lines
14 <meta name="apple-mobile-web-app-capable" content="yes">14 <meta name="apple-mobile-web-app-capable" content="yes">
15 <meta name="apple-mobile-web-app-status-bar-style" content="black">15 <meta name="apple-mobile-web-app-status-bar-style" content="black">
16 <meta name="format-detection" content="telephone=no">16 <meta name="format-detection" content="telephone=no">
17 <meta name="theme-color" content="#eee">
17 <meta name="robots" content="noodp">18 <meta name="robots" content="noodp">
18 <title>DevDocs API Documentation</title>19 <title>DevDocs API Documentation</title>
19 <link rel="canonical" href="<%= canonical_origin %>">20 <link rel="canonical" href="<%= canonical_origin %>">
@@ -32,7 +33,7 @@ · 12 unmodified lines
32 <link rel="mask-icon" href="<%= App.cdn_origin %>/images/webkit-mask-icon.svg" color="#398df0">33 <link rel="mask-icon" href="<%= App.cdn_origin %>/images/webkit-mask-icon.svg" color="#398df0">
33 <%= stylesheet_tag 'application' %>34 <%= stylesheet_tag 'application' %>
34</head>35</head>
35<body<%= %( class="#{app_layout}") if app_layout %>>36<body>
36<noscript class="_fail">DevDocs requires JavaScript to run.</noscript>37<noscript class="_fail">DevDocs requires JavaScript to run.</noscript>
37<%= erb :app -%>38<%= erb :app -%>
38<%= javascript_tag 'application', asset_host: false %>39<%= javascript_tag 'application', asset_host: false %>
views/manifest.erb+0−14
@@ -1,14 +0,0 @@
1CACHE MANIFEST
2# <%= app_theme %> <%= app_size %> <%= app_layout %>
3
4CACHE:
5/
6<%= manifest_asset_urls.join "\n" %>
7<%= doc_index_urls.join "\n" %>
8
9NETWORK:
10/s/
11*
12
13FALLBACK:
14/ /
views/other.erb+3−2
@@ -1,10 +1,11 @@
1<!DOCTYPE html>1<!DOCTYPE html>
2<html lang="en" class="_booting _theme-<%= app_theme %>">2<html lang="en" class="_booting">
3<head>3<head>
4 <meta charset="utf-8">4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">5 <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
6 <% if doc_index_page? %><meta name="description" content="<%= @doc['name'] %> <%= @doc['release'] %> API documentation with instant search, offline support, keyboard shortcuts, mobile version, and more."><% else %><meta name="robots" content="noindex"><% end %>6 <% if doc_index_page? %><meta name="description" content="<%= @doc['name'] %> <%= @doc['release'] %> API documentation with instant search, offline support, keyboard shortcuts, mobile version, and more."><% else %><meta name="robots" content="noindex"><% end %>
7 <meta name="format-detection" content="telephone=no">7 <meta name="format-detection" content="telephone=no">
8 <meta name="theme-color" content="#eee">
8 <meta property="og:image" content="<%= App.cdn_origin %>/images/icon-320.png">9 <meta property="og:image" content="<%= App.cdn_origin %>/images/icon-320.png">
9 <title>DevDocs<%= " &mdash; #{@doc['full_name']} documentation" if doc_index_page? %></title>10 <title>DevDocs<%= " &mdash; #{@doc['full_name']} documentation" if doc_index_page? %></title>
10 <link rel="canonical" href="<%= canonical_origin %><%= request.path %>">11 <link rel="canonical" href="<%= canonical_origin %><%= request.path %>">
@@ -13,7 +14,7 @@ · 2 unmodified lines
13 <link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Search DevDocs">14 <link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Search DevDocs">
14 <%= stylesheet_tag 'application' %>15 <%= stylesheet_tag 'application' %>
15</head>16</head>
16<body<%= %( class="#{app_layout}") if app_layout %> data-doc="<%= CGI::escape_html @doc.to_json %>">17<body data-doc="<%= CGI::escape_html @doc.to_json %>">
17<noscript class="_fail">DevDocs requires JavaScript to run.</noscript>18<noscript class="_fail">DevDocs requires JavaScript to run.</noscript>
18<%= erb :app -%>19<%= erb :app -%>
19<%= javascript_tag 'application', asset_host: false %><% unless App.production? %>20<%= javascript_tag 'application', asset_host: false %><% unless App.production? %>
views/service-worker.js.erb+49−0
@@ -0,0 +1,49 @@
1<%# The name of the cache to store responses in %>
2<%# If the cache name changes DevDocs is assumed to be updated %>
3const cacheName = '<%= service_worker_cache_name %>';
4
5<%# Url's to cache when the service worker is installed %>
6const urlsToCache = [
7 '/',
8 '/favicon.ico',
9 '/manifest.json',
10 '<%= service_worker_asset_urls.join "',\n '" %>',
11 '<%= doc_index_urls.join "',\n '" %>',
12];
13
14<%# Set-up the cache %>
15self.addEventListener('install', event => {
16 self.skipWaiting();
17
18 event.waitUntil(
19 caches.open(cacheName).then(cache => cache.addAll(urlsToCache)),
20 );
21});
22
23<%# Remove old caches %>
24self.addEventListener('activate', event => {
25 event.waitUntil((async () => {
26 const keys = await caches.keys();
27 const jobs = keys.map(key => key !== cacheName ? caches.delete(key) : Promise.resolve());
28 return Promise.all(jobs);
29 })());
30});
31
32<%# Handle HTTP requests %>
33self.addEventListener('fetch', event => {
34 event.respondWith((async () => {
35 const cachedResponse = await caches.match(event.request);
36 if (cachedResponse) return cachedResponse;
37
38 const url = new URL(event.request.url);
39
40 <%# Attempt to return the index page from the cache if the user is visiting a url like devdocs.io/offline or devdocs.io/javascript/global_objects/array/find %>
41 <%# The index page will handle the routing %>
42 if (url.origin === location.origin && !url.pathname.includes('.')) {
43 const cachedIndex = await caches.match('/');
44 if (cachedIndex) return cachedIndex;
45 }
46
47 return fetch(event.request);
48 })());
49});
views/unsupported.erb+3−3
@@ -11,9 +11,9 @@ · 10 unmodified lines
11 <p class="_fail-text">DevDocs is an API documentation browser which supports the following browsers:</p>11 <p class="_fail-text">DevDocs is an API documentation browser which supports the following browsers:</p>
12 <ul class="_fail-list">12 <ul class="_fail-list">
13 <li>Recent versions of Firefox, Chrome, or Opera</li>13 <li>Recent versions of Firefox, Chrome, or Opera</li>
14 <li>Safari 9.1+</li>14 <li>Safari 11.1+</li>
15 <li>Edge 16+</li>15 <li>Edge 17+</li>
16 <li>iOS 10+</li>16 <li>iOS 11.3+</li>
17 </ul>17 </ul>
18 <p class="_fail-text">18 <p class="_fail-text">
19 If you're unable to upgrade, we apologize.19 If you're unable to upgrade, we apologize.