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 @@
11 .DS_Store
22 .bundle
3+log
34 tmp
45 public/assets
56 public/fonts
Dockerfile+1−0
@@ -1,6 +1,7 @@
11 FROM ruby:2.6.0
22
33 ENV LANG=C.UTF-8
4+ENV ENABLE_SERVICE_WORKER=true
45
56 WORKDIR /devdocs
67
Dockerfile-alpine+1−0
@@ -1,6 +1,7 @@
11 FROM ruby:2.6.0-alpine
22
33 ENV LANG=C.UTF-8
4+ENV ENABLE_SERVICE_WORKER=true
45
56 WORKDIR /devdocs
67
README.md+5−5
@@ -59,14 +59,14 @@ The web app is all client-side JavaScript, written in [CoffeeScript](http://coff · 58 unmodified lines
5959
6060 Many 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.
6161
62−Another 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.
62+Another 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.
6363
6464 DevDocs being a developer tool, the browser requirements are high:
6565
6666 * Recent versions of Firefox, Chrome, or Opera
67−* Safari 9.1+
68−* Edge 16+
69−* iOS 10+
67+* Safari 11.1+
68+* Edge 17+
69+* iOS 11.3+
7070
7171 This allows the code to take advantage of the latest DOM and HTML5 APIs and make developing DevDocs a lot more fun!
7272
@@ -156,7 +156,7 @@ Contributions are welcome. Please read the [contributing guidelines](./.github/C · 83 unmodified lines
156156 * [Doc Browser](https://github.com/qwfy/doc-browser) is a native Linux app that supports DevDocs docsets
157157 * [GNOME Application](https://github.com/hardpixel/devdocs-desktop) GTK3 application with search integrated in headerbar
158158 * [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 enabled
159+* [Android Application](https://github.com/Merith-TK/devdocs_webapp_kotlin) is a fully working, advanced WebView
160160
161161 ## Copyright / License
162162
assets/javascripts/app/app.coffee+8−6
@@ -13,10 +13,12 @@ · 12 unmodified lines
1313
1414 @el = $('._app')
1515 @localStorage = new LocalStorageStore
16− @appCache = new app.AppCache if app.AppCache.isEnabled()
16+ @serviceWorker = new app.ServiceWorker if app.ServiceWorker.isEnabled()
1717 @settings = new app.Settings
1818 @db = new app.DB()
1919
20+ @settings.initLayout()
21+
2022 @docs = new app.collections.Docs
2123 @disabledDocs = new app.collections.Docs
2224 @entries = new app.collections.Entries
@@ -147,7 +149,7 @@ · 124 unmodified lines
147149 saveDocs: ->
148150 @settings.setDocs(doc.slug for doc in @docs.all())
149151 @db.migrate()
150− @appCache?.updateInBackground()
152+ @serviceWorker?.updateInBackground()
151153
152154 welcomeBack: ->
153155 visitCount = @settings.get('count')
@@ -167,14 +169,14 @@ · 13 unmodified lines
167169 reload: ->
168170 @docs.clearCache()
169171 @disabledDocs.clearCache()
170− if @appCache then @appCache.reload() else @reboot()
172+ if @serviceWorker then @serviceWorker.reload() else @reboot()
171173 return
172174
173175 reset: ->
174176 @localStorage.reset()
175177 @settings.reset()
176178 @db?.reset()
177− @appCache?.update()
179+ @serviceWorker?.update()
178180 window.location = '/'
179181 return
180182
@@ -193,9 +195,9 @@ · 12 unmodified lines
193195 return
194196
195197 indexHost: ->
196− # Can't load the index files from the host/CDN when applicationCache is
198+ # Can't load the index files from the host/CDN when service worker is
197199 # 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']
199201
200202 onBootError: (args...) ->
201203 @trigger 'bootError'
assets/javascripts/app/appcache.coffee+0−42
@@ -1,42 +0,0 @@
1−class 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
1313 version: <%= Time.now.to_i %>
1414 release: <%= Time.now.utc.httpdate.to_json %>
1515 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 @@
1+class 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
1919 'news'
2020 ]
2121
22+ LAYOUTS: ['_max-width', '_sidebar-hidden', '_native-scrollbars']
23+
2224 @defaults:
2325 count: 0
2426 hideDisabled: false
@@ -38,6 +40,7 @@ class app.Settings · 13 unmodified lines
3840 set: (key, value) ->
3941 @store.set(key, value)
4042 delete @cache[key]
43+ @toggleDark(value) if key == 'dark'
4144 return
4245
4346 del: (key) ->
@@ -63,6 +66,8 @@ class app.Settings · 19 unmodified lines
6366 return
6467
6568 setLayout: (name, enable) ->
69+ @toggleLayout(name, enable)
70+
6671 layout = (@store.get('layout') || '').split(' ')
6772 $.arrayDelete(layout, '')
6873
@@ -104,3 +109,28 @@ class app.Settings · 35 unmodified lines
104109 @store.reset()
105110 @cache = {}
106111 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
33 @lastCheck = Date.now()
44
55 $.on window, 'focus', @onFocus
6− app.appCache.on 'updateready', @onUpdateReady if app.appCache
6+ app.serviceWorker?.on 'updateready', @onUpdateReady
77
88 setTimeout @checkDocs, 0
99
1010 check: ->
11− if app.appCache
12− app.appCache.update()
11+ if app.serviceWorker
12+ app.serviceWorker.update()
1313 else
1414 ajax
1515 url: $('script[src*="application"]').getAttribute('src')
assets/javascripts/templates/error_tmpl.coffee+5−5
@@ -12,8 +12,8 @@ app.templates.notFoundPage = -> · 11 unmodified lines
1212
1313 app.templates.pageLoadError = ->
1414 error """ The page failed to load. """,
15− """ It may be missing from the server (try reloading the app) or you could be offline.<br>
16− If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """,
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're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """,
1717 """ #{back} &middot; <a href="/##{location.pathname}" target="_top" class="_error-link">Reload</a>
1818 &middot; <a href="#" class="_error-link" data-retry>Retry</a> """
1919
@@ -57,9 +57,9 @@ app.templates.unsupportedBrowser = """ · 37 unmodified lines
5757 <p class="_fail-text">DevDocs is an API documentation browser which supports the following browsers:
5858 <ul class="_fail-list">
5959 <li>Recent versions of Firefox, Chrome, or Opera
60− <li>Safari 9.1+
61− <li>Edge 16+
62− <li>iOS 10+
60+ <li>Safari 11.1+
61+ <li>Edge 17+
62+ <li>iOS 11.3+
6363 </ul>
6464 <p class="_fail-text">
6565 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
2525 <h2 class="_block-heading">Questions & Answers</h2>
2626 <dl>
2727 <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>
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.
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://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.
3030 <dt>Can I close the tab/browser?
3131 <dd>#{canICloseTheTab()}
3232 <dt>What if I don't update a documentation?
@@ -41,10 +41,15 @@ app.templates.offlinePage = (docs) -> """ · 8 unmodified lines
4141 """
4242
4343 canICloseTheTab = ->
44− if app.AppCache.isEnabled()
44+ if app.ServiceWorker.isEnabled()
4545 """ 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). """
4646 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>
4853 The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). """
4954
5055 app.templates.offlineDoc = (doc, status) ->
assets/javascripts/templates/pages/root_tmpl.coffee.erb+1−1
@@ -8,7 +8,7 @@ app.templates.intro = """ · 7 unmodified lines
88 <p>Thanks for downloading DevDocs. Here are a few things you should know:
99 <ol class="_intro-list">
1010 <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>.
1212 <li>Run <code>thor docs:list</code> to see all available documentations.
1313 <li>Run <code>thor docs:download &lt;name&gt;</code> to download documentations.
1414 <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
123123 @render @tmpl('pageLoadError')
124124 @resetClass()
125125 @addClass @constructor.errorClass
126− app.appCache?.update()
126+ app.serviceWorker?.update()
127127 return
128128
129129 cache: ->
assets/javascripts/views/content/settings_page.coffee+1−11
@@ -1,7 +1,4 @@
11 class app.views.SettingsPage extends app.View
2− LAYOUTS = ['_max-width', '_sidebar-hidden', '_native-scrollbars']
3− SIDEBAR_HIDDEN_LAYOUT = '_sidebar-hidden'
4−
52 @className: '_static'
63
74 @events:
@@ -17,25 +14,18 @@ class app.views.SettingsPage extends app.View · 9 unmodified lines
1714 settings.dark = app.settings.get('dark')
1815 settings.smoothScroll = !app.settings.get('fastScroll')
1916 settings.arrowScroll = app.settings.get('arrowScroll')
20− settings[layout] = app.settings.hasLayout(layout) for layout in LAYOUTS
17+ settings[layout] = app.settings.hasLayout(layout) for layout in app.settings.LAYOUTS
2118 settings
2219
2320 getTitle: ->
2421 'Preferences'
2522
2623 toggleDark: (enable) ->
27− html = document.documentElement
28− html.classList.toggle('_theme-default')
29− html.classList.toggle('_theme-dark')
3024 app.settings.set('dark', !!enable)
31− app.appCache?.updateInBackground()
3225 return
3326
3427 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')
3728 app.settings.setLayout(layout, enable)
38− app.appCache?.updateInBackground()
3929 return
4030
4131 toggleSmoothScroll: (enable) ->
assets/javascripts/views/layout/resizer.coffee+2−9
@@ -11,9 +11,6 @@ class app.views.Resizer extends app.View · 10 unmodified lines
1111 init: ->
1212 @el.setAttribute('draggable', 'true')
1313 @appendTo $('._app')
14−
15− @style = $('style[data-resizer]')
16− @size = @style.getAttribute('data-size')
1714 return
1815
1916 MIN = 260
@@ -24,15 +21,11 @@ class app.views.Resizer extends app.View · 4 unmodified lines
2421 return unless value > 0
2522 value = Math.min(Math.max(Math.round(value), MIN), MAX)
2623 newSize = "#{value}px"
27− @style.innerHTML = @style.innerHTML.replace(new RegExp(@size, 'g'), newSize)
28− @size = newSize
29− if save
30− app.settings.setSize(value)
31− app.appCache?.updateInBackground()
24+ document.documentElement.style.setProperty('--sidebarWidth', newSize)
25+ app.settings.setSize(value) if save
3226 return
3327
3428 onDragStart: (event) =>
35− @style.removeAttribute('disabled')
3629 event.dataTransfer.effectAllowed = 'link'
3730 event.dataTransfer.setData('Text', '')
3831 $.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
2525 if super
2626 @render()
2727 document.body.classList.remove(SIDEBAR_HIDDEN_LAYOUT)
28− app.appCache?.on 'progress', @onAppCacheProgress
2928 return
3029
3130 deactivate: ->
@@ -33,7 +32,6 @@ class app.views.Settings extends app.View · 1 unmodified line
3332 @resetClass()
3433 @docPicker.detach()
3534 document.body.classList.add(SIDEBAR_HIDDEN_LAYOUT) if app.settings.hasLayout(SIDEBAR_HIDDEN_LAYOUT)
36− app.appCache?.off 'progress', @onAppCacheProgress
3735 return
3836
3937 render: ->
@@ -52,7 +50,7 @@ class app.views.Settings extends app.View · 12 unmodified lines
5250 docs = @docPicker.getSelectedDocs()
5351 app.settings.setDocs(docs)
5452
55− @saveBtn.textContent = if app.appCache then 'Downloading\u2026' else 'Saving\u2026'
53+ @saveBtn.textContent = 'Saving\u2026'
5654 disabledDocs = new app.collections.Docs(doc for doc in app.docs.all() when docs.indexOf(doc.slug) is -1)
5755 disabledDocs.uninstall ->
5856 app.db.migrate()
@@ -83,9 +81,3 @@ class app.views.Settings extends app.View · 24 unmodified lines
8381 $.stopEvent(event)
8482 app.router.show '/'
8583 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
8383
8484 Once 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.
8585
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.
8787 - If you're deploying frontend changes, monitor [Sentry](https://sentry.io/devdocs/devdocs-js/) for new JS errors once the deploy is done.
8888 - 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.
8989
lib/app.rb+32−20
@@ -192,35 +192,47 @@ class App < Sinatra::Application · 191 unmodified lines
192192 request.query_string.empty? ? nil : "?#{request.query_string}"
193193 end
194194
195− def manifest_asset_urls
196− @@manifest_asset_urls ||= [
195+ def service_worker_asset_urls
196+ @@service_worker_asset_urls ||= [
197197 javascript_path('application', asset_host: false),
198198 stylesheet_path('application'),
199199 image_path('docs-1.png'),
200200 image_path('docs-1@2x.png'),
201201 image_path('docs-2.png'),
202202 image_path('docs-2@2x.png'),
203− asset_path('docs.js')
204− ]
203+ asset_path('docs.js'),
204+ App.production? ? nil : javascript_path('debug'),
205+ ].compact
205206 end
206207
207− def app_size
208− @app_size ||= memoized_cookies['size'].nil? ? '20rem' : "#{memoized_cookies['size']}px"
209− end
208+ # Returns a cache name for the service worker to use which changes if any of the assets changes
209+ # When a manifest exist, this name is only created once based on the asset manifest because it never changes without a server restart
210+ # 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
210216
211− def app_layout
212− memoized_cookies['layout']
213− end
217+ digest = Sprockets::Manifest
218+ .new(nil, App.assets_manifest_path)
219+ .files
220+ .values
221+ .map {|file| file["digest"]}
222+ .join
214223
215− def app_theme
216− @app_theme ||= memoized_cookies['dark'].nil? ? 'default' : 'dark'
217− end
224+ return @@service_worker_cache_name ||= Digest::MD5.hexdigest(digest)
225+ else
226+ paths = App.sprockets
227+ .each_file
228+ .to_a
229+ .reject {|file| file.start_with?(App.docs_path)}
218230
219− def dark_theme?
220− app_theme == 'dark'
231+ return App.sprockets.pack_hexdigest(App.sprockets.files_digest(paths))
232+ end
221233 end
222234
223− def redirect_via_js(path) # courtesy of HTML5 App Cache
235+ def redirect_via_js(path)
224236 response.set_cookie :initial_path, value: path, expires: Time.now + 15, path: '/'
225237 redirect '/', 302
226238 end
@@ -243,15 +255,15 @@ class App < Sinatra::Application · 16 unmodified lines
243255 end
244256 end
245257
246− get '/manifest.appcache' do
247− content_type 'text/cache-manifest'
258+ get '/service-worker.js' do
259+ content_type 'application/javascript'
248260 expires 0, :'no-cache'
249− erb :manifest
261+ erb :'service-worker.js'
250262 end
251263
252264 get '/' do
253265 return redirect "/#q=#{params[:q]}" if params[:q]
254− return redirect '/' unless request.query_string.empty? # courtesy of HTML5 App Cache
266+ return redirect '/' unless request.query_string.empty?
255267 response.headers['Content-Security-Policy'] = settings.csp if settings.csp
256268 erb :index
257269 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
44 "description": "API Documentation Browser",
55 "start_url": "/",
66 "display": "standalone",
7+ "background_color": "#EEEEEE",
78 "icons": [
89 {
910 "src": "/images/webapp-icon-32.png",
@@ -25,6 +26,11 @@ · 15 unmodified lines
2526 "sizes": "128x128",
2627 "type": "image/png"
2728 },
29+ {
30+ "src": "/images/webapp-icon-192.png",
31+ "sizes": "192x192",
32+ "type": "image/png"
33+ },
2834 {
2935 "src": "/images/webapp-icon-256.png",
3036 "sizes": "256x256",
test/app_test.rb+0−81
@@ -43,35 +43,6 @@ class AppTest < MiniTest::Spec · 42 unmodified lines
4343 assert last_response.redirect?
4444 assert_equal 'https://example.org/', last_response['Location']
4545 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
7546 end
7647
7748 describe "/[static-page]" do
@@ -106,58 +77,6 @@ class AppTest < MiniTest::Spec · 28 unmodified lines
10677 end
10778 end
10879
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−
16180 describe "/[doc]" do
16281 it "renders when the doc exists and isn't enabled" do
16382 set_cookie('docs=html~5')
views/app.erb+1−13
@@ -28,13 +28,7 @@ · 27 unmodified lines
2828 </nav>
2929 </header>
3030 <section class="_sidebar" tabindex="-1">
31− <div class="_list" role="navigation">
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>
31+ <div class="_list" role="navigation"></div>
3832 </section>
3933 <div class="_container" role="document">
4034 <main class="_content _content-loading" role="main"></main>
@@ -63,9 +57,3 @@ · 22 unmodified lines
6357 <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>
6458 </defs>
6559 </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 @@
11 <!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">
33 <head>
44 <meta charset="utf-8">
55 <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
1414 <meta name="apple-mobile-web-app-capable" content="yes">
1515 <meta name="apple-mobile-web-app-status-bar-style" content="black">
1616 <meta name="format-detection" content="telephone=no">
17+ <meta name="theme-color" content="#eee">
1718 <meta name="robots" content="noodp">
1819 <title>DevDocs API Documentation</title>
1920 <link rel="canonical" href="<%= canonical_origin %>">
@@ -32,7 +33,7 @@ · 12 unmodified lines
3233 <link rel="mask-icon" href="<%= App.cdn_origin %>/images/webkit-mask-icon.svg" color="#398df0">
3334 <%= stylesheet_tag 'application' %>
3435 </head>
35−<body<%= %( class="#{app_layout}") if app_layout %>>
36+<body>
3637 <noscript class="_fail">DevDocs requires JavaScript to run.</noscript>
3738 <%= erb :app -%>
3839 <%= javascript_tag 'application', asset_host: false %>
views/manifest.erb+0−14
@@ -1,14 +0,0 @@
1−CACHE MANIFEST
2−# <%= app_theme %> <%= app_size %> <%= app_layout %>
3−
4−CACHE:
5−/
6−<%= manifest_asset_urls.join "\n" %>
7−<%= doc_index_urls.join "\n" %>
8−
9−NETWORK:
10−/s/
11−*
12−
13−FALLBACK:
14−/ /
views/other.erb+3−2
@@ -1,10 +1,11 @@
11 <!DOCTYPE html>
2−<html lang="en" class="_booting _theme-<%= app_theme %>">
2+<html lang="en" class="_booting">
33 <head>
44 <meta charset="utf-8">
55 <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
66 <% 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 %>
77 <meta name="format-detection" content="telephone=no">
8+ <meta name="theme-color" content="#eee">
89 <meta property="og:image" content="<%= App.cdn_origin %>/images/icon-320.png">
910 <title>DevDocs<%= " &mdash; #{@doc['full_name']} documentation" if doc_index_page? %></title>
1011 <link rel="canonical" href="<%= canonical_origin %><%= request.path %>">
@@ -13,7 +14,7 @@ · 2 unmodified lines
1314 <link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Search DevDocs">
1415 <%= stylesheet_tag 'application' %>
1516 </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 %>">
1718 <noscript class="_fail">DevDocs requires JavaScript to run.</noscript>
1819 <%= erb :app -%>
1920 <%= 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 %>
3+const cacheName = '<%= service_worker_cache_name %>';
4+
5+<%# Url's to cache when the service worker is installed %>
6+const 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 %>
15+self.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 %>
24+self.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 %>
33+self.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
1111 <p class="_fail-text">DevDocs is an API documentation browser which supports the following browsers:</p>
1212 <ul class="_fail-list">
1313 <li>Recent versions of Firefox, Chrome, or Opera</li>
14− <li>Safari 9.1+</li>
15− <li>Edge 16+</li>
16− <li>iOS 10+</li>
14+ <li>Safari 11.1+</li>
15+ <li>Edge 17+</li>
16+ <li>iOS 11.3+</li>
1717 </ul>
1818 <p class="_fail-text">
1919 If you're unable to upgrade, we apologize.