diff --git a/Gemfile b/Gemfile index f276b263..c17c5ce8 100644 --- a/Gemfile +++ b/Gemfile @@ -14,7 +14,6 @@ gem 'yajl-ruby', require: false group :app do gem 'browser' gem 'chunky_png' - gem 'coffee-script' gem 'erubi' gem 'image_optim_pack', platforms: :ruby gem 'image_optim' diff --git a/Gemfile.lock b/Gemfile.lock index 115d6860..1ef5ce11 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,10 +14,6 @@ GEM byebug (11.1.3) chunky_png (1.4.0) coderay (1.1.3) - coffee-script (2.4.1) - coffee-script-source - execjs - coffee-script-source (1.12.2) concurrent-ruby (1.1.10) daemons (1.4.1) erubi (1.12.0) @@ -146,7 +142,6 @@ DEPENDENCIES better_errors browser chunky_png - coffee-script erubi html-pipeline image_optim diff --git a/assets/javascripts/app/app.coffee b/assets/javascripts/app/app.coffee deleted file mode 100644 index b55e552c..00000000 --- a/assets/javascripts/app/app.coffee +++ /dev/null @@ -1,283 +0,0 @@ -@app = - _$: $ - _$$: $$ - _page: page - collections: {} - models: {} - templates: {} - views: {} - - init: -> - try @initErrorTracking() catch - return unless @browserCheck() - - @el = $('._app') - @localStorage = new LocalStorageStore - @serviceWorker = new app.ServiceWorker if app.ServiceWorker.isEnabled() - @settings = new app.Settings - @db = new app.DB() - - @settings.initLayout() - - @docs = new app.collections.Docs - @disabledDocs = new app.collections.Docs - @entries = new app.collections.Entries - - @router = new app.Router - @shortcuts = new app.Shortcuts - @document = new app.views.Document - @mobile = new app.views.Mobile if @isMobile() - - if document.body.hasAttribute('data-doc') - @DOC = JSON.parse(document.body.getAttribute('data-doc')) - @bootOne() - else if @DOCS - @bootAll() - else - @onBootError() - return - - browserCheck: -> - return true if @isSupportedBrowser() - document.body.innerHTML = app.templates.unsupportedBrowser - @hideLoadingScreen() - false - - initErrorTracking: -> - # Show a warning message and don't track errors when the app is loaded - # from a domain other than our own, because things are likely to break. - # (e.g. cross-domain requests) - if @isInvalidLocation() - new app.views.Notif 'InvalidLocation' - else - if @config.sentry_dsn - Raven.config @config.sentry_dsn, - release: @config.release - whitelistUrls: [/devdocs/] - includePaths: [/devdocs/] - ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/] - tags: - 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.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(@) - CookiesStore.onBlocked = @onCookieBlocked - return - - bootOne: -> - @doc = new app.models.Doc @DOC - @docs.reset [@doc] - @doc.load @start.bind(@), @onBootError.bind(@), readCache: true - new app.views.Notice 'singleDoc', @doc - delete @DOC - return - - bootAll: -> - docs = @settings.getDocs() - for doc in @DOCS - (if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc) - @migrateDocs() - @docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true - delete @DOCS - return - - start: -> - @entries.add doc.toEntry() for doc in @docs.all() - @entries.add doc.toEntry() for doc in @disabledDocs.all() - @initDoc(doc) for doc in @docs.all() - @trigger 'ready' - @router.start() - @hideLoadingScreen() - setTimeout => - @welcomeBack() unless @doc - @removeEvent 'ready bootError' - , 50 - return - - initDoc: (doc) -> - 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', '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) - @docs.add(doc) - - @saveDocs() if needsSaving - return - - enableDoc: (doc, _onSuccess, onError) -> - return if @docs.contains(doc) - - onSuccess = => - return if @docs.contains(doc) - @disabledDocs.remove(doc) - @docs.add(doc) - @docs.sort() - @initDoc(doc) - @saveDocs() - if app.settings.get('autoInstall') - doc.install(_onSuccess, onError) - else - _onSuccess() - return - - doc.load onSuccess, onError, writeCache: true - return - - saveDocs: -> - @settings.setDocs(doc.slug for doc in @docs.all()) - @db.migrate() - @serviceWorker?.updateInBackground() - - welcomeBack: -> - visitCount = @settings.get('count') - @settings.set 'count', ++visitCount - new app.views.Notif 'Share', autoHide: null if visitCount is 5 - new app.views.News() - new app.views.Updates() - @updateChecker = new app.UpdateChecker() - - reboot: -> - if location.pathname isnt '/' and location.pathname isnt '/settings' - window.location = "/##{location.pathname}" - else - window.location = '/' - return - - reload: -> - @docs.clearCache() - @disabledDocs.clearCache() - if @serviceWorker then @serviceWorker.reload() else @reboot() - return - - reset: -> - @localStorage.reset() - @settings.reset() - @db?.reset() - @serviceWorker?.update() - window.location = '/' - return - - showTip: (tip) -> - return if @isSingleDoc() - tips = @settings.getTips() - if tips.indexOf(tip) is -1 - tips.push(tip) - @settings.setTips(tips) - new app.views.Tip(tip) - return - - hideLoadingScreen: -> - document.body.classList.add '_overlay-scrollbars' if $.overlayScrollbarsEnabled() - document.documentElement.classList.remove '_booting' - return - - indexHost: -> - # Can't load the index files from the host/CDN when service worker is - # enabled because it doesn't support caching URLs that use CORS. - @config[if @serviceWorker and @settings.hasDocs() then 'index_path' else 'docs_origin'] - - onBootError: (args...) -> - @trigger 'bootError' - @hideLoadingScreen() - return - - onQuotaExceeded: -> - return if @quotaExceeded - @quotaExceeded = true - new app.views.Notif 'QuotaExceeded', autoHide: null - 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... - @previousErrorHandler? args... - @hideLoadingScreen() - @errorNotif or= new app.views.Notif 'Error' - @errorNotif.show() - return - - onInjectionError: -> - unless @injectionError - @injectionError = true - 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', level: 'info' - return - - isInjectionError: -> - # Some browser extensions expect the entire web to use jQuery. - # I gave up trying to fight back. - window.$ isnt app._$ or window.$$ isnt app._$$ or window.page isnt app._page or typeof $.empty isnt 'function' or typeof page.show isnt 'function' - - isAppError: (error, file) -> - # Ignore errors from external scripts. - file and file.indexOf('devdocs') isnt -1 and file.indexOf('.js') is file.length - 3 - - isSupportedBrowser: -> - try - features = - bind: !!Function::bind - pushState: !!history.pushState - matchMedia: !!window.matchMedia - insertAdjacentHTML: !!document.body.insertAdjacentHTML - defaultPrevented: document.createEvent('CustomEvent').defaultPrevented is false - cssVariables: !!CSS?.supports?('(--t: 0)') - - for key, value of features when not value - Raven.captureMessage "unsupported/#{key}", level: 'info' - return false - - true - catch error - Raven.captureMessage 'unsupported/exception', level: 'info', extra: { error: error } - false - - isSingleDoc: -> - document.body.hasAttribute('data-doc') - - isMobile: -> - @_isMobile ?= app.views.Mobile.detect() - - isAndroidWebview: -> - @_isAndroidWebview ?= app.views.Mobile.detectAndroidWebview() - - isInvalidLocation: -> - @config.env is 'production' and location.host.indexOf(app.config.production_host) isnt 0 - -$.extend app, Events diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js new file mode 100644 index 00000000..06d5327e --- /dev/null +++ b/assets/javascripts/app/app.js @@ -0,0 +1,419 @@ +class App extends Events { + _$ = $; + _$$ = $$; + _page = page; + collections = {}; + models = {}; + templates = {}; + views = {}; + + init() { + try { + this.initErrorTracking(); + } catch (error) {} + if (!this.browserCheck()) { + return; + } + + this.el = $("._app"); + this.localStorage = new LocalStorageStore(); + if (app.ServiceWorker.isEnabled()) { + this.serviceWorker = new app.ServiceWorker(); + } + this.settings = new app.Settings(); + this.db = new app.DB(); + + this.settings.initLayout(); + + this.docs = new app.collections.Docs(); + this.disabledDocs = new app.collections.Docs(); + this.entries = new app.collections.Entries(); + + this.router = new app.Router(); + this.shortcuts = new app.Shortcuts(); + this.document = new app.views.Document(); + if (this.isMobile()) { + this.mobile = new app.views.Mobile(); + } + + if (document.body.hasAttribute("data-doc")) { + this.DOC = JSON.parse(document.body.getAttribute("data-doc")); + this.bootOne(); + } else if (this.DOCS) { + this.bootAll(); + } else { + this.onBootError(); + } + } + + browserCheck() { + if (this.isSupportedBrowser()) { + return true; + } + document.body.innerHTML = app.templates.unsupportedBrowser; + this.hideLoadingScreen(); + return false; + } + + initErrorTracking() { + // Show a warning message and don't track errors when the app is loaded + // from a domain other than our own, because things are likely to break. + // (e.g. cross-domain requests) + if (this.isInvalidLocation()) { + new app.views.Notif("InvalidLocation"); + } else { + if (this.config.sentry_dsn) { + Raven.config(this.config.sentry_dsn, { + release: this.config.release, + whitelistUrls: [/devdocs/], + includePaths: [/devdocs/], + ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/], + tags: { + mode: this.isSingleDoc() ? "single" : "full", + iframe: (window.top !== window).toString(), + electron: (!!window.process?.versions?.electron).toString(), + }, + shouldSendCallback: () => { + try { + if (this.isInjectionError()) { + this.onInjectionError(); + return false; + } + if (this.isAndroidWebview()) { + return false; + } + } catch (error) {} + return true; + }, + dataCallback(data) { + try { + data.user ||= {}; + Object.assign(data.user, app.settings.dump()); + if (data.user.docs) { + data.user.docs = data.user.docs.split("/"); + } + if (app.lastIDBTransaction) { + data.user.lastIDBTransaction = app.lastIDBTransaction; + } + data.tags.scriptCount = document.scripts.length; + } catch (error) {} + return data; + }, + }).install(); + } + this.previousErrorHandler = onerror; + window.onerror = this.onWindowError.bind(this); + CookiesStore.onBlocked = this.onCookieBlocked; + } + } + + bootOne() { + this.doc = new app.models.Doc(this.DOC); + this.docs.reset([this.doc]); + this.doc.load(this.start.bind(this), this.onBootError.bind(this), { + readCache: true, + }); + new app.views.Notice("singleDoc", this.doc); + delete this.DOC; + } + + bootAll() { + const docs = this.settings.getDocs(); + for (var doc of this.DOCS) { + (docs.includes(doc.slug) ? this.docs : this.disabledDocs).add(doc); + } + this.migrateDocs(); + this.docs.load(this.start.bind(this), this.onBootError.bind(this), { + readCache: true, + writeCache: true, + }); + delete this.DOCS; + } + + start() { + let doc; + for (doc of this.docs.all()) { + this.entries.add(doc.toEntry()); + } + for (doc of this.disabledDocs.all()) { + this.entries.add(doc.toEntry()); + } + for (doc of this.docs.all()) { + this.initDoc(doc); + } + this.trigger("ready"); + this.router.start(); + this.hideLoadingScreen(); + setTimeout(() => { + if (!this.doc) { + this.welcomeBack(); + } + return this.removeEvent("ready bootError"); + }, 50); + } + + initDoc(doc) { + for (var type of doc.types.all()) { + doc.entries.add(type.toEntry()); + } + this.entries.add(doc.entries.all()); + } + + migrateDocs() { + let needsSaving; + for (var slug of this.settings.getDocs()) { + if (!this.docs.findBy("slug", slug)) { + var doc; + + needsSaving = true; + if (slug === "webpack~2") { + doc = this.disabledDocs.findBy("slug", "webpack"); + } + if (slug === "angular~4_typescript") { + doc = this.disabledDocs.findBy("slug", "angular"); + } + if (slug === "angular~2_typescript") { + doc = this.disabledDocs.findBy("slug", "angular~2"); + } + if (!doc) { + doc = this.disabledDocs.findBy("slug_without_version", slug); + } + if (doc) { + this.disabledDocs.remove(doc); + this.docs.add(doc); + } + } + } + + if (needsSaving) { + this.saveDocs(); + } + } + + enableDoc(doc, _onSuccess, onError) { + if (this.docs.contains(doc)) { + return; + } + + const onSuccess = () => { + if (this.docs.contains(doc)) { + return; + } + this.disabledDocs.remove(doc); + this.docs.add(doc); + this.docs.sort(); + this.initDoc(doc); + this.saveDocs(); + if (app.settings.get("autoInstall")) { + doc.install(_onSuccess, onError); + } else { + _onSuccess(); + } + }; + + doc.load(onSuccess, onError, { writeCache: true }); + } + + saveDocs() { + this.settings.setDocs(this.docs.all().map((doc) => doc.slug)); + this.db.migrate(); + return this.serviceWorker != null + ? this.serviceWorker.updateInBackground() + : undefined; + } + + welcomeBack() { + let visitCount = this.settings.get("count"); + this.settings.set("count", ++visitCount); + if (visitCount === 5) { + new app.views.Notif("Share", { autoHide: null }); + } + new app.views.News(); + new app.views.Updates(); + return (this.updateChecker = new app.UpdateChecker()); + } + + reboot() { + if (location.pathname !== "/" && location.pathname !== "/settings") { + window.location = `/#${location.pathname}`; + } else { + window.location = "/"; + } + } + + reload() { + this.docs.clearCache(); + this.disabledDocs.clearCache(); + if (this.serviceWorker) { + this.serviceWorker.reload(); + } else { + this.reboot(); + } + } + + reset() { + this.localStorage.reset(); + this.settings.reset(); + if (this.db != null) { + this.db.reset(); + } + if (this.serviceWorker != null) { + this.serviceWorker.update(); + } + window.location = "/"; + } + + showTip(tip) { + if (this.isSingleDoc()) { + return; + } + const tips = this.settings.getTips(); + if (!tips.includes(tip)) { + tips.push(tip); + this.settings.setTips(tips); + new app.views.Tip(tip); + } + } + + hideLoadingScreen() { + if ($.overlayScrollbarsEnabled()) { + document.body.classList.add("_overlay-scrollbars"); + } + document.documentElement.classList.remove("_booting"); + } + + indexHost() { + // Can't load the index files from the host/CDN when service worker is + // enabled because it doesn't support caching URLs that use CORS. + return this.config[ + this.serviceWorker && this.settings.hasDocs() + ? "index_path" + : "docs_origin" + ]; + } + + onBootError(...args) { + this.trigger("bootError"); + this.hideLoadingScreen(); + } + + onQuotaExceeded() { + if (this.quotaExceeded) { + return; + } + this.quotaExceeded = true; + new app.views.Notif("QuotaExceeded", { autoHide: null }); + } + + onCookieBlocked(key, value, actual) { + if (this.cookieBlocked) { + return; + } + this.cookieBlocked = true; + new app.views.Notif("CookieBlocked", { autoHide: null }); + Raven.captureMessage(`CookieBlocked/${key}`, { + level: "warning", + extra: { value, actual }, + }); + } + + onWindowError(...args) { + if (this.cookieBlocked) { + return; + } + if (this.isInjectionError(...args)) { + this.onInjectionError(); + } else if (this.isAppError(...args)) { + if (typeof this.previousErrorHandler === "function") { + this.previousErrorHandler(...args); + } + this.hideLoadingScreen(); + if (!this.errorNotif) { + this.errorNotif = new app.views.Notif("Error"); + } + this.errorNotif.show(); + } + } + + onInjectionError() { + if (!this.injectionError) { + this.injectionError = true; + 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", { level: "info" }); + } + } + + isInjectionError() { + // Some browser extensions expect the entire web to use jQuery. + // I gave up trying to fight back. + return ( + window.$ !== app._$ || + window.$$ !== app._$$ || + window.page !== app._page || + typeof $.empty !== "function" || + typeof page.show !== "function" + ); + } + + isAppError(error, file) { + // Ignore errors from external scripts. + return file && file.includes("devdocs") && file.endsWith(".js"); + } + + isSupportedBrowser() { + try { + const features = { + bind: !!Function.prototype.bind, + pushState: !!history.pushState, + matchMedia: !!window.matchMedia, + insertAdjacentHTML: !!document.body.insertAdjacentHTML, + defaultPrevented: + document.createEvent("CustomEvent").defaultPrevented === false, + cssVariables: !!CSS.supports?.("(--t: 0)"), + }; + + for (var key in features) { + var value = features[key]; + if (!value) { + Raven.captureMessage(`unsupported/${key}`, { level: "info" }); + return false; + } + } + + return true; + } catch (error) { + Raven.captureMessage("unsupported/exception", { + level: "info", + extra: { error }, + }); + return false; + } + } + + isSingleDoc() { + return document.body.hasAttribute("data-doc"); + } + + isMobile() { + return this._isMobile != null + ? this._isMobile + : (this._isMobile = app.views.Mobile.detect()); + } + + isAndroidWebview() { + return this._isAndroidWebview != null + ? this._isAndroidWebview + : (this._isAndroidWebview = app.views.Mobile.detectAndroidWebview()); + } + + isInvalidLocation() { + return ( + this.config.env === "production" && + !location.host.startsWith(app.config.production_host) + ); + } +} + +this.app = new App(); diff --git a/assets/javascripts/app/config.coffee.erb b/assets/javascripts/app/config.coffee.erb deleted file mode 100644 index 97e91ace..00000000 --- a/assets/javascripts/app/config.coffee.erb +++ /dev/null @@ -1,18 +0,0 @@ -app.config = - db_filename: 'db.json' - default_docs: <%= App.default_docs.to_json %> - docs_origin: '<%= App.docs_origin %>' - env: '<%= App.environment %>' - history_cache_size: 10 - index_filename: 'index.json' - index_path: '/<%= App.docs_prefix %>' - max_results: 50 - production_host: 'devdocs.io' - search_param: 'q' - sentry_dsn: '<%= App.sentry_dsn %>' - version: <%= Time.now.to_i %> - release: <%= Time.now.utc.httpdate.to_json %> - mathml_stylesheet: '/mathml.css' - favicon_spritesheet: '<%= image_path('sprites/docs.png') %>' - service_worker_path: '/service-worker.js' - service_worker_enabled: <%= App.environment == :production || ENV['ENABLE_SERVICE_WORKER'] == 'true' %> diff --git a/assets/javascripts/app/config.js.erb b/assets/javascripts/app/config.js.erb new file mode 100644 index 00000000..56ce2652 --- /dev/null +++ b/assets/javascripts/app/config.js.erb @@ -0,0 +1,19 @@ +app.config = { + db_filename: 'db.json', + default_docs: <%= App.default_docs.to_json %>, + docs_origin: '<%= App.docs_origin %>', + env: '<%= App.environment %>', + history_cache_size: 10, + index_filename: 'index.json', + index_path: '/<%= App.docs_prefix %>', + max_results: 50, + production_host: 'devdocs.io', + search_param: 'q', + sentry_dsn: '<%= App.sentry_dsn %>', + version: <%= Time.now.to_i %>, + release: <%= Time.now.utc.httpdate.to_json %>, + mathml_stylesheet: '/mathml.css', + favicon_spritesheet: '<%= image_path('sprites/docs.png') %>', + service_worker_path: '/service-worker.js', + service_worker_enabled: <%= App.environment == :production || ENV['ENABLE_SERVICE_WORKER'] == 'true' %>, +} diff --git a/assets/javascripts/app/db.coffee b/assets/javascripts/app/db.coffee deleted file mode 100644 index 28e4b0ea..00000000 --- a/assets/javascripts/app/db.coffee +++ /dev/null @@ -1,382 +0,0 @@ -class app.DB - NAME = 'docs' - VERSION = 15 - - constructor: -> - @versionMultipler = if $.isIE() then 1e5 else 1e9 - @useIndexedDB = @useIndexedDB() - @callbacks = [] - - db: (fn) -> - return fn() unless @useIndexedDB - @callbacks.push(fn) if fn - return if @open - - try - @open = true - req = indexedDB.open(NAME, VERSION * @versionMultipler + @userVersion()) - req.onsuccess = @onOpenSuccess - req.onerror = @onOpenError - req.onupgradeneeded = @onUpgradeNeeded - catch error - @fail 'exception', error - return - - onOpenSuccess: (event) => - db = event.target.result - - 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() - @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 - - 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 - @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 - - onUpgradeNeeded: (event) -> - return unless db = event.target.result - - objectStoreNames = $.makeArray(db.objectStoreNames) - - unless $.arrayDelete(objectStoreNames, 'docs') - try db.createObjectStore('docs') - - for doc in app.docs.all() when not $.arrayDelete(objectStoreNames, doc.slug) - try db.createObjectStore(doc.slug) - - for name in objectStoreNames - try db.deleteObjectStore(name) - return - - store: (doc, data, onSuccess, onError, _retry = true) -> - @db (db) => - unless db - onError() - return - - txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false - txn.oncomplete = => - @cachedDocs?[doc.slug] = doc.mtime - onSuccess() - return - txn.onerror = (event) => - event.preventDefault() - 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) - store.clear() - store.add(content, path) for path, content of data - - store = txn.objectStore('docs') - store.put(doc.mtime, doc.slug) - return - return - - unstore: (doc, onSuccess, onError, _retry = true) -> - @db (db) => - unless db - onError() - return - - txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false - txn.oncomplete = => - delete @cachedDocs?[doc.slug] - onSuccess() - return - txn.onerror = (event) -> - event.preventDefault() - if txn.error?.name is 'NotFoundError' and _retry - @migrate() - setTimeout => - @unstore(doc, onSuccess, onError, false) - , 0 - else - onError(event) - return - - store = txn.objectStore('docs') - store.delete(doc.slug) - - store = txn.objectStore(doc.slug) - store.clear() - return - return - - version: (doc, fn) -> - if (version = @cachedVersion(doc))? - fn(version) - return - - @db (db) => - unless db - fn(false) - return - - txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - store = txn.objectStore('docs') - - req = store.get(doc.slug) - req.onsuccess = -> - fn(req.result) - return - req.onerror = (event) -> - event.preventDefault() - fn(false) - return - return - return - - cachedVersion: (doc) -> - return unless @cachedDocs - @cachedDocs[doc.slug] or false - - versions: (docs, fn) -> - if versions = @cachedVersions(docs) - fn(versions) - return - - @db (db) => - unless db - fn(false) - return - - txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - txn.oncomplete = -> - fn(result) - return - store = txn.objectStore('docs') - result = {} - - docs.forEach (doc) -> - req = store.get(doc.slug) - req.onsuccess = -> - result[doc.slug] = req.result - return - req.onerror = (event) -> - event.preventDefault() - result[doc.slug] = false - return - return - return - - cachedVersions: (docs) -> - return unless @cachedDocs - result = {} - result[doc.slug] = @cachedVersion(doc) for doc in docs - result - - load: (entry, onSuccess, onError) -> - if @shouldLoadWithIDB(entry) - onError = @loadWithXHR.bind(@, entry, onSuccess, onError) - @loadWithIDB entry, onSuccess, onError - else - @loadWithXHR entry, onSuccess, onError - - loadWithXHR: (entry, onSuccess, onError) -> - ajax - url: entry.fileUrl() - dataType: 'html' - success: onSuccess - error: onError - - loadWithIDB: (entry, onSuccess, onError) -> - @db (db) => - unless db - onError() - return - - unless db.objectStoreNames.contains(entry.doc.slug) - onError() - @loadDocsCache(db) - return - - txn = @idbTransaction db, stores: [entry.doc.slug], mode: 'readonly' - store = txn.objectStore(entry.doc.slug) - - req = store.get(entry.dbPath()) - req.onsuccess = -> - if req.result then onSuccess(req.result) else onError() - return - req.onerror = (event) -> - event.preventDefault() - onError() - return - @loadDocsCache(db) - return - - loadDocsCache: (db) -> - return if @cachedDocs - @cachedDocs = {} - - txn = @idbTransaction db, stores: ['docs'], mode: 'readonly' - txn.oncomplete = => - setTimeout(@checkForCorruptedDocs, 50) - return - - req = txn.objectStore('docs').openCursor() - req.onsuccess = (event) => - return unless cursor = event.target.result - @cachedDocs[cursor.key] = cursor.value - cursor.continue() - return - req.onerror = (event) -> - event.preventDefault() - 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]) - - idbTransaction: (db, options) -> - app.lastIDBTransaction = [options.stores, options.mode] - txn = db.transaction(options.stores, options.mode) - unless options.ignoreError is false - txn.onerror = (event) -> - event.preventDefault() - return - unless options.ignoreAbort is false - txn.onabort = (event) -> - event.preventDefault() - return - txn - - reset: -> - try indexedDB?.deleteDatabase(NAME) catch - return - - useIndexedDB: -> - try - if !app.isSingleDoc() and window.indexedDB - true - else - @reason = 'not_supported' - false - catch - false - - migrate: -> - app.settings.set('schema', @userVersion() + 1) - return - - setUserVersion: (version) -> - app.settings.set('schema', version) - return - - userVersion: -> - app.settings.get('schema') diff --git a/assets/javascripts/app/db.js b/assets/javascripts/app/db.js new file mode 100644 index 00000000..c0260c74 --- /dev/null +++ b/assets/javascripts/app/db.js @@ -0,0 +1,559 @@ +app.DB = class DB { + static NAME = "docs"; + static VERSION = 15; + + constructor() { + this.versionMultipler = $.isIE() ? 1e5 : 1e9; + this.useIndexedDB = this.useIndexedDB(); + this.callbacks = []; + } + + db(fn) { + if (!this.useIndexedDB) { + return fn(); + } + if (fn) { + this.callbacks.push(fn); + } + if (this.open) { + return; + } + + try { + this.open = true; + const req = indexedDB.open( + DB.NAME, + DB.VERSION * this.versionMultipler + this.userVersion(), + ); + req.onsuccess = (event) => this.onOpenSuccess(event); + req.onerror = (event) => this.onOpenError(event); + req.onupgradeneeded = (event) => this.onUpgradeNeeded(event); + } catch (error) { + this.fail("exception", error); + } + } + + onOpenSuccess(event) { + let error; + const db = event.target.result; + + if (db.objectStoreNames.length === 0) { + try { + db.close(); + } catch (error1) {} + this.open = false; + this.fail("empty"); + } else if ((error = this.buggyIDB(db))) { + try { + db.close(); + } catch (error2) {} + this.open = false; + this.fail("buggy", error); + } else { + this.runCallbacks(db); + this.open = false; + db.close(); + } + } + + onOpenError(event) { + event.preventDefault(); + this.open = false; + const { error } = event.target; + + switch (error.name) { + case "QuotaExceededError": + this.onQuotaExceededError(); + break; + case "VersionError": + this.onVersionError(); + break; + case "InvalidStateError": + this.fail("private_mode"); + break; + default: + this.fail("cant_open", error); + } + } + + fail(reason, error) { + this.cachedDocs = null; + this.useIndexedDB = false; + if (!this.reason) { + this.reason = reason; + } + if (!this.error) { + this.error = error; + } + if (error) { + if (typeof console.error === "function") { + console.error("IDB error", error); + } + } + this.runCallbacks(); + if (error && reason === "cant_open") { + Raven.captureMessage(`${error.name}: ${error.message}`, { + level: "warning", + fingerprint: [error.name], + }); + } + } + + onQuotaExceededError() { + this.reset(); + this.db(); + app.onQuotaExceeded(); + Raven.captureMessage("QuotaExceededError", { level: "warning" }); + } + + onVersionError() { + const req = indexedDB.open(DB.NAME); + req.onsuccess = (event) => { + return this.handleVersionMismatch(event.target.result.version); + }; + req.onerror = function (event) { + event.preventDefault(); + return this.fail("cant_open", error); + }; + } + + handleVersionMismatch(actualVersion) { + if (Math.floor(actualVersion / this.versionMultipler) !== DB.VERSION) { + this.fail("version"); + } else { + this.setUserVersion(actualVersion - DB.VERSION * this.versionMultipler); + this.db(); + } + } + + buggyIDB(db) { + if (this.checkedBuggyIDB) { + return; + } + this.checkedBuggyIDB = true; + try { + this.idbTransaction(db, { + stores: $.makeArray(db.objectStoreNames).slice(0, 2), + mode: "readwrite", + }).abort(); // https://bugs.webkit.org/show_bug.cgi?id=136937 + return; + } catch (error) { + return error; + } + } + + runCallbacks(db) { + let fn; + while ((fn = this.callbacks.shift())) { + fn(db); + } + } + + onUpgradeNeeded(event) { + let db; + if (!(db = event.target.result)) { + return; + } + + const objectStoreNames = $.makeArray(db.objectStoreNames); + + if (!$.arrayDelete(objectStoreNames, "docs")) { + try { + db.createObjectStore("docs"); + } catch (error) {} + } + + for (var doc of app.docs.all()) { + if (!$.arrayDelete(objectStoreNames, doc.slug)) { + try { + db.createObjectStore(doc.slug); + } catch (error1) {} + } + } + + for (var name of objectStoreNames) { + try { + db.deleteObjectStore(name); + } catch (error2) {} + } + } + + store(doc, data, onSuccess, onError, _retry) { + if (_retry == null) { + _retry = true; + } + this.db((db) => { + if (!db) { + onError(); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs", doc.slug], + mode: "readwrite", + ignoreError: false, + }); + txn.oncomplete = () => { + if (this.cachedDocs != null) { + this.cachedDocs[doc.slug] = doc.mtime; + } + onSuccess(); + }; + txn.onerror = (event) => { + event.preventDefault(); + if (txn.error?.name === "NotFoundError" && _retry) { + this.migrate(); + setTimeout(() => { + return this.store(doc, data, onSuccess, onError, false); + }, 0); + } else { + onError(event); + } + }; + + let store = txn.objectStore(doc.slug); + store.clear(); + for (var path in data) { + var content = data[path]; + store.add(content, path); + } + + store = txn.objectStore("docs"); + store.put(doc.mtime, doc.slug); + }); + } + + unstore(doc, onSuccess, onError, _retry) { + if (_retry == null) { + _retry = true; + } + this.db((db) => { + if (!db) { + onError(); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs", doc.slug], + mode: "readwrite", + ignoreError: false, + }); + txn.oncomplete = () => { + if (this.cachedDocs != null) { + delete this.cachedDocs[doc.slug]; + } + onSuccess(); + }; + txn.onerror = function (event) { + event.preventDefault(); + if (txn.error?.name === "NotFoundError" && _retry) { + this.migrate(); + setTimeout(() => { + return this.unstore(doc, onSuccess, onError, false); + }, 0); + } else { + onError(event); + } + }; + + let store = txn.objectStore("docs"); + store.delete(doc.slug); + + store = txn.objectStore(doc.slug); + store.clear(); + }); + } + + version(doc, fn) { + const version = this.cachedVersion(doc); + if (version != null) { + fn(version); + return; + } + + this.db((db) => { + if (!db) { + fn(false); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readonly", + }); + const store = txn.objectStore("docs"); + + const req = store.get(doc.slug); + req.onsuccess = function () { + fn(req.result); + }; + req.onerror = function (event) { + event.preventDefault(); + fn(false); + }; + }); + } + + cachedVersion(doc) { + if (!this.cachedDocs) { + return; + } + return this.cachedDocs[doc.slug] || false; + } + + versions(docs, fn) { + const versions = this.cachedVersions(docs); + if (versions) { + fn(versions); + return; + } + + return this.db((db) => { + if (!db) { + fn(false); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readonly", + }); + txn.oncomplete = function () { + fn(result); + }; + const store = txn.objectStore("docs"); + var result = {}; + + docs.forEach((doc) => { + const req = store.get(doc.slug); + req.onsuccess = function () { + result[doc.slug] = req.result; + }; + req.onerror = function (event) { + event.preventDefault(); + result[doc.slug] = false; + }; + }); + }); + } + + cachedVersions(docs) { + if (!this.cachedDocs) { + return; + } + const result = {}; + for (var doc of docs) { + result[doc.slug] = this.cachedVersion(doc); + } + return result; + } + + load(entry, onSuccess, onError) { + if (this.shouldLoadWithIDB(entry)) { + return this.loadWithIDB(entry, onSuccess, () => + this.loadWithXHR(entry, onSuccess, onError), + ); + } else { + return this.loadWithXHR(entry, onSuccess, onError); + } + } + + loadWithXHR(entry, onSuccess, onError) { + return ajax({ + url: entry.fileUrl(), + dataType: "html", + success: onSuccess, + error: onError, + }); + } + + loadWithIDB(entry, onSuccess, onError) { + return this.db((db) => { + if (!db) { + onError(); + return; + } + + if (!db.objectStoreNames.contains(entry.doc.slug)) { + onError(); + this.loadDocsCache(db); + return; + } + + const txn = this.idbTransaction(db, { + stores: [entry.doc.slug], + mode: "readonly", + }); + const store = txn.objectStore(entry.doc.slug); + + const req = store.get(entry.dbPath()); + req.onsuccess = function () { + if (req.result) { + onSuccess(req.result); + } else { + onError(); + } + }; + req.onerror = function (event) { + event.preventDefault(); + onError(); + }; + this.loadDocsCache(db); + }); + } + + loadDocsCache(db) { + if (this.cachedDocs) { + return; + } + this.cachedDocs = {}; + + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readonly", + }); + txn.oncomplete = () => { + setTimeout(() => this.checkForCorruptedDocs(), 50); + }; + + const req = txn.objectStore("docs").openCursor(); + req.onsuccess = (event) => { + let cursor; + if (!(cursor = event.target.result)) { + return; + } + this.cachedDocs[cursor.key] = cursor.value; + cursor.continue(); + }; + req.onerror = function (event) { + event.preventDefault(); + }; + } + + checkForCorruptedDocs() { + this.db((db) => { + let slug; + this.corruptedDocs = []; + const docs = (() => { + const result = []; + for (var key in this.cachedDocs) { + var value = this.cachedDocs[key]; + if (value) { + result.push(key); + } + } + return result; + })(); + if (docs.length === 0) { + return; + } + + for (slug of docs) { + if (!app.docs.findBy("slug", slug)) { + this.corruptedDocs.push(slug); + } + } + + for (slug of this.corruptedDocs) { + $.arrayDelete(docs, slug); + } + + if (docs.length === 0) { + setTimeout(() => this.deleteCorruptedDocs(), 0); + return; + } + + const txn = this.idbTransaction(db, { + stores: docs, + mode: "readonly", + ignoreError: false, + }); + txn.oncomplete = () => { + if (this.corruptedDocs.length > 0) { + setTimeout(() => this.deleteCorruptedDocs(), 0); + } + }; + + for (var doc of docs) { + txn.objectStore(doc).get("index").onsuccess = (event) => { + if (!event.target.result) { + this.corruptedDocs.push(event.target.source.name); + } + }; + } + }); + } + + deleteCorruptedDocs() { + this.db((db) => { + let doc; + const txn = this.idbTransaction(db, { + stores: ["docs"], + mode: "readwrite", + ignoreError: false, + }); + const store = txn.objectStore("docs"); + while ((doc = this.corruptedDocs.pop())) { + this.cachedDocs[doc] = false; + store.delete(doc); + } + }); + Raven.captureMessage("corruptedDocs", { + level: "info", + extra: { docs: this.corruptedDocs.join(",") }, + }); + } + + shouldLoadWithIDB(entry) { + return ( + this.useIndexedDB && (!this.cachedDocs || this.cachedDocs[entry.doc.slug]) + ); + } + + idbTransaction(db, options) { + app.lastIDBTransaction = [options.stores, options.mode]; + const txn = db.transaction(options.stores, options.mode); + if (options.ignoreError !== false) { + txn.onerror = function (event) { + event.preventDefault(); + }; + } + if (options.ignoreAbort !== false) { + txn.onabort = function (event) { + event.preventDefault(); + }; + } + return txn; + } + + reset() { + try { + indexedDB?.deleteDatabase(DB.NAME); + } catch (error) {} + } + + useIndexedDB() { + try { + if (!app.isSingleDoc() && window.indexedDB) { + return true; + } else { + this.reason = "not_supported"; + return false; + } + } catch (error) { + return false; + } + } + + migrate() { + app.settings.set("schema", this.userVersion() + 1); + } + + setUserVersion(version) { + app.settings.set("schema", version); + } + + userVersion() { + return app.settings.get("schema"); + } +}; diff --git a/assets/javascripts/app/router.coffee b/assets/javascripts/app/router.coffee deleted file mode 100644 index ba25148a..00000000 --- a/assets/javascripts/app/router.coffee +++ /dev/null @@ -1,154 +0,0 @@ -class app.Router - $.extend @prototype, Events - - @routes: [ - ['*', 'before' ] - ['/', 'root' ] - ['/settings', 'settings' ] - ['/offline', 'offline' ] - ['/about', 'about' ] - ['/news', 'news' ] - ['/help', 'help' ] - ['/:doc-:type/', 'type' ] - ['/:doc/', 'doc' ] - ['/:doc/:path(*)', 'entry' ] - ['*', 'notFound' ] - ] - - constructor: -> - for [path, method] in @constructor.routes - page path, @[method].bind(@) - @setInitialPath() - - start: -> - page.start() - return - - show: (path) -> - page.show(path) - return - - triggerRoute: (name) -> - @trigger name, @context - @trigger 'after', name, @context - return - - before: (context, next) -> - previousContext = @context - @context = context - @trigger 'before', context - - 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 - return next() - - type: (context, next) -> - doc = app.docs.findBySlug(context.params.doc) - - if type = doc?.types.findBy 'slug', context.params.type - context.doc = doc - context.type = type - @triggerRoute 'type' - return - else - return next() - - entry: (context, next) -> - doc = app.docs.findBySlug(context.params.doc) - return next() unless doc - path = context.params.path - hash = context.hash - - if entry = doc.findEntryByPathAndHash(path, hash) - context.doc = doc - context.entry = entry - @triggerRoute 'entry' - return - else if path.slice(-6) is '/index' - path = path.substr(0, path.length - 6) - return entry.fullPath() if entry = doc.findEntryByPathAndHash(path, hash) - else - path = "#{path}/index" - return entry.fullPath() if entry = doc.findEntryByPathAndHash(path, hash) - - return next() - - root: -> - return '/' if app.isSingleDoc() - @triggerRoute 'root' - return - - settings: (context) -> - return "/#/#{context.path}" if app.isSingleDoc() - @triggerRoute 'settings' - return - - 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 - - notFound: (context) -> - @triggerRoute 'notFound' - return - - isIndex: -> - @context?.path is '/' or (app.isSingleDoc() and @context?.entry?.isIndex()) - - isSettings: -> - @context?.path is '/settings' - - 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 location.pathname is '/' - if path = @getInitialPathFromHash() - page.replace path + location.search, null, true - else if path = @getInitialPathFromCookie() - page.replace path + location.search + location.hash, null, true - return - - getInitialPathFromHash: -> - try - (new RegExp "#/(.+)").exec(decodeURIComponent location.hash)?[1] - catch - - getInitialPathFromCookie: -> - if path = Cookies.get('initial_path') - Cookies.expire('initial_path') - path - - replaceHash: (hash) -> - page.replace location.pathname + location.search + (hash or ''), null, true - return diff --git a/assets/javascripts/app/router.js b/assets/javascripts/app/router.js new file mode 100644 index 00000000..c120b9bf --- /dev/null +++ b/assets/javascripts/app/router.js @@ -0,0 +1,209 @@ +app.Router = class Router extends Events { + static routes = [ + ["*", "before"], + ["/", "root"], + ["/settings", "settings"], + ["/offline", "offline"], + ["/about", "about"], + ["/news", "news"], + ["/help", "help"], + ["/:doc-:type/", "type"], + ["/:doc/", "doc"], + ["/:doc/:path(*)", "entry"], + ["*", "notFound"], + ]; + + constructor() { + super(); + for (var [path, method] of this.constructor.routes) { + page(path, this[method].bind(this)); + } + this.setInitialPath(); + } + + start() { + page.start(); + } + + show(path) { + page.show(path); + } + + triggerRoute(name) { + this.trigger(name, this.context); + this.trigger("after", name, this.context); + } + + before(context, next) { + let res; + const previousContext = this.context; + this.context = context; + this.trigger("before", context); + + if ((res = next())) { + this.context = previousContext; + return res; + } else { + return; + } + } + + doc(context, next) { + let doc; + if ( + (doc = + app.docs.findBySlug(context.params.doc) || + app.disabledDocs.findBySlug(context.params.doc)) + ) { + context.doc = doc; + context.entry = doc.toEntry(); + this.triggerRoute("entry"); + return; + } else { + return next(); + } + } + + type(context, next) { + const doc = app.docs.findBySlug(context.params.doc); + const type = doc?.types?.findBy("slug", context.params.type); + + if (type) { + context.doc = doc; + context.type = type; + this.triggerRoute("type"); + return; + } else { + return next(); + } + } + + entry(context, next) { + let entry; + const doc = app.docs.findBySlug(context.params.doc); + if (!doc) { + return next(); + } + let { path } = context.params; + const { hash } = context; + + if ((entry = doc.findEntryByPathAndHash(path, hash))) { + context.doc = doc; + context.entry = entry; + this.triggerRoute("entry"); + return; + } else if (path.slice(-6) === "/index") { + path = path.substr(0, path.length - 6); + if ((entry = doc.findEntryByPathAndHash(path, hash))) { + return entry.fullPath(); + } + } else { + path = `${path}/index`; + if ((entry = doc.findEntryByPathAndHash(path, hash))) { + return entry.fullPath(); + } + } + + return next(); + } + + root() { + if (app.isSingleDoc()) { + return "/"; + } + this.triggerRoute("root"); + } + + settings(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + this.triggerRoute("settings"); + } + + offline(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + this.triggerRoute("offline"); + } + + about(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + context.page = "about"; + this.triggerRoute("page"); + } + + news(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + context.page = "news"; + this.triggerRoute("page"); + } + + help(context) { + if (app.isSingleDoc()) { + return `/#/${context.path}`; + } + context.page = "help"; + this.triggerRoute("page"); + } + + notFound(context) { + this.triggerRoute("notFound"); + } + + isIndex() { + return ( + this.context?.path === "/" || + (app.isSingleDoc() && this.context?.entry?.isIndex()) + ); + } + + isSettings() { + return this.context?.path === "/settings"; + } + + setInitialPath() { + // Remove superfluous forward slashes at the beginning of the path + let path; + if ( + (path = location.pathname.replace(/^\/{2,}/g, "/")) !== location.pathname + ) { + page.replace(path + location.search + location.hash, null, true); + } + + if (location.pathname === "/") { + if ((path = this.getInitialPathFromHash())) { + page.replace(path + location.search, null, true); + } else if ((path = this.getInitialPathFromCookie())) { + page.replace(path + location.search + location.hash, null, true); + } + } + } + + getInitialPathFromHash() { + try { + return new RegExp("#/(.+)").exec(decodeURIComponent(location.hash))?.[1]; + } catch (error) {} + } + + getInitialPathFromCookie() { + let path; + if ((path = Cookies.get("initial_path"))) { + Cookies.expire("initial_path"); + return path; + } + } + + replaceHash(hash) { + page.replace( + location.pathname + location.search + (hash || ""), + null, + true, + ); + } +}; diff --git a/assets/javascripts/app/searcher.coffee b/assets/javascripts/app/searcher.coffee deleted file mode 100644 index 79f6a304..00000000 --- a/assets/javascripts/app/searcher.coffee +++ /dev/null @@ -1,292 +0,0 @@ -# -# Match functions -# - -SEPARATOR = '.' - -query = -queryLength = -value = -valueLength = -matcher = # current match function -fuzzyRegexp = # query fuzzy regexp -index = # position of the query in the string being matched -lastIndex = # last position of the query in the string being matched -match = # regexp match data -matchIndex = -matchLength = -score = # score for the current match -separators = # counter -i = null # cursor - -`function exactMatch() {` -index = value.indexOf(query) -return unless index >= 0 - -lastIndex = value.lastIndexOf(query) - -if index isnt lastIndex - return Math.max(scoreExactMatch(), ((index = lastIndex) and scoreExactMatch()) or 0) -else - return scoreExactMatch() -`}` - -`function scoreExactMatch() {` -# Remove one point for each unmatched character. -score = 100 - (valueLength - queryLength) - -if index > 0 - # If the character preceding the query is a dot, assign the same score - # as if the query was found at the beginning of the string, minus one. - if value.charAt(index - 1) is SEPARATOR - score += index - 1 - # Don't match a single-character query unless it's found at the beginning - # of the string or is preceded by a dot. - else if queryLength is 1 - return - # (1) Remove one point for each unmatched character up to the nearest - # preceding dot or the beginning of the string. - # (2) Remove one point for each unmatched character following the query. - else - i = index - 2 - i-- while i >= 0 and value.charAt(i) isnt SEPARATOR - score -= (index - i) + # (1) - (valueLength - queryLength - index) # (2) - - # Remove one point for each dot preceding the query, except for the one - # immediately before the query. - separators = 0 - i = index - 2 - while i >= 0 - separators++ if value.charAt(i) is SEPARATOR - i-- - score -= separators - -# Remove five points for each dot following the query. -separators = 0 -i = valueLength - queryLength - index - 1 -while i >= 0 - separators++ if value.charAt(index + queryLength + i) is SEPARATOR - i-- -score -= separators * 5 - -return Math.max 1, score -`}` - -`function fuzzyMatch() {` -return if valueLength <= queryLength or value.indexOf(query) >= 0 -return unless match = fuzzyRegexp.exec(value) -matchIndex = match.index -matchLength = match[0].length -score = scoreFuzzyMatch() -if match = fuzzyRegexp.exec(value.slice(i = value.lastIndexOf(SEPARATOR) + 1)) - matchIndex = i + match.index - matchLength = match[0].length - return Math.max(score, scoreFuzzyMatch()) -else - return score -`}` - -`function scoreFuzzyMatch() {` -# When the match is at the beginning of the string or preceded by a dot. -if matchIndex is 0 or value.charAt(matchIndex - 1) is SEPARATOR - return Math.max 66, 100 - matchLength -# When the match is at the end of the string. -else if matchIndex + matchLength is valueLength - return Math.max 33, 67 - matchLength -# When the match is in the middle of the string. -else - return Math.max 1, 34 - matchLength -`}` - -# -# Searchers -# - -class app.Searcher - $.extend @prototype, Events - - CHUNK_SIZE = 20000 - - DEFAULTS = - max_results: app.config.max_results - fuzzy_min_length: 3 - - SEPARATORS_REGEXP = /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g - EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/ - INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/ - EMPTY_PARANTHESES_REGEXP = /\(\)/ - EVENT_REGEXP = /\ event$/ - DOT_REGEXP = /\.+/g - WHITESPACE_REGEXP = /\s/g - - EMPTY_STRING = '' - ELLIPSIS = '...' - STRING = 'string' - - @normalizeString: (string) -> - string - .toLowerCase() - .replace ELLIPSIS, EMPTY_STRING - .replace EVENT_REGEXP, EMPTY_STRING - .replace INFO_PARANTHESES_REGEXP, EMPTY_STRING - .replace SEPARATORS_REGEXP, SEPARATOR - .replace DOT_REGEXP, SEPARATOR - .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 - - find: (data, attr, q) -> - @kill() - - @data = data - @attr = attr - @query = q - @setup() - - if @isValid() then @match() else @end() - return - - setup: -> - query = @query = @constructor.normalizeQuery(@query) - queryLength = query.length - @dataLength = @data.length - @matchers = [exactMatch] - @totalResults = 0 - @setupFuzzy() - return - - setupFuzzy: -> - if queryLength >= @options.fuzzy_min_length - fuzzyRegexp = @queryToFuzzyRegexp(query) - @matchers.push(fuzzyMatch) - else - fuzzyRegexp = null - return - - isValid: -> - queryLength > 0 and query isnt SEPARATOR - - end: -> - @triggerResults [] unless @totalResults - @trigger 'end' - @free() - return - - kill: -> - if @timeout - clearTimeout @timeout - @free() - return - - free: -> - @data = @attr = @dataLength = @matchers = @matcher = @query = - @totalResults = @scoreMap = @cursor = @timeout = null - return - - match: => - if not @foundEnough() and @matcher = @matchers.shift() - @setupMatcher() - @matchChunks() - else - @end() - return - - setupMatcher: -> - @cursor = 0 - @scoreMap = new Array(101) - return - - matchChunks: => - @matchChunk() - - if @cursor is @dataLength or @scoredEnough() - @delay @match - @sendResults() - else - @delay @matchChunks - return - - matchChunk: -> - matcher = @matcher - for [0...@chunkSize()] - value = @data[@cursor][@attr] - if value.split # string - valueLength = value.length - @addResult(@data[@cursor], score) if score = matcher() - else # array - score = 0 - for value in @data[@cursor][@attr] - valueLength = value.length - score = Math.max(score, matcher() || 0) - @addResult(@data[@cursor], score) if score > 0 - @cursor++ - return - - chunkSize: -> - if @cursor + CHUNK_SIZE > @dataLength - @dataLength % CHUNK_SIZE - else - CHUNK_SIZE - - scoredEnough: -> - @scoreMap[100]?.length >= @options.max_results - - foundEnough: -> - @totalResults >= @options.max_results - - addResult: (object, score) -> - (@scoreMap[Math.round(score)] or= []).push(object) - @totalResults++ - return - - getResults: -> - results = [] - for objects in @scoreMap by -1 when objects - results.push.apply results, objects - results[0...@options.max_results] - - sendResults: -> - results = @getResults() - @triggerResults results if results.length - return - - triggerResults: (results) -> - @trigger 'results', results - return - - delay: (fn) -> - @timeout = setTimeout(fn, 1) - - queryToFuzzyRegexp: (string) -> - chars = string.split '' - chars[i] = $.escapeRegexp(char) for char, i in chars - new RegExp chars.join('.*?') # abc -> /a.*?b.*?c.*?/ - -class app.SynchronousSearcher extends app.Searcher - match: => - if @matcher - @allResults or= [] - @allResults.push.apply @allResults, @getResults() - super - - free: -> - @allResults = null - super - - end: -> - @sendResults true - super - - sendResults: (end) -> - if end and @allResults?.length - @triggerResults @allResults - - delay: (fn) -> - fn() diff --git a/assets/javascripts/app/searcher.js b/assets/javascripts/app/searcher.js new file mode 100644 index 00000000..7cd6e826 --- /dev/null +++ b/assets/javascripts/app/searcher.js @@ -0,0 +1,396 @@ +// +// Match functions +// + +let fuzzyRegexp, + i, + index, + lastIndex, + match, + matcher, + matchIndex, + matchLength, + queryLength, + score, + separators, + value, + valueLength; +const SEPARATOR = "."; + +let query = + (queryLength = + value = + valueLength = + matcher = // current match function + fuzzyRegexp = // query fuzzy regexp + index = // position of the query in the string being matched + lastIndex = // last position of the query in the string being matched + match = // regexp match data + matchIndex = + matchLength = + score = // score for the current match + separators = // counter + i = + null); // cursor + +function exactMatch() { + index = value.indexOf(query); + if (!(index >= 0)) { + return; + } + + lastIndex = value.lastIndexOf(query); + + if (index !== lastIndex) { + return Math.max( + scoreExactMatch(), + ((index = lastIndex) && scoreExactMatch()) || 0, + ); + } else { + return scoreExactMatch(); + } +} + +function scoreExactMatch() { + // Remove one point for each unmatched character. + score = 100 - (valueLength - queryLength); + + if (index > 0) { + // If the character preceding the query is a dot, assign the same score + // as if the query was found at the beginning of the string, minus one. + if (value.charAt(index - 1) === SEPARATOR) { + score += index - 1; + // Don't match a single-character query unless it's found at the beginning + // of the string or is preceded by a dot. + } else if (queryLength === 1) { + return; + // (1) Remove one point for each unmatched character up to the nearest + // preceding dot or the beginning of the string. + // (2) Remove one point for each unmatched character following the query. + } else { + i = index - 2; + while (i >= 0 && value.charAt(i) !== SEPARATOR) { + i--; + } + score -= + index - + i + // (1) + (valueLength - queryLength - index); // (2) + } + + // Remove one point for each dot preceding the query, except for the one + // immediately before the query. + separators = 0; + i = index - 2; + while (i >= 0) { + if (value.charAt(i) === SEPARATOR) { + separators++; + } + i--; + } + score -= separators; + } + + // Remove five points for each dot following the query. + separators = 0; + i = valueLength - queryLength - index - 1; + while (i >= 0) { + if (value.charAt(index + queryLength + i) === SEPARATOR) { + separators++; + } + i--; + } + score -= separators * 5; + + return Math.max(1, score); +} + +function fuzzyMatch() { + if (valueLength <= queryLength || value.includes(query)) { + return; + } + if (!(match = fuzzyRegexp.exec(value))) { + return; + } + matchIndex = match.index; + matchLength = match[0].length; + score = scoreFuzzyMatch(); + if ( + (match = fuzzyRegexp.exec( + value.slice((i = value.lastIndexOf(SEPARATOR) + 1)), + )) + ) { + matchIndex = i + match.index; + matchLength = match[0].length; + return Math.max(score, scoreFuzzyMatch()); + } else { + return score; + } +} + +function scoreFuzzyMatch() { + // When the match is at the beginning of the string or preceded by a dot. + if (matchIndex === 0 || value.charAt(matchIndex - 1) === SEPARATOR) { + return Math.max(66, 100 - matchLength); + // When the match is at the end of the string. + } else if (matchIndex + matchLength === valueLength) { + return Math.max(33, 67 - matchLength); + // When the match is in the middle of the string. + } else { + return Math.max(1, 34 - matchLength); + } +} + +// +// Searchers +// + +app.Searcher = class Searcher extends Events { + static CHUNK_SIZE = 20000; + + static DEFAULTS = { + max_results: app.config.max_results, + fuzzy_min_length: 3, + }; + + static SEPARATORS_REGEXP = + /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g; + static EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/; + static INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/; + static EMPTY_PARANTHESES_REGEXP = /\(\)/; + static EVENT_REGEXP = /\ event$/; + static DOT_REGEXP = /\.+/g; + static WHITESPACE_REGEXP = /\s/g; + + static EMPTY_STRING = ""; + static ELLIPSIS = "..."; + static STRING = "string"; + + static normalizeString(string) { + return string + .toLowerCase() + .replace(Searcher.ELLIPSIS, Searcher.EMPTY_STRING) + .replace(Searcher.EVENT_REGEXP, Searcher.EMPTY_STRING) + .replace(Searcher.INFO_PARANTHESES_REGEXP, Searcher.EMPTY_STRING) + .replace(Searcher.SEPARATORS_REGEXP, SEPARATOR) + .replace(Searcher.DOT_REGEXP, SEPARATOR) + .replace(Searcher.EMPTY_PARANTHESES_REGEXP, Searcher.EMPTY_STRING) + .replace(Searcher.WHITESPACE_REGEXP, Searcher.EMPTY_STRING); + } + + static normalizeQuery(string) { + string = this.normalizeString(string); + return string.replace(Searcher.EOS_SEPARATORS_REGEXP, "$1."); + } + + constructor(options) { + super(); + this.options = { ...Searcher.DEFAULTS, ...(options || {}) }; + } + + find(data, attr, q) { + this.kill(); + + this.data = data; + this.attr = attr; + this.query = q; + this.setup(); + + if (this.isValid()) { + this.match(); + } else { + this.end(); + } + } + + setup() { + query = this.query = this.constructor.normalizeQuery(this.query); + queryLength = query.length; + this.dataLength = this.data.length; + this.matchers = [exactMatch]; + this.totalResults = 0; + this.setupFuzzy(); + } + + setupFuzzy() { + if (queryLength >= this.options.fuzzy_min_length) { + fuzzyRegexp = this.queryToFuzzyRegexp(query); + this.matchers.push(fuzzyMatch); + } else { + fuzzyRegexp = null; + } + } + + isValid() { + return queryLength > 0 && query !== SEPARATOR; + } + + end() { + if (!this.totalResults) { + this.triggerResults([]); + } + this.trigger("end"); + this.free(); + } + + kill() { + if (this.timeout) { + clearTimeout(this.timeout); + this.free(); + } + } + + free() { + this.data = null; + this.attr = null; + this.dataLength = null; + this.matchers = null; + this.matcher = null; + this.query = null; + this.totalResults = null; + this.scoreMap = null; + this.cursor = null; + this.timeout = null; + } + + match() { + if (!this.foundEnough() && (this.matcher = this.matchers.shift())) { + this.setupMatcher(); + this.matchChunks(); + } else { + this.end(); + } + } + + setupMatcher() { + this.cursor = 0; + this.scoreMap = new Array(101); + } + + matchChunks() { + this.matchChunk(); + + if (this.cursor === this.dataLength || this.scoredEnough()) { + this.delay(() => this.match()); + this.sendResults(); + } else { + this.delay(() => this.matchChunks()); + } + } + + matchChunk() { + ({ matcher } = this); + for (let j = 0, end = this.chunkSize(); j < end; j++) { + value = this.data[this.cursor][this.attr]; + if (value.split) { + // string + valueLength = value.length; + if ((score = matcher())) { + this.addResult(this.data[this.cursor], score); + } + } else { + // array + score = 0; + for (value of Array.from(this.data[this.cursor][this.attr])) { + valueLength = value.length; + score = Math.max(score, matcher() || 0); + } + if (score > 0) { + this.addResult(this.data[this.cursor], score); + } + } + this.cursor++; + } + } + + chunkSize() { + if (this.cursor + Searcher.CHUNK_SIZE > this.dataLength) { + return this.dataLength % Searcher.CHUNK_SIZE; + } else { + return Searcher.CHUNK_SIZE; + } + } + + scoredEnough() { + return this.scoreMap[100]?.length >= this.options.max_results; + } + + foundEnough() { + return this.totalResults >= this.options.max_results; + } + + addResult(object, score) { + let name; + ( + this.scoreMap[(name = Math.round(score))] || (this.scoreMap[name] = []) + ).push(object); + this.totalResults++; + } + + getResults() { + const results = []; + for (let j = this.scoreMap.length - 1; j >= 0; j--) { + var objects = this.scoreMap[j]; + if (objects) { + results.push(...objects); + } + } + return results.slice(0, this.options.max_results); + } + + sendResults() { + const results = this.getResults(); + if (results.length) { + this.triggerResults(results); + } + } + + triggerResults(results) { + this.trigger("results", results); + } + + delay(fn) { + return (this.timeout = setTimeout(fn, 1)); + } + + queryToFuzzyRegexp(string) { + const chars = string.split(""); + for (i = 0; i < chars.length; i++) { + var char = chars[i]; + chars[i] = $.escapeRegexp(char); + } + return new RegExp(chars.join(".*?")); // abc -> /a.*?b.*?c.*?/ + } +}; + +app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher { + match() { + if (this.matcher) { + if (!this.allResults) { + this.allResults = []; + } + this.allResults.push(...this.getResults()); + } + return super.match(...arguments); + } + + free() { + this.allResults = null; + return super.free(...arguments); + } + + end() { + this.sendResults(true); + return super.end(...arguments); + } + + sendResults(end) { + if (end && this.allResults?.length) { + return this.triggerResults(this.allResults); + } + } + + delay(fn) { + return fn(); + } +}; diff --git a/assets/javascripts/app/serviceworker.coffee b/assets/javascripts/app/serviceworker.coffee deleted file mode 100644 index 40235566..00000000 --- a/assets/javascripts/app/serviceworker.coffee +++ /dev/null @@ -1,49 +0,0 @@ -class app.ServiceWorker - $.extend @prototype, Events - - @isEnabled: -> - !!navigator.serviceWorker and app.config.service_worker_enabled - - constructor: -> - @registration = null - @notifyUpdate = true - - navigator.serviceWorker.register(app.config.service_worker_path, {scope: '/'}) - .then( - (registration) => @updateRegistration(registration), - (error) -> console.error('Could not register service worker:', error) - ) - - update: -> - return unless @registration - @notifyUpdate = true - return @registration.update().catch(->) - - updateInBackground: -> - return unless @registration - @notifyUpdate = false - return @registration.update().catch(->) - - reload: -> - return @updateInBackground().then(() -> app.reboot()) - - updateRegistration: (registration) -> - @registration = registration - $.on @registration, 'updatefound', @onUpdateFound - return - - onUpdateFound: => - $.off @installingRegistration, 'statechange', @onStateChange() if @installingRegistration - @installingRegistration = @registration.installing - $.on @installingRegistration, 'statechange', @onStateChange - return - - onStateChange: => - if @installingRegistration and @installingRegistration.state == 'installed' and navigator.serviceWorker.controller - @installingRegistration = null - @onUpdateReady() - return - - onUpdateReady: -> - @trigger 'updateready' if @notifyUpdate - return diff --git a/assets/javascripts/app/serviceworker.js b/assets/javascripts/app/serviceworker.js new file mode 100644 index 00000000..4c35a32c --- /dev/null +++ b/assets/javascripts/app/serviceworker.js @@ -0,0 +1,69 @@ +app.ServiceWorker = class ServiceWorker extends Events { + static isEnabled() { + return !!navigator.serviceWorker && app.config.service_worker_enabled; + } + + constructor() { + super(); + this.onStateChange = this.onStateChange.bind(this); + this.registration = null; + this.notifyUpdate = true; + + navigator.serviceWorker + .register(app.config.service_worker_path, { scope: "/" }) + .then( + (registration) => this.updateRegistration(registration), + (error) => console.error("Could not register service worker:", error), + ); + } + + update() { + if (!this.registration) { + return; + } + this.notifyUpdate = true; + return this.registration.update().catch(() => {}); + } + + updateInBackground() { + if (!this.registration) { + return; + } + this.notifyUpdate = false; + return this.registration.update().catch(() => {}); + } + + reload() { + return this.updateInBackground().then(() => app.reboot()); + } + + updateRegistration(registration) { + this.registration = registration; + $.on(this.registration, "updatefound", () => this.onUpdateFound()); + } + + onUpdateFound() { + if (this.installingRegistration) { + $.off(this.installingRegistration, "statechange", this.onStateChange); + } + this.installingRegistration = this.registration.installing; + $.on(this.installingRegistration, "statechange", this.onStateChange); + } + + onStateChange() { + if ( + this.installingRegistration && + this.installingRegistration.state === "installed" && + navigator.serviceWorker.controller + ) { + this.installingRegistration = null; + this.onUpdateReady(); + } + } + + onUpdateReady() { + if (this.notifyUpdate) { + this.trigger("updateready"); + } + } +}; diff --git a/assets/javascripts/app/settings.coffee b/assets/javascripts/app/settings.coffee deleted file mode 100644 index 74e32a65..00000000 --- a/assets/javascripts/app/settings.coffee +++ /dev/null @@ -1,170 +0,0 @@ -class app.Settings - PREFERENCE_KEYS = [ - 'hideDisabled' - 'hideIntro' - 'manualUpdate' - 'fastScroll' - 'arrowScroll' - 'analyticsConsent' - 'docs' - 'dark' # legacy - 'theme' - 'layout' - 'size' - 'tips' - 'noAutofocus' - 'autoInstall' - 'spaceScroll' - 'spaceTimeout' - ] - - INTERNAL_KEYS = [ - 'count' - 'schema' - 'version' - 'news' - ] - - LAYOUTS: [ - '_max-width' - '_sidebar-hidden' - '_native-scrollbars' - '_text-justify-hyphenate' - ] - - @defaults: - count: 0 - hideDisabled: false - hideIntro: false - news: 0 - manualUpdate: false - schema: 1 - analyticsConsent: false - theme: 'auto' - spaceScroll: 1 - spaceTimeout: 0.5 - - constructor: -> - @store = new CookiesStore - @cache = {} - @autoSupported = window.matchMedia('(prefers-color-scheme)').media != 'not all' - if @autoSupported - @darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') - @darkModeQuery.addListener => @setTheme(@get('theme')) - - - get: (key) -> - return @cache[key] if @cache.hasOwnProperty(key) - @cache[key] = @store.get(key) ? @constructor.defaults[key] - if key == 'theme' and @cache[key] == 'auto' and !@darkModeQuery - @cache[key] = 'default' - else - @cache[key] - - set: (key, value) -> - @store.set(key, value) - delete @cache[key] - @setTheme(value) if key == 'theme' - return - - del: (key) -> - @store.del(key) - delete @cache[key] - return - - hasDocs: -> - try !!@store.get('docs') - - getDocs: -> - @store.get('docs')?.split('/') or app.config.default_docs - - setDocs: (docs) -> - @set 'docs', docs.join('/') - return - - getTips: -> - @store.get('tips')?.split('/') or [] - - setTips: (tips) -> - @set 'tips', tips.join('/') - return - - setLayout: (name, enable) -> - @toggleLayout(name, enable) - - layout = (@store.get('layout') || '').split(' ') - $.arrayDelete(layout, '') - - if enable - layout.push(name) if layout.indexOf(name) is -1 - else - $.arrayDelete(layout, name) - - if layout.length > 0 - @set 'layout', layout.join(' ') - else - @del 'layout' - return - - hasLayout: (name) -> - layout = (@store.get('layout') || '').split(' ') - layout.indexOf(name) isnt -1 - - setSize: (value) -> - @set 'size', value - return - - dump: -> - @store.dump() - - export: -> - data = @dump() - delete data[key] for key in INTERNAL_KEYS - data - - import: (data) -> - for key, value of @export() - @del key unless data.hasOwnProperty(key) - for key, value of data - @set key, value if PREFERENCE_KEYS.indexOf(key) isnt -1 - return - - reset: -> - @store.reset() - @cache = {} - return - - initLayout: -> - if @get('dark') is 1 - @set('theme', 'dark') - @del 'dark' - @setTheme(@get('theme')) - @toggleLayout(layout, @hasLayout(layout)) for layout in @LAYOUTS - @initSidebarWidth() - return - - setTheme: (theme) -> - if theme is 'auto' - theme = if @darkModeQuery.matches then 'dark' else 'default' - classList = document.documentElement.classList - classList.remove('_theme-default', '_theme-dark') - classList.add('_theme-' + theme) - @updateColorMeta() - return - - updateColorMeta: -> - color = getComputedStyle(document.documentElement).getPropertyValue('--headerBackground').trim() - $('meta[name=theme-color]').setAttribute('content', color) - return - - toggleLayout: (layout, enable) -> - classList = document.body.classList - # sidebar is always shown for settings; its state is updated in app.views.Settings - classList.toggle(layout, enable) unless layout is '_sidebar-hidden' and app.router?.isSettings - classList.toggle('_overlay-scrollbars', $.overlayScrollbarsEnabled()) - return - - initSidebarWidth: -> - size = @get('size') - document.documentElement.style.setProperty('--sidebarWidth', size + 'px') if size - return diff --git a/assets/javascripts/app/settings.js b/assets/javascripts/app/settings.js new file mode 100644 index 00000000..1dbe3440 --- /dev/null +++ b/assets/javascripts/app/settings.js @@ -0,0 +1,213 @@ +app.Settings = class Settings { + static PREFERENCE_KEYS = [ + "hideDisabled", + "hideIntro", + "manualUpdate", + "fastScroll", + "arrowScroll", + "analyticsConsent", + "docs", + "dark", // legacy + "theme", + "layout", + "size", + "tips", + "noAutofocus", + "autoInstall", + "spaceScroll", + "spaceTimeout", + ]; + + static INTERNAL_KEYS = ["count", "schema", "version", "news"]; + + static LAYOUTS = [ + "_max-width", + "_sidebar-hidden", + "_native-scrollbars", + "_text-justify-hyphenate", + ]; + + static defaults = { + count: 0, + hideDisabled: false, + hideIntro: false, + news: 0, + manualUpdate: false, + schema: 1, + analyticsConsent: false, + theme: "auto", + spaceScroll: 1, + spaceTimeout: 0.5, + }; + + constructor() { + this.store = new CookiesStore(); + this.cache = {}; + this.autoSupported = + window.matchMedia("(prefers-color-scheme)").media !== "not all"; + if (this.autoSupported) { + this.darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); + this.darkModeQuery.addListener(() => this.setTheme(this.get("theme"))); + } + } + + get(key) { + let left; + if (this.cache.hasOwnProperty(key)) { + return this.cache[key]; + } + this.cache[key] = + (left = this.store.get(key)) != null + ? left + : this.constructor.defaults[key]; + if (key === "theme" && this.cache[key] === "auto" && !this.darkModeQuery) { + return (this.cache[key] = "default"); + } else { + return this.cache[key]; + } + } + + set(key, value) { + this.store.set(key, value); + delete this.cache[key]; + if (key === "theme") { + this.setTheme(value); + } + } + + del(key) { + this.store.del(key); + delete this.cache[key]; + } + + hasDocs() { + try { + return !!this.store.get("docs"); + } catch (error) {} + } + + getDocs() { + return this.store.get("docs")?.split("/") || app.config.default_docs; + } + + setDocs(docs) { + this.set("docs", docs.join("/")); + } + + getTips() { + return this.store.get("tips")?.split("/") || []; + } + + setTips(tips) { + this.set("tips", tips.join("/")); + } + + setLayout(name, enable) { + this.toggleLayout(name, enable); + + const layout = (this.store.get("layout") || "").split(" "); + $.arrayDelete(layout, ""); + + if (enable) { + if (!layout.includes(name)) { + layout.push(name); + } + } else { + $.arrayDelete(layout, name); + } + + if (layout.length > 0) { + this.set("layout", layout.join(" ")); + } else { + this.del("layout"); + } + } + + hasLayout(name) { + const layout = (this.store.get("layout") || "").split(" "); + return layout.includes(name); + } + + setSize(value) { + this.set("size", value); + } + + dump() { + return this.store.dump(); + } + + export() { + const data = this.dump(); + for (var key of Settings.INTERNAL_KEYS) { + delete data[key]; + } + return data; + } + + import(data) { + let key, value; + const object = this.export(); + for (key in object) { + value = object[key]; + if (!data.hasOwnProperty(key)) { + this.del(key); + } + } + for (key in data) { + value = data[key]; + if (Settings.PREFERENCE_KEYS.includes(key)) { + this.set(key, value); + } + } + } + + reset() { + this.store.reset(); + this.cache = {}; + } + + initLayout() { + if (this.get("dark") === 1) { + this.set("theme", "dark"); + this.del("dark"); + } + this.setTheme(this.get("theme")); + for (var layout of app.Settings.LAYOUTS) { + this.toggleLayout(layout, this.hasLayout(layout)); + } + this.initSidebarWidth(); + } + + setTheme(theme) { + if (theme === "auto") { + theme = this.darkModeQuery.matches ? "dark" : "default"; + } + const { classList } = document.documentElement; + classList.remove("_theme-default", "_theme-dark"); + classList.add("_theme-" + theme); + this.updateColorMeta(); + } + + updateColorMeta() { + const color = getComputedStyle(document.documentElement) + .getPropertyValue("--headerBackground") + .trim(); + $("meta[name=theme-color]").setAttribute("content", color); + } + + toggleLayout(layout, enable) { + const { classList } = document.body; + // sidebar is always shown for settings; its state is updated in app.views.Settings + if (layout !== "_sidebar-hidden" || !app.router?.isSettings) { + classList.toggle(layout, enable); + } + classList.toggle("_overlay-scrollbars", $.overlayScrollbarsEnabled()); + } + + initSidebarWidth() { + const size = this.get("size"); + if (size) { + document.documentElement.style.setProperty("--sidebarWidth", size + "px"); + } + } +}; diff --git a/assets/javascripts/app/shortcuts.coffee b/assets/javascripts/app/shortcuts.coffee deleted file mode 100644 index 28ddf0b8..00000000 --- a/assets/javascripts/app/shortcuts.coffee +++ /dev/null @@ -1,193 +0,0 @@ -class app.Shortcuts - $.extend @prototype, Events - - constructor: -> - @isMac = $.isMac() - @start() - - start: -> - $.on document, 'keydown', @onKeydown - $.on document, 'keypress', @onKeypress - return - - stop: -> - $.off document, 'keydown', @onKeydown - $.off document, 'keypress', @onKeypress - return - - swapArrowKeysBehavior: -> - app.settings.get('arrowScroll') - - spaceScroll: -> - app.settings.get('spaceScroll') - - showTip: -> - app.showTip('KeyNav') - @showTip = null - - spaceTimeout: -> - app.settings.get('spaceTimeout') - - 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 - @handleKeydownShiftEvent event unless event.altKey - else if event.altKey - @handleKeydownAltEvent event - else - @handleKeydownEvent event - - event.preventDefault() if result is false - return - - onKeypress: (event) => - return if @buggyEvent(event) or (event.charCode == 63 and document.activeElement.tagName == 'INPUT') - unless event.ctrlKey or event.metaKey - result = @handleKeypressEvent event - event.preventDefault() if result is false - return - - 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 - - switch event.which - when 8 - @trigger 'typing' unless event.target.form - when 13 - @trigger 'enter' - when 27 - @trigger 'escape' - false - when 32 - if event.target.type is 'search' and @spaceScroll() and (not @lastKeypress or @lastKeypress < Date.now() - (@spaceTimeout() * 1000)) - @trigger 'pageDown' - false - when 33 - @trigger 'pageUp' - when 34 - @trigger 'pageDown' - when 35 - @trigger 'pageBottom' unless event.target.form - when 36 - @trigger 'pageTop' unless event.target.form - when 37 - @trigger 'left' unless event.target.value - when 38 - @trigger 'up' - @showTip?() - false - when 39 - @trigger 'right' unless event.target.value - when 40 - @trigger 'down' - @showTip?() - false - when 191 - unless event.target.form - @trigger 'typing' - false - - handleKeydownSuperEvent: (event) -> - switch event.which - when 13 - @trigger 'superEnter' - when 37 - if @isMac - @trigger 'superLeft' - false - when 38 - @trigger 'pageTop' - false - when 39 - if @isMac - @trigger 'superRight' - false - when 40 - @trigger 'pageBottom' - false - when 188 - @trigger 'preferences' - false - - 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 - - switch event.which - when 32 - @trigger 'pageUp' - false - when 38 - unless getSelection()?.toString() - @trigger 'altUp' - false - when 40 - unless getSelection()?.toString() - @trigger 'altDown' - false - - 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 - unless @isMac - @trigger 'superLeft' - false - when 38 - @trigger 'altUp' - false - when 39 - unless @isMac - @trigger 'superRight' - false - when 40 - @trigger 'altDown' - false - when 67 - @trigger 'altC' - false - when 68 - @trigger 'altD' - false - when 70 - @trigger 'altF', event - when 71 - @trigger 'altG' - false - when 79 - @trigger 'altO' - false - when 82 - @trigger 'altR' - false - when 83 - @trigger 'altS' - false - - handleKeypressEvent: (event) -> - if event.which is 63 and not event.target.value - @trigger 'help' - 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/shortcuts.js b/assets/javascripts/app/shortcuts.js new file mode 100644 index 00000000..05e09cf7 --- /dev/null +++ b/assets/javascripts/app/shortcuts.js @@ -0,0 +1,295 @@ +app.Shortcuts = class Shortcuts extends Events { + constructor() { + super(); + this.onKeydown = this.onKeydown.bind(this); + this.onKeypress = this.onKeypress.bind(this); + this.isMac = $.isMac(); + this.start(); + } + + start() { + $.on(document, "keydown", this.onKeydown); + $.on(document, "keypress", this.onKeypress); + } + + stop() { + $.off(document, "keydown", this.onKeydown); + $.off(document, "keypress", this.onKeypress); + } + + swapArrowKeysBehavior() { + return app.settings.get("arrowScroll"); + } + + spaceScroll() { + return app.settings.get("spaceScroll"); + } + + showTip() { + app.showTip("KeyNav"); + return (this.showTip = null); + } + + spaceTimeout() { + return app.settings.get("spaceTimeout"); + } + + onKeydown(event) { + if (this.buggyEvent(event)) { + return; + } + const result = (() => { + if (event.ctrlKey || event.metaKey) { + if (!event.altKey && !event.shiftKey) { + return this.handleKeydownSuperEvent(event); + } + } else if (event.shiftKey) { + if (!event.altKey) { + return this.handleKeydownShiftEvent(event); + } + } else if (event.altKey) { + return this.handleKeydownAltEvent(event); + } else { + return this.handleKeydownEvent(event); + } + })(); + + if (result === false) { + event.preventDefault(); + } + } + + onKeypress(event) { + if ( + this.buggyEvent(event) || + (event.charCode === 63 && document.activeElement.tagName === "INPUT") + ) { + return; + } + if (!event.ctrlKey && !event.metaKey) { + const result = this.handleKeypressEvent(event); + if (result === false) { + event.preventDefault(); + } + } + } + + handleKeydownEvent(event, _force) { + if ( + !_force && + [37, 38, 39, 40].includes(event.which) && + this.swapArrowKeysBehavior() + ) { + return this.handleKeydownAltEvent(event, true); + } + + if ( + !event.target.form && + ((48 <= event.which && event.which <= 57) || + (65 <= event.which && event.which <= 90)) + ) { + this.trigger("typing"); + return; + } + + switch (event.which) { + case 8: + if (!event.target.form) { + return this.trigger("typing"); + } + break; + case 13: + return this.trigger("enter"); + case 27: + this.trigger("escape"); + return false; + case 32: + if ( + event.target.type === "search" && + this.spaceScroll() && + (!this.lastKeypress || + this.lastKeypress < Date.now() - this.spaceTimeout() * 1000) + ) { + this.trigger("pageDown"); + return false; + } + break; + case 33: + return this.trigger("pageUp"); + case 34: + return this.trigger("pageDown"); + case 35: + if (!event.target.form) { + return this.trigger("pageBottom"); + } + break; + case 36: + if (!event.target.form) { + return this.trigger("pageTop"); + } + break; + case 37: + if (!event.target.value) { + return this.trigger("left"); + } + break; + case 38: + this.trigger("up"); + if (typeof this.showTip === "function") { + this.showTip(); + } + return false; + case 39: + if (!event.target.value) { + return this.trigger("right"); + } + break; + case 40: + this.trigger("down"); + if (typeof this.showTip === "function") { + this.showTip(); + } + return false; + case 191: + if (!event.target.form) { + this.trigger("typing"); + return false; + } + break; + } + } + + handleKeydownSuperEvent(event) { + switch (event.which) { + case 13: + return this.trigger("superEnter"); + case 37: + if (this.isMac) { + this.trigger("superLeft"); + return false; + } + break; + case 38: + this.trigger("pageTop"); + return false; + case 39: + if (this.isMac) { + this.trigger("superRight"); + return false; + } + break; + case 40: + this.trigger("pageBottom"); + return false; + case 188: + this.trigger("preferences"); + return false; + } + } + + handleKeydownShiftEvent(event, _force) { + if ( + !_force && + [37, 38, 39, 40].includes(event.which) && + this.swapArrowKeysBehavior() + ) { + return this.handleKeydownEvent(event, true); + } + + if (!event.target.form && 65 <= event.which && event.which <= 90) { + this.trigger("typing"); + return; + } + + switch (event.which) { + case 32: + this.trigger("pageUp"); + return false; + case 38: + if (!getSelection()?.toString()) { + this.trigger("altUp"); + return false; + } + break; + case 40: + if (!getSelection()?.toString()) { + this.trigger("altDown"); + return false; + } + break; + } + } + + handleKeydownAltEvent(event, _force) { + if ( + !_force && + [37, 38, 39, 40].includes(event.which) && + this.swapArrowKeysBehavior() + ) { + return this.handleKeydownEvent(event, true); + } + + switch (event.which) { + case 9: + return this.trigger("altRight", event); + case 37: + if (!this.isMac) { + this.trigger("superLeft"); + return false; + } + break; + case 38: + this.trigger("altUp"); + return false; + case 39: + if (!this.isMac) { + this.trigger("superRight"); + return false; + } + break; + case 40: + this.trigger("altDown"); + return false; + case 67: + this.trigger("altC"); + return false; + case 68: + this.trigger("altD"); + return false; + case 70: + return this.trigger("altF", event); + case 71: + this.trigger("altG"); + return false; + case 79: + this.trigger("altO"); + return false; + case 82: + this.trigger("altR"); + return false; + case 83: + this.trigger("altS"); + return false; + } + } + + handleKeypressEvent(event) { + if (event.which === 63 && !event.target.value) { + this.trigger("help"); + return false; + } else { + return (this.lastKeypress = Date.now()); + } + } + + buggyEvent(event) { + try { + event.target; + event.ctrlKey; + event.which; + return false; + } catch (error) { + return true; + } + } +}; diff --git a/assets/javascripts/app/update_checker.coffee b/assets/javascripts/app/update_checker.coffee deleted file mode 100644 index 3558d6bc..00000000 --- a/assets/javascripts/app/update_checker.coffee +++ /dev/null @@ -1,39 +0,0 @@ -class app.UpdateChecker - constructor: -> - @lastCheck = Date.now() - - $.on window, 'focus', @onFocus - app.serviceWorker?.on 'updateready', @onUpdateReady - - setTimeout @checkDocs, 0 - - check: -> - if app.serviceWorker - app.serviceWorker.update() - else - ajax - url: $('script[src*="application"]').getAttribute('src') - dataType: 'application/javascript' - error: (_, xhr) => @onUpdateReady() if xhr.status is 404 - return - - onUpdateReady: -> - new app.views.Notif 'UpdateReady', autoHide: null - return - - checkDocs: => - unless app.settings.get('manualUpdate') - app.docs.updateInBackground() - else - app.docs.checkForUpdates (i) => @onDocsUpdateReady() if i > 0 - return - - onDocsUpdateReady: -> - new app.views.Notif 'UpdateDocs', autoHide: null - return - - onFocus: => - if Date.now() - @lastCheck > 21600e3 - @lastCheck = Date.now() - @check() - return diff --git a/assets/javascripts/app/update_checker.js b/assets/javascripts/app/update_checker.js new file mode 100644 index 00000000..82d3cc92 --- /dev/null +++ b/assets/javascripts/app/update_checker.js @@ -0,0 +1,55 @@ +app.UpdateChecker = class UpdateChecker { + constructor() { + this.lastCheck = Date.now(); + + $.on(window, "focus", () => this.onFocus()); + if (app.serviceWorker) { + app.serviceWorker.on("updateready", () => this.onUpdateReady()); + } + + setTimeout(() => this.checkDocs(), 0); + } + + check() { + if (app.serviceWorker) { + app.serviceWorker.update(); + } else { + ajax({ + url: $('script[src*="application"]').getAttribute("src"), + dataType: "application/javascript", + error: (_, xhr) => { + if (xhr.status === 404) { + return this.onUpdateReady(); + } + }, + }); + } + } + + onUpdateReady() { + new app.views.Notif("UpdateReady", { autoHide: null }); + } + + checkDocs() { + if (!app.settings.get("manualUpdate")) { + app.docs.updateInBackground(); + } else { + app.docs.checkForUpdates((i) => { + if (i > 0) { + return this.onDocsUpdateReady(); + } + }); + } + } + + onDocsUpdateReady() { + new app.views.Notif("UpdateDocs", { autoHide: null }); + } + + onFocus() { + if (Date.now() - this.lastCheck > 21600e3) { + this.lastCheck = Date.now(); + this.check(); + } + } +}; diff --git a/assets/javascripts/application.js b/assets/javascripts/application.js new file mode 100644 index 00000000..0bfa4568 --- /dev/null +++ b/assets/javascripts/application.js @@ -0,0 +1,33 @@ +//= require_tree ./vendor + +//= require lib/license +//= require_tree ./lib + +//= require app/app +//= require app/config +//= require_tree ./app + +//= require collections/collection +//= require_tree ./collections + +//= require models/model +//= require_tree ./models + +//= require views/view +//= require_tree ./views + +//= require_tree ./templates + +//= require tracking + +var init = function () { + document.removeEventListener("DOMContentLoaded", init, false); + + if (document.body) { + return app.init(); + } else { + return setTimeout(init, 42); + } +}; + +document.addEventListener("DOMContentLoaded", init, false); diff --git a/assets/javascripts/application.js.coffee b/assets/javascripts/application.js.coffee deleted file mode 100644 index 6bf87f1c..00000000 --- a/assets/javascripts/application.js.coffee +++ /dev/null @@ -1,31 +0,0 @@ -#= require_tree ./vendor - -#= require lib/license -#= require_tree ./lib - -#= require app/app -#= require app/config -#= require_tree ./app - -#= require collections/collection -#= require_tree ./collections - -#= require models/model -#= require_tree ./models - -#= require views/view -#= require_tree ./views - -#= require_tree ./templates - -#= require tracking - -init = -> - document.removeEventListener 'DOMContentLoaded', init, false - - if document.body - app.init() - else - setTimeout(init, 42) - -document.addEventListener 'DOMContentLoaded', init, false diff --git a/assets/javascripts/collections/collection.coffee b/assets/javascripts/collections/collection.coffee deleted file mode 100644 index b902a498..00000000 --- a/assets/javascripts/collections/collection.coffee +++ /dev/null @@ -1,55 +0,0 @@ -class app.Collection - constructor: (objects = []) -> - @reset objects - - model: -> - app.models[@constructor.model] - - reset: (objects = []) -> - @models = [] - @add object for object in objects - return - - add: (object) -> - if object instanceof app.Model - @models.push object - else if object instanceof Array - @add obj for obj in object - else if object instanceof app.Collection - @models.push object.all()... - else - @models.push new (@model())(object) - return - - remove: (model) -> - @models.splice @models.indexOf(model), 1 - return - - size: -> - @models.length - - isEmpty: -> - @models.length is 0 - - each: (fn) -> - fn(model) for model in @models - return - - all: -> - @models - - contains: (model) -> - @models.indexOf(model) >= 0 - - findBy: (attr, value) -> - for model in @models - return model if model[attr] is value - return - - 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/collection.js b/assets/javascripts/collections/collection.js new file mode 100644 index 00000000..79bca0be --- /dev/null +++ b/assets/javascripts/collections/collection.js @@ -0,0 +1,80 @@ +app.Collection = class Collection { + constructor(objects) { + if (objects == null) { + objects = []; + } + this.reset(objects); + } + + model() { + return app.models[this.constructor.model]; + } + + reset(objects) { + if (objects == null) { + objects = []; + } + this.models = []; + for (var object of objects) { + this.add(object); + } + } + + add(object) { + if (object instanceof app.Model) { + this.models.push(object); + } else if (object instanceof Array) { + for (var obj of object) { + this.add(obj); + } + } else if (object instanceof app.Collection) { + this.models.push(...(object.all() || [])); + } else { + this.models.push(new (this.model())(object)); + } + } + + remove(model) { + this.models.splice(this.models.indexOf(model), 1); + } + + size() { + return this.models.length; + } + + isEmpty() { + return this.models.length === 0; + } + + each(fn) { + for (var model of this.models) { + fn(model); + } + } + + all() { + return this.models; + } + + contains(model) { + return this.models.includes(model); + } + + findBy(attr, value) { + return this.models.find((model) => model[attr] === value); + } + + findAllBy(attr, value) { + return this.models.filter((model) => model[attr] === value); + } + + countAllBy(attr, value) { + let i = 0; + for (var model of this.models) { + if (model[attr] === value) { + i += 1; + } + } + return i; + } +}; diff --git a/assets/javascripts/collections/docs.coffee b/assets/javascripts/collections/docs.coffee deleted file mode 100644 index d76e0f07..00000000 --- a/assets/javascripts/collections/docs.coffee +++ /dev/null @@ -1,85 +0,0 @@ -class app.collections.Docs extends app.Collection - @model: 'Doc' - - 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) -> - 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. - CONCURRENCY = 3 - load: (onComplete, onError, options) -> - i = 0 - - next = => - if i < @models.length - @models[i].load(next, fail, options) - else if i is @models.length + CONCURRENCY - 1 - onComplete() - i++ - return - - fail = (args...) -> - if onError - onError(args...) - onError = null - next() - return - - next() for [0...CONCURRENCY] - return - - clearCache: -> - doc.clearCache() for doc in @models - return - - uninstall: (callback) -> - i = 0 - next = => - if i < @models.length - @models[i++].uninstall(next, next) - else - callback() - return - next() - return - - getInstallStatuses: (callback) -> - app.db.versions @models, (statuses) -> - if statuses - for key, value of statuses - statuses[key] = installed: !!value, mtime: value - callback(statuses) - return - return - - checkForUpdates: (callback) -> - @getInstallStatuses (statuses) => - i = 0 - if statuses - i += 1 for slug, status of statuses when @findBy('slug', slug).isOutdated(status) - callback(i) - return - return - - updateInBackground: -> - @getInstallStatuses (statuses) => - return unless statuses - for slug, status of statuses - doc = @findBy 'slug', slug - doc.install($.noop, $.noop) if doc.isOutdated(status) - return - return diff --git a/assets/javascripts/collections/docs.js b/assets/javascripts/collections/docs.js new file mode 100644 index 00000000..d4aa9c84 --- /dev/null +++ b/assets/javascripts/collections/docs.js @@ -0,0 +1,124 @@ +app.collections.Docs = class Docs extends app.Collection { + static model = "Doc"; + static NORMALIZE_VERSION_RGX = /\.(\d)$/; + static NORMALIZE_VERSION_SUB = ".0$1"; + + // Load models concurrently. + // It's not pretty but I didn't want to import a promise library only for this. + static CONCURRENCY = 3; + + findBySlug(slug) { + return ( + this.findBy("slug", slug) || this.findBy("slug_without_version", slug) + ); + } + sort() { + return this.models.sort((a, b) => { + if (a.name === b.name) { + if ( + !a.version || + a.version.replace( + Docs.NORMALIZE_VERSION_RGX, + Docs.NORMALIZE_VERSION_SUB, + ) > + b.version.replace( + Docs.NORMALIZE_VERSION_RGX, + Docs.NORMALIZE_VERSION_SUB, + ) + ) { + return -1; + } else { + return 1; + } + } else if (a.name.toLowerCase() > b.name.toLowerCase()) { + return 1; + } else { + return -1; + } + }); + } + load(onComplete, onError, options) { + let i = 0; + + var next = () => { + if (i < this.models.length) { + this.models[i].load(next, fail, options); + } else if (i === this.models.length + Docs.CONCURRENCY - 1) { + onComplete(); + } + i++; + }; + + var fail = function (...args) { + if (onError) { + onError(args); + onError = null; + } + next(); + }; + + for (let j = 0, end = Docs.CONCURRENCY; j < end; j++) { + next(); + } + } + + clearCache() { + for (var doc of this.models) { + doc.clearCache(); + } + } + + uninstall(callback) { + let i = 0; + var next = () => { + if (i < this.models.length) { + this.models[i++].uninstall(next, next); + } else { + callback(); + } + }; + next(); + } + + getInstallStatuses(callback) { + app.db.versions(this.models, (statuses) => { + if (statuses) { + for (var key in statuses) { + var value = statuses[key]; + statuses[key] = { installed: !!value, mtime: value }; + } + } + callback(statuses); + }); + } + + checkForUpdates(callback) { + this.getInstallStatuses((statuses) => { + let i = 0; + if (statuses) { + for (var slug in statuses) { + var status = statuses[slug]; + if (this.findBy("slug", slug).isOutdated(status)) { + i += 1; + } + } + } + callback(i); + }); + } + + updateInBackground() { + this.getInstallStatuses((statuses) => { + if (!statuses) { + return; + } + for (var slug in statuses) { + var status = statuses[slug]; + var doc = this.findBy("slug", slug); + if (doc.isOutdated(status)) { + doc.install($.noop, $.noop); + } + } + }); + } +}; diff --git a/assets/javascripts/collections/entries.coffee b/assets/javascripts/collections/entries.coffee deleted file mode 100644 index f978b68b..00000000 --- a/assets/javascripts/collections/entries.coffee +++ /dev/null @@ -1,2 +0,0 @@ -class app.collections.Entries extends app.Collection - @model: 'Entry' diff --git a/assets/javascripts/collections/entries.js b/assets/javascripts/collections/entries.js new file mode 100644 index 00000000..2ea74707 --- /dev/null +++ b/assets/javascripts/collections/entries.js @@ -0,0 +1,3 @@ +app.collections.Entries = class Entries extends app.Collection { + static model = "Entry"; +}; diff --git a/assets/javascripts/collections/types.coffee b/assets/javascripts/collections/types.coffee deleted file mode 100644 index 8e76eeab..00000000 --- a/assets/javascripts/collections/types.coffee +++ /dev/null @@ -1,19 +0,0 @@ -class app.collections.Types extends app.Collection - @model: 'Type' - - groups: -> - result = [] - for type in @models - (result[@_groupFor(type)] ||= []).push(type) - result.filter (e) -> e.length > 0 - - 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/collections/types.js b/assets/javascripts/collections/types.js new file mode 100644 index 00000000..0d23be09 --- /dev/null +++ b/assets/javascripts/collections/types.js @@ -0,0 +1,26 @@ +app.collections.Types = class Types extends app.Collection { + static model = "Type"; + static GUIDES_RGX = + /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i; + static APPENDIX_RGX = /appendix/i; + + groups() { + const result = []; + for (var type of this.models) { + const name = this._groupFor(type); + result[name] ||= []; + result[name].push(type); + } + return result.filter((e) => e.length > 0); + } + + _groupFor(type) { + if (Types.GUIDES_RGX.test(type.name)) { + return 0; + } else if (Types.APPENDIX_RGX.test(type.name)) { + return 2; + } else { + return 1; + } + } +}; diff --git a/assets/javascripts/debug.js b/assets/javascripts/debug.js new file mode 100644 index 00000000..8fcba75b --- /dev/null +++ b/assets/javascripts/debug.js @@ -0,0 +1,105 @@ +// +// App +// + +const _init = app.init; +app.init = function () { + console.time("Init"); + _init.call(app); + console.timeEnd("Init"); + return console.time("Load"); +}; + +const _start = app.start; +app.start = function () { + console.timeEnd("Load"); + console.time("Start"); + _start.call(app, ...arguments); + return console.timeEnd("Start"); +}; + +// +// Searcher +// + +app.Searcher = class TimingSearcher extends app.Searcher { + setup() { + console.groupCollapsed(`Search: ${this.query}`); + console.time("Total"); + return super.setup(); + } + + match() { + if (this.matcher) { + console.timeEnd(this.matcher.name); + } + return super.match(); + } + + setupMatcher() { + console.time(this.matcher.name); + return super.setupMatcher(); + } + + end() { + console.log(`Results: ${this.totalResults}`); + console.timeEnd("Total"); + console.groupEnd(); + return super.end(); + } + + kill() { + if (this.timeout) { + if (this.matcher) { + console.timeEnd(this.matcher.name); + } + console.groupEnd(); + console.timeEnd("Total"); + console.warn("Killed"); + } + return super.kill(); + } +}; + +// +// View tree +// + +this.viewTree = function (view, level, visited) { + if (view == null) { + view = app.document; + } + if (level == null) { + level = 0; + } + if (visited == null) { + visited = []; + } + if (visited.includes(view)) { + return; + } + visited.push(view); + + console.log( + `%c ${Array(level + 1).join(" ")}${ + view.constructor.name + }: ${!!view.activated}`, + "color:" + ((view.activated && "green") || "red"), + ); + + for (var key of Object.keys(view || {})) { + var value = view[key]; + if (key !== "view" && value) { + if (typeof value === "object" && value.setupElement) { + this.viewTree(value, level + 1, visited); + } else if (value.constructor.toString().match(/Object\(\)/)) { + for (var k of Object.keys(value || {})) { + var v = value[k]; + if (v && typeof v === "object" && v.setupElement) { + this.viewTree(v, level + 1, visited); + } + } + } + } + } +}; diff --git a/assets/javascripts/debug.js.coffee b/assets/javascripts/debug.js.coffee deleted file mode 100644 index 032d93ac..00000000 --- a/assets/javascripts/debug.js.coffee +++ /dev/null @@ -1,85 +0,0 @@ -return unless console?.time and console.groupCollapsed - -# -# App -# - -_init = app.init -app.init = -> - console.time 'Init' - _init.call(app) - console.timeEnd 'Init' - console.time 'Load' - -_start = app.start -app.start = -> - console.timeEnd 'Load' - console.time 'Start' - _start.call(app, arguments...) - console.timeEnd 'Start' - -# -# Searcher -# - -_super = app.Searcher -_proto = app.Searcher.prototype - -app.Searcher = -> - _super.apply @, arguments - - _setup = @setup.bind(@) - @setup = -> - console.groupCollapsed "Search: #{@query}" - console.time 'Total' - _setup() - - _match = @match.bind(@) - @match = => - console.timeEnd @matcher.name if @matcher - _match() - - _setupMatcher = @setupMatcher.bind(@) - @setupMatcher = -> - console.time @matcher.name - _setupMatcher() - - _end = @end.bind(@) - @end = -> - console.log "Results: #{@totalResults}" - console.timeEnd 'Total' - console.groupEnd() - _end() - - _kill = @kill.bind(@) - @kill = -> - if @timeout - console.timeEnd @matcher.name if @matcher - console.groupEnd() - console.timeEnd 'Total' - console.warn 'Killed' - _kill() - - return - -$.extend(app.Searcher, _super) -_proto.constructor = app.Searcher -app.Searcher.prototype = _proto - -# -# View tree -# - -@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 own key, value of view when key isnt 'view' and value - if typeof value is 'object' and value.setupElement - @viewTree(value, level + 1, visited) - else if value.constructor.toString().match(/Object\(\)/) - @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 deleted file mode 100644 index 4138ce7b..00000000 --- a/assets/javascripts/lib/ajax.coffee +++ /dev/null @@ -1,118 +0,0 @@ -MIME_TYPES = - json: 'application/json' - html: 'text/html' - -@ajax = (options) -> - applyDefaults(options) - serializeData(options) - - xhr = new XMLHttpRequest() - xhr.open(options.type, options.url, options.async) - - applyCallbacks(xhr, options) - applyHeaders(xhr, options) - - xhr.send(options.data) - - if options.async - abort: abort.bind(undefined, xhr) - else - parseResponse(xhr, options) - -ajax.defaults = - async: true - dataType: 'json' - timeout: 30 - type: 'GET' - # contentType - # context - # data - # error - # headers - # progress - # success - # url - -applyDefaults = (options) -> - for key of ajax.defaults - options[key] ?= ajax.defaults[key] - return - -serializeData = (options) -> - return unless options.data - - if options.type is 'GET' - options.url += '?' + serializeParams(options.data) - options.data = null - else - options.data = serializeParams(options.data) - return - -serializeParams = (params) -> - ("#{encodeURIComponent key}=#{encodeURIComponent value}" for key, value of params).join '&' - -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) - onComplete(xhr, options) - return - return - -applyHeaders = (xhr, options) -> - options.headers or= {} - - if options.contentType - options.headers['Content-Type'] = options.contentType - - if not options.headers['Content-Type'] and options.data and options.type isnt 'GET' - options.headers['Content-Type'] = 'application/x-www-form-urlencoded' - - if options.dataType - options.headers['Accept'] = MIME_TYPES[options.dataType] or options.dataType - - for key, value of options.headers - xhr.setRequestHeader(key, value) - return - -onComplete = (xhr, options) -> - if 200 <= xhr.status < 300 - if (response = parseResponse(xhr, options))? - onSuccess response, xhr, options - else - onError 'invalid', xhr, options - else - onError 'error', xhr, options - return - -onSuccess = (response, xhr, options) -> - options.success?.call options.context, response, xhr, options - return - -onError = (type, xhr, options) -> - options.error?.call options.context, type, xhr, options - return - -onTimeout = (xhr, options) -> - xhr.abort() - onError 'timeout', xhr, options - return - -abort = (xhr) -> - clearTimeout(xhr.timer) - xhr.onreadystatechange = null - xhr.abort() - return - -parseResponse = (xhr, options) -> - if options.dataType is 'json' - parseJSON(xhr.responseText) - else - xhr.responseText - -parseJSON = (json) -> - try JSON.parse(json) catch diff --git a/assets/javascripts/lib/ajax.js b/assets/javascripts/lib/ajax.js new file mode 100644 index 00000000..78527561 --- /dev/null +++ b/assets/javascripts/lib/ajax.js @@ -0,0 +1,166 @@ +const MIME_TYPES = { + json: "application/json", + html: "text/html", +}; + +function ajax(options) { + applyDefaults(options); + serializeData(options); + + const xhr = new XMLHttpRequest(); + xhr.open(options.type, options.url, options.async); + + applyCallbacks(xhr, options); + applyHeaders(xhr, options); + + xhr.send(options.data); + + if (options.async) { + return { abort: abort.bind(undefined, xhr) }; + } else { + return parseResponse(xhr, options); + } + + function applyDefaults(options) { + for (var key in ajax.defaults) { + if (options[key] == null) { + options[key] = ajax.defaults[key]; + } + } + } + + function serializeData(options) { + if (!options.data) { + return; + } + + if (options.type === "GET") { + options.url += "?" + serializeParams(options.data); + options.data = null; + } else { + options.data = serializeParams(options.data); + } + } + + function serializeParams(params) { + return Object.entries(params) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + ) + .join("&"); + } + + function applyCallbacks(xhr, options) { + if (!options.async) { + return; + } + + xhr.timer = setTimeout( + onTimeout.bind(undefined, xhr, options), + options.timeout * 1000, + ); + if (options.progress) { + xhr.onprogress = options.progress; + } + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + clearTimeout(xhr.timer); + onComplete(xhr, options); + } + }; + } + + function applyHeaders(xhr, options) { + if (!options.headers) { + options.headers = {}; + } + + if (options.contentType) { + options.headers["Content-Type"] = options.contentType; + } + + if ( + !options.headers["Content-Type"] && + options.data && + options.type !== "GET" + ) { + options.headers["Content-Type"] = "application/x-www-form-urlencoded"; + } + + if (options.dataType) { + options.headers["Accept"] = + MIME_TYPES[options.dataType] || options.dataType; + } + + for (var key in options.headers) { + var value = options.headers[key]; + xhr.setRequestHeader(key, value); + } + } + + function onComplete(xhr, options) { + if (200 <= xhr.status && xhr.status < 300) { + let response; + if ((response = parseResponse(xhr, options)) != null) { + onSuccess(response, xhr, options); + } else { + onError("invalid", xhr, options); + } + } else { + onError("error", xhr, options); + } + } + + function onSuccess(response, xhr, options) { + if (options.success != null) { + options.success.call(options.context, response, xhr, options); + } + } + + function onError(type, xhr, options) { + if (options.error != null) { + options.error.call(options.context, type, xhr, options); + } + } + + function onTimeout(xhr, options) { + xhr.abort(); + onError("timeout", xhr, options); + } + + function abort(xhr) { + clearTimeout(xhr.timer); + xhr.onreadystatechange = null; + xhr.abort(); + } + + function parseResponse(xhr, options) { + if (options.dataType === "json") { + return parseJSON(xhr.responseText); + } else { + return xhr.responseText; + } + } + + function parseJSON(json) { + try { + return JSON.parse(json); + } catch (error) {} + } +} + +ajax.defaults = { + async: true, + dataType: "json", + timeout: 30, + type: "GET", + // contentType + // context + // data + // error + // headers + // progress + // success + // url +}; diff --git a/assets/javascripts/lib/cookies_store.coffee b/assets/javascripts/lib/cookies_store.coffee deleted file mode 100644 index eaf1bd4f..00000000 --- a/assets/javascripts/lib/cookies_store.coffee +++ /dev/null @@ -1,42 +0,0 @@ -class @CookiesStore - # Intentionally called CookiesStore instead of CookieStore - # Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome - # Related issue: https://github.com/freeCodeCamp/devdocs/issues/932 - - 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 - value = parseInt(value, 10) if value and INT.test?(value) - 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/cookies_store.js b/assets/javascripts/lib/cookies_store.js new file mode 100644 index 00000000..7878855c --- /dev/null +++ b/assets/javascripts/lib/cookies_store.js @@ -0,0 +1,63 @@ +// Intentionally called CookiesStore instead of CookieStore +// Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome +// Related issue: https://github.com/freeCodeCamp/devdocs/issues/932 +class CookiesStore { + static INT = /^\d+$/; + + static onBlocked() {} + + get(key) { + let value = Cookies.get(key); + if (value != null && CookiesStore.INT.test(value)) { + value = parseInt(value, 10); + } + return value; + } + + set(key, value) { + if (value === false) { + this.del(key); + return; + } + + if (value === true) { + value = 1; + } + if ( + value && + (typeof CookiesStore.INT.test === "function" + ? CookiesStore.INT.test(value) + : undefined) + ) { + value = parseInt(value, 10); + } + Cookies.set(key, "" + value, { path: "/", expires: 1e8 }); + if (this.get(key) !== value) { + CookiesStore.onBlocked(key, value, this.get(key)); + } + } + + del(key) { + Cookies.expire(key); + } + + reset() { + try { + for (var cookie of document.cookie.split(/;\s?/)) { + Cookies.expire(cookie.split("=")[0]); + } + return; + } catch (error) {} + } + + dump() { + const result = {}; + for (var cookie of document.cookie.split(/;\s?/)) { + if (cookie[0] !== "_") { + cookie = cookie.split("="); + result[cookie[0]] = cookie[1]; + } + } + return result; + } +} diff --git a/assets/javascripts/lib/events.coffee b/assets/javascripts/lib/events.coffee deleted file mode 100644 index 05936076..00000000 --- a/assets/javascripts/lib/events.coffee +++ /dev/null @@ -1,28 +0,0 @@ -@Events = - on: (event, callback) -> - if event.indexOf(' ') >= 0 - @on name, callback for name in event.split(' ') - else - ((@_callbacks ?= {})[event] ?= []).push callback - @ - - off: (event, callback) -> - if event.indexOf(' ') >= 0 - @off name, callback for name in event.split(' ') - else if (callbacks = @_callbacks?[event]) and (index = callbacks.indexOf callback) >= 0 - callbacks.splice index, 1 - delete @_callbacks[event] unless callbacks.length - @ - - 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' - @ - - removeEvent: (event) -> - if @_callbacks? - delete @_callbacks[name] for name in event.split(' ') - @ diff --git a/assets/javascripts/lib/events.js b/assets/javascripts/lib/events.js new file mode 100644 index 00000000..d735a3d5 --- /dev/null +++ b/assets/javascripts/lib/events.js @@ -0,0 +1,58 @@ +class Events { + on(event, callback) { + if (event.includes(" ")) { + for (var name of event.split(" ")) { + this.on(name, callback); + } + } else { + this._callbacks ||= {}; + this._callbacks[event] ||= []; + this._callbacks[event].push(callback); + } + return this; + } + + off(event, callback) { + let callbacks, index; + if (event.includes(" ")) { + for (var name of event.split(" ")) { + this.off(name, callback); + } + } else if ( + (callbacks = this._callbacks?.[event]) && + (index = callbacks.indexOf(callback)) >= 0 + ) { + callbacks.splice(index, 1); + if (!callbacks.length) { + delete this._callbacks[event]; + } + } + return this; + } + + trigger(event, ...args) { + this.eventInProgress = { name: event, args }; + const callbacks = this._callbacks?.[event]; + if (callbacks) { + for (const callback of callbacks.slice(0)) { + if (typeof callback === "function") { + callback(...args); + } + } + } + this.eventInProgress = null; + if (event !== "all") { + this.trigger("all", event, ...args); + } + return this; + } + + removeEvent(event) { + if (this._callbacks != null) { + for (var name of event.split(" ")) { + delete this._callbacks[name]; + } + } + return this; + } +} diff --git a/assets/javascripts/lib/favicon.coffee b/assets/javascripts/lib/favicon.coffee deleted file mode 100644 index 428eae45..00000000 --- a/assets/javascripts/lib/favicon.coffee +++ /dev/null @@ -1,76 +0,0 @@ -defaultUrl = null -currentSlug = null - -imageCache = {} -urlCache = {} - -withImage = (url, action) -> - if imageCache[url] - action(imageCache[url]) - else - img = new Image() - img.crossOrigin = 'anonymous' - img.src = url - img.onload = () => - imageCache[url] = img - action(img) - -@setFaviconForDoc = (doc) -> - return if currentSlug == doc.slug - - favicon = $('link[rel="icon"]') - - if defaultUrl == null - defaultUrl = favicon.href - - if urlCache[doc.slug] - favicon.href = urlCache[doc.slug] - currentSlug = doc.slug - return - - iconEl = $("._icon-#{doc.slug.split('~')[0]}") - return if iconEl == null - - styles = window.getComputedStyle(iconEl, ':before') - - backgroundPositionX = styles['background-position-x'] - backgroundPositionY = styles['background-position-y'] - return if backgroundPositionX == undefined || backgroundPositionY == undefined - - bgUrl = app.config.favicon_spritesheet - sourceSize = 16 - sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2))) - sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2))) - - withImage(bgUrl, (docImg) -> - withImage(defaultUrl, (defaultImg) -> - size = defaultImg.width - - canvas = document.createElement('canvas') - ctx = canvas.getContext('2d') - - canvas.width = size - canvas.height = size - ctx.drawImage(defaultImg, 0, 0) - - docIconPercentage = 65 - destinationCoords = size / 100 * (100 - docIconPercentage) - destinationSize = size / 100 * docIconPercentage - - ctx.drawImage(docImg, sourceX, sourceY, sourceSize, sourceSize, destinationCoords, destinationCoords, destinationSize, destinationSize) - - try - urlCache[doc.slug] = canvas.toDataURL() - favicon.href = urlCache[doc.slug] - - currentSlug = doc.slug - catch error - Raven.captureException error, { level: 'info' } - @resetFavicon() - ) - ) - -@resetFavicon = () -> - if defaultUrl != null and currentSlug != null - $('link[rel="icon"]').href = defaultUrl - currentSlug = null diff --git a/assets/javascripts/lib/favicon.js b/assets/javascripts/lib/favicon.js new file mode 100644 index 00000000..c4be8d74 --- /dev/null +++ b/assets/javascripts/lib/favicon.js @@ -0,0 +1,101 @@ +let defaultUrl = null; +let currentSlug = null; + +const imageCache = {}; +const urlCache = {}; + +const withImage = function (url, action) { + if (imageCache[url]) { + return action(imageCache[url]); + } else { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = url; + return (img.onload = () => { + imageCache[url] = img; + return action(img); + }); + } +}; + +this.setFaviconForDoc = function (doc) { + if (currentSlug === doc.slug) { + return; + } + + const favicon = $('link[rel="icon"]'); + + if (defaultUrl === null) { + defaultUrl = favicon.href; + } + + if (urlCache[doc.slug]) { + favicon.href = urlCache[doc.slug]; + currentSlug = doc.slug; + return; + } + + const iconEl = $(`._icon-${doc.slug.split("~")[0]}`); + if (iconEl === null) { + return; + } + + const styles = window.getComputedStyle(iconEl, ":before"); + + const backgroundPositionX = styles["background-position-x"]; + const backgroundPositionY = styles["background-position-y"]; + if (backgroundPositionX === undefined || backgroundPositionY === undefined) { + return; + } + + const bgUrl = app.config.favicon_spritesheet; + const sourceSize = 16; + const sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2))); + const sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2))); + + return withImage(bgUrl, (docImg) => + withImage(defaultUrl, function (defaultImg) { + const size = defaultImg.width; + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + canvas.width = size; + canvas.height = size; + ctx.drawImage(defaultImg, 0, 0); + + const docIconPercentage = 65; + const destinationCoords = (size / 100) * (100 - docIconPercentage); + const destinationSize = (size / 100) * docIconPercentage; + + ctx.drawImage( + docImg, + sourceX, + sourceY, + sourceSize, + sourceSize, + destinationCoords, + destinationCoords, + destinationSize, + destinationSize, + ); + + try { + urlCache[doc.slug] = canvas.toDataURL(); + favicon.href = urlCache[doc.slug]; + + return (currentSlug = doc.slug); + } catch (error) { + Raven.captureException(error, { level: "info" }); + return this.resetFavicon(); + } + }), + ); +}; + +this.resetFavicon = function () { + if (defaultUrl !== null && currentSlug !== null) { + $('link[rel="icon"]').href = defaultUrl; + return (currentSlug = null); + } +}; diff --git a/assets/javascripts/lib/license.coffee b/assets/javascripts/lib/license.js similarity index 96% rename from assets/javascripts/lib/license.coffee rename to assets/javascripts/lib/license.js index c397b93b..883885a3 100644 --- a/assets/javascripts/lib/license.coffee +++ b/assets/javascripts/lib/license.js @@ -1,7 +1,7 @@ -### +/* * Copyright 2013-2023 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: * http://mozilla.org/MPL/2.0/ -### + */ diff --git a/assets/javascripts/lib/local_storage_store.coffee b/assets/javascripts/lib/local_storage_store.coffee deleted file mode 100644 index f4438c86..00000000 --- a/assets/javascripts/lib/local_storage_store.coffee +++ /dev/null @@ -1,23 +0,0 @@ -class @LocalStorageStore - get: (key) -> - try - JSON.parse localStorage.getItem(key) - catch - - set: (key, value) -> - try - localStorage.setItem(key, JSON.stringify(value)) - true - catch - - del: (key) -> - try - localStorage.removeItem(key) - true - catch - - reset: -> - try - localStorage.clear() - true - catch diff --git a/assets/javascripts/lib/local_storage_store.js b/assets/javascripts/lib/local_storage_store.js new file mode 100644 index 00000000..25a4ee90 --- /dev/null +++ b/assets/javascripts/lib/local_storage_store.js @@ -0,0 +1,28 @@ +this.LocalStorageStore = class LocalStorageStore { + get(key) { + try { + return JSON.parse(localStorage.getItem(key)); + } catch (error) {} + } + + set(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch (error) {} + } + + del(key) { + try { + localStorage.removeItem(key); + return true; + } catch (error) {} + } + + reset() { + try { + localStorage.clear(); + return true; + } catch (error) {} + } +}; diff --git a/assets/javascripts/lib/page.coffee b/assets/javascripts/lib/page.coffee deleted file mode 100644 index 5ad89b32..00000000 --- a/assets/javascripts/lib/page.coffee +++ /dev/null @@ -1,223 +0,0 @@ -### - * Based on github.com/visionmedia/page.js - * Licensed under the MIT license - * Copyright 2012 TJ Holowaychuk -### - -running = false -currentState = null -callbacks = [] - -@page = (value, fn) -> - if typeof value is 'function' - page '*', value - else if typeof fn is 'function' - route = new Route(value) - callbacks.push route.middleware(fn) - else if typeof value is 'string' - page.show(value, fn) - else - page.start(value) - return - -page.start = (options = {}) -> - unless running - running = true - addEventListener 'popstate', onpopstate - addEventListener 'click', onclick - page.replace currentPath(), null, null, true - return - -page.stop = -> - if running - running = false - removeEventListener 'click', onclick - removeEventListener 'popstate', onpopstate - return - -page.show = (path, state) -> - return if path is currentState?.path - context = new Context(path, state) - previousState = currentState - currentState = context.state - if res = page.dispatch(context) - currentState = previousState - location.assign(res) - else - context.pushState() - updateCanonicalLink() - track() - context - -page.replace = (path, state, skipDispatch, init) -> - context = new Context(path, state or currentState) - context.init = init - currentState = context.state - result = page.dispatch(context) unless skipDispatch - if result - context = new Context(result) - context.init = init - currentState = context.state - page.dispatch(context) - context.replaceState() - updateCanonicalLink() - track() unless skipDispatch - context - -page.dispatch = (context) -> - i = 0 - next = -> - 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 - -class Context - @initialPath: currentPath() - @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 - - @isSameSession: (state) -> - state.sessionId is @sessionId - - constructor: (@path = '/', @state = {}) -> - @pathname = @path.replace /(?:\?([^#]*))?(?:#(.*))?$/, (_, query, hash) => - @query = query - @hash = hash - '' - - @state.id ?= @constructor.stateId++ - @state.sessionId ?= @constructor.sessionId - @state.path = @path - - pushState: -> - history.pushState @state, '', @path - return - - replaceState: -> - try history.replaceState @state, '', @path # NS_ERROR_FAILURE in Firefox - return - -class Route - constructor: (@path, options = {}) -> - @keys = [] - @regexp = pathtoRegexp @path, @keys - - middleware: (fn) -> - (context, next) => - if @match context.pathname, params = [] - context.params = params - return fn(context, next) - else - return next() - - match: (path, params) -> - return unless matchData = @regexp.exec(path) - - for value, i in matchData[1..] - value = decodeURIComponent value if typeof value is 'string' - if key = @keys[i] - params[key.name] = value - else - params.push value - true - -pathtoRegexp = (path, keys) -> - return path if path instanceof RegExp - - path = "(#{path.join '|'})" if path instanceof Array - path = path - .replace /\/\(/g, '(?:/' - .replace /(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, (_, slash = '', format = '', key, capture, optional) -> - keys.push name: key, optional: !!optional - str = if optional then '' else slash - str += '(?:' - str += slash if optional - str += format - str += capture or if format then '([^/.]+?)' else '([^/]+?)' - str += ')' - str += optional if optional - str - .replace /([\/.])/g, '\\$1' - .replace /\*/g, '(.*)' - - new RegExp "^#{path}$" - -onpopstate = (event) -> - return if not event.state or Context.isInitialPopState(event.state) - - if Context.isSameSession(event.state) - page.replace(event.state.path, event.state) - else - location.reload() - return - -onclick = (event) -> - try - return if event.which isnt 1 or event.metaKey or event.ctrlKey or event.shiftKey or event.defaultPrevented - catch - return - - link = $.eventTarget(event) - link = link.parentNode while link and link.tagName isnt 'A' - - if link and not link.target and isSameOrigin(link.href) - event.preventDefault() - 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', "https://#{location.host}#{location.pathname}") - -trackers = [] - -page.track = (fn) -> - trackers.push(fn) - return - -track = -> - return unless app.config.env == 'production' - return if navigator.doNotTrack == '1' - return if navigator.globalPrivacyControl - - consentGiven = Cookies.get('analyticsConsent') - consentAsked = Cookies.get('analyticsConsentAsked') - - if consentGiven == '1' - tracker.call() for tracker in trackers - else if consentGiven == undefined and consentAsked == undefined - # Only ask for consent once per browser session - Cookies.set('analyticsConsentAsked', '1') - - new app.views.Notif 'AnalyticsConsent', autoHide: null - return - -@resetAnalytics = -> - for cookie in document.cookie.split(/;\s?/) - name = cookie.split('=')[0] - if name[0] == '_' && name[1] != '_' - Cookies.expire(name) - return diff --git a/assets/javascripts/lib/page.js b/assets/javascripts/lib/page.js new file mode 100644 index 00000000..f368fbba --- /dev/null +++ b/assets/javascripts/lib/page.js @@ -0,0 +1,338 @@ +/* + * Based on github.com/visionmedia/page.js + * Licensed under the MIT license + * Copyright 2012 TJ Holowaychuk + */ + +let running = false; +let currentState = null; +const callbacks = []; + +this.page = function (value, fn) { + if (typeof value === "function") { + page("*", value); + } else if (typeof fn === "function") { + const route = new Route(value); + callbacks.push(route.middleware(fn)); + } else if (typeof value === "string") { + page.show(value, fn); + } else { + page.start(value); + } +}; + +page.start = function (options) { + if (options == null) { + options = {}; + } + if (!running) { + running = true; + addEventListener("popstate", onpopstate); + addEventListener("click", onclick); + page.replace(currentPath(), null, null, true); + } +}; + +page.stop = function () { + if (running) { + running = false; + removeEventListener("click", onclick); + removeEventListener("popstate", onpopstate); + } +}; + +page.show = function (path, state) { + let res; + if (path === currentState?.path) { + return; + } + const context = new Context(path, state); + const previousState = currentState; + currentState = context.state; + if ((res = page.dispatch(context))) { + currentState = previousState; + location.assign(res); + } else { + context.pushState(); + updateCanonicalLink(); + track(); + } + return context; +}; + +page.replace = function (path, state, skipDispatch, init) { + let result; + let context = new Context(path, state || currentState); + context.init = init; + currentState = context.state; + if (!skipDispatch) { + result = page.dispatch(context); + } + if (result) { + context = new Context(result); + context.init = init; + currentState = context.state; + page.dispatch(context); + } + context.replaceState(); + updateCanonicalLink(); + if (!skipDispatch) { + track(); + } + return context; +}; + +page.dispatch = function (context) { + let i = 0; + var next = function () { + let fn, res; + if ((fn = callbacks[i++])) { + res = fn(context, next); + } + return res; + }; + return next(); +}; + +page.canGoBack = () => !Context.isIntialState(currentState); + +page.canGoForward = () => !Context.isLastState(currentState); + +const currentPath = () => location.pathname + location.search + location.hash; + +class Context { + static isIntialState(state) { + return state.id === 0; + } + + static isLastState(state) { + return state.id === this.stateId - 1; + } + + static isInitialPopState(state) { + return state.path === this.initialPath && this.stateId === 1; + } + + static isSameSession(state) { + return state.sessionId === this.sessionId; + } + + constructor(path, state) { + this.initialPath = currentPath(); + this.sessionId = Date.now(); + this.stateId = 0; + if (path == null) { + path = "/"; + } + this.path = path; + if (state == null) { + state = {}; + } + this.state = state; + this.pathname = this.path.replace( + /(?:\?([^#]*))?(?:#(.*))?$/, + (_, query, hash) => { + this.query = query; + this.hash = hash; + return ""; + }, + ); + + if (this.state.id == null) { + this.state.id = this.constructor.stateId++; + } + if (this.state.sessionId == null) { + this.state.sessionId = this.constructor.sessionId; + } + this.state.path = this.path; + } + + pushState() { + history.pushState(this.state, "", this.path); + } + + replaceState() { + try { + history.replaceState(this.state, "", this.path); + } catch (error) {} // NS_ERROR_FAILURE in Firefox + } +} + +class Route { + constructor(path, options) { + this.path = path; + if (options == null) { + options = {}; + } + this.keys = []; + this.regexp = pathToRegexp(this.path, this.keys); + } + + middleware(fn) { + return (context, next) => { + let params; + if (this.match(context.pathname, (params = []))) { + context.params = params; + return fn(context, next); + } else { + return next(); + } + }; + } + + match(path, params) { + let matchData; + if (!(matchData = this.regexp.exec(path))) { + return; + } + + const iterable = matchData.slice(1); + for (let i = 0; i < iterable.length; i++) { + var key; + var value = iterable[i]; + if (typeof value === "string") { + value = decodeURIComponent(value); + } + if ((key = this.keys[i])) { + params[key.name] = value; + } else { + params.push(value); + } + } + return true; + } +} + +var pathToRegexp = function (path, keys) { + if (path instanceof RegExp) { + return path; + } + + if (path instanceof Array) { + path = `(${path.join("|")})`; + } + path = path + .replace(/\/\(/g, "(?:/") + .replace( + /(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, + (_, slash, format, key, capture, optional) => { + if (slash == null) { + slash = ""; + } + if (format == null) { + format = ""; + } + keys.push({ name: key, optional: !!optional }); + let str = optional ? "" : slash; + str += "(?:"; + if (optional) { + str += slash; + } + str += format; + str += capture || (format ? "([^/.]+?)" : "([^/]+?)"); + str += ")"; + if (optional) { + str += optional; + } + return str; + }, + ) + .replace(/([\/.])/g, "\\$1") + .replace(/\*/g, "(.*)"); + + return new RegExp(`^${path}$`); +}; + +var onpopstate = function (event) { + if (!event.state || Context.isInitialPopState(event.state)) { + return; + } + + if (Context.isSameSession(event.state)) { + page.replace(event.state.path, event.state); + } else { + location.reload(); + } +}; + +var onclick = function (event) { + try { + if ( + event.which !== 1 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.defaultPrevented + ) { + return; + } + } catch (error) { + return; + } + + let link = $.eventTarget(event); + while (link && link.tagName !== "A") { + link = link.parentNode; + } + + if (link && !link.target && isSameOrigin(link.href)) { + event.preventDefault(); + let path = link.pathname + link.search + link.hash; + path = path.replace(/^\/\/+/, "/"); // IE11 bug + page.show(path); + } +}; + +var isSameOrigin = (url) => + url.startsWith(`${location.protocol}//${location.hostname}`); + +var updateCanonicalLink = function () { + if (!this.canonicalLink) { + this.canonicalLink = document.head.querySelector('link[rel="canonical"]'); + } + return this.canonicalLink.setAttribute( + "href", + `https://${location.host}${location.pathname}`, + ); +}; + +const trackers = []; + +page.track = function (fn) { + trackers.push(fn); +}; + +var track = function () { + if (app.config.env !== "production") { + return; + } + if (navigator.doNotTrack === "1") { + return; + } + if (navigator.globalPrivacyControl) { + return; + } + + const consentGiven = Cookies.get("analyticsConsent"); + const consentAsked = Cookies.get("analyticsConsentAsked"); + + if (consentGiven === "1") { + for (var tracker of trackers) { + tracker.call(); + } + } else if (consentGiven === undefined && consentAsked === undefined) { + // Only ask for consent once per browser session + Cookies.set("analyticsConsentAsked", "1"); + + new app.views.Notif("AnalyticsConsent", { autoHide: null }); + } +}; + +this.resetAnalytics = function () { + for (var cookie of document.cookie.split(/;\s?/)) { + var name = cookie.split("=")[0]; + if (name[0] === "_" && name[1] !== "_") { + Cookies.expire(name); + } + } +}; diff --git a/assets/javascripts/lib/util.coffee b/assets/javascripts/lib/util.coffee deleted file mode 100644 index 001b13de..00000000 --- a/assets/javascripts/lib/util.coffee +++ /dev/null @@ -1,399 +0,0 @@ -# -# Traversing -# - -@$ = (selector, el = document) -> - try el.querySelector(selector) catch - -@$$ = (selector, el = document) -> - try el.querySelectorAll(selector) catch - -$.id = (id) -> - document.getElementById(id) - -$.hasChild = (parent, el) -> - return unless parent - while el - return true if el is parent - return if el is document.body - el = el.parentNode - -$.closestLink = (el, parent = document.body) -> - while el - return el if el.tagName is 'A' - return if el is parent - el = el.parentNode - -# -# Events -# - -$.on = (el, event, callback, useCapture = false) -> - if event.indexOf(' ') >= 0 - $.on el, name, callback for name in event.split(' ') - else - el.addEventListener(event, callback, useCapture) - return - -$.off = (el, event, callback, useCapture = false) -> - if event.indexOf(' ') >= 0 - $.off el, name, callback for name in event.split(' ') - else - el.removeEventListener(event, callback, useCapture) - return - -$.trigger = (el, type, canBubble = true, cancelable = true) -> - event = document.createEvent 'Event' - event.initEvent(type, canBubble, cancelable) - el.dispatchEvent(event) - return - -$.click = (el) -> - event = document.createEvent 'MouseEvent' - event.initMouseEvent 'click', true, true, window, null, 0, 0, 0, 0, false, false, false, false, 0, null - el.dispatchEvent(event) - return - -$.stopEvent = (event) -> - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - return - -$.eventTarget = (event) -> - event.target.correspondingUseElement || event.target - -# -# Manipulation -# - -buildFragment = (value) -> - fragment = document.createDocumentFragment() - - if $.isCollection(value) - fragment.appendChild(child) for child in $.makeArray(value) - else - fragment.innerHTML = value - - fragment - -$.append = (el, value) -> - if typeof value is 'string' - el.insertAdjacentHTML 'beforeend', value - else - value = buildFragment(value) if $.isCollection(value) - el.appendChild(value) - return - -$.prepend = (el, value) -> - if not el.firstChild - $.append(value) - else if typeof value is 'string' - el.insertAdjacentHTML 'afterbegin', value - else - value = buildFragment(value) if $.isCollection(value) - el.insertBefore(value, el.firstChild) - return - -$.before = (el, value) -> - if typeof value is 'string' or $.isCollection(value) - value = buildFragment(value) - - el.parentNode.insertBefore(value, el) - return - -$.after = (el, value) -> - if typeof value is 'string' or $.isCollection(value) - value = buildFragment(value) - - if el.nextSibling - el.parentNode.insertBefore(value, el.nextSibling) - else - el.parentNode.appendChild(value) - return - -$.remove = (value) -> - if $.isCollection(value) - el.parentNode?.removeChild(el) for el in $.makeArray(value) - else - value.parentNode?.removeChild(value) - return - -$.empty = (el) -> - el.removeChild(el.firstChild) while el.firstChild - return - -# Calls the function while the element is off the DOM to avoid triggering -# unnecessary reflows and repaints. -$.batchUpdate = (el, fn) -> - parent = el.parentNode - sibling = el.nextSibling - parent.removeChild(el) - - fn(el) - - if (sibling) - parent.insertBefore(el, sibling) - else - parent.appendChild(el) - return - -# -# Offset -# - -$.rect = (el) -> - el.getBoundingClientRect() - -$.offset = (el, container = document.body) -> - top = 0 - left = 0 - - while el and el isnt container - top += el.offsetTop - left += el.offsetLeft - el = el.offsetParent - - top: top - left: left - -$.scrollParent = (el) -> - while (el = el.parentNode) and el.nodeType is 1 - break if el.scrollTop > 0 - break if getComputedStyle(el)?.overflowY in ['auto', 'scroll'] - el - -$.scrollTo = (el, parent, position = 'center', options = {}) -> - return unless el - - parent ?= $.scrollParent(el) - return unless parent - - parentHeight = parent.clientHeight - parentScrollHeight = parent.scrollHeight - return unless parentScrollHeight > parentHeight - - top = $.offset(el, parent).top - offsetTop = parent.firstElementChild.offsetTop - - switch position - when 'top' - 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 - 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 + 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...) -> - parent ?= $.scrollParent(el) - return unless parent - - $.scrollTo el, parent, args... - - # Lock the scroll position on the target element for up to 3 seconds while - # nearby images are loaded and rendered. - for image in parent.getElementsByTagName('img') when not image.complete - do -> - onLoad = (event) -> - clearTimeout(timeout) - unbind(event.target) - $.scrollTo el, parent, args... - - unbind = (target) -> - $.off target, 'load', onLoad - - $.on image, 'load', onLoad - timeout = setTimeout unbind.bind(null, image), 3000 - return - -# Calls the function while locking the element's position relative to the window. -$.lockScroll = (el, fn) -> - if parent = $.scrollParent(el) - top = $.rect(el).top - top -= $.rect(parent).top unless parent in [document.body, document.documentElement] - fn() - parent.scrollTop = $.offset(el, parent).top - top - else - 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 -# - -$.extend = (target, objects...) -> - for object in objects when object - for key, value of object - target[key] = value - target - -$.makeArray = (object) -> - if Array.isArray(object) - object - else - Array::slice.apply(object) - -$.arrayDelete = (array, object) -> - index = array.indexOf(object) - if index >= 0 - array.splice(index, 1) - true - else - false - -# Returns true if the object is an array or a collection of DOM elements. -$.isCollection = (object) -> - Array.isArray(object) or typeof object?.item is 'function' - -ESCAPE_HTML_MAP = - '&': '&' - '<': '<' - '>': '>' - '"': '"' - "'": ''' - '/': '/' - -ESCAPE_HTML_REGEXP = /[&<>"'\/]/g - -$.escape = (string) -> - string.replace ESCAPE_HTML_REGEXP, (match) -> ESCAPE_HTML_MAP[match] - -ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g - -$.escapeRegexp = (string) -> - string.replace ESCAPE_REGEXP, "\\$1" - -$.urlDecode = (string) -> - decodeURIComponent string.replace(/\+/g, '%20') - -$.classify = (string) -> - string = string.split('_') - for substr, i in 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 -# - -$.noop = -> - -$.popup = (value) -> - 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 - -isMac = null -$.isMac = -> - isMac ?= navigator.userAgent?.indexOf('Mac') >= 0 - -isIE = null -$.isIE = -> - isIE ?= navigator.userAgent?.indexOf('MSIE') >= 0 || navigator.userAgent?.indexOf('rv:11.0') >= 0 - -isChromeForAndroid = null -$.isChromeForAndroid = -> - isChromeForAndroid ?= navigator.userAgent?.indexOf('Android') >= 0 && /Chrome\/([.0-9])+ Mobile/.test(navigator.userAgent) - -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' - delay: 1000 - -$.highlight = (el, options = {}) -> - options = $.extend {}, HIGHLIGHT_DEFAULTS, options - el.classList.add(options.className) - setTimeout (-> el.classList.remove(options.className)), options.delay - return - -$.copyToClipboard = (string) -> - textarea = document.createElement('textarea') - textarea.style.position = 'fixed' - textarea.style.opacity = 0 - textarea.value = string - document.body.appendChild(textarea) - try - textarea.select() - result = !!document.execCommand('copy') - catch - result = false - finally - document.body.removeChild(textarea) - result diff --git a/assets/javascripts/lib/util.js b/assets/javascripts/lib/util.js new file mode 100644 index 00000000..3aa301bc --- /dev/null +++ b/assets/javascripts/lib/util.js @@ -0,0 +1,546 @@ +// +// Traversing +// + +let smoothDistance, smoothDuration, smoothEnd, smoothStart; +this.$ = function (selector, el) { + if (el == null) { + el = document; + } + try { + return el.querySelector(selector); + } catch (error) {} +}; + +this.$$ = function (selector, el) { + if (el == null) { + el = document; + } + try { + return el.querySelectorAll(selector); + } catch (error) {} +}; + +$.id = (id) => document.getElementById(id); + +$.hasChild = function (parent, el) { + if (!parent) { + return; + } + while (el) { + if (el === parent) { + return true; + } + if (el === document.body) { + return; + } + el = el.parentNode; + } +}; + +$.closestLink = function (el, parent) { + if (parent == null) { + parent = document.body; + } + while (el) { + if (el.tagName === "A") { + return el; + } + if (el === parent) { + return; + } + el = el.parentNode; + } +}; + +// +// Events +// + +$.on = function (el, event, callback, useCapture) { + if (useCapture == null) { + useCapture = false; + } + if (event.includes(" ")) { + for (var name of event.split(" ")) { + $.on(el, name, callback); + } + } else { + el.addEventListener(event, callback, useCapture); + } +}; + +$.off = function (el, event, callback, useCapture) { + if (useCapture == null) { + useCapture = false; + } + if (event.includes(" ")) { + for (var name of event.split(" ")) { + $.off(el, name, callback); + } + } else { + el.removeEventListener(event, callback, useCapture); + } +}; + +$.trigger = function (el, type, canBubble, cancelable) { + const event = new Event(type, { + bubbles: canBubble ?? true, + cancelable: cancelable ?? true, + }); + el.dispatchEvent(event); +}; + +$.click = function (el) { + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }); + el.dispatchEvent(event); +}; + +$.stopEvent = function (event) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); +}; + +$.eventTarget = (event) => event.target.correspondingUseElement || event.target; + +// +// Manipulation +// + +const buildFragment = function (value) { + const fragment = document.createDocumentFragment(); + + if ($.isCollection(value)) { + for (var child of $.makeArray(value)) { + fragment.appendChild(child); + } + } else { + fragment.innerHTML = value; + } + + return fragment; +}; + +$.append = function (el, value) { + if (typeof value === "string") { + el.insertAdjacentHTML("beforeend", value); + } else { + if ($.isCollection(value)) { + value = buildFragment(value); + } + el.appendChild(value); + } +}; + +$.prepend = function (el, value) { + if (!el.firstChild) { + $.append(value); + } else if (typeof value === "string") { + el.insertAdjacentHTML("afterbegin", value); + } else { + if ($.isCollection(value)) { + value = buildFragment(value); + } + el.insertBefore(value, el.firstChild); + } +}; + +$.before = function (el, value) { + if (typeof value === "string" || $.isCollection(value)) { + value = buildFragment(value); + } + + el.parentNode.insertBefore(value, el); +}; + +$.after = function (el, value) { + if (typeof value === "string" || $.isCollection(value)) { + value = buildFragment(value); + } + + if (el.nextSibling) { + el.parentNode.insertBefore(value, el.nextSibling); + } else { + el.parentNode.appendChild(value); + } +}; + +$.remove = function (value) { + if ($.isCollection(value)) { + for (var el of $.makeArray(value)) { + if (el.parentNode != null) { + el.parentNode.removeChild(el); + } + } + } else { + if (value.parentNode != null) { + value.parentNode.removeChild(value); + } + } +}; + +$.empty = function (el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +}; + +// Calls the function while the element is off the DOM to avoid triggering +// unnecessary reflows and repaints. +$.batchUpdate = function (el, fn) { + const parent = el.parentNode; + const sibling = el.nextSibling; + parent.removeChild(el); + + fn(el); + + if (sibling) { + parent.insertBefore(el, sibling); + } else { + parent.appendChild(el); + } +}; + +// +// Offset +// + +$.rect = (el) => el.getBoundingClientRect(); + +$.offset = function (el, container) { + if (container == null) { + container = document.body; + } + let top = 0; + let left = 0; + + while (el && el !== container) { + top += el.offsetTop; + left += el.offsetLeft; + el = el.offsetParent; + } + + return { + top, + left, + }; +}; + +$.scrollParent = function (el) { + while ((el = el.parentNode) && el.nodeType === 1) { + if (el.scrollTop > 0) { + break; + } + if (["auto", "scroll"].includes(getComputedStyle(el)?.overflowY ?? "")) { + break; + } + } + return el; +}; + +$.scrollTo = function (el, parent, position, options) { + if (position == null) { + position = "center"; + } + if (options == null) { + options = {}; + } + if (!el) { + return; + } + + if (parent == null) { + parent = $.scrollParent(el); + } + if (!parent) { + return; + } + + const parentHeight = parent.clientHeight; + const parentScrollHeight = parent.scrollHeight; + if (!(parentScrollHeight > parentHeight)) { + return; + } + + const { top } = $.offset(el, parent); + const { offsetTop } = parent.firstElementChild; + + switch (position) { + case "top": + parent.scrollTop = top - offsetTop - (options.margin || 0); + break; + case "center": + parent.scrollTop = + top - Math.round(parentHeight / 2 - el.offsetHeight / 2); + break; + case "continuous": + var { scrollTop } = parent; + var height = el.offsetHeight; + + var lastElementOffset = + parent.lastElementChild.offsetTop + + parent.lastElementChild.offsetHeight; + var offsetBottom = + lastElementOffset > 0 ? parentScrollHeight - lastElementOffset : 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 - offsetTop <= scrollTop + height * (options.topGap || 1)) { + parent.scrollTop = top - offsetTop - height * (options.topGap || 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 + offsetBottom >= + scrollTop + parentHeight - height * ((options.bottomGap || 1) + 1) + ) { + parent.scrollTop = + top + + offsetBottom - + parentHeight + + height * ((options.bottomGap || 1) + 1); + } + break; + } +}; + +$.scrollToWithImageLock = function (el, parent, ...args) { + if (parent == null) { + parent = $.scrollParent(el); + } + if (!parent) { + return; + } + + $.scrollTo(el, parent, ...args); + + // Lock the scroll position on the target element for up to 3 seconds while + // nearby images are loaded and rendered. + for (var image of parent.getElementsByTagName("img")) { + if (!image.complete) { + (function () { + let timeout; + const onLoad = function (event) { + clearTimeout(timeout); + unbind(event.target); + return $.scrollTo(el, parent, ...args); + }; + + var unbind = (target) => $.off(target, "load", onLoad); + + $.on(image, "load", onLoad); + return (timeout = setTimeout(unbind.bind(null, image), 3000)); + })(); + } + } +}; + +// Calls the function while locking the element's position relative to the window. +$.lockScroll = function (el, fn) { + let parent; + if ((parent = $.scrollParent(el))) { + let { top } = $.rect(el); + if (![document.body, document.documentElement].includes(parent)) { + top -= $.rect(parent).top; + } + fn(); + parent.scrollTop = $.offset(el, parent).top - top; + } else { + fn(); + } +}; + +let smoothScroll = + (smoothStart = + smoothEnd = + smoothDistance = + smoothDuration = + null); + +$.smoothScroll = function (el, end) { + smoothEnd = end; + + if (smoothScroll) { + const 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)); + const startTime = Date.now(); + + smoothScroll = function () { + const p = Math.min(1, (Date.now() - startTime) / smoothDuration); + const y = Math.max( + 0, + Math.floor( + smoothStart + + smoothDistance * (p < 0.5 ? 2 * p * p : p * (4 - p * 2) - 1), + ), + ); + el.scrollTop = y; + if (p === 1) { + return (smoothScroll = null); + } else { + return requestAnimationFrame(smoothScroll); + } + }; + return requestAnimationFrame(smoothScroll); +}; + +// +// Utilities +// + +$.makeArray = function (object) { + if (Array.isArray(object)) { + return object; + } else { + return Array.prototype.slice.apply(object); + } +}; + +$.arrayDelete = function (array, object) { + const index = array.indexOf(object); + if (index >= 0) { + array.splice(index, 1); + return true; + } else { + return false; + } +}; + +// Returns true if the object is an array or a collection of DOM elements. +$.isCollection = (object) => + Array.isArray(object) || typeof object?.item === "function"; + +const ESCAPE_HTML_MAP = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "/": "/", +}; + +const ESCAPE_HTML_REGEXP = /[&<>"'\/]/g; + +$.escape = (string) => + string.replace(ESCAPE_HTML_REGEXP, (match) => ESCAPE_HTML_MAP[match]); + +const ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g; + +$.escapeRegexp = (string) => string.replace(ESCAPE_REGEXP, "\\$1"); + +$.urlDecode = (string) => decodeURIComponent(string.replace(/\+/g, "%20")); + +$.classify = function (string) { + string = string.split("_"); + for (let i = 0; i < string.length; i++) { + var substr = string[i]; + string[i] = substr[0].toUpperCase() + substr.slice(1); + } + return string.join(""); +}; + +// +// Miscellaneous +// + +$.noop = function () {}; + +$.popup = function (value) { + try { + const win = window.open(); + if (win.opener) { + win.opener = null; + } + win.location = value.href || value; + } catch (error) { + window.open(value.href || value, "_blank"); + } +}; + +let isMac = null; +$.isMac = () => + isMac != null ? isMac : (isMac = navigator.userAgent.includes("Mac")); + +let isIE = null; +$.isIE = () => + isIE != null + ? isIE + : (isIE = + navigator.userAgent.includes("MSIE") || + navigator.userAgent.includes("rv:11.0")); + +let isChromeForAndroid = null; +$.isChromeForAndroid = () => + isChromeForAndroid != null + ? isChromeForAndroid + : (isChromeForAndroid = + navigator.userAgent.includes("Android") && + /Chrome\/([.0-9])+ Mobile/.test(navigator.userAgent)); + +let isAndroid = null; +$.isAndroid = () => + isAndroid != null + ? isAndroid + : (isAndroid = navigator.userAgent.includes("Android")); + +let isIOS = null; +$.isIOS = () => + isIOS != null + ? isIOS + : (isIOS = + navigator.userAgent.includes("iPhone") || + navigator.userAgent.includes("iPad")); + +$.overlayScrollbarsEnabled = function () { + if (!$.isMac()) { + return false; + } + const div = document.createElement("div"); + div.setAttribute( + "style", + "width: 100px; height: 100px; overflow: scroll; position: absolute", + ); + document.body.appendChild(div); + const result = div.offsetWidth === div.clientWidth; + document.body.removeChild(div); + return result; +}; + +const HIGHLIGHT_DEFAULTS = { + className: "highlight", + delay: 1000, +}; + +$.highlight = function (el, options) { + options = { ...HIGHLIGHT_DEFAULTS, ...(options || {}) }; + el.classList.add(options.className); + setTimeout(() => el.classList.remove(options.className), options.delay); +}; + +$.copyToClipboard = function (string) { + let result; + const textarea = document.createElement("textarea"); + textarea.style.position = "fixed"; + textarea.style.opacity = 0; + textarea.value = string; + document.body.appendChild(textarea); + try { + textarea.select(); + result = !!document.execCommand("copy"); + } catch (error) { + result = false; + } finally { + document.body.removeChild(textarea); + } + return result; +}; diff --git a/assets/javascripts/models/doc.coffee b/assets/javascripts/models/doc.coffee deleted file mode 100644 index c51e13fa..00000000 --- a/assets/javascripts/models/doc.coffee +++ /dev/null @@ -1,147 +0,0 @@ -class app.models.Doc extends app.Model - # Attributes: name, slug, type, version, release, db_size, mtime, links - - constructor: -> - super - @reset @ - @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) -> - @resetEntries data.entries - @resetTypes data.types - return - - resetEntries: (entries) -> - @entries = new app.collections.Entries(entries) - @entries.each (entry) => entry.doc = @ - return - - resetTypes: (types) -> - @types = new app.collections.Types(types) - @types.each (type) => type.doc = @ - return - - fullPath: (path = '') -> - path = "/#{path}" unless path[0] is '/' - "/#{@slug}#{path}" - - fileUrl: (path) -> - "#{app.config.docs_origin}#{@fullPath(path)}?#{@mtime}" - - dbUrl: -> - "#{app.config.docs_origin}/#{@slug}/#{app.config.db_filename}?#{@mtime}" - - indexUrl: -> - "#{app.indexHost()}/#{@slug}/#{app.config.index_filename}?#{@mtime}" - - toEntry: -> - 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}" - entry - else if path is 'index' - @toEntry() - else - @entries.findBy 'path', path - - load: (onSuccess, onError, options = {}) -> - return if options.readCache and @_loadFromCache(onSuccess) - - callback = (data) => - @reset data - onSuccess() - @_setCache data if options.writeCache - return - - ajax - url: @indexUrl() - success: callback - error: onError - - clearCache: -> - app.localStorage.del @slug - return - - _loadFromCache: (onSuccess) -> - return unless data = @_getCache() - - callback = => - @reset data - onSuccess() - return - - setTimeout callback, 0 - true - - _getCache: -> - return unless data = app.localStorage.get @slug - - if data[0] is @mtime - return data[1] - else - @clearCache() - return - - _setCache: (data) -> - app.localStorage.set @slug, [@mtime, data] - return - - install: (onSuccess, onError, onProgress) -> - return if @installing - @installing = true - - error = => - @installing = null - onError() - return - - success = (data) => - @installing = null - app.db.store @, data, onSuccess, error - return - - ajax - url: @dbUrl() - success: success - error: error - progress: onProgress - timeout: 3600 - return - - uninstall: (onSuccess, onError) -> - return if @installing - @installing = true - - success = => - @installing = null - onSuccess() - return - - error = => - @installing = null - onError() - return - - app.db.unstore @, success, error - return - - getInstallStatus: (callback) -> - app.db.version @, (value) -> - callback installed: !!value, mtime: value - return - - isOutdated: (status) -> - return false if not status - isInstalled = status.installed or app.settings.get('autoInstall') - isInstalled and @mtime isnt status.mtime diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js new file mode 100644 index 00000000..53d573cd --- /dev/null +++ b/assets/javascripts/models/doc.js @@ -0,0 +1,202 @@ +app.models.Doc = class Doc extends app.Model { + // Attributes: name, slug, type, version, release, db_size, mtime, links + + constructor() { + super(...arguments); + this.reset(this); + this.slug_without_version = this.slug.split("~")[0]; + this.fullName = `${this.name}` + (this.version ? ` ${this.version}` : ""); + this.icon = this.slug_without_version; + if (this.version) { + this.short_version = this.version.split(" ")[0]; + } + this.text = this.toEntry().text; + } + + reset(data) { + this.resetEntries(data.entries); + this.resetTypes(data.types); + } + + resetEntries(entries) { + this.entries = new app.collections.Entries(entries); + this.entries.each((entry) => { + return (entry.doc = this); + }); + } + + resetTypes(types) { + this.types = new app.collections.Types(types); + this.types.each((type) => { + return (type.doc = this); + }); + } + + fullPath(path) { + if (path == null) { + path = ""; + } + if (path[0] !== "/") { + path = `/${path}`; + } + return `/${this.slug}${path}`; + } + + fileUrl(path) { + return `${app.config.docs_origin}${this.fullPath(path)}?${this.mtime}`; + } + + dbUrl() { + return `${app.config.docs_origin}/${this.slug}/${app.config.db_filename}?${this.mtime}`; + } + + indexUrl() { + return `${app.indexHost()}/${this.slug}/${app.config.index_filename}?${ + this.mtime + }`; + } + + toEntry() { + if (this.entry) { + return this.entry; + } + this.entry = new app.models.Entry({ + doc: this, + name: this.fullName, + path: "index", + }); + if (this.version) { + this.entry.addAlias(this.name); + } + return this.entry; + } + + findEntryByPathAndHash(path, hash) { + let entry; + if (hash && (entry = this.entries.findBy("path", `${path}#${hash}`))) { + return entry; + } else if (path === "index") { + return this.toEntry(); + } else { + return this.entries.findBy("path", path); + } + } + + load(onSuccess, onError, options) { + if (options == null) { + options = {}; + } + if (options.readCache && this._loadFromCache(onSuccess)) { + return; + } + + const callback = (data) => { + this.reset(data); + onSuccess(); + if (options.writeCache) { + this._setCache(data); + } + }; + + return ajax({ + url: this.indexUrl(), + success: callback, + error: onError, + }); + } + + clearCache() { + app.localStorage.del(this.slug); + } + + _loadFromCache(onSuccess) { + let data; + if (!(data = this._getCache())) { + return; + } + + const callback = () => { + this.reset(data); + onSuccess(); + }; + + setTimeout(callback, 0); + return true; + } + + _getCache() { + let data; + if (!(data = app.localStorage.get(this.slug))) { + return; + } + + if (data[0] === this.mtime) { + return data[1]; + } else { + this.clearCache(); + return; + } + } + + _setCache(data) { + app.localStorage.set(this.slug, [this.mtime, data]); + } + + install(onSuccess, onError, onProgress) { + if (this.installing) { + return; + } + this.installing = true; + + const error = () => { + this.installing = null; + onError(); + }; + + const success = (data) => { + this.installing = null; + app.db.store(this, data, onSuccess, error); + }; + + ajax({ + url: this.dbUrl(), + success, + error, + progress: onProgress, + timeout: 3600, + }); + } + + uninstall(onSuccess, onError) { + if (this.installing) { + return; + } + this.installing = true; + + const success = () => { + this.installing = null; + onSuccess(); + }; + + const error = () => { + this.installing = null; + onError(); + }; + + app.db.unstore(this, success, error); + } + + getInstallStatus(callback) { + app.db.version(this, (value) => + callback({ installed: !!value, mtime: value }), + ); + } + + isOutdated(status) { + if (!status) { + return false; + } + const isInstalled = status.installed || app.settings.get("autoInstall"); + return isInstalled && this.mtime !== status.mtime; + } +}; diff --git a/assets/javascripts/models/entry.coffee b/assets/javascripts/models/entry.coffee deleted file mode 100644 index 2d07c159..00000000 --- a/assets/javascripts/models/entry.coffee +++ /dev/null @@ -1,85 +0,0 @@ -#= require app/searcher - -class app.models.Entry extends app.Model - # Attributes: name, type, path - - constructor: -> - super - @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 - - dbPath: -> - @path.replace /#.*/, '' - - filePath: -> - @doc.fullPath @_filePath() - - fileUrl: -> - @doc.fileUrl @_filePath() - - _filePath: -> - result = @path.replace /#.*/, '' - result += '.html' unless result[-5..-1] is '.html' - result - - isIndex: -> - @path is 'index' - - getType: -> - @doc.types.findBy 'name', @type - - loadFile: (onSuccess, onError) -> - app.db.load(@, onSuccess, onError) - - applyAliases = (string) -> - if ALIASES.hasOwnProperty(string) - return [string, ALIASES[string]] - else - words = string.split('.') - for word, i in words when ALIASES.hasOwnProperty(word) - words[i] = ALIASES[word] - return [string, words.join('.')] - return string - - @ALIASES = ALIASES = - 'angular': 'ng' - 'angular.js': 'ng' - 'backbone.js': 'bb' - 'c++': 'cpp' - 'coffeescript': 'cs' - 'crystal': 'cr' - 'elixir': 'ex' - 'javascript': 'js' - 'julia': 'jl' - 'jquery': '$' - 'knockout.js': 'ko' - 'kubernetes': 'k8s' - 'less': 'ls' - 'lodash': '_' - 'löve': 'love' - 'marionette': 'mn' - 'markdown': 'md' - 'matplotlib': 'mpl' - '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/models/entry.js b/assets/javascripts/models/entry.js new file mode 100644 index 00000000..79c81342 --- /dev/null +++ b/assets/javascripts/models/entry.js @@ -0,0 +1,105 @@ +//= require app/searcher + +app.models.Entry = class Entry extends app.Model { + static applyAliases(string) { + if (Entry.ALIASES.hasOwnProperty(string)) { + return [string, Entry.ALIASES[string]]; + } else { + const words = string.split("."); + for (let i = 0; i < words.length; i++) { + var word = words[i]; + if (Entry.ALIASES.hasOwnProperty(word)) { + words[i] = Entry.ALIASES[word]; + return [string, words.join(".")]; + } + } + } + return string; + } + + static ALIASES = { + angular: "ng", + "angular.js": "ng", + "backbone.js": "bb", + "c++": "cpp", + coffeescript: "cs", + crystal: "cr", + elixir: "ex", + javascript: "js", + julia: "jl", + jquery: "$", + "knockout.js": "ko", + kubernetes: "k8s", + less: "ls", + lodash: "_", + löve: "love", + marionette: "mn", + markdown: "md", + matplotlib: "mpl", + 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": "_", + }; + // Attributes: name, type, path + + constructor() { + super(...arguments); + this.text = Entry.applyAliases(app.Searcher.normalizeString(this.name)); + } + + addAlias(name) { + const text = Entry.applyAliases(app.Searcher.normalizeString(name)); + if (!Array.isArray(this.text)) { + this.text = [this.text]; + } + this.text.push(Array.isArray(text) ? text[1] : text); + } + + fullPath() { + return this.doc.fullPath(this.isIndex() ? "" : this.path); + } + + dbPath() { + return this.path.replace(/#.*/, ""); + } + + filePath() { + return this.doc.fullPath(this._filePath()); + } + + fileUrl() { + return this.doc.fileUrl(this._filePath()); + } + + _filePath() { + let result = this.path.replace(/#.*/, ""); + if (result.slice(-5) !== ".html") { + result += ".html"; + } + return result; + } + + isIndex() { + return this.path === "index"; + } + + getType() { + return this.doc.types.findBy("name", this.type); + } + + loadFile(onSuccess, onError) { + return app.db.load(this, onSuccess, onError); + } +}; diff --git a/assets/javascripts/models/model.coffee b/assets/javascripts/models/model.coffee deleted file mode 100644 index 7f157f7c..00000000 --- a/assets/javascripts/models/model.coffee +++ /dev/null @@ -1,3 +0,0 @@ -class app.Model - constructor: (attributes) -> - @[key] = value for key, value of attributes diff --git a/assets/javascripts/models/model.js b/assets/javascripts/models/model.js new file mode 100644 index 00000000..def06e55 --- /dev/null +++ b/assets/javascripts/models/model.js @@ -0,0 +1,8 @@ +app.Model = class Model { + constructor(attributes) { + for (var key in attributes) { + var value = attributes[key]; + this[key] = value; + } + } +}; diff --git a/assets/javascripts/models/type.coffee b/assets/javascripts/models/type.coffee deleted file mode 100644 index 6351ad16..00000000 --- a/assets/javascripts/models/type.coffee +++ /dev/null @@ -1,14 +0,0 @@ -class app.models.Type extends app.Model - # Attributes: name, slug, count - - fullPath: -> - "/#{@doc.slug}-#{@slug}/" - - entries: -> - @doc.entries.findAllBy 'type', @name - - toEntry: -> - new app.models.Entry - doc: @doc - name: "#{@doc.name} / #{@name}" - path: '..' + @fullPath() diff --git a/assets/javascripts/models/type.js b/assets/javascripts/models/type.js new file mode 100644 index 00000000..bc264ac1 --- /dev/null +++ b/assets/javascripts/models/type.js @@ -0,0 +1,19 @@ +app.models.Type = class Type extends app.Model { + // Attributes: name, slug, count + + fullPath() { + return `/${this.doc.slug}-${this.slug}/`; + } + + entries() { + return this.doc.entries.findAllBy("type", this.name); + } + + toEntry() { + return new app.models.Entry({ + doc: this.doc, + name: `${this.doc.name} / ${this.name}`, + path: ".." + this.fullPath(), + }); + } +}; diff --git a/assets/javascripts/templates/base.coffee b/assets/javascripts/templates/base.coffee deleted file mode 100644 index 841d1e0b..00000000 --- a/assets/javascripts/templates/base.coffee +++ /dev/null @@ -1,11 +0,0 @@ -app.templates.render = (name, value, args...) -> - template = app.templates[name] - - if Array.isArray(value) - result = '' - result += template(val, args...) for val in value - result - else if typeof template is 'function' - template(value, args...) - else - template diff --git a/assets/javascripts/templates/base.js b/assets/javascripts/templates/base.js new file mode 100644 index 00000000..fc445ef1 --- /dev/null +++ b/assets/javascripts/templates/base.js @@ -0,0 +1,15 @@ +app.templates.render = function (name, value, ...args) { + const template = app.templates[name]; + + if (Array.isArray(value)) { + let result = ""; + for (var val of value) { + result += template(val, ...args); + } + return result; + } else if (typeof template === "function") { + return template(value, ...args); + } else { + return template; + } +}; diff --git a/assets/javascripts/templates/error_tmpl.coffee b/assets/javascripts/templates/error_tmpl.coffee deleted file mode 100644 index 9cca1f9d..00000000 --- a/assets/javascripts/templates/error_tmpl.coffee +++ /dev/null @@ -1,73 +0,0 @@ -error = (title, text = '', links = '') -> - text = """

#{text}

""" if text - links = """""" if links - """

#{title}

#{text}#{links}
""" - -back = 'Go back' - -app.templates.notFoundPage = -> - error """ Page not found. """, - """ It may be missing from the source documentation or this could be a bug. """, - back - -app.templates.pageLoadError = -> - error """ The page failed to load. """, - """ It may be missing from the server (try reloading the app) or you could be offline (try installing the documentation for offline usage when online again).
- If you're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """, - """ #{back} · Reload - · Retry """ - -app.templates.bootError = -> - error """ The app failed to load. """, - """ 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, exception) -> - if reason is 'cookie_blocked' - return error """ Cookies must be enabled to use offline mode. """ - - reason = switch reason - when 'not_supported' - """ 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 occurred when trying to open the IndexedDB database:
- #{exception.name}: #{exception.message} """ - when 'cant_open' - """ An error occurred 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 = """ -
-

Your browser is unsupported, sorry.

-

DevDocs is an API documentation browser which supports the following browsers: -

    -
  • Recent versions of Firefox, Chrome, or Opera -
  • Safari 11.1+ -
  • Edge 17+ -
  • iOS 11.3+ -
-

- If you're unable to upgrade, we apologize. - We decided to prioritize speed and new features over support for older browsers. -

- Note: if you're already using one of the browsers above, check your settings and add-ons. - The app uses feature detection, not user agent sniffing. -

- — @DevDocs -

-""" diff --git a/assets/javascripts/templates/error_tmpl.js b/assets/javascripts/templates/error_tmpl.js new file mode 100644 index 00000000..c047d46e --- /dev/null +++ b/assets/javascripts/templates/error_tmpl.js @@ -0,0 +1,95 @@ +const error = function (title, text, links) { + if (text == null) { + text = ""; + } + if (links == null) { + links = ""; + } + if (text) { + text = `

${text}

`; + } + if (links) { + links = ``; + } + return `

${title}

${text}${links}
`; +}; + +const back = 'Go back'; + +app.templates.notFoundPage = () => + error( + " Page not found. ", + " It may be missing from the source documentation or this could be a bug. ", + back, + ); + +app.templates.pageLoadError = () => + error( + " The page failed to load. ", + ` It may be missing from the server (try reloading the app) or you could be offline (try installing the documentation for offline usage when online again).
+If you're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. `, + ` ${back} · ReloadRetry `, + ); + +app.templates.bootError = () => + error( + " The app failed to load. ", + ` 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 = function (reason, exception) { + if (reason === "cookie_blocked") { + return error(" Cookies must be enabled to use offline mode. "); + } + + reason = (() => { + switch (reason) { + case "not_supported": + return ` DevDocs requires IndexedDB to cache documentations for offline access.
+Unfortunately your browser either doesn't support IndexedDB or doesn't make it available. `; + case "buggy": + return ` DevDocs requires IndexedDB to cache documentations for offline access.
+Unfortunately your browser's implementation of IndexedDB contains bugs that prevent DevDocs from using it. `; + case "private_mode": + return ` Your browser appears to be running in private mode.
+This prevents DevDocs from caching documentations for offline access.`; + case "exception": + return ` An error occurred when trying to open the IndexedDB database:
+${exception.name}: ${exception.message} `; + case "cant_open": + return ` An error occurred 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. `; + case "version": + return ` The IndexedDB database was modified with a newer version of the app.
+Reload the page to use offline mode. `; + case "empty": + return ' The IndexedDB database appears to be corrupted. Try resetting the app. '; + } + })(); + + return error("Offline mode is unavailable.", reason); +}; + +app.templates.unsupportedBrowser = `\ +
+

Your browser is unsupported, sorry.

+

DevDocs is an API documentation browser which supports the following browsers: +

    +
  • Recent versions of Firefox, Chrome, or Opera +
  • Safari 11.1+ +
  • Edge 17+ +
  • iOS 11.3+ +
+

+ If you're unable to upgrade, we apologize. + We decided to prioritize speed and new features over support for older browsers. +

+ Note: if you're already using one of the browsers above, check your settings and add-ons. + The app uses feature detection, not user agent sniffing. +

+ — @DevDocs +

\ +`; diff --git a/assets/javascripts/templates/notice_tmpl.coffee b/assets/javascripts/templates/notice_tmpl.coffee deleted file mode 100644 index 10cc534e..00000000 --- a/assets/javascripts/templates/notice_tmpl.coffee +++ /dev/null @@ -1,9 +0,0 @@ -notice = (text) -> """

#{text}

""" - -app.templates.singleDocNotice = (doc) -> - 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 disabled. - To enable it, go to Preferences. """ diff --git a/assets/javascripts/templates/notice_tmpl.js b/assets/javascripts/templates/notice_tmpl.js new file mode 100644 index 00000000..49793eb5 --- /dev/null +++ b/assets/javascripts/templates/notice_tmpl.js @@ -0,0 +1,9 @@ +const notice = (text) => `

${text}

`; + +app.templates.singleDocNotice = (doc) => + 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 disabled. +To enable it, go to Preferences. `); diff --git a/assets/javascripts/templates/notif_tmpl.coffee b/assets/javascripts/templates/notif_tmpl.coffee deleted file mode 100644 index 0821036e..00000000 --- a/assets/javascripts/templates/notif_tmpl.coffee +++ /dev/null @@ -1,76 +0,0 @@ -notif = (title, html) -> - html = html.replace /#{title} - #{html} - - - - -
- - - - - - - - #{docs} -
DocumentationSizeStatusAction
-
-

Note: your browser may delete DevDocs's offline data if your computer is running low on disk space and you haven't used the app in a while. Load this page before going offline to make sure the data is still there. -

Questions & Answers

-
-
How does this work? -
Each page is cached as a key-value pair in IndexedDB (downloaded from a single file).
- The app also uses Service Workers and localStorage to cache the assets and index files. -
Can I close the tab/browser? -
#{canICloseTheTab()} -
What if I don't update a documentation? -
You'll see outdated content and some pages will be missing or broken, because the rest of the app (including data for the search and sidebar) uses a different caching mechanism that's updated automatically. -
I found a bug, where do I report it? -
In the issue tracker. Thanks! -
How do I uninstall/reset the app? -
Click here. -
Why aren't all documentations listed above? -
You have to enable them first. -
-""" - -canICloseTheTab = -> - if app.ServiceWorker.isEnabled() - """ Yes! Even offline, you can open a new tab, go to devdocs.io, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). """ - else - reason = "aren't available in your browser (or are disabled)" - - if app.config.env != 'production' - reason = "are disabled in your development instance of DevDocs (enable them by setting the ENABLE_SERVICE_WORKER environment variable to true)" - - """ No. Service Workers #{reason}, so loading devdocs.io offline won't work.
- The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). """ - -app.templates.offlineDoc = (doc, status) -> - outdated = doc.isOutdated(status) - - html = """ - - #{doc.fullName} - #{Math.ceil(doc.db_size / 100000) / 10} MB - """ - - html += if !(status and status.installed) - """ - - - - """ - else if outdated - """ - Outdated - - - """ - else - """ - Up‑to‑date - - """ - - html + '' diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js new file mode 100644 index 00000000..ad467531 --- /dev/null +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -0,0 +1,88 @@ +app.templates.offlinePage = (docs) => `\ +

Offline Documentation

+ +
+ + +
+ +
+ + + + + + + + ${docs} +
DocumentationSizeStatusAction
+
+

Note: your browser may delete DevDocs's offline data if your computer is running low on disk space and you haven't used the app in a while. Load this page before going offline to make sure the data is still there. +

Questions & Answers

+
+
How does this work? +
Each page is cached as a key-value pair in IndexedDB (downloaded from a single file).
+ The app also uses Service Workers and localStorage to cache the assets and index files. +
Can I close the tab/browser? +
${canICloseTheTab()} +
What if I don't update a documentation? +
You'll see outdated content and some pages will be missing or broken, because the rest of the app (including data for the search and sidebar) uses a different caching mechanism that's updated automatically. +
I found a bug, where do I report it? +
In the issue tracker. Thanks! +
How do I uninstall/reset the app? +
Click here. +
Why aren't all documentations listed above? +
You have to enable them first. +
\ +`; + +var canICloseTheTab = function () { + if (app.ServiceWorker.isEnabled()) { + return ' Yes! Even offline, you can open a new tab, go to devdocs.io, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). '; + } else { + let reason = "aren't available in your browser (or are disabled)"; + + if (app.config.env !== "production") { + reason = + "are disabled in your development instance of DevDocs (enable them by setting the ENABLE_SERVICE_WORKER environment variable to true)"; + } + + return ` No. Service Workers ${reason}, so loading devdocs.io offline won't work.
+The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). `; + } +}; + +app.templates.offlineDoc = function (doc, status) { + const outdated = doc.isOutdated(status); + + let html = `\ + + ${doc.fullName} + ${ + Math.ceil(doc.db_size / 100000) / 10 + } MB\ +`; + + html += !(status && status.installed) + ? `\ +- +\ +` + : outdated + ? `\ +Outdated + - \ +` + : `\ +Up‑to‑date +\ +`; + + return html + ""; +}; diff --git a/assets/javascripts/templates/pages/root_tmpl.coffee.erb b/assets/javascripts/templates/pages/root_tmpl.coffee.erb deleted file mode 100644 index 559a30c9..00000000 --- a/assets/javascripts/templates/pages/root_tmpl.coffee.erb +++ /dev/null @@ -1,74 +0,0 @@ -app.templates.splash = """
DevDocs
""" - -<% if App.development? %> -app.templates.intro = """ -
- Stop showing this message -

Hi there!

-

Thanks for downloading DevDocs. Here are a few things you should know: -

    -
  1. Your local version of DevDocs won't self-update. Unless you're modifying the code, - we recommend using the hosted version at devdocs.io. -
  2. Run thor docs:list to see all available documentations. -
  3. Run thor docs:download <name> to download documentations. -
  4. Run thor docs:download --installed to update all downloaded documentations. -
  5. To be notified about new versions, don't forget to watch the repository on GitHub. -
  6. The issue tracker is the preferred channel for bug reports and - feature requests. For everything else, use Discord. -
  7. Contributions are welcome. See the guidelines. -
  8. DevDocs is licensed under the terms of the Mozilla Public License v2.0. For more information, - see the COPYRIGHT and - LICENSE files. -
-

Happy coding! -

-""" -<% else %> -app.templates.intro = """ -
- Stop showing this message -

Welcome!

-

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. - Here's what you should know before you start: -

    -
  1. Open the Preferences to enable more docs and customize the UI. -
  2. You don't have to use your mouse — see the list of keyboard shortcuts. -
  3. The search supports fuzzy matching (e.g. "bgcp" brings up "background-clip"). -
  4. To search a specific documentation, type its name (or an abbr.), then Tab. -
  5. You can search using your browser's address bar — learn how. -
  6. DevDocs works offline, on mobile, and can be installed as web app. -
  7. For the latest news, follow @DevDocs. -
  8. DevDocs is free and open source. - -
  9. And if you're new to coding, check out freeCodeCamp's open source curriculum. -
-

Happy coding! -

-""" -<% end %> - -app.templates.mobileIntro = """ -
-

Welcome!

-

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. - Here's what you should know before you start: -

    -
  1. Pick your docs in the Preferences. -
  2. The search supports fuzzy matching. -
  3. To search a specific documentation, type its name (or an abbr.), then Space. -
  4. For the latest news, follow @DevDocs. -
  5. DevDocs is open source. -
-

Happy coding! - Stop showing this message -

-""" - -app.templates.androidWarning = """ -
-

Hi there

-

DevDocs is running inside an Android WebView. Some features may not work properly. -

If you downloaded an app called DevDocs on the Play Store, please uninstall it — it's made by someone who is using (and profiting from) the name DevDocs without permission. -

To install DevDocs on your phone, visit devdocs.io in Chrome and select "Add to home screen" in the menu. -

-""" diff --git a/assets/javascripts/templates/pages/root_tmpl.js.erb b/assets/javascripts/templates/pages/root_tmpl.js.erb new file mode 100644 index 00000000..b8e12047 --- /dev/null +++ b/assets/javascripts/templates/pages/root_tmpl.js.erb @@ -0,0 +1,74 @@ +app.templates.splash = "
DevDocs
"; + +<% if App.development? %> +app.templates.intro = `\ +
+ Stop showing this message +

Hi there!

+

Thanks for downloading DevDocs. Here are a few things you should know: +

    +
  1. Your local version of DevDocs won't self-update. Unless you're modifying the code, + we recommend using the hosted version at devdocs.io. +
  2. Run thor docs:list to see all available documentations. +
  3. Run thor docs:download <name> to download documentations. +
  4. Run thor docs:download --installed to update all downloaded documentations. +
  5. To be notified about new versions, don't forget to watch the repository on GitHub. +
  6. The issue tracker is the preferred channel for bug reports and + feature requests. For everything else, use Discord. +
  7. Contributions are welcome. See the guidelines. +
  8. DevDocs is licensed under the terms of the Mozilla Public License v2.0. For more information, + see the COPYRIGHT and + LICENSE files. +
+

Happy coding! +

\ +`; +<% else %> +app.templates.intro = `\ +
+ Stop showing this message +

Welcome!

+

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. + Here's what you should know before you start: +

    +
  1. Open the Preferences to enable more docs and customize the UI. +
  2. You don't have to use your mouse — see the list of keyboard shortcuts. +
  3. The search supports fuzzy matching (e.g. "bgcp" brings up "background-clip"). +
  4. To search a specific documentation, type its name (or an abbr.), then Tab. +
  5. You can search using your browser's address bar — learn how. +
  6. DevDocs works offline, on mobile, and can be installed as web app. +
  7. For the latest news, follow @DevDocs. +
  8. DevDocs is free and open source. + +
  9. And if you're new to coding, check out freeCodeCamp's open source curriculum. +
+

Happy coding! +

\ +`; +<% end %> + +app.templates.mobileIntro = `\ +
+

Welcome!

+

DevDocs combines multiple API documentations in a fast, organized, and searchable interface. + Here's what you should know before you start: +

    +
  1. Pick your docs in the Preferences. +
  2. The search supports fuzzy matching. +
  3. To search a specific documentation, type its name (or an abbr.), then Space. +
  4. For the latest news, follow @DevDocs. +
  5. DevDocs is open source. +
+

Happy coding! + Stop showing this message +

\ +`; + +app.templates.androidWarning = `\ +
+

Hi there

+

DevDocs is running inside an Android WebView. Some features may not work properly. +

If you downloaded an app called DevDocs on the Play Store, please uninstall it — it's made by someone who is using (and profiting from) the name DevDocs without permission. +

To install DevDocs on your phone, visit devdocs.io in Chrome and select "Add to home screen" in the menu. +

\ +`; diff --git a/assets/javascripts/templates/pages/settings_tmpl.coffee b/assets/javascripts/templates/pages/settings_tmpl.coffee deleted file mode 100644 index 048afa1a..00000000 --- a/assets/javascripts/templates/pages/settings_tmpl.coffee +++ /dev/null @@ -1,81 +0,0 @@ -themeOption = ({ label, value }, settings) -> """ - -""" - -app.templates.settingsPage = (settings) -> """ -

Preferences

- -
-

Theme:

-
- #{if settings.autoSupported - themeOption label: "Automatic Matches system setting", value: "auto", settings - else - ""} - #{themeOption label: "Light", value: "default", settings} - #{themeOption label: "Dark", value: "dark", settings} -
-
- -
-

General:

- -
- - - - - - -
-
- -
-

Scrolling:

- -
- - - - - -
-
- -

- - - -

- -""" diff --git a/assets/javascripts/templates/pages/settings_tmpl.js b/assets/javascripts/templates/pages/settings_tmpl.js new file mode 100644 index 00000000..1fae3975 --- /dev/null +++ b/assets/javascripts/templates/pages/settings_tmpl.js @@ -0,0 +1,112 @@ +const themeOption = ({ label, value }, settings) => `\ +\ +`; + +app.templates.settingsPage = (settings) => `\ +

Preferences

+ +
+

Theme:

+
+ ${ + settings.autoSupported + ? themeOption( + { + label: "Automatic Matches system setting", + value: "auto", + }, + settings, + ) + : "" + } + ${themeOption({ label: "Light", value: "default" }, settings)} + ${themeOption({ label: "Dark", value: "dark" }, settings)} +
+
+ +
+

General:

+ +
+ + + + + + +
+
+ +
+

Scrolling:

+ +
+ + + + + +
+
+ +

+ + + +

+ \ +`; diff --git a/assets/javascripts/templates/pages/type_tmpl.coffee b/assets/javascripts/templates/pages/type_tmpl.coffee deleted file mode 100644 index c419a6a8..00000000 --- a/assets/javascripts/templates/pages/type_tmpl.coffee +++ /dev/null @@ -1,6 +0,0 @@ -app.templates.typePage = (type) -> - """

#{type.doc.fullName} / #{type.name}

- """ - -app.templates.typePageEntry = (entry) -> - """
  • #{$.escape entry.name}
  • """ diff --git a/assets/javascripts/templates/pages/type_tmpl.js b/assets/javascripts/templates/pages/type_tmpl.js new file mode 100644 index 00000000..8e072332 --- /dev/null +++ b/assets/javascripts/templates/pages/type_tmpl.js @@ -0,0 +1,11 @@ +app.templates.typePage = (type) => { + return `

    ${type.doc.fullName} / ${type.name}

    + `; +}; + +app.templates.typePageEntry = (entry) => { + return `
  • ${$.escape(entry.name)}
  • `; +}; diff --git a/assets/javascripts/templates/path_tmpl.coffee b/assets/javascripts/templates/path_tmpl.coffee deleted file mode 100644 index f28925c9..00000000 --- a/assets/javascripts/templates/path_tmpl.coffee +++ /dev/null @@ -1,7 +0,0 @@ -arrow = """""" - -app.templates.path = (doc, type, entry) -> - html = """#{doc.fullName}""" - html += """#{arrow}#{type.name}""" if type - html += """#{arrow}#{$.escape entry.name}""" if entry - html diff --git a/assets/javascripts/templates/path_tmpl.js b/assets/javascripts/templates/path_tmpl.js new file mode 100644 index 00000000..9d02c042 --- /dev/null +++ b/assets/javascripts/templates/path_tmpl.js @@ -0,0 +1,15 @@ +app.templates.path = function (doc, type, entry) { + const arrow = ''; + let html = `${doc.fullName}`; + if (type) { + html += `${arrow}${ + type.name + }`; + } + if (entry) { + html += `${arrow}${$.escape(entry.name)}`; + } + return html; +}; diff --git a/assets/javascripts/templates/sidebar_tmpl.coffee b/assets/javascripts/templates/sidebar_tmpl.coffee deleted file mode 100644 index 46797e56..00000000 --- a/assets/javascripts/templates/sidebar_tmpl.coffee +++ /dev/null @@ -1,68 +0,0 @@ -templates = app.templates - -arrow = """""" - -templates.sidebarDoc = (doc, options = {}) -> - link = """""" - if options.disabled - link += """Enable""" - else - link += arrow - link += """#{doc.release}""" if doc.release - link += """#{doc.name}""" - link += " #{doc.version}" if options.fullName or options.disabled and doc.version - link + "" - -templates.sidebarType = (type) -> - """#{arrow}#{type.count}#{$.escape type.name}""" - -templates.sidebarEntry = (entry) -> - """#{$.escape entry.name}""" - -templates.sidebarResult = (entry) -> - addons = if entry.isIndex() and app.disabledDocs.contains(entry.doc) - """Enable""" - else - """""" - addons += """#{entry.doc.short_version}""" if entry.doc.version and not entry.isIndex() - """#{addons}#{$.escape entry.name}""" - -templates.sidebarNoResults = -> - html = """
    No results.
    """ - html += """ -
    Note: documentations must be enabled to appear in the search.
    - """ unless app.isSingleDoc() or app.disabledDocs.isEmpty() - html - -templates.sidebarPageLink = (count) -> - """Show more\u2026 (#{count})""" - -templates.sidebarLabel = (doc, options = {}) -> - label = """""" - -templates.sidebarVersionedDoc = (doc, versions, options = {}) -> - html = """
    #{arrow}#{doc.name}
    #{versions}
    """ - -templates.sidebarDisabled = (options) -> - """
    #{arrow}Disabled (#{options.count}) Customize
    """ - -templates.sidebarDisabledList = (html) -> - """
    #{html}
    """ - -templates.sidebarDisabledVersionedDoc = (doc, versions) -> - """#{arrow}#{doc.name}
    #{versions}
    """ - -templates.docPickerHeader = """
    Documentation Enable
    """ - -templates.docPickerNote = """ -
    Tip: for faster and better search results, select only the docs you need.
    - Vote for new documentation - """ diff --git a/assets/javascripts/templates/sidebar_tmpl.js b/assets/javascripts/templates/sidebar_tmpl.js new file mode 100644 index 00000000..80db6aef --- /dev/null +++ b/assets/javascripts/templates/sidebar_tmpl.js @@ -0,0 +1,111 @@ +const { templates } = app; + +const arrow = ''; + +templates.sidebarDoc = function (doc, options) { + if (options == null) { + options = {}; + } + let link = ``; + if (options.disabled) { + link += `Enable`; + } else { + link += arrow; + } + if (doc.release) { + link += `${doc.release}`; + } + link += `${doc.name}`; + if (options.fullName || (options.disabled && doc.version)) { + link += ` ${doc.version}`; + } + return link + ""; +}; + +templates.sidebarType = (type) => + `${arrow}${ + type.count + }${$.escape(type.name)}`; + +templates.sidebarEntry = (entry) => + `${$.escape( + entry.name, + )}`; + +templates.sidebarResult = function (entry) { + let addons = + entry.isIndex() && app.disabledDocs.contains(entry.doc) + ? `Enable` + : ''; + if (entry.doc.version && !entry.isIndex()) { + addons += `${entry.doc.short_version}`; + } + return `${addons}${$.escape( + entry.name, + )}`; +}; + +templates.sidebarNoResults = function () { + let html = '
    No results.
    '; + if (!app.isSingleDoc() && !app.disabledDocs.isEmpty()) { + html += `\ +
    Note: documentations must be enabled to appear in the search.
    \ +`; + } + return html; +}; + +templates.sidebarPageLink = (count) => + `Show more\u2026 (${count})`; + +templates.sidebarLabel = function (doc, options) { + if (options == null) { + options = {}; + } + let label = '`; +}; + +templates.sidebarVersionedDoc = function (doc, versions, options) { + if (options == null) { + options = {}; + } + let html = `
    ${arrow}${doc.name}
    ${versions}
    ` + ); +}; + +templates.sidebarDisabled = (options) => + `
    ${arrow}Disabled (${options.count}) Customize
    `; + +templates.sidebarDisabledList = (html) => + `
    ${html}
    `; + +templates.sidebarDisabledVersionedDoc = (doc, versions) => + `${arrow}${doc.name}
    ${versions}
    `; + +templates.docPickerHeader = + '
    Documentation Enable
    '; + +templates.docPickerNote = `\ +
    Tip: for faster and better search results, select only the docs you need.
    +Vote for new documentation\ +`; diff --git a/assets/javascripts/templates/tip_tmpl.coffee b/assets/javascripts/templates/tip_tmpl.coffee deleted file mode 100644 index 55979fa4..00000000 --- a/assets/javascripts/templates/tip_tmpl.coffee +++ /dev/null @@ -1,10 +0,0 @@ -app.templates.tipKeyNav = () -> """ -

    - ProTip - (click to dismiss) -

    - Hit #{if app.settings.get('arrowScroll') then 'shift +' else ''} to navigate the sidebar.
    - Hit space / shift space#{if app.settings.get('arrowScroll') then ' or ↓/↑' else ', alt ↓/↑ or shift ↓/↑'} to scroll the page. -

    - See all keyboard shortcuts -""" diff --git a/assets/javascripts/templates/tip_tmpl.js b/assets/javascripts/templates/tip_tmpl.js new file mode 100644 index 00000000..223ffe95 --- /dev/null +++ b/assets/javascripts/templates/tip_tmpl.js @@ -0,0 +1,16 @@ +app.templates.tipKeyNav = () => `\ +

    + ProTip + (click to dismiss) +

    + Hit ${ + app.settings.get("arrowScroll") ? 'shift +' : "" + } to navigate the sidebar.
    + Hit space / shift space${ + app.settings.get("arrowScroll") + ? ' or ↓/↑' + : ', alt ↓/↑ or shift ↓/↑' + } to scroll the page. +

    + See all keyboard shortcuts\ +`; diff --git a/assets/javascripts/tracking.js b/assets/javascripts/tracking.js index a68ca493..c15781f5 100644 --- a/assets/javascripts/tracking.js +++ b/assets/javascripts/tracking.js @@ -1,32 +1,55 @@ try { - if (app.config.env === 'production') { - if (Cookies.get('analyticsConsent') === '1') { - (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ - (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), - m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); - ga('create', 'UA-5544833-12', 'devdocs.io'); - page.track(function() { - ga('send', 'pageview', { + if (app.config.env === "production") { + if (Cookies.get("analyticsConsent") === "1") { + (function (i, s, o, g, r, a, m) { + i["GoogleAnalyticsObject"] = r; + (i[r] = + i[r] || + function () { + (i[r].q = i[r].q || []).push(arguments); + }), + (i[r].l = 1 * new Date()); + (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]); + a.async = 1; + a.src = g; + m.parentNode.insertBefore(a, m); + })( + window, + document, + "script", + "https://www.google-analytics.com/analytics.js", + "ga", + ); + ga("create", "UA-5544833-12", "devdocs.io"); + page.track(function () { + ga("send", "pageview", { page: location.pathname + location.search + location.hash, - dimension1: app.router.context && app.router.context.doc && app.router.context.doc.slug_without_version + dimension1: + app.router.context && + app.router.context.doc && + app.router.context.doc.slug_without_version, }); }); - page.track(function() { - if (window._gauges) - _gauges.push(['track']); + page.track(function () { + if (window._gauges) _gauges.push(["track"]); else - (function() { - var _gauges=_gauges||[];!function(){var a=document.createElement("script"); - a.type="text/javascript",a.async=!0,a.id="gauges-tracker", - a.setAttribute("data-site-id","51c15f82613f5d7819000067"), - a.src="https://secure.gaug.es/track.js";var b=document.getElementsByTagName("script")[0]; - b.parentNode.insertBefore(a,b)}(); + (function () { + var _gauges = _gauges || []; + !(function () { + var a = document.createElement("script"); + (a.type = "text/javascript"), + (a.async = !0), + (a.id = "gauges-tracker"), + a.setAttribute("data-site-id", "51c15f82613f5d7819000067"), + (a.src = "https://secure.gaug.es/track.js"); + var b = document.getElementsByTagName("script")[0]; + b.parentNode.insertBefore(a, b); + })(); })(); }); } else { resetAnalytics(); } } -} catch(e) { } +} catch (e) {} diff --git a/assets/javascripts/vendor/cookies.js b/assets/javascripts/vendor/cookies.js index ad7597cd..e592a5de 100644 --- a/assets/javascripts/vendor/cookies.js +++ b/assets/javascripts/vendor/cookies.js @@ -5,171 +5,204 @@ * This is free and unencumbered software released into the public domain. */ (function (global, undefined) { - 'use strict'; + "use strict"; - var factory = function (window) { - if (typeof window.document !== 'object') { - throw new Error('Cookies.js requires a `window` with a `document` object'); + var factory = function (window) { + if (typeof window.document !== "object") { + throw new Error( + "Cookies.js requires a `window` with a `document` object", + ); + } + + var Cookies = function (key, value, options) { + return arguments.length === 1 + ? Cookies.get(key) + : Cookies.set(key, value, options); + }; + + // Allows for setter injection in unit tests + Cookies._document = window.document; + + // Used to ensure cookie keys do not collide with + // built-in `Object` properties + Cookies._cacheKeyPrefix = "cookey."; // Hurr hurr, :) + + Cookies._maxExpireDate = new Date("Fri, 31 Dec 9999 23:59:59 UTC"); + + Cookies.defaults = { + path: "/", + SameSite: "Strict", + secure: true, + }; + + Cookies.get = function (key) { + if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) { + Cookies._renewCache(); + } + + var value = Cookies._cache[Cookies._cacheKeyPrefix + key]; + + return value === undefined ? undefined : decodeURIComponent(value); + }; + + Cookies.set = function (key, value, options) { + options = Cookies._getExtendedOptions(options); + options.expires = Cookies._getExpiresDate( + value === undefined ? -1 : options.expires, + ); + + Cookies._document.cookie = Cookies._generateCookieString( + key, + value, + options, + ); + + return Cookies; + }; + + Cookies.expire = function (key, options) { + return Cookies.set(key, undefined, options); + }; + + Cookies._getExtendedOptions = function (options) { + return { + path: (options && options.path) || Cookies.defaults.path, + domain: (options && options.domain) || Cookies.defaults.domain, + SameSite: (options && options.SameSite) || Cookies.defaults.SameSite, + expires: (options && options.expires) || Cookies.defaults.expires, + secure: + options && options.secure !== undefined + ? options.secure + : Cookies.defaults.secure, + }; + }; + + Cookies._isValidDate = function (date) { + return ( + Object.prototype.toString.call(date) === "[object Date]" && + !isNaN(date.getTime()) + ); + }; + + Cookies._getExpiresDate = function (expires, now) { + now = now || new Date(); + + if (typeof expires === "number") { + expires = + expires === Infinity + ? Cookies._maxExpireDate + : new Date(now.getTime() + expires * 1000); + } else if (typeof expires === "string") { + expires = new Date(expires); + } + + if (expires && !Cookies._isValidDate(expires)) { + throw new Error( + "`expires` parameter cannot be converted to a valid Date instance", + ); + } + + return expires; + }; + + Cookies._generateCookieString = function (key, value, options) { + key = key.replace(/[^#$&+\^`|]/g, encodeURIComponent); + key = key.replace(/\(/g, "%28").replace(/\)/g, "%29"); + value = (value + "").replace( + /[^!#$&-+\--:<-\[\]-~]/g, + encodeURIComponent, + ); + options = options || {}; + + var cookieString = key + "=" + value; + cookieString += options.path ? ";path=" + options.path : ""; + cookieString += options.domain ? ";domain=" + options.domain : ""; + cookieString += options.SameSite ? ";SameSite=" + options.SameSite : ""; + cookieString += options.expires + ? ";expires=" + options.expires.toUTCString() + : ""; + cookieString += options.secure ? ";secure" : ""; + + return cookieString; + }; + + Cookies._getCacheFromString = function (documentCookie) { + var cookieCache = {}; + var cookiesArray = documentCookie ? documentCookie.split("; ") : []; + + for (var i = 0; i < cookiesArray.length; i++) { + var cookieKvp = Cookies._getKeyValuePairFromCookieString( + cookiesArray[i], + ); + + if ( + cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] === undefined + ) { + cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] = + cookieKvp.value; } + } - var Cookies = function (key, value, options) { - return arguments.length === 1 ? - Cookies.get(key) : Cookies.set(key, value, options); - }; + return cookieCache; + }; - // Allows for setter injection in unit tests - Cookies._document = window.document; - - // Used to ensure cookie keys do not collide with - // built-in `Object` properties - Cookies._cacheKeyPrefix = 'cookey.'; // Hurr hurr, :) + Cookies._getKeyValuePairFromCookieString = function (cookieString) { + // "=" is a valid character in a cookie value according to RFC6265, so cannot `split('=')` + var separatorIndex = cookieString.indexOf("="); + + // IE omits the "=" when the cookie value is an empty string + separatorIndex = + separatorIndex < 0 ? cookieString.length : separatorIndex; + + var key = cookieString.substr(0, separatorIndex); + var decodedKey; + try { + decodedKey = decodeURIComponent(key); + } catch (e) { + if (console && typeof console.error === "function") { + console.error('Could not decode cookie with key "' + key + '"', e); + } + } - Cookies._maxExpireDate = new Date('Fri, 31 Dec 9999 23:59:59 UTC'); + return { + key: decodedKey, + value: cookieString.substr(separatorIndex + 1), // Defer decoding value until accessed + }; + }; - Cookies.defaults = { - path: '/', - SameSite: 'Strict', - secure: true - }; - - Cookies.get = function (key) { - if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) { - Cookies._renewCache(); - } - - var value = Cookies._cache[Cookies._cacheKeyPrefix + key]; - - return value === undefined ? undefined : decodeURIComponent(value); - }; - - Cookies.set = function (key, value, options) { - options = Cookies._getExtendedOptions(options); - options.expires = Cookies._getExpiresDate(value === undefined ? -1 : options.expires); - - Cookies._document.cookie = Cookies._generateCookieString(key, value, options); - - return Cookies; - }; - - Cookies.expire = function (key, options) { - return Cookies.set(key, undefined, options); - }; - - Cookies._getExtendedOptions = function (options) { - return { - path: options && options.path || Cookies.defaults.path, - domain: options && options.domain || Cookies.defaults.domain, - SameSite: options && options.SameSite || Cookies.defaults.SameSite, - expires: options && options.expires || Cookies.defaults.expires, - secure: options && options.secure !== undefined ? options.secure : Cookies.defaults.secure - }; - }; - - Cookies._isValidDate = function (date) { - return Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date.getTime()); - }; - - Cookies._getExpiresDate = function (expires, now) { - now = now || new Date(); - - if (typeof expires === 'number') { - expires = expires === Infinity ? - Cookies._maxExpireDate : new Date(now.getTime() + expires * 1000); - } else if (typeof expires === 'string') { - expires = new Date(expires); - } - - if (expires && !Cookies._isValidDate(expires)) { - throw new Error('`expires` parameter cannot be converted to a valid Date instance'); - } - - return expires; - }; - - Cookies._generateCookieString = function (key, value, options) { - key = key.replace(/[^#$&+\^`|]/g, encodeURIComponent); - key = key.replace(/\(/g, '%28').replace(/\)/g, '%29'); - value = (value + '').replace(/[^!#$&-+\--:<-\[\]-~]/g, encodeURIComponent); - options = options || {}; - - var cookieString = key + '=' + value; - cookieString += options.path ? ';path=' + options.path : ''; - cookieString += options.domain ? ';domain=' + options.domain : ''; - cookieString += options.SameSite ? ';SameSite=' + options.SameSite : ''; - cookieString += options.expires ? ';expires=' + options.expires.toUTCString() : ''; - cookieString += options.secure ? ';secure' : ''; - - return cookieString; - }; - - Cookies._getCacheFromString = function (documentCookie) { - var cookieCache = {}; - var cookiesArray = documentCookie ? documentCookie.split('; ') : []; - - for (var i = 0; i < cookiesArray.length; i++) { - var cookieKvp = Cookies._getKeyValuePairFromCookieString(cookiesArray[i]); - - if (cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] === undefined) { - cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] = cookieKvp.value; - } - } - - return cookieCache; - }; - - Cookies._getKeyValuePairFromCookieString = function (cookieString) { - // "=" is a valid character in a cookie value according to RFC6265, so cannot `split('=')` - var separatorIndex = cookieString.indexOf('='); - - // IE omits the "=" when the cookie value is an empty string - separatorIndex = separatorIndex < 0 ? cookieString.length : separatorIndex; - - var key = cookieString.substr(0, separatorIndex); - var decodedKey; - try { - decodedKey = decodeURIComponent(key); - } catch (e) { - if (console && typeof console.error === 'function') { - console.error('Could not decode cookie with key "' + key + '"', e); - } - } - - return { - key: decodedKey, - value: cookieString.substr(separatorIndex + 1) // Defer decoding value until accessed - }; - }; - - Cookies._renewCache = function () { - Cookies._cache = Cookies._getCacheFromString(Cookies._document.cookie); - Cookies._cachedDocumentCookie = Cookies._document.cookie; - }; - - Cookies._areEnabled = function () { - var testKey = 'cookies.js'; - var areEnabled = Cookies.set(testKey, 1).get(testKey) === '1'; - Cookies.expire(testKey); - return areEnabled; - }; - - Cookies.enabled = Cookies._areEnabled(); - - return Cookies; + Cookies._renewCache = function () { + Cookies._cache = Cookies._getCacheFromString(Cookies._document.cookie); + Cookies._cachedDocumentCookie = Cookies._document.cookie; }; - var cookiesExport = (global && typeof global.document === 'object') ? factory(global) : factory; - // AMD support - if (typeof define === 'function' && define.amd) { - define(function () { return cookiesExport; }); + Cookies._areEnabled = function () { + var testKey = "cookies.js"; + var areEnabled = Cookies.set(testKey, 1).get(testKey) === "1"; + Cookies.expire(testKey); + return areEnabled; + }; + + Cookies.enabled = Cookies._areEnabled(); + + return Cookies; + }; + var cookiesExport = + global && typeof global.document === "object" ? factory(global) : factory; + + // AMD support + if (typeof define === "function" && define.amd) { + define(function () { + return cookiesExport; + }); // CommonJS/Node.js support - } else if (typeof exports === 'object') { - // Support Node.js specific `module.exports` (which can be a function) - if (typeof module === 'object' && typeof module.exports === 'object') { - exports = module.exports = cookiesExport; - } - // But always support CommonJS module 1.1.1 spec (`exports` cannot be a function) - exports.Cookies = cookiesExport; - } else { - global.Cookies = cookiesExport; + } else if (typeof exports === "object") { + // Support Node.js specific `module.exports` (which can be a function) + if (typeof module === "object" && typeof module.exports === "object") { + exports = module.exports = cookiesExport; } -})(typeof window === 'undefined' ? this : window); + // But always support CommonJS module 1.1.1 spec (`exports` cannot be a function) + exports.Cookies = cookiesExport; + } else { + global.Cookies = cookiesExport; + } +})(typeof window === "undefined" ? this : window); diff --git a/assets/javascripts/vendor/mathml.js b/assets/javascripts/vendor/mathml.js index b9782958..12972620 100644 --- a/assets/javascripts/vendor/mathml.js +++ b/assets/javascripts/vendor/mathml.js @@ -4,17 +4,22 @@ * Adapted from: https://github.com/fred-wang/mathml.css */ (function () { - window.addEventListener("load", function() { + window.addEventListener("load", function () { var box, div, link, namespaceURI; // First check whether the page contains any element. namespaceURI = "http://www.w3.org/1998/Math/MathML"; // Create a div to test mspace, using Kuma's "offscreen" CSS - document.body.insertAdjacentHTML("afterbegin", "

    "); + document.body.insertAdjacentHTML( + "afterbegin", + "
    ", + ); div = document.body.firstChild; box = div.firstChild.firstChild.getBoundingClientRect(); document.body.removeChild(div); - if (Math.abs(box.height - 23) > 1 || Math.abs(box.width - 77) > 1) { + if (Math.abs(box.height - 23) > 1 || Math.abs(box.width - 77) > 1) { window.supportsMathML = false; } }); -}()); +})(); diff --git a/assets/javascripts/vendor/prism.js b/assets/javascripts/vendor/prism.js index 1c8591ae..8eda8d21 100644 --- a/assets/javascripts/vendor/prism.js +++ b/assets/javascripts/vendor/prism.js @@ -2,13 +2,13 @@ https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+bash+c+cpp+cmake+coffeescript+crystal+d+dart+diff+django+elixir+erlang+go+groovy+java+json+julia+kotlin+latex+lua+markdown+markup-templating+matlab+nginx+nim+nix+ocaml+perl+php+python+qml+r+jsx+ruby+rust+scss+scala+shell-session+sql+typescript+yaml+zig */ /// -var _self = (typeof window !== 'undefined') - ? window // if in browser - : ( - (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) - ? self // if in worker - : {} // if in node js - ); +var _self = + typeof window !== "undefined" + ? window // if in browser + : typeof WorkerGlobalScope !== "undefined" && + self instanceof WorkerGlobalScope + ? self // if in worker + : {}; // if in node js /** * Prism: Lightweight, robust, elegant syntax highlighting @@ -19,1200 +19,1260 @@ var _self = (typeof window !== 'undefined') * @public */ var Prism = (function (_self) { - - // Private helper vars - var lang = /(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i; - var uniqueId = 0; - - // The grammar object for plaintext - var plainTextGrammar = {}; - - - var _ = { - /** - * By default, Prism will attempt to highlight all code elements (by calling {@link Prism.highlightAll}) on the - * current page after the page finished loading. This might be a problem if e.g. you wanted to asynchronously load - * additional languages or plugins yourself. - * - * By setting this value to `true`, Prism will not automatically highlight all code elements on the page. - * - * You obviously have to change this value before the automatic highlighting started. To do this, you can add an - * empty Prism object into the global scope before loading the Prism script like this: - * - * ```js - * window.Prism = window.Prism || {}; - * Prism.manual = true; - * // add a new - - """ - source.replace / +\ +`, + ); + return source.replace(/