decaffeinate: Convert app.coffee and 75 other files to JS

pull/1441/head
decaffeinate 1 year ago committed by Simon Legner
parent 6cc430ffc4
commit e4fbca722b

@ -1,283 +1,352 @@
@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
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS207: Consider shorter variations of null checks
* DS208: Avoid top-level this
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
this.app = {
_$: $,
_$$: $$,
_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: (!!__guard__(window.process != null ? window.process.versions : undefined, x => x.electron)).toString()
},
shouldSendCallback: () => {
try {
if (this.isInjectionError()) {
this.onInjectionError();
return false;
}
if (this.isAndroidWebview()) {
return false;
}
} catch (error) {}
return true;
},
dataCallback(data) {
try {
$.extend(data.user || (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 Array.from(this.DOCS)) {
(docs.indexOf(doc.slug) >= 0 ? 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 Array.from(this.docs.all())) { this.entries.add(doc.toEntry()); }
for (doc of Array.from(this.disabledDocs.all())) { this.entries.add(doc.toEntry()); }
for (doc of Array.from(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 Array.from(doc.types.all())) { doc.entries.add(type.toEntry()); }
this.entries.add(doc.entries.all());
},
migrateDocs() {
let needsSaving;
for (var slug of Array.from(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(Array.from(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.indexOf(tip) === -1) {
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(...Array.from(args || []))) {
this.onInjectionError();
} else if (this.isAppError(...Array.from(args || []))) {
if (typeof this.previousErrorHandler === 'function') {
this.previousErrorHandler(...Array.from(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.indexOf('devdocs') !== -1) && (file.indexOf('.js') === (file.length - 3));
},
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: !!__guardMethod__(CSS, 'supports', o => o.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.indexOf(app.config.production_host) !== 0);
}
};
$.extend(app, Events);
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}
function __guardMethod__(obj, methodName, transform) {
if (typeof obj !== 'undefined' && obj !== null && typeof obj[methodName] === 'function') {
return transform(obj, methodName);
} else {
return undefined;
}
}

@ -1,382 +1,486 @@
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')
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let NAME = undefined;
let VERSION = undefined;
const Cls = (app.DB = class DB {
static initClass() {
NAME = 'docs';
VERSION = 15;
}
constructor() {
this.onOpenSuccess = this.onOpenSuccess.bind(this);
this.onOpenError = this.onOpenError.bind(this);
this.checkForCorruptedDocs = this.checkForCorruptedDocs.bind(this);
this.deleteCorruptedDocs = this.deleteCorruptedDocs.bind(this);
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(NAME, (VERSION * this.versionMultipler) + this.userVersion());
req.onsuccess = this.onOpenSuccess;
req.onerror = this.onOpenError;
req.onupgradeneeded = this.onUpgradeNeeded;
} 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(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) !== VERSION) {
this.fail('version');
} else {
this.setUserVersion(actualVersion - (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 Array.from(app.docs.all())) {
if (!$.arrayDelete(objectStoreNames, doc.slug)) {
try { db.createObjectStore(doc.slug); } catch (error1) {}
}
}
for (var name of Array.from(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 != null ? txn.error.name : undefined) === '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 != null ? txn.error.name : undefined) === '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) {
let version;
if ((version = this.cachedVersion(doc)) != 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) {
let versions;
if (versions = this.cachedVersions(docs)) {
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(function(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 Array.from(docs)) { result[doc.slug] = this.cachedVersion(doc); }
return result;
}
load(entry, onSuccess, onError) {
if (this.shouldLoadWithIDB(entry)) {
onError = this.loadWithXHR.bind(this, entry, onSuccess, onError);
return this.loadWithIDB(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 Array.from(docs)) {
if (!app.docs.findBy('slug', slug)) {
this.corruptedDocs.push(slug);
}
}
for (slug of Array.from(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 Array.from(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 { if (typeof indexedDB !== 'undefined' && indexedDB !== null) {
indexedDB.deleteDatabase(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');
}
});
Cls.initClass();
return Cls;
})();

@ -1,154 +1,199 @@
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')
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.Router = class Router {
static initClass() {
$.extend(this.prototype, Events);
this.routes = [
['*', 'before' ],
['/', 'root' ],
['/settings', 'settings' ],
['/offline', 'offline' ],
['/about', 'about' ],
['/news', 'news' ],
['/help', 'help' ],
['/:doc-:type/', 'type' ],
['/:doc/', 'doc' ],
['/:doc/:path(*)', 'entry' ],
['*', 'notFound' ]
];
}
constructor() {
for (var [path, method] of Array.from(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) {
let type;
const doc = app.docs.findBySlug(context.params.doc);
if (type = doc != null ? doc.types.findBy('slug', context.params.type) : undefined) {
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
replaceHash: (hash) ->
page.replace location.pathname + location.search + (hash or ''), null, true
return
} = 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 != null ? this.context.path : undefined) === '/') || (app.isSingleDoc() && __guard__(this.context != null ? this.context.entry : undefined, x => x.isIndex()));
}
isSettings() {
return (this.context != null ? this.context.path : undefined) === '/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 __guard__((new RegExp("#/(.+)")).exec(decodeURIComponent(location.hash)), x => x[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);
}
});
Cls.initClass();
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,292 +1,373 @@
#
# 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()
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS104: Avoid inline assignments
* DS202: Simplify dynamic range loops
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* DS209: Avoid top-level return
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//
// 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.indexOf(query) >= 0)) { 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
//
(function() {
let CHUNK_SIZE = undefined;
let DEFAULTS = undefined;
let SEPARATORS_REGEXP = undefined;
let EOS_SEPARATORS_REGEXP = undefined;
let INFO_PARANTHESES_REGEXP = undefined;
let EMPTY_PARANTHESES_REGEXP = undefined;
let EVENT_REGEXP = undefined;
let DOT_REGEXP = undefined;
let WHITESPACE_REGEXP = undefined;
let EMPTY_STRING = undefined;
let ELLIPSIS = undefined;
let STRING = undefined;
const Cls = (app.Searcher = class Searcher {
static initClass() {
$.extend(this.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';
}
static normalizeString(string) {
return 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);
}
static normalizeQuery(string) {
string = this.normalizeString(string);
return string.replace(EOS_SEPARATORS_REGEXP, '$1.');
}
constructor(options) {
this.match = this.match.bind(this);
this.matchChunks = this.matchChunks.bind(this);
if (options == null) { options = {}; }
this.options = $.extend({}, 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 = (this.attr = (this.dataLength = (this.matchers = (this.matcher = (this.query =
(this.totalResults = (this.scoreMap = (this.cursor = (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(), asc = 0 <= end; asc ? j < end : j > end; asc ? j++ : 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 + CHUNK_SIZE) > this.dataLength) {
return this.dataLength % CHUNK_SIZE;
} else {
return CHUNK_SIZE;
}
}
scoredEnough() {
return (this.scoreMap[100] != null ? this.scoreMap[100].length : undefined) >= 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.apply(results, 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('.*?'));
}
});
Cls.initClass();
return Cls; // abc -> /a.*?b.*?c.*?/
})();
app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher {
constructor(...args) {
this.match = this.match.bind(this);
super(...args);
}
match() {
if (this.matcher) {
if (!this.allResults) { this.allResults = []; }
this.allResults.push.apply(this.allResults, 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 != null ? this.allResults.length : undefined)) {
return this.triggerResults(this.allResults);
}
}
delay(fn) {
return fn();
}
};

@ -1,49 +1,66 @@
class app.ServiceWorker
$.extend @prototype, Events
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.ServiceWorker = class ServiceWorker {
static initClass() {
$.extend(this.prototype, Events);
}
@isEnabled: ->
!!navigator.serviceWorker and app.config.service_worker_enabled
static isEnabled() {
return !!navigator.serviceWorker && app.config.service_worker_enabled;
}
constructor: ->
@registration = null
@notifyUpdate = true
constructor() {
this.onUpdateFound = this.onUpdateFound.bind(this);
this.onStateChange = this.onStateChange.bind(this);
this.registration = null;
this.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
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(function() {});
}
updateInBackground() {
if (!this.registration) { return; }
this.notifyUpdate = false;
return this.registration.update().catch(function() {});
}
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'); }
}
});
Cls.initClass();

@ -1,170 +1,219 @@
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
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS104: Avoid inline assignments
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let PREFERENCE_KEYS = undefined;
let INTERNAL_KEYS = undefined;
const Cls = (app.Settings = class Settings {
static initClass() {
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'
];
this.prototype.LAYOUTS = [
'_max-width',
'_sidebar-hidden',
'_native-scrollbars',
'_text-justify-hyphenate'
];
this.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 __guard__(this.store.get('docs'), x => x.split('/')) || app.config.default_docs;
}
setDocs(docs) {
this.set('docs', docs.join('/'));
}
getTips() {
return __guard__(this.store.get('tips'), x => x.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.indexOf(name) === -1) { 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.indexOf(name) !== -1;
}
setSize(value) {
this.set('size', value);
}
dump() {
return this.store.dump();
}
export() {
const data = this.dump();
for (var key of Array.from(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 (PREFERENCE_KEYS.indexOf(key) !== -1) { 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 Array.from(this.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 != null ? app.router.isSettings : undefined)) { classList.toggle(layout, enable); }
classList.toggle('_overlay-scrollbars', $.overlayScrollbarsEnabled());
}
initSidebarWidth() {
const size = this.get('size');
if (size) { document.documentElement.style.setProperty('--sidebarWidth', size + 'px'); }
}
});
Cls.initClass();
return Cls;
})();
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,193 +1,259 @@
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
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS205: Consider reworking code to avoid use of IIFEs
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.Shortcuts = class Shortcuts {
static initClass() {
$.extend(this.prototype, Events);
}
constructor() {
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 (!__guard__(getSelection(), x => x.toString())) {
this.trigger('altUp');
return false;
}
break;
case 40:
if (!__guard__(getSelection(), x1 => x1.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;
}
}
});
Cls.initClass();
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,39 +1,54 @@
class app.UpdateChecker
constructor: ->
@lastCheck = Date.now()
/*
* decaffeinate suggestions:
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
app.UpdateChecker = class UpdateChecker {
constructor() {
this.checkDocs = this.checkDocs.bind(this);
this.onFocus = this.onFocus.bind(this);
this.lastCheck = Date.now();
$.on window, 'focus', @onFocus
app.serviceWorker?.on 'updateready', @onUpdateReady
$.on(window, 'focus', this.onFocus);
if (app.serviceWorker != null) {
app.serviceWorker.on('updateready', this.onUpdateReady);
}
setTimeout @checkDocs, 0
setTimeout(this.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
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
return
onUpdateReady() {
new app.views.Notif('UpdateReady', {autoHide: null});
}
checkDocs: =>
unless app.settings.get('manualUpdate')
app.docs.updateInBackground()
else
app.docs.checkForUpdates (i) => @onDocsUpdateReady() if i > 0
return
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
return
onDocsUpdateReady() {
new app.views.Notif('UpdateDocs', {autoHide: null});
}
onFocus: =>
if Date.now() - @lastCheck > 21600e3
@lastCheck = Date.now()
@check()
return
onFocus() {
if ((Date.now() - this.lastCheck) > 21600e3) {
this.lastCheck = Date.now();
this.check();
}
}
};

@ -1,31 +1,38 @@
#= require_tree ./vendor
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require_tree ./vendor
#= require lib/license
#= require_tree ./lib
//= require lib/license
//= require_tree ./lib
#= require app/app
#= require app/config
#= require_tree ./app
//= require app/app
//= require app/config
//= require_tree ./app
#= require collections/collection
#= require_tree ./collections
//= require collections/collection
//= require_tree ./collections
#= require models/model
#= require_tree ./models
//= require models/model
//= require_tree ./models
#= require views/view
#= require_tree ./views
//= require views/view
//= require_tree ./views
#= require_tree ./templates
//= require_tree ./templates
#= require tracking
//= require tracking
init = ->
document.removeEventListener 'DOMContentLoaded', init, false
var init = function() {
document.removeEventListener('DOMContentLoaded', init, false);
if document.body
app.init()
else
setTimeout(init, 42)
if (document.body) {
return app.init();
} else {
return setTimeout(init, 42);
}
};
document.addEventListener 'DOMContentLoaded', init, false
document.addEventListener('DOMContentLoaded', init, false);

@ -1,55 +1,75 @@
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
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
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 Array.from(objects)) { this.add(object); }
}
add(object) {
if (object instanceof app.Model) {
this.models.push(object);
} else if (object instanceof Array) {
for (var obj of Array.from(object)) { this.add(obj); }
} else if (object instanceof app.Collection) {
this.models.push(...Array.from(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 Array.from(this.models)) { fn(model); }
}
all() {
return this.models;
}
contains(model) {
return this.models.indexOf(model) >= 0;
}
findBy(attr, value) {
for (var model of Array.from(this.models)) {
if (model[attr] === value) { return model; }
}
}
findAllBy(attr, value) {
return Array.from(this.models).filter((model) => model[attr] === value);
}
countAllBy(attr, value) {
let i = 0;
for (var model of Array.from(this.models)) { if (model[attr] === value) { i += 1; } }
return i;
}
};

@ -1,85 +1,117 @@
class app.collections.Docs extends app.Collection
@model: 'Doc'
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS202: Simplify dynamic range loops
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let NORMALIZE_VERSION_RGX = undefined;
let NORMALIZE_VERSION_SUB = undefined;
let CONCURRENCY = undefined;
const Cls = (app.collections.Docs = class Docs extends app.Collection {
static initClass() {
this.model = 'Doc';
NORMALIZE_VERSION_RGX = /\.(\d)$/;
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.
CONCURRENCY = 3;
}
findBySlug: (slug) ->
@findBy('slug', slug) or @findBy('slug_without_version', slug)
findBySlug(slug) {
return this.findBy('slug', slug) || this.findBy('slug_without_version', slug);
}
sort() {
return this.models.sort(function(a, b) {
if (a.name === b.name) {
if (!a.version || (a.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB) > b.version.replace(NORMALIZE_VERSION_RGX, 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;
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
var next = () => {
if (i < this.models.length) {
this.models[i].load(next, fail, options);
} else if (i === ((this.models.length + CONCURRENCY) - 1)) {
onComplete();
}
i++;
};
# 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
var fail = function(...args) {
if (onError) {
onError(...Array.from(args || []));
onError = null;
}
next();
};
next = =>
if i < @models.length
@models[i].load(next, fail, options)
else if i is @models.length + CONCURRENCY - 1
onComplete()
i++
return
for (let j = 0, end = CONCURRENCY, asc = 0 <= end; asc ? j < end : j > end; asc ? j++ : j--) { next(); }
}
fail = (args...) ->
if onError
onError(args...)
onError = null
next()
return
clearCache() {
for (var doc of Array.from(this.models)) { doc.clearCache(); }
}
next() for [0...CONCURRENCY]
return
uninstall(callback) {
let i = 0;
var next = () => {
if (i < this.models.length) {
this.models[i++].uninstall(next, next);
} else {
callback();
}
};
next();
}
clearCache: ->
doc.clearCache() for doc in @models
return
getInstallStatuses(callback) {
app.db.versions(this.models, function(statuses) {
if (statuses) {
for (var key in statuses) {
var value = statuses[key];
statuses[key] = {installed: !!value, mtime: value};
}
}
callback(statuses);
});
}
uninstall: (callback) ->
i = 0
next = =>
if i < @models.length
@models[i++].uninstall(next, next)
else
callback()
return
next()
return
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);
});
}
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
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); }
}
});
}
});
Cls.initClass();
return Cls;
})();

@ -1,2 +1,11 @@
class app.collections.Entries extends app.Collection
@model: 'Entry'
/*
* decaffeinate suggestions:
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.collections.Entries = class Entries extends app.Collection {
static initClass() {
this.model = 'Entry';
}
});
Cls.initClass();

@ -1,19 +1,41 @@
class app.collections.Types extends app.Collection
@model: 'Type'
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS104: Avoid inline assignments
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let GUIDES_RGX = undefined;
let APPENDIX_RGX = undefined;
const Cls = (app.collections.Types = class Types extends app.Collection {
static initClass() {
this.model = 'Type';
GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i;
APPENDIX_RGX = /appendix/i;
}
groups: ->
result = []
for type in @models
(result[@_groupFor(type)] ||= []).push(type)
result.filter (e) -> e.length > 0
groups() {
const result = [];
for (var type of Array.from(this.models)) {
var name;
(result[name = this._groupFor(type)] || (result[name] = [])).push(type);
}
return 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
_groupFor(type) {
if (GUIDES_RGX.test(type.name)) {
return 0;
} else if (APPENDIX_RGX.test(type.name)) {
return 2;
} else {
return 1;
}
}
});
Cls.initClass();
return Cls;
})();

@ -1,85 +1,110 @@
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
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS203: Remove `|| {}` from converted for-own loops
* DS207: Consider shorter variations of null checks
* DS208: Avoid top-level this
* DS209: Avoid top-level return
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
if (!(typeof console !== 'undefined' && console !== null ? console.time : undefined) || !console.groupCollapsed) { return; }
//
// 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
//
const _super = app.Searcher;
const _proto = app.Searcher.prototype;
app.Searcher = function() {
_super.apply(this, arguments);
const _setup = this.setup.bind(this);
this.setup = function() {
console.groupCollapsed(`Search: ${this.query}`);
console.time('Total');
return _setup();
};
const _match = this.match.bind(this);
this.match = () => {
if (this.matcher) { console.timeEnd(this.matcher.name); }
return _match();
};
const _setupMatcher = this.setupMatcher.bind(this);
this.setupMatcher = function() {
console.time(this.matcher.name);
return _setupMatcher();
};
const _end = this.end.bind(this);
this.end = function() {
console.log(`Results: ${this.totalResults}`);
console.timeEnd('Total');
console.groupEnd();
return _end();
};
const _kill = this.kill.bind(this);
this.kill = function() {
if (this.timeout) {
if (this.matcher) { console.timeEnd(this.matcher.name); }
console.groupEnd();
console.timeEnd('Total');
console.warn('Killed');
}
return _kill();
};
};
$.extend(app.Searcher, _super);
_proto.constructor = app.Searcher;
app.Searcher.prototype = _proto;
//
// 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.indexOf(view) >= 0) { 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); } }
}
}
}
};

@ -1,118 +1,154 @@
MIME_TYPES =
json: 'application/json'
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS207: Consider shorter variations of null checks
* DS208: Avoid top-level this
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const MIME_TYPES = {
json: 'application/json',
html: 'text/html'
};
@ajax = (options) ->
applyDefaults(options)
serializeData(options)
this.ajax = function(options) {
applyDefaults(options);
serializeData(options);
xhr = new XMLHttpRequest()
xhr.open(options.type, options.url, options.async)
const xhr = new XMLHttpRequest();
xhr.open(options.type, options.url, options.async);
applyCallbacks(xhr, options)
applyHeaders(xhr, options)
applyCallbacks(xhr, options);
applyHeaders(xhr, options);
xhr.send(options.data)
xhr.send(options.data);
if options.async
abort: abort.bind(undefined, xhr)
else
parseResponse(xhr, options)
if (options.async) {
return {abort: abort.bind(undefined, xhr)};
} else {
return parseResponse(xhr, options);
}
};
ajax.defaults =
async: true
dataType: 'json'
timeout: 30
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
};
// contentType
// context
// data
// error
// headers
// progress
// success
// url
var applyDefaults = function(options) {
for (var key in ajax.defaults) {
if (options[key] == null) { options[key] = ajax.defaults[key]; }
}
};
var serializeData = function(options) {
if (!options.data) { return; }
if (options.type === 'GET') {
options.url += '?' + serializeParams(options.data);
options.data = null;
} else {
options.data = serializeParams(options.data);
}
};
var serializeParams = params => ((() => {
const result = [];
for (var key in params) {
var value = params[key];
result.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
return result;
})()).join('&');
var applyCallbacks = function(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);
}
};
};
var applyHeaders = function(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);
}
};
var onComplete = function(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);
}
};
var onSuccess = function(response, xhr, options) {
if (options.success != null) {
options.success.call(options.context, response, xhr, options);
}
};
var onError = function(type, xhr, options) {
if (options.error != null) {
options.error.call(options.context, type, xhr, options);
}
};
var onTimeout = function(xhr, options) {
xhr.abort();
onError('timeout', xhr, options);
};
var abort = function(xhr) {
clearTimeout(xhr.timer);
xhr.onreadystatechange = null;
xhr.abort();
};
var parseResponse = function(xhr, options) {
if (options.dataType === 'json') {
return parseJSON(xhr.responseText);
} else {
return xhr.responseText;
}
};
var parseJSON = function(json) {
try { return JSON.parse(json); } catch (error) {}
};

@ -1,42 +1,66 @@
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
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let INT = undefined;
const Cls = (this.CookiesStore = class CookiesStore {
static initClass() {
// 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+$/;
}
INT = /^\d+$/
static onBlocked() {}
@onBlocked: ->
get(key) {
let value = Cookies.get(key);
if ((value != null) && INT.test(value)) { value = parseInt(value, 10); }
return value;
}
get: (key) ->
value = Cookies.get(key)
value = parseInt(value, 10) if value? and INT.test(value)
value
set(key, value) {
if (value === false) {
this.del(key);
return;
}
set: (key, value) ->
if value == false
@del(key)
return
if (value === true) { value = 1; }
if (value && (typeof INT.test === 'function' ? INT.test(value) : undefined)) { value = parseInt(value, 10); }
Cookies.set(key, '' + value, {path: '/', expires: 1e8});
if (this.get(key) !== value) { this.constructor.onBlocked(key, value, this.get(key)); }
}
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);
}
del: (key) ->
Cookies.expire(key)
return
reset() {
try {
for (var cookie of Array.from(document.cookie.split(/;\s?/))) {
Cookies.expire(cookie.split('=')[0]);
}
return;
} catch (error) {}
}
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
dump() {
const result = {};
for (var cookie of Array.from(document.cookie.split(/;\s?/))) {
if (cookie[0] !== '_') {
cookie = cookie.split('=');
result[cookie[0]] = cookie[1];
}
}
return result;
}
});
Cls.initClass();
return Cls;
})();

@ -1,28 +1,51 @@
@Events =
on: (event, callback) ->
if event.indexOf(' ') >= 0
@on name, callback for name in event.split(' ')
else
((@_callbacks ?= {})[event] ?= []).push callback
@
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS104: Avoid inline assignments
* DS207: Consider shorter variations of null checks
* DS208: Avoid top-level this
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
this.Events = {
on(event, callback) {
if (event.indexOf(' ') >= 0) {
for (var name of Array.from(event.split(' '))) { this.on(name, callback); }
} else {
let base;
(((base = this._callbacks != null ? this._callbacks : (this._callbacks = {})))[event] != null ? base[event] : (base[event] = [])).push(callback);
}
return this;
},
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
@
off(event, callback) {
let callbacks, index;
if (event.indexOf(' ') >= 0) {
for (var name of Array.from(event.split(' '))) { this.off(name, callback); }
} else if ((callbacks = this._callbacks != null ? this._callbacks[event] : undefined) && ((index = callbacks.indexOf(callback)) >= 0)) {
callbacks.splice(index, 1);
if (!callbacks.length) { delete this._callbacks[event]; }
}
return this;
},
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'
@
trigger(event, ...args) {
let callbacks;
this.eventInProgress = { name: event, args };
if (callbacks = this._callbacks != null ? this._callbacks[event] : undefined) {
for (var callback of Array.from(callbacks.slice(0))) { if (typeof callback === 'function') {
callback(...Array.from(args || []));
} }
}
this.eventInProgress = null;
if (event !== 'all') { this.trigger('all', event, ...Array.from(args)); }
return this;
},
removeEvent: (event) ->
if @_callbacks?
delete @_callbacks[name] for name in event.split(' ')
@
removeEvent(event) {
if (this._callbacks != null) {
for (var name of Array.from(event.split(' '))) { delete this._callbacks[name]; }
}
return this;
}
};

@ -1,76 +1,89 @@
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
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS208: Avoid top-level this
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
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;
}
};

@ -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/
###
*/

@ -1,23 +1,33 @@
class @LocalStorageStore
get: (key) ->
try
JSON.parse localStorage.getItem(key)
catch
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
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))
true
catch
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {}
}
del: (key) ->
try
localStorage.removeItem(key)
true
catch
del(key) {
try {
localStorage.removeItem(key);
return true;
} catch (error) {}
}
reset: ->
try
localStorage.clear()
true
catch
reset() {
try {
localStorage.clear();
return true;
} catch (error) {}
}
};

@ -1,223 +1,280 @@
###
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* DS208: Avoid top-level this
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
/*
* Based on github.com/visionmedia/page.js
* Licensed under the MIT license
* Copyright 2012 TJ Holowaychuk <tj@vision-media.ca>
###
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
*/
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 != null ? currentState.path : undefined)) { 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);
var currentPath = () => location.pathname + location.search + location.hash;
class Context {
static initClass() {
this.initialPath = currentPath();
this.sessionId = Date.now();
this.stateId = 0;
}
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) {
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
}
}
Context.initClass();
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) ->
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
.replace(/\/\(/g, '(?:/')
.replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, function(_, 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.indexOf(`${location.protocol}//${location.hostname}`) === 0;
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 Array.from(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 Array.from(document.cookie.split(/;\s?/))) {
var name = cookie.split('=')[0];
if ((name[0] === '_') && (name[1] !== '_')) {
Cookies.expire(name);
}
}
};

@ -1,399 +1,491 @@
#
# 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 =
'&': '&amp;'
'<': '&lt;'
'>': '&gt;'
'"': '&quot;'
"'": '&#x27;'
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS104: Avoid inline assignments
* DS204: Change includes calls to have a more natural evaluation order
* DS207: Consider shorter variations of null checks
* DS208: Avoid top-level this
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//
// 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.indexOf(' ') >= 0) {
for (var name of Array.from(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.indexOf(' ') >= 0) {
for (var name of Array.from(event.split(' '))) { $.off(el, name, callback); }
} else {
el.removeEventListener(event, callback, useCapture);
}
};
$.trigger = function(el, type, canBubble, cancelable) {
if (canBubble == null) { canBubble = true; }
if (cancelable == null) { cancelable = true; }
const event = document.createEvent('Event');
event.initEvent(type, canBubble, cancelable);
el.dispatchEvent(event);
};
$.click = function(el) {
const event = document.createEvent('MouseEvent');
event.initMouseEvent('click', true, true, window, null, 0, 0, 0, 0, false, false, false, false, 0, null);
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 Array.from($.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 Array.from($.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)) {
var needle;
if (el.scrollTop > 0) { break; }
if ((needle = __guard__(getComputedStyle(el), x => x.overflowY), ['auto', 'scroll'].includes(needle))) { 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 != null) ? 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, ...Array.from(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 Array.from(parent.getElementsByTagName('img'))) {
if (!image.complete) {
(function() {
let timeout;
const onLoad = function(event) {
clearTimeout(timeout);
unbind(event.target);
return $.scrollTo(el, parent, ...Array.from(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) {
if (!window.requestAnimationFrame) {
el.scrollTop = end;
return;
}
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
//
$.extend = function(target, ...objects) {
for (var object of Array.from(objects)) {
if (object) {
for (var key in object) {
var value = object[key];
target[key] = value;
}
}
}
return target;
};
$.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 != null ? object.item : undefined) === 'function');
const ESCAPE_HTML_MAP = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;'
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'
};
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('');
};
$.framify = function(fn, obj) {
if (window.requestAnimationFrame) {
return (...args) => requestAnimationFrame(fn.bind(obj, ...Array.from(args)));
} else {
return fn;
}
};
$.requestAnimationFrame = function(fn) {
if (window.requestAnimationFrame) {
requestAnimationFrame(fn);
} else {
setTimeout(fn, 0);
}
};
//
// 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 != null ? navigator.userAgent.indexOf('Mac') : undefined) >= 0);
let isIE = null;
$.isIE = () => isIE != null ? isIE : (isIE = ((navigator.userAgent != null ? navigator.userAgent.indexOf('MSIE') : undefined) >= 0) || ((navigator.userAgent != null ? navigator.userAgent.indexOf('rv:11.0') : undefined) >= 0));
let isChromeForAndroid = null;
$.isChromeForAndroid = () => isChromeForAndroid != null ? isChromeForAndroid : (isChromeForAndroid = ((navigator.userAgent != null ? navigator.userAgent.indexOf('Android') : undefined) >= 0) && /Chrome\/([.0-9])+ Mobile/.test(navigator.userAgent));
let isAndroid = null;
$.isAndroid = () => isAndroid != null ? isAndroid : (isAndroid = (navigator.userAgent != null ? navigator.userAgent.indexOf('Android') : undefined) >= 0);
let isIOS = null;
$.isIOS = () => isIOS != null ? isIOS : (isIOS = ((navigator.userAgent != null ? navigator.userAgent.indexOf('iPhone') : undefined) >= 0) || ((navigator.userAgent != null ? navigator.userAgent.indexOf('iPad') : undefined) >= 0));
$.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 = (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
};
$.highlight = function(el, options) {
if (options == null) { options = {}; }
options = $.extend({}, 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;
};
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,147 +1,174 @@
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
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
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'
@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
});
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 @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
});
}
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
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
});
}
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);
}
};

@ -1,85 +1,116 @@
#= require app/searcher
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require app/searcher
class app.models.Entry extends app.Model
# Attributes: name, type, path
(function() {
let applyAliases = undefined;
const Cls = (app.models.Entry = class Entry extends app.Model {
static initClass() {
let ALIASES;
applyAliases = function(string) {
if (ALIASES.hasOwnProperty(string)) {
return [string, ALIASES[string]];
} else {
const words = string.split('.');
for (let i = 0; i < words.length; i++) {
var word = words[i];
if (ALIASES.hasOwnProperty(word)) {
words[i] = ALIASES[word];
return [string, words.join('.')];
}
}
}
return string;
};
this.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': '_'
});
}
// Attributes: name, type, path
constructor: ->
super
@text = applyAliases(app.Searcher.normalizeString(@name))
constructor() {
super(...arguments);
this.text = applyAliases(app.Searcher.normalizeString(this.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
addAlias(name) {
const text = applyAliases(app.Searcher.normalizeString(name));
if (!Array.isArray(this.text)) { this.text = [this.text]; }
this.text.push(Array.isArray(text) ? text[1] : text);
}
fullPath: ->
@doc.fullPath if @isIndex() then '' else @path
fullPath() {
return this.doc.fullPath(this.isIndex() ? '' : this.path);
}
dbPath: ->
@path.replace /#.*/, ''
dbPath() {
return this.path.replace(/#.*/, '');
}
filePath: ->
@doc.fullPath @_filePath()
filePath() {
return this.doc.fullPath(this._filePath());
}
fileUrl: ->
@doc.fileUrl @_filePath()
fileUrl() {
return this.doc.fileUrl(this._filePath());
}
_filePath: ->
result = @path.replace /#.*/, ''
result += '.html' unless result[-5..-1] is '.html'
result
_filePath() {
let result = this.path.replace(/#.*/, '');
if (result.slice(-5) !== '.html') { result += '.html'; }
return result;
}
isIndex: ->
@path is 'index'
isIndex() {
return this.path === 'index';
}
getType: ->
@doc.types.findBy 'name', @type
getType() {
return this.doc.types.findBy('name', this.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': '_'
loadFile(onSuccess, onError) {
return app.db.load(this, onSuccess, onError);
}
});
Cls.initClass();
return Cls;
})();

@ -1,3 +1,5 @@
class app.Model
constructor: (attributes) ->
@[key] = value for key, value of attributes
app.Model = class Model {
constructor(attributes) {
for (var key in attributes) { var value = attributes[key]; this[key] = value; }
}
};

@ -1,14 +1,24 @@
class app.models.Type extends app.Model
# Attributes: name, slug, count
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
app.models.Type = class Type extends app.Model {
// Attributes: name, slug, count
fullPath: ->
"/#{@doc.slug}-#{@slug}/"
fullPath() {
return `/${this.doc.slug}-${this.slug}/`;
}
entries: ->
@doc.entries.findAllBy 'type', @name
entries() {
return this.doc.entries.findAllBy('type', this.name);
}
toEntry: ->
new app.models.Entry
doc: @doc
name: "#{@doc.name} / #{@name}"
path: '..' + @fullPath()
toEntry() {
return new app.models.Entry({
doc: this.doc,
name: `${this.doc.name} / ${this.name}`,
path: '..' + this.fullPath()
});
}
};

@ -1,11 +1,19 @@
app.templates.render = (name, value, args...) ->
template = app.templates[name]
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
app.templates.render = function(name, value, ...args) {
const 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
if (Array.isArray(value)) {
let result = '';
for (var val of Array.from(value)) { result += template(val, ...Array.from(args)); }
return result;
} else if (typeof template === 'function') {
return template(value, ...Array.from(args));
} else {
return template;
}
};

@ -1,73 +1,85 @@
error = (title, text = '', links = '') ->
text = """<p class="_error-text">#{text}</p>""" if text
links = """<p class="_error-links">#{links}</p>""" if links
"""<div class="_error"><h1 class="_error-title">#{title}</h1>#{text}#{links}</div>"""
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const error = function(title, text, links) {
if (text == null) { text = ''; }
if (links == null) { links = ''; }
if (text) { text = `<p class="_error-text">${text}</p>`; }
if (links) { links = `<p class="_error-links">${links}</p>`; }
return `<div class="_error"><h1 class="_error-title">${title}</h1>${text}${links}</div>`;
};
back = '<a href="#" data-behavior="back" class="_error-link">Go back</a>'
const back = '<a href="#" data-behavior="back" class="_error-link">Go back</a>';
app.templates.notFoundPage = ->
error """ Page not found. """,
""" It may be missing from the source documentation or this could be a bug. """,
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 <a href="/offline">installing the documentation for offline usage</a> when online again).<br>
If you're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """,
""" #{back} &middot; <a href="/##{location.pathname}" target="_top" class="_error-link">Reload</a>
&middot; <a href="#" class="_error-link" data-retry>Retry</a> """
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 <a href="/offline">installing the documentation for offline usage</a> when online again).<br>
If you're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. `,
` ${back} &middot; <a href="/#${location.pathname}" target="_top" class="_error-link">Reload</a>
&middot; <a href="#" class="_error-link" data-retry>Retry</a> `
);
app.templates.bootError = ->
error """ The app failed to load. """,
""" Check your Internet connection and try <a href="#" data-behavior="reload">reloading</a>.<br>
If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """
app.templates.bootError = () => error(" The app failed to load. ",
` Check your Internet connection and try <a href="#" data-behavior="reload">reloading</a>.<br>
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. """
app.templates.offlineError = function(reason, exception) {
if (reason === '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.<br>
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.<br>
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.<br>
This prevents DevDocs from caching documentations for offline access."""
when 'exception'
""" An error occurred when trying to open the IndexedDB database:<br>
<code class="_label">#{exception.name}: #{exception.message}</code> """
when 'cant_open'
""" An error occurred when trying to open the IndexedDB database:<br>
<code class="_label">#{exception.name}: #{exception.message}</code><br>
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.<br>
<a href="#" data-behavior="reload">Reload the page</a> to use offline mode. """
when 'empty'
""" The IndexedDB database appears to be corrupted. Try <a href="#" data-behavior="reset">resetting the app</a>. """
reason = (() => { switch (reason) {
case 'not_supported':
return ` DevDocs requires IndexedDB to cache documentations for offline access.<br>
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.<br>
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.<br>
This prevents DevDocs from caching documentations for offline access.`;
case 'exception':
return ` An error occurred when trying to open the IndexedDB database:<br>
<code class="_label">${exception.name}: ${exception.message}</code> `;
case 'cant_open':
return ` An error occurred when trying to open the IndexedDB database:<br>
<code class="_label">${exception.name}: ${exception.message}</code><br>
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.<br>
<a href="#" data-behavior="reload">Reload the page</a> to use offline mode. `;
case 'empty':
return " The IndexedDB database appears to be corrupted. Try <a href=\"#\" data-behavior=\"reset\">resetting the app</a>. ";
} })();
error 'Offline mode is unavailable.', reason
return error('Offline mode is unavailable.', reason);
};
app.templates.unsupportedBrowser = """
<div class="_fail">
<h1 class="_fail-title">Your browser is unsupported, sorry.</h1>
<p class="_fail-text">DevDocs is an API documentation browser which supports the following browsers:
<ul class="_fail-list">
<li>Recent versions of Firefox, Chrome, or Opera
<li>Safari 11.1+
<li>Edge 17+
<li>iOS 11.3+
</ul>
<p class="_fail-text">
If you're unable to upgrade, we apologize.
We decided to prioritize speed and new features over support for older browsers.
<p class="_fail-text">
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.
<p class="_fail-text">
&mdash; <a href="https://twitter.com/DevDocs">@DevDocs</a>
</div>
"""
app.templates.unsupportedBrowser = `\
<div class="_fail">
<h1 class="_fail-title">Your browser is unsupported, sorry.</h1>
<p class="_fail-text">DevDocs is an API documentation browser which supports the following browsers:
<ul class="_fail-list">
<li>Recent versions of Firefox, Chrome, or Opera
<li>Safari 11.1+
<li>Edge 17+
<li>iOS 11.3+
</ul>
<p class="_fail-text">
If you're unable to upgrade, we apologize.
We decided to prioritize speed and new features over support for older browsers.
<p class="_fail-text">
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.
<p class="_fail-text">
&mdash; <a href="https://twitter.com/DevDocs">@DevDocs</a>
</div>\
`;

@ -1,9 +1,14 @@
notice = (text) -> """<p class="_notice-text">#{text}</p>"""
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const notice = text => `<p class="_notice-text">${text}</p>`;
app.templates.singleDocNotice = (doc) ->
notice """ You're browsing the #{doc.fullName} documentation. To browse all docs, go to
<a href="//#{app.config.production_host}" target="_top">#{app.config.production_host}</a> (or press <code>esc</code>). """
app.templates.singleDocNotice = doc => notice(` You're browsing the ${doc.fullName} documentation. To browse all docs, go to
<a href="//${app.config.production_host}" target="_top">${app.config.production_host}</a> (or press <code>esc</code>). `
);
app.templates.disabledDocNotice = ->
notice """ <strong>This documentation is disabled.</strong>
To enable it, go to <a href="/settings" class="_notice-link">Preferences</a>. """
app.templates.disabledDocNotice = () => notice(` <strong>This documentation is disabled.</strong>
To enable it, go to <a href="/settings" class="_notice-link">Preferences</a>. `
);

@ -1,76 +1,81 @@
notif = (title, html) ->
html = html.replace /<a /g, '<a class="_notif-link" '
""" <h5 class="_notif-title">#{title}</h5>
#{html}
<button type="button" class="_notif-close" title="Close"><svg><use xlink:href="#icon-close"/></svg>Close</a>
"""
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const notif = function(title, html) {
html = html.replace(/<a /g, '<a class="_notif-link" ');
return ` <h5 class="_notif-title">${title}</h5>
${html}
<button type="button" class="_notif-close" title="Close"><svg><use xlink:href="#icon-close"/></svg>Close</a>\
`;
};
textNotif = (title, message) ->
notif title, """<p class="_notif-text">#{message}"""
const textNotif = (title, message) => notif(title, `<p class="_notif-text">${message}`);
app.templates.notifUpdateReady = ->
textNotif """<span data-behavior="reboot">DevDocs has been updated.</span>""",
"""<span data-behavior="reboot"><a href="#" data-behavior="reboot">Reload the page</a> to use the new version.</span>"""
app.templates.notifUpdateReady = () => textNotif("<span data-behavior=\"reboot\">DevDocs has been updated.</span>",
"<span data-behavior=\"reboot\"><a href=\"#\" data-behavior=\"reboot\">Reload the page</a> to use the new version.</span>");
app.templates.notifError = ->
textNotif """ Oops, an error occurred. """,
""" Try <a href="#" data-behavior="hard-reload">reloading</a>, and if the problem persists,
<a href="#" data-behavior="reset">resetting the app</a>.<br>
You can also report this issue on <a href="https://github.com/freeCodeCamp/devdocs/issues/new" target="_blank" rel="noopener">GitHub</a>. """
app.templates.notifError = () => textNotif(" Oops, an error occurred. ",
` Try <a href="#" data-behavior="hard-reload">reloading</a>, and if the problem persists,
<a href="#" data-behavior="reset">resetting the app</a>.<br>
You can also report this issue on <a href="https://github.com/freeCodeCamp/devdocs/issues/new" target="_blank" rel="noopener">GitHub</a>. `
);
app.templates.notifQuotaExceeded = ->
textNotif """ The offline database has exceeded its size limitation. """,
""" Unfortunately this quota can't be detected programmatically, and the database can't be opened while over the quota, so it had to be reset. """
app.templates.notifQuotaExceeded = () => textNotif(" The offline database has exceeded its size limitation. ",
" Unfortunately this quota can't be detected programmatically, and the database can't be opened while over the quota, so it had to be reset. ");
app.templates.notifCookieBlocked = ->
textNotif """ Please enable cookies. """,
""" DevDocs will not work properly if cookies are disabled. """
app.templates.notifCookieBlocked = () => textNotif(" Please enable cookies. ",
" DevDocs will not work properly if cookies are disabled. ");
app.templates.notifInvalidLocation = ->
textNotif """ DevDocs must be loaded from #{app.config.production_host} """,
""" Otherwise things are likely to break. """
app.templates.notifInvalidLocation = () => textNotif(` DevDocs must be loaded from ${app.config.production_host} `,
" Otherwise things are likely to break. ");
app.templates.notifImportInvalid = ->
textNotif """ Oops, an error occurred. """,
""" The file you selected is invalid. """
app.templates.notifImportInvalid = () => textNotif(" Oops, an error occurred. ",
" The file you selected is invalid. ");
app.templates.notifNews = (news) ->
notif 'Changelog', """<div class="_notif-content _notif-news">#{app.templates.newsList(news, years: false)}</div>"""
app.templates.notifNews = news => notif('Changelog', `<div class="_notif-content _notif-news">${app.templates.newsList(news, {years: false})}</div>`);
app.templates.notifUpdates = (docs, disabledDocs) ->
html = '<div class="_notif-content _notif-news">'
app.templates.notifUpdates = function(docs, disabledDocs) {
let doc;
let html = '<div class="_notif-content _notif-news">';
if docs.length > 0
html += '<div class="_news-row">'
html += '<ul class="_notif-list">'
for doc in docs
html += "<li>#{doc.name}"
html += " <code>&rarr;</code> #{doc.release}" if doc.release
html += '</ul></div>'
if (docs.length > 0) {
html += '<div class="_news-row">';
html += '<ul class="_notif-list">';
for (doc of Array.from(docs)) {
html += `<li>${doc.name}`;
if (doc.release) { html += ` <code>&rarr;</code> ${doc.release}`; }
}
html += '</ul></div>';
}
if disabledDocs.length > 0
html += '<div class="_news-row"><p class="_news-title">Disabled:'
html += '<ul class="_notif-list">'
for doc in disabledDocs
html += "<li>#{doc.name}"
html += " <code>&rarr;</code> #{doc.release}" if doc.release
html += """<span class="_notif-info"><a href="/settings">Enable</a></span>"""
html += '</ul></div>'
if (disabledDocs.length > 0) {
html += '<div class="_news-row"><p class="_news-title">Disabled:';
html += '<ul class="_notif-list">';
for (doc of Array.from(disabledDocs)) {
html += `<li>${doc.name}`;
if (doc.release) { html += ` <code>&rarr;</code> ${doc.release}`; }
html += "<span class=\"_notif-info\"><a href=\"/settings\">Enable</a></span>";
}
html += '</ul></div>';
}
notif 'Updates', "#{html}</div>"
return notif('Updates', `${html}</div>`);
};
app.templates.notifShare = ->
textNotif """ Hi there! """,
""" Like DevDocs? Help us reach more developers by sharing the link with your friends on
<a href="https://out.devdocs.io/s/tw" target="_blank" rel="noopener">Twitter</a>, <a href="https://out.devdocs.io/s/fb" target="_blank" rel="noopener">Facebook</a>,
<a href="https://out.devdocs.io/s/re" target="_blank" rel="noopener">Reddit</a>, etc.<br>Thanks :) """
app.templates.notifShare = () => textNotif(" Hi there! ",
` Like DevDocs? Help us reach more developers by sharing the link with your friends on
<a href="https://out.devdocs.io/s/tw" target="_blank" rel="noopener">Twitter</a>, <a href="https://out.devdocs.io/s/fb" target="_blank" rel="noopener">Facebook</a>,
<a href="https://out.devdocs.io/s/re" target="_blank" rel="noopener">Reddit</a>, etc.<br>Thanks :) `
);
app.templates.notifUpdateDocs = ->
textNotif """ Documentation updates available. """,
""" <a href="/offline">Install them</a> as soon as possible to avoid broken pages. """
app.templates.notifUpdateDocs = () => textNotif(" Documentation updates available. ",
" <a href=\"/offline\">Install them</a> as soon as possible to avoid broken pages. ");
app.templates.notifAnalyticsConsent = ->
textNotif """ Tracking cookies """,
""" We would like to gather usage data about how DevDocs is used through Google Analytics and Gauges. We only collect anonymous traffic information.
Please confirm if you accept our tracking cookies. You can always change your decision in the settings.
<br><span class="_notif-right"><a href="#" data-behavior="accept-analytics">Accept</a> or <a href="#" data-behavior="decline-analytics">Decline</a></span> """
app.templates.notifAnalyticsConsent = () => textNotif(" Tracking cookies ",
` We would like to gather usage data about how DevDocs is used through Google Analytics and Gauges. We only collect anonymous traffic information.
Please confirm if you accept our tracking cookies. You can always change your decision in the settings.
<br><span class="_notif-right"><a href="#" data-behavior="accept-analytics">Accept</a> or <a href="#" data-behavior="decline-analytics">Decline</a></span> `
);

@ -1,91 +1,106 @@
app.templates.aboutPage = ->
all_docs = app.docs.all().concat(app.disabledDocs.all()...)
# de-duplicate docs by doc.name
docs = []
docs.push doc for doc in all_docs when not (docs.find (d) -> d.name == doc.name)
"""
<nav class="_toc" role="directory">
<h3 class="_toc-title">Table of Contents</h3>
<ul class="_toc-list">
<li><a href="#copyright">Copyright</a>
<li><a href="#plugins">Plugins</a>
<li><a href="#faq">FAQ</a>
<li><a href="#credits">Credits</a>
<li><a href="#privacy">Privacy Policy</a>
</ul>
</nav>
<h1 class="_lined-heading">DevDocs: API Documentation Browser</h1>
<p>DevDocs combines multiple developer documentations in a clean and organized web UI with instant search, offline support, mobile version, dark theme, keyboard shortcuts, and more.
<p>DevDocs is free and <a href="https://github.com/freeCodeCamp/devdocs">open source</a>. It was created by <a href="https://thibaut.me">Thibaut Courouble</a> and is operated by <a href="https://www.freecodecamp.org/">freeCodeCamp</a>.
<p>To keep up-to-date with the latest news:
<ul>
<li>Follow <a href="https://twitter.com/DevDocs">@DevDocs</a> on Twitter
<li>Watch the repository on <a href="https://github.com/freeCodeCamp/devdocs/subscription">GitHub</a> <iframe class="_github-btn" src="https://ghbtns.com/github-btn.html?user=freeCodeCamp&repo=devdocs&type=watch&count=true" allowtransparency="true" frameborder="0" scrolling="0" width="100" height="20" tabindex="-1"></iframe>
<li>Join the <a href="https://discord.gg/PRyKn3Vbay">Discord</a> chat room
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
app.templates.aboutPage = function() {
let doc;
const all_docs = app.docs.all().concat(...Array.from(app.disabledDocs.all() || []));
// de-duplicate docs by doc.name
const docs = [];
for (doc of Array.from(all_docs)) { if (!(docs.find(d => d.name === doc.name))) { docs.push(doc); } }
return `\
<nav class="_toc" role="directory">
<h3 class="_toc-title">Table of Contents</h3>
<ul class="_toc-list">
<li><a href="#copyright">Copyright</a>
<li><a href="#plugins">Plugins</a>
<li><a href="#faq">FAQ</a>
<li><a href="#credits">Credits</a>
<li><a href="#privacy">Privacy Policy</a>
</ul>
</nav>
<h2 class="_block-heading" id="copyright">Copyright and License</h2>
<p class="_note">
<strong>Copyright 2013&ndash;2023 Thibaut Courouble and <a href="https://github.com/freeCodeCamp/devdocs/graphs/contributors">other contributors</a></strong><br>
This software is licensed under the terms of the Mozilla Public License v2.0.<br>
You may obtain a copy of the source code at <a href="https://github.com/freeCodeCamp/devdocs">github.com/freeCodeCamp/devdocs</a>.<br>
For more information, see the <a href="https://github.com/freeCodeCamp/devdocs/blob/main/COPYRIGHT">COPYRIGHT</a>
and <a href="https://github.com/freeCodeCamp/devdocs/blob/main/LICENSE">LICENSE</a> files.
<h1 class="_lined-heading">DevDocs: API Documentation Browser</h1>
<p>DevDocs combines multiple developer documentations in a clean and organized web UI with instant search, offline support, mobile version, dark theme, keyboard shortcuts, and more.
<p>DevDocs is free and <a href="https://github.com/freeCodeCamp/devdocs">open source</a>. It was created by <a href="https://thibaut.me">Thibaut Courouble</a> and is operated by <a href="https://www.freecodecamp.org/">freeCodeCamp</a>.
<p>To keep up-to-date with the latest news:
<ul>
<li>Follow <a href="https://twitter.com/DevDocs">@DevDocs</a> on Twitter
<li>Watch the repository on <a href="https://github.com/freeCodeCamp/devdocs/subscription">GitHub</a> <iframe class="_github-btn" src="https://ghbtns.com/github-btn.html?user=freeCodeCamp&repo=devdocs&type=watch&count=true" allowtransparency="true" frameborder="0" scrolling="0" width="100" height="20" tabindex="-1"></iframe>
<li>Join the <a href="https://discord.gg/PRyKn3Vbay">Discord</a> chat room
</ul>
<h2 class="_block-heading" id="plugins">Plugins and Extensions</h2>
<ul>
<li><a href="https://sublime.wbond.net/packages/DevDocs">Sublime Text package</a>
<li><a href="https://atom.io/packages/devdocs">Atom package</a>
<li><a href="https://marketplace.visualstudio.com/items?itemName=deibit.devdocs">Visual Studio Code extension</a>
<li><a href="https://github.com/yannickglt/alfred-devdocs">Alfred workflow</a>
<li><a href="https://github.com/search?q=topic%3Adevdocs&type=Repositories">More</a>
</ul>
<h2 class="_block-heading" id="copyright">Copyright and License</h2>
<p class="_note">
<strong>Copyright 2013&ndash;2023 Thibaut Courouble and <a href="https://github.com/freeCodeCamp/devdocs/graphs/contributors">other contributors</a></strong><br>
This software is licensed under the terms of the Mozilla Public License v2.0.<br>
You may obtain a copy of the source code at <a href="https://github.com/freeCodeCamp/devdocs">github.com/freeCodeCamp/devdocs</a>.<br>
For more information, see the <a href="https://github.com/freeCodeCamp/devdocs/blob/main/COPYRIGHT">COPYRIGHT</a>
and <a href="https://github.com/freeCodeCamp/devdocs/blob/main/LICENSE">LICENSE</a> files.
<h2 class="_block-heading" id="faq">Questions & Answers</h2>
<dl>
<dt>Where can I suggest new docs and features?
<dd>You can suggest and vote for new docs on the <a href="https://trello.com/b/6BmTulfx/devdocs-documentation">Trello board</a>.<br>
If you have a specific feature request, add it to the <a href="https://github.com/freeCodeCamp/devdocs/issues">issue tracker</a>.<br>
Otherwise, come talk to us in the <a href="https://discord.gg/PRyKn3Vbay">Discord</a> chat room.
<dt>Where can I report bugs?
<dd>In the <a href="https://github.com/freeCodeCamp/devdocs/issues">issue tracker</a>. Thanks!
</dl>
<h2 class="_block-heading" id="plugins">Plugins and Extensions</h2>
<ul>
<li><a href="https://sublime.wbond.net/packages/DevDocs">Sublime Text package</a>
<li><a href="https://atom.io/packages/devdocs">Atom package</a>
<li><a href="https://marketplace.visualstudio.com/items?itemName=deibit.devdocs">Visual Studio Code extension</a>
<li><a href="https://github.com/yannickglt/alfred-devdocs">Alfred workflow</a>
<li><a href="https://github.com/search?q=topic%3Adevdocs&type=Repositories">More</a>
</ul>
<h2 class="_block-heading" id="credits">Credits</h2>
<h2 class="_block-heading" id="faq">Questions & Answers</h2>
<dl>
<dt>Where can I suggest new docs and features?
<dd>You can suggest and vote for new docs on the <a href="https://trello.com/b/6BmTulfx/devdocs-documentation">Trello board</a>.<br>
If you have a specific feature request, add it to the <a href="https://github.com/freeCodeCamp/devdocs/issues">issue tracker</a>.<br>
Otherwise, come talk to us in the <a href="https://discord.gg/PRyKn3Vbay">Discord</a> chat room.
<dt>Where can I report bugs?
<dd>In the <a href="https://github.com/freeCodeCamp/devdocs/issues">issue tracker</a>. Thanks!
</dl>
<p><strong>Special thanks to:</strong>
<ul>
<li><a href="https://sentry.io/">Sentry</a> and <a href="https://get.gaug.es/?utm_source=devdocs&utm_medium=referral&utm_campaign=sponsorships" title="Real Time Web Analytics">Gauges</a> for offering a free account to DevDocs
<li><a href="https://out.devdocs.io/s/maxcdn">MaxCDN</a>, <a href="https://out.devdocs.io/s/shopify">Shopify</a>, <a href="https://out.devdocs.io/s/jetbrains">JetBrains</a> and <a href="https://out.devdocs.io/s/code-school">Code School</a> for sponsoring DevDocs in the past
<li><a href="https://www.heroku.com">Heroku</a> and <a href="https://newrelic.com/">New Relic</a> for providing awesome free service
<li><a href="https://www.jeremykratz.com/">Jeremy Kratz</a> for the C/C++ logo
</ul>
<h2 class="_block-heading" id="credits">Credits</h2>
<div class="_table">
<table class="_credits">
<tr>
<th>Documentation
<th>Copyright/License
<th>Source code
#{(
"<tr>
<td><a href=\"#{doc.links?.home}\">#{doc.name}</a></td>
<td>#{doc.attribution}</td>
<td><a href=\"#{doc.links?.code}\">Source code</a></td>
</tr>" for doc in docs
).join('')}
</table>
</div>
<p><strong>Special thanks to:</strong>
<ul>
<li><a href="https://sentry.io/">Sentry</a> and <a href="https://get.gaug.es/?utm_source=devdocs&utm_medium=referral&utm_campaign=sponsorships" title="Real Time Web Analytics">Gauges</a> for offering a free account to DevDocs
<li><a href="https://out.devdocs.io/s/maxcdn">MaxCDN</a>, <a href="https://out.devdocs.io/s/shopify">Shopify</a>, <a href="https://out.devdocs.io/s/jetbrains">JetBrains</a> and <a href="https://out.devdocs.io/s/code-school">Code School</a> for sponsoring DevDocs in the past
<li><a href="https://www.heroku.com">Heroku</a> and <a href="https://newrelic.com/">New Relic</a> for providing awesome free service
<li><a href="https://www.jeremykratz.com/">Jeremy Kratz</a> for the C/C++ logo
</ul>
<h2 class="_block-heading" id="privacy">Privacy Policy</h2>
<ul>
<li><a href="https://devdocs.io">devdocs.io</a> ("App") is operated by <a href="https://www.freecodecamp.org/">freeCodeCamp</a> ("We").
<li>We do not collect personal information through the app.
<li>We use Google Analytics and Gauges to collect anonymous traffic information if you have given consent to this. You can change your decision in the <a href="/settings">settings</a>.
<li>We use Sentry to collect crash data and improve the app.
<li>The app uses cookies to store user preferences.
<li>By using the app, you signify your acceptance of this policy. If you do not agree to this policy, please do not use the app.
<li>If you have any questions regarding privacy, please email <a href="mailto:privacy@freecodecamp.org">privacy@freecodecamp.org</a>.
</ul>
"""
<div class="_table">
<table class="_credits">
<tr>
<th>Documentation
<th>Copyright/License
<th>Source code
${((() => {
const result = [];
for (doc of Array.from(docs)) { result.push(`<tr> \
<td><a href=\"${(doc.links != null ? doc.links.home : undefined)}\">${doc.name}</a></td> \
<td>${doc.attribution}</td> \
<td><a href=\"${(doc.links != null ? doc.links.code : undefined)}\">Source code</a></td> \
</tr>`);
}
return result;
})()).join('')}
</table>
</div>
<h2 class="_block-heading" id="privacy">Privacy Policy</h2>
<ul>
<li><a href="https://devdocs.io">devdocs.io</a> ("App") is operated by <a href="https://www.freecodecamp.org/">freeCodeCamp</a> ("We").
<li>We do not collect personal information through the app.
<li>We use Google Analytics and Gauges to collect anonymous traffic information if you have given consent to this. You can change your decision in the <a href="/settings">settings</a>.
<li>We use Sentry to collect crash data and improve the app.
<li>The app uses cookies to store user preferences.
<li>By using the app, you signify your acceptance of this policy. If you do not agree to this policy, please do not use the app.
<li>If you have any questions regarding privacy, please email <a href="mailto:privacy@freecodecamp.org">privacy@freecodecamp.org</a>.
</ul>\
`;
};

@ -1,169 +1,193 @@
app.templates.helpPage = ->
ctrlKey = if $.isMac() then 'cmd' else 'ctrl'
navKey = if $.isMac() then 'cmd' else 'alt'
arrowScroll = app.settings.get('arrowScroll')
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
app.templates.helpPage = function() {
let key, value;
const ctrlKey = $.isMac() ? 'cmd' : 'ctrl';
const navKey = $.isMac() ? 'cmd' : 'alt';
const arrowScroll = app.settings.get('arrowScroll');
aliases_one = {}
aliases_two = {}
keys = Object.keys(app.models.Entry.ALIASES)
middle = Math.ceil(keys.length / 2) - 1
for key, i in keys
(if i > middle then aliases_two else aliases_one)[key] = app.models.Entry.ALIASES[key]
const aliases_one = {};
const aliases_two = {};
const keys = Object.keys(app.models.Entry.ALIASES);
const middle = Math.ceil(keys.length / 2) - 1;
for (let i = 0; i < keys.length; i++) {
key = keys[i];
(i > middle ? aliases_two : aliases_one)[key] = app.models.Entry.ALIASES[key];
}
"""
<nav class="_toc" role="directory">
<h3 class="_toc-title">Table of Contents</h3>
<ul class="_toc-list">
<li><a href="#managing-documentations">Managing Documentations</a>
<li><a href="#search">Search</a>
<li><a href="#shortcuts">Keyboard Shortcuts</a>
<li><a href="#aliases">Search Aliases</a>
</ul>
</nav>
return `\
<nav class="_toc" role="directory">
<h3 class="_toc-title">Table of Contents</h3>
<ul class="_toc-list">
<li><a href="#managing-documentations">Managing Documentations</a>
<li><a href="#search">Search</a>
<li><a href="#shortcuts">Keyboard Shortcuts</a>
<li><a href="#aliases">Search Aliases</a>
</ul>
</nav>
<h1 class="_lined-heading">User Guide</h1>
<h1 class="_lined-heading">User Guide</h1>
<h2 class="_block-heading" id="managing-documentations">Managing Documentations</h2>
<p>
Documentations can be enabled and disabled in the <a href="/settings">Preferences</a>.
Alternatively, you can enable a documentation by searching for it in the main search
and clicking the "Enable" link in the results.
For faster and better search, only enable the documentations you plan on actively using.
<p>
Once a documentation is enabled, it becomes part of the search and its content can be downloaded for offline access and faster page loads when online in the <a href="/offline">Offline</a> area.
<h2 class="_block-heading" id="managing-documentations">Managing Documentations</h2>
<p>
Documentations can be enabled and disabled in the <a href="/settings">Preferences</a>.
Alternatively, you can enable a documentation by searching for it in the main search
and clicking the "Enable" link in the results.
For faster and better search, only enable the documentations you plan on actively using.
<p>
Once a documentation is enabled, it becomes part of the search and its content can be downloaded for offline access and faster page loads when online in the <a href="/offline">Offline</a> area.
<h2 class="_block-heading" id="search">Search</h2>
<p>
The search is case-insensitive and ignores whitespace. It supports fuzzy matching
(e.g. <code class="_label">bgcp</code> matches <code class="_label">background-clip</code>)
as well as aliases (full list <a href="#aliases">below</a>).
<dl>
<dt id="doc_search">Searching a single documentation
<dd>
The search can be scoped to a single documentation by typing its name (or an abbreviation)
and pressing <code class="_label">tab</code> (<code class="_label">space</code>&nbsp;on mobile).
For example, to search the JavaScript documentation, enter <code class="_label">javascript</code>
or <code class="_label">js</code>, then <code class="_label">tab</code>.<br>
To clear the current scope, empty the search field and hit <code class="_label">backspace</code> or
<code class="_label">esc</code>.
<dt id="url_search">Prefilling the search field
<dd>
The search can be prefilled from the URL by visiting <a href="/#q=keyword" target="_top">devdocs.io/#q=keyword</a>.
Characters after <code class="_label">#q=</code> will be used as search query.<br>
To search a single documentation, add its name (or an abbreviation) and a space before the keyword:
<a href="/#q=js%20date" target="_top">devdocs.io/#q=js date</a>.
<dt id="browser_search">Searching using the address bar
<dd>
DevDocs supports OpenSearch. It can easily be installed as a search engine on most web browsers:
<ul>
<li>On Chrome, the setup is done automatically. Simply press <code class="_label">tab</code> when devdocs.io is autocompleted
in the omnibox (to set a custom keyword, click <em>Manage search engines\u2026</em> in Chrome's settings).
<li>On Firefox, <a href="https://support.mozilla.org/en-US/kb/add-or-remove-search-engine-firefox#w_add-a-search-engine-from-the-address-bar">add the search from the address bar</a>:
Click in the address bar, and select <em>Add Search Engine</em>. Then, you can add a keyword for this search engine in the preferences.
</dl>
<p>
<i>Note: the above search features only work for documentations that are enabled.</i>
<h2 class="_block-heading" id="search">Search</h2>
<p>
The search is case-insensitive and ignores whitespace. It supports fuzzy matching
(e.g. <code class="_label">bgcp</code> matches <code class="_label">background-clip</code>)
as well as aliases (full list <a href="#aliases">below</a>).
<dl>
<dt id="doc_search">Searching a single documentation
<dd>
The search can be scoped to a single documentation by typing its name (or an abbreviation)
and pressing <code class="_label">tab</code> (<code class="_label">space</code>&nbsp;on mobile).
For example, to search the JavaScript documentation, enter <code class="_label">javascript</code>
or <code class="_label">js</code>, then <code class="_label">tab</code>.<br>
To clear the current scope, empty the search field and hit <code class="_label">backspace</code> or
<code class="_label">esc</code>.
<dt id="url_search">Prefilling the search field
<dd>
The search can be prefilled from the URL by visiting <a href="/#q=keyword" target="_top">devdocs.io/#q=keyword</a>.
Characters after <code class="_label">#q=</code> will be used as search query.<br>
To search a single documentation, add its name (or an abbreviation) and a space before the keyword:
<a href="/#q=js%20date" target="_top">devdocs.io/#q=js date</a>.
<dt id="browser_search">Searching using the address bar
<dd>
DevDocs supports OpenSearch. It can easily be installed as a search engine on most web browsers:
<ul>
<li>On Chrome, the setup is done automatically. Simply press <code class="_label">tab</code> when devdocs.io is autocompleted
in the omnibox (to set a custom keyword, click <em>Manage search engines\u2026</em> in Chrome's settings).
<li>On Firefox, <a href="https://support.mozilla.org/en-US/kb/add-or-remove-search-engine-firefox#w_add-a-search-engine-from-the-address-bar">add the search from the address bar</a>:
Click in the address bar, and select <em>Add Search Engine</em>. Then, you can add a keyword for this search engine in the preferences.
</dl>
<p>
<i>Note: the above search features only work for documentations that are enabled.</i>
<h2 class="_block-heading" id="shortcuts">Keyboard Shortcuts</h2>
<h3 class="_shortcuts-title">Sidebar</h3>
<dl class="_shortcuts-dl">
<dt class="_shortcuts-dt">
#{if arrowScroll then '<code class="_shortcut-code">shift</code> + ' else ''}
<code class="_shortcut-code">&darr;</code>
<code class="_shortcut-code">&uarr;</code>
<dd class="_shortcuts-dd">Move selection
<dt class="_shortcuts-dt">
#{if arrowScroll then '<code class="_shortcut-code">shift</code> + ' else ''}
<code class="_shortcut-code">&rarr;</code>
<code class="_shortcut-code">&larr;</code>
<dd class="_shortcuts-dd">Show/hide sub-list
<dt class="_shortcuts-dt">
<code class="_shortcut-code">enter</code>
<dd class="_shortcuts-dd">Open selection
<dt class="_shortcuts-dt">
<code class="_shortcut-code">#{ctrlKey} + enter</code>
<dd class="_shortcuts-dd">Open selection in a new tab
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + r</code>
<dd class="_shortcuts-dd">Reveal current page in sidebar
</dl>
<h3 class="_shortcuts-title">Browsing</h3>
<dl class="_shortcuts-dl">
<dt class="_shortcuts-dt">
<code class="_shortcut-code">#{navKey} + &larr;</code>
<code class="_shortcut-code">#{navKey} + &rarr;</code>
<dd class="_shortcuts-dd">Go back/forward
<dt class="_shortcuts-dt">
#{if arrowScroll
<h2 class="_block-heading" id="shortcuts">Keyboard Shortcuts</h2>
<h3 class="_shortcuts-title">Sidebar</h3>
<dl class="_shortcuts-dl">
<dt class="_shortcuts-dt">
${arrowScroll ? '<code class="_shortcut-code">shift</code> + ' : ''}
<code class="_shortcut-code">&darr;</code>
<code class="_shortcut-code">&uarr;</code>
<dd class="_shortcuts-dd">Move selection
<dt class="_shortcuts-dt">
${arrowScroll ? '<code class="_shortcut-code">shift</code> + ' : ''}
<code class="_shortcut-code">&rarr;</code>
<code class="_shortcut-code">&larr;</code>
<dd class="_shortcuts-dd">Show/hide sub-list
<dt class="_shortcuts-dt">
<code class="_shortcut-code">enter</code>
<dd class="_shortcuts-dd">Open selection
<dt class="_shortcuts-dt">
<code class="_shortcut-code">${ctrlKey} + enter</code>
<dd class="_shortcuts-dd">Open selection in a new tab
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + r</code>
<dd class="_shortcuts-dd">Reveal current page in sidebar
</dl>
<h3 class="_shortcuts-title">Browsing</h3>
<dl class="_shortcuts-dl">
<dt class="_shortcuts-dt">
<code class="_shortcut-code">${navKey} + &larr;</code>
<code class="_shortcut-code">${navKey} + &rarr;</code>
<dd class="_shortcuts-dd">Go back/forward
<dt class="_shortcuts-dt">
${arrowScroll ?
'<code class="_shortcut-code">&darr;</code> ' +
'<code class="_shortcut-code">&uarr;</code>'
else
:
'<code class="_shortcut-code">alt + &darr;</code> ' +
'<code class="_shortcut-code">alt + &uarr;</code>' +
'<br>' +
'<code class="_shortcut-code">shift + &darr;</code> ' +
'<code class="_shortcut-code">shift + &uarr;</code>'}
<dd class="_shortcuts-dd">Scroll step by step<br><br>
<dt class="_shortcuts-dt">
<code class="_shortcut-code">space</code>
<code class="_shortcut-code">shift + space</code>
<dd class="_shortcuts-dd">Scroll screen by screen
<dt class="_shortcuts-dt">
<code class="_shortcut-code">#{ctrlKey} + &uarr;</code>
<code class="_shortcut-code">#{ctrlKey} + &darr;</code>
<dd class="_shortcuts-dd">Scroll to the top/bottom
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + f</code>
<dd class="_shortcuts-dd">Focus first link in the content area<br>(press tab to focus the other links)
</dl>
<h3 class="_shortcuts-title">App</h3>
<dl class="_shortcuts-dl">
<dt class="_shortcuts-dt">
<code class="_shortcut-code">ctrl + ,</code>
<dd class="_shortcuts-dd">Open preferences
<dt class="_shortcuts-dt">
<code class="_shortcut-code">esc</code>
<dd class="_shortcuts-dd">Clear search field / reset UI
<dt class="_shortcuts-dt">
<code class="_shortcut-code">?</code>
<dd class="_shortcuts-dd">Show this page
</dl>
<h3 class="_shortcuts-title">Miscellaneous</h3>
<dl class="_shortcuts-dl">
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + c</code>
<dd class="_shortcuts-dd">Copy URL of original page
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + o</code>
<dd class="_shortcuts-dd">Open original page
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + g</code>
<dd class="_shortcuts-dd">Search on Google
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + s</code>
<dd class="_shortcuts-dd">Search on Stack Overflow
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + d</code>
<dd class="_shortcuts-dd">Search on DuckDuckGo
</dl>
<p class="_note _note-green">
<strong>Tip:</strong> If the cursor is no longer in the search field, press <code class="_label">/</code> or
continue to type and it will refocus the search field and start showing new results.
<dd class="_shortcuts-dd">Scroll step by step<br><br>
<dt class="_shortcuts-dt">
<code class="_shortcut-code">space</code>
<code class="_shortcut-code">shift + space</code>
<dd class="_shortcuts-dd">Scroll screen by screen
<dt class="_shortcuts-dt">
<code class="_shortcut-code">${ctrlKey} + &uarr;</code>
<code class="_shortcut-code">${ctrlKey} + &darr;</code>
<dd class="_shortcuts-dd">Scroll to the top/bottom
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + f</code>
<dd class="_shortcuts-dd">Focus first link in the content area<br>(press tab to focus the other links)
</dl>
<h3 class="_shortcuts-title">App</h3>
<dl class="_shortcuts-dl">
<dt class="_shortcuts-dt">
<code class="_shortcut-code">ctrl + ,</code>
<dd class="_shortcuts-dd">Open preferences
<dt class="_shortcuts-dt">
<code class="_shortcut-code">esc</code>
<dd class="_shortcuts-dd">Clear search field / reset UI
<dt class="_shortcuts-dt">
<code class="_shortcut-code">?</code>
<dd class="_shortcuts-dd">Show this page
</dl>
<h3 class="_shortcuts-title">Miscellaneous</h3>
<dl class="_shortcuts-dl">
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + c</code>
<dd class="_shortcuts-dd">Copy URL of original page
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + o</code>
<dd class="_shortcuts-dd">Open original page
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + g</code>
<dd class="_shortcuts-dd">Search on Google
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + s</code>
<dd class="_shortcuts-dd">Search on Stack Overflow
<dt class="_shortcuts-dt">
<code class="_shortcut-code">alt + d</code>
<dd class="_shortcuts-dd">Search on DuckDuckGo
</dl>
<p class="_note _note-green">
<strong>Tip:</strong> If the cursor is no longer in the search field, press <code class="_label">/</code> or
continue to type and it will refocus the search field and start showing new results.
<h2 class="_block-heading" id="aliases">Search Aliases</h2>
<div class="_aliases">
<table>
<tr>
<th>Word
<th>Alias
#{("<tr><td class=\"_code\">#{key}<td class=\"_code\">#{value}" for key, value of aliases_one).join('')}
</table>
<table>
<tr>
<th>Word
<th>Alias
#{("<tr><td class=\"_code\">#{key}<td class=\"_code\">#{value}" for key, value of aliases_two).join('')}
</table>
</div>
<p>Feel free to suggest new aliases on <a href="https://github.com/freeCodeCamp/devdocs/issues/new">GitHub</a>.
"""
<h2 class="_block-heading" id="aliases">Search Aliases</h2>
<div class="_aliases">
<table>
<tr>
<th>Word
<th>Alias
${((() => {
const result = [];
for (key in aliases_one) {
value = aliases_one[key];
result.push(`<tr><td class=\"_code\">${key}<td class=\"_code\">${value}`);
}
return result;
})()).join('')}
</table>
<table>
<tr>
<th>Word
<th>Alias
${((() => {
const result1 = [];
for (key in aliases_two) {
value = aliases_two[key];
result1.push(`<tr><td class=\"_code\">${key}<td class=\"_code\">${value}`);
}
return result1;
})()).join('')}
</table>
</div>
<p>Feel free to suggest new aliases on <a href="https://github.com/freeCodeCamp/devdocs/issues/new">GitHub</a>.\
`;
};

@ -1,80 +1,89 @@
app.templates.offlinePage = (docs) -> """
<h1 class="_lined-heading">Offline Documentation</h1>
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
app.templates.offlinePage = docs => `\
<h1 class="_lined-heading">Offline Documentation</h1>
<div class="_docs-tools">
<label>
<input type="checkbox" name="autoUpdate" value="1" #{if app.settings.get('manualUpdate') then '' else 'checked'}>Install updates automatically
</label>
<div class="_docs-links">
<button type="button" class="_btn-link" data-action-all="install">Install all</button><button type="button" class="_btn-link" data-action-all="update"><strong>Update all</strong></button><button type="button" class="_btn-link" data-action-all="uninstall">Uninstall all</button>
</div>
<div class="_docs-tools">
<label>
<input type="checkbox" name="autoUpdate" value="1" ${app.settings.get('manualUpdate') ? '' : 'checked'}>Install updates automatically
</label>
<div class="_docs-links">
<button type="button" class="_btn-link" data-action-all="install">Install all</button><button type="button" class="_btn-link" data-action-all="update"><strong>Update all</strong></button><button type="button" class="_btn-link" data-action-all="uninstall">Uninstall all</button>
</div>
</div>
<div class="_table">
<table class="_docs">
<tr>
<th>Documentation</th>
<th class="_docs-size">Size</th>
<th>Status</th>
<th>Action</th>
</tr>
#{docs}
</table>
</div>
<p class="_note"><strong>Note:</strong> 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.
<h2 class="_block-heading">Questions & Answers</h2>
<dl>
<dt>How does this work?
<dd>Each page is cached as a key-value pair in <a href="https://devdocs.io/dom/indexeddb_api">IndexedDB</a> (downloaded from a single file).<br>
The app also uses <a href="https://devdocs.io/dom/service_worker_api/using_service_workers">Service Workers</a> and <a href="https://devdocs.io/dom/web_storage_api">localStorage</a> to cache the assets and index files.
<dt>Can I close the tab/browser?
<dd>#{canICloseTheTab()}
<dt>What if I don't update a documentation?
<dd>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.
<dt>I found a bug, where do I report it?
<dd>In the <a href="https://github.com/freeCodeCamp/devdocs/issues">issue tracker</a>. Thanks!
<dt>How do I uninstall/reset the app?
<dd>Click <a href="#" data-behavior="reset">here</a>.
<dt>Why aren't all documentations listed above?
<dd>You have to <a href="/settings">enable</a> them first.
</dl>
"""
<div class="_table">
<table class="_docs">
<tr>
<th>Documentation</th>
<th class="_docs-size">Size</th>
<th>Status</th>
<th>Action</th>
</tr>
${docs}
</table>
</div>
<p class="_note"><strong>Note:</strong> 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.
<h2 class="_block-heading">Questions & Answers</h2>
<dl>
<dt>How does this work?
<dd>Each page is cached as a key-value pair in <a href="https://devdocs.io/dom/indexeddb_api">IndexedDB</a> (downloaded from a single file).<br>
The app also uses <a href="https://devdocs.io/dom/service_worker_api/using_service_workers">Service Workers</a> and <a href="https://devdocs.io/dom/web_storage_api">localStorage</a> to cache the assets and index files.
<dt>Can I close the tab/browser?
<dd>${canICloseTheTab()}
<dt>What if I don't update a documentation?
<dd>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.
<dt>I found a bug, where do I report it?
<dd>In the <a href="https://github.com/freeCodeCamp/devdocs/issues">issue tracker</a>. Thanks!
<dt>How do I uninstall/reset the app?
<dd>Click <a href="#" data-behavior="reset">here</a>.
<dt>Why aren't all documentations listed above?
<dd>You have to <a href="/settings">enable</a> them first.
</dl>\
`;
canICloseTheTab = ->
if app.ServiceWorker.isEnabled()
""" Yes! Even offline, you can open a new tab, go to <a href="//devdocs.io">devdocs.io</a>, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). """
else
reason = "aren't available in your browser (or are disabled)"
var canICloseTheTab = function() {
if (app.ServiceWorker.isEnabled()) {
return " Yes! Even offline, you can open a new tab, go to <a href=\"//devdocs.io\">devdocs.io</a>, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). ";
} 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 <code>ENABLE_SERVICE_WORKER</code> environment variable to <code>true</code>)"
if (app.config.env !== 'production') {
reason = "are disabled in your development instance of DevDocs (enable them by setting the <code>ENABLE_SERVICE_WORKER</code> environment variable to <code>true</code>)";
}
""" No. Service Workers #{reason}, so loading <a href="//devdocs.io">devdocs.io</a> offline won't work.<br>
The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). """
return ` No. Service Workers ${reason}, so loading <a href="//devdocs.io">devdocs.io</a> offline won't work.<br>
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)
app.templates.offlineDoc = function(doc, status) {
const outdated = doc.isOutdated(status);
html = """
<tr data-slug="#{doc.slug}"#{if outdated then ' class="_highlight"' else ''}>
<td class="_docs-name _icon-#{doc.icon}">#{doc.fullName}</td>
<td class="_docs-size">#{Math.ceil(doc.db_size / 100000) / 10}&nbsp;<small>MB</small></td>
"""
let html = `\
<tr data-slug="${doc.slug}"${outdated ? ' class="_highlight"' : ''}>
<td class="_docs-name _icon-${doc.icon}">${doc.fullName}</td>
<td class="_docs-size">${Math.ceil(doc.db_size / 100000) / 10}&nbsp;<small>MB</small></td>\
`;
html += if !(status and status.installed)
"""
<td>-</td>
<td><button type="button" class="_btn-link" data-action="install">Install</button></td>
"""
else if outdated
"""
<td><strong>Outdated</strong></td>
<td><button type="button" class="_btn-link _bold" data-action="update">Update</button> - <button type="button" class="_btn-link" data-action="uninstall">Uninstall</button></td>
"""
else
"""
<td>Up&#8209;to&#8209;date</td>
<td><button type="button" class="_btn-link" data-action="uninstall">Uninstall</button></td>
"""
html += !(status && status.installed) ?
`\
<td>-</td>
<td><button type="button" class="_btn-link" data-action="install">Install</button></td>\
`
: outdated ?
`\
<td><strong>Outdated</strong></td>
<td><button type="button" class="_btn-link _bold" data-action="update">Update</button> - <button type="button" class="_btn-link" data-action="uninstall">Uninstall</button></td>\
`
:
`\
<td>Up&#8209;to&#8209;date</td>
<td><button type="button" class="_btn-link" data-action="uninstall">Uninstall</button></td>\
`;
html + '</tr>'
return html + '</tr>';
};

@ -1,81 +1,86 @@
themeOption = ({ label, value }, settings) -> """
<label class="_settings-label _theme-label">
<input type="radio" name="theme" value="#{value}"#{if settings.theme == value then ' checked' else ''}>
#{label}
</label>
"""
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const themeOption = ({ label, value }, settings) => `\
<label class="_settings-label _theme-label">
<input type="radio" name="theme" value="${value}"${settings.theme === value ? ' checked' : ''}>
${label}
</label>\
`;
app.templates.settingsPage = (settings) -> """
<h1 class="_lined-heading">Preferences</h1>
app.templates.settingsPage = settings => `\
<h1 class="_lined-heading">Preferences</h1>
<div class="_settings-fieldset">
<h2 class="_settings-legend">Theme:</h2>
<div class="_settings-inputs">
#{if settings.autoSupported
themeOption label: "Automatic <small>Matches system setting</small>", value: "auto", settings
else
<div class="_settings-fieldset">
<h2 class="_settings-legend">Theme:</h2>
<div class="_settings-inputs">
${settings.autoSupported ?
themeOption({label: "Automatic <small>Matches system setting</small>", value: "auto"}, settings)
:
""}
#{themeOption label: "Light", value: "default", settings}
#{themeOption label: "Dark", value: "dark", settings}
</div>
${themeOption({label: "Light", value: "default"}, settings)}
${themeOption({label: "Dark", value: "dark"}, settings)}
</div>
</div>
<div class="_settings-fieldset">
<h2 class="_settings-legend">General:</h2>
<div class="_settings-fieldset">
<h2 class="_settings-legend">General:</h2>
<div class="_settings-inputs">
<label class="_settings-label _setting-max-width">
<input type="checkbox" form="settings" name="layout" value="_max-width"#{if settings['_max-width'] then ' checked' else ''}>Enable fixed-width layout
</label>
<label class="_settings-label _setting-text-justify-hyphenate">
<input type="checkbox" form="settings" name="layout" value="_text-justify-hyphenate"#{if settings['_text-justify-hyphenate'] then ' checked' else ''}>Enable justified layout and automatic hyphenation
</label>
<label class="_settings-label _hide-on-mobile">
<input type="checkbox" form="settings" name="layout" value="_sidebar-hidden"#{if settings['_sidebar-hidden'] then ' checked' else ''}>Automatically hide and show the sidebar
<small>Tip: drag the edge of the sidebar to resize it.</small>
</label>
<label class="_settings-label _hide-on-mobile">
<input type="checkbox" form="settings" name="noAutofocus" value="_no-autofocus"#{if settings.noAutofocus then ' checked' else ''}>Disable autofocus of search input
</label>
<label class="_settings-label">
<input type="checkbox" form="settings" name="autoInstall" value="_auto-install"#{if settings.autoInstall then ' checked' else ''}>Automatically download documentation for offline use
<small>Only enable this when bandwidth isn't a concern to you.</small>
</label>
<label class="_settings-label _hide-in-development">
<input type="checkbox" form="settings" name="analyticsConsent"#{if settings.analyticsConsent then ' checked' else ''}>Enable tracking cookies
<small>With this checked, we enable Google Analytics and Gauges to collect anonymous traffic information.</small>
</label>
</div>
<div class="_settings-inputs">
<label class="_settings-label _setting-max-width">
<input type="checkbox" form="settings" name="layout" value="_max-width"${settings['_max-width'] ? ' checked' : ''}>Enable fixed-width layout
</label>
<label class="_settings-label _setting-text-justify-hyphenate">
<input type="checkbox" form="settings" name="layout" value="_text-justify-hyphenate"${settings['_text-justify-hyphenate'] ? ' checked' : ''}>Enable justified layout and automatic hyphenation
</label>
<label class="_settings-label _hide-on-mobile">
<input type="checkbox" form="settings" name="layout" value="_sidebar-hidden"${settings['_sidebar-hidden'] ? ' checked' : ''}>Automatically hide and show the sidebar
<small>Tip: drag the edge of the sidebar to resize it.</small>
</label>
<label class="_settings-label _hide-on-mobile">
<input type="checkbox" form="settings" name="noAutofocus" value="_no-autofocus"${settings.noAutofocus ? ' checked' : ''}>Disable autofocus of search input
</label>
<label class="_settings-label">
<input type="checkbox" form="settings" name="autoInstall" value="_auto-install"${settings.autoInstall ? ' checked' : ''}>Automatically download documentation for offline use
<small>Only enable this when bandwidth isn't a concern to you.</small>
</label>
<label class="_settings-label _hide-in-development">
<input type="checkbox" form="settings" name="analyticsConsent"${settings.analyticsConsent ? ' checked' : ''}>Enable tracking cookies
<small>With this checked, we enable Google Analytics and Gauges to collect anonymous traffic information.</small>
</label>
</div>
</div>
<div class="_settings-fieldset _hide-on-mobile">
<h2 class="_settings-legend">Scrolling:</h2>
<div class="_settings-fieldset _hide-on-mobile">
<h2 class="_settings-legend">Scrolling:</h2>
<div class="_settings-inputs">
<label class="_settings-label">
<input type="checkbox" form="settings" name="smoothScroll" value="1"#{if settings.smoothScroll then ' checked' else ''}>Use smooth scrolling
</label>
<label class="_settings-label _setting-native-scrollbar">
<input type="checkbox" form="settings" name="layout" value="_native-scrollbars"#{if settings['_native-scrollbars'] then ' checked' else ''}>Use native scrollbars
</label>
<label class="_settings-label">
<input type="checkbox" form="settings" name="arrowScroll" value="1"#{if settings.arrowScroll then ' checked' else ''}>Use arrow keys to scroll the main content area
<small>With this checked, use <code class="_label">shift</code> + <code class="_label">&uarr;</code><code class="_label">&darr;</code><code class="_label">&larr;</code><code class="_label">&rarr;</code> to navigate the sidebar.</small>
</label>
<label class="_settings-label">
<input type="checkbox" form="settings" name="spaceScroll" value="1"#{if settings.spaceScroll then ' checked' else ''}>Use spacebar to scroll during search
</label>
<label class="_settings-label">
<input type="number" step="0.1" form="settings" name="spaceTimeout" min="0" max="5" value="#{settings.spaceTimeout}"> Delay until you can scroll by pressing space
<small>Time in seconds</small>
</label>
</div>
<div class="_settings-inputs">
<label class="_settings-label">
<input type="checkbox" form="settings" name="smoothScroll" value="1"${settings.smoothScroll ? ' checked' : ''}>Use smooth scrolling
</label>
<label class="_settings-label _setting-native-scrollbar">
<input type="checkbox" form="settings" name="layout" value="_native-scrollbars"${settings['_native-scrollbars'] ? ' checked' : ''}>Use native scrollbars
</label>
<label class="_settings-label">
<input type="checkbox" form="settings" name="arrowScroll" value="1"${settings.arrowScroll ? ' checked' : ''}>Use arrow keys to scroll the main content area
<small>With this checked, use <code class="_label">shift</code> + <code class="_label">&uarr;</code><code class="_label">&darr;</code><code class="_label">&larr;</code><code class="_label">&rarr;</code> to navigate the sidebar.</small>
</label>
<label class="_settings-label">
<input type="checkbox" form="settings" name="spaceScroll" value="1"${settings.spaceScroll ? ' checked' : ''}>Use spacebar to scroll during search
</label>
<label class="_settings-label">
<input type="number" step="0.1" form="settings" name="spaceTimeout" min="0" max="5" value="${settings.spaceTimeout}"> Delay until you can scroll by pressing space
<small>Time in seconds</small>
</label>
</div>
</div>
<p class="_hide-on-mobile">
<button type="button" class="_btn" data-action="export">Export</button>
<label class="_btn _file-btn"><input type="file" form="settings" name="import" accept=".json">Import</label>
<p class="_hide-on-mobile">
<button type="button" class="_btn" data-action="export">Export</button>
<label class="_btn _file-btn"><input type="file" form="settings" name="import" accept=".json">Import</label>
<p>
<button type="button" class="_btn-link _reset-btn" data-behavior="reset">Reset all preferences and data</button>
"""
<p>
<button type="button" class="_btn-link _reset-btn" data-behavior="reset">Reset all preferences and data</button>\
`;

@ -1,6 +1,9 @@
app.templates.typePage = (type) ->
""" <h1>#{type.doc.fullName} / #{type.name}</h1>
<ul class="_entry-list">#{app.templates.render 'typePageEntry', type.entries()}</ul> """
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
app.templates.typePage = type => ` <h1>${type.doc.fullName} / ${type.name}</h1>
<ul class="_entry-list">${app.templates.render('typePageEntry', type.entries())}</ul> `;
app.templates.typePageEntry = (entry) ->
"""<li><a href="#{entry.fullPath()}">#{$.escape entry.name}</a></li>"""
app.templates.typePageEntry = entry => `<li><a href="${entry.fullPath()}">${$.escape(entry.name)}</a></li>`;

@ -1,7 +1,13 @@
arrow = """<svg class="_path-arrow"><use xlink:href="#icon-dir"/></svg>"""
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const arrow = "<svg class=\"_path-arrow\"><use xlink:href=\"#icon-dir\"/></svg>";
app.templates.path = (doc, type, entry) ->
html = """<a href="#{doc.fullPath()}" class="_path-item _icon-#{doc.icon}">#{doc.fullName}</a>"""
html += """#{arrow}<a href="#{type.fullPath()}" class="_path-item">#{type.name}</a>""" if type
html += """#{arrow}<span class="_path-item">#{$.escape entry.name}</span>""" if entry
html
app.templates.path = function(doc, type, entry) {
let html = `<a href="${doc.fullPath()}" class="_path-item _icon-${doc.icon}">${doc.fullName}</a>`;
if (type) { html += `${arrow}<a href="${type.fullPath()}" class="_path-item">${type.name}</a>`; }
if (entry) { html += `${arrow}<span class="_path-item">${$.escape(entry.name)}</span>`; }
return html;
};

@ -1,68 +1,79 @@
templates = app.templates
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const {
templates
} = app;
arrow = """<svg class="_list-arrow"><use xlink:href="#icon-dir"/></svg>"""
const arrow = "<svg class=\"_list-arrow\"><use xlink:href=\"#icon-dir\"/></svg>";
templates.sidebarDoc = (doc, options = {}) ->
link = """<a href="#{doc.fullPath()}" class="_list-item _icon-#{doc.icon} """
link += if options.disabled then '_list-disabled' else '_list-dir'
link += """" data-slug="#{doc.slug}" title="#{doc.fullName}" tabindex="-1">"""
if options.disabled
link += """<span class="_list-enable" data-enable="#{doc.slug}">Enable</span>"""
else
link += arrow
link += """<span class="_list-count">#{doc.release}</span>""" if doc.release
link += """<span class="_list-text">#{doc.name}"""
link += " #{doc.version}" if options.fullName or options.disabled and doc.version
link + "</span></a>"
templates.sidebarDoc = function(doc, options) {
if (options == null) { options = {}; }
let link = `<a href="${doc.fullPath()}" class="_list-item _icon-${doc.icon} `;
link += options.disabled ? '_list-disabled' : '_list-dir';
link += `" data-slug="${doc.slug}" title="${doc.fullName}" tabindex="-1">`;
if (options.disabled) {
link += `<span class="_list-enable" data-enable="${doc.slug}">Enable</span>`;
} else {
link += arrow;
}
if (doc.release) { link += `<span class="_list-count">${doc.release}</span>`; }
link += `<span class="_list-text">${doc.name}`;
if (options.fullName || (options.disabled && doc.version)) { link += ` ${doc.version}`; }
return link + "</span></a>";
};
templates.sidebarType = (type) ->
"""<a href="#{type.fullPath()}" class="_list-item _list-dir" data-slug="#{type.slug}" tabindex="-1">#{arrow}<span class="_list-count">#{type.count}</span><span class="_list-text">#{$.escape type.name}</span></a>"""
templates.sidebarType = type => `<a href="${type.fullPath()}" class="_list-item _list-dir" data-slug="${type.slug}" tabindex="-1">${arrow}<span class="_list-count">${type.count}</span><span class="_list-text">${$.escape(type.name)}</span></a>`;
templates.sidebarEntry = (entry) ->
"""<a href="#{entry.fullPath()}" class="_list-item _list-hover" tabindex="-1">#{$.escape entry.name}</a>"""
templates.sidebarEntry = entry => `<a href="${entry.fullPath()}" class="_list-item _list-hover" tabindex="-1">${$.escape(entry.name)}</a>`;
templates.sidebarResult = (entry) ->
addons = if entry.isIndex() and app.disabledDocs.contains(entry.doc)
"""<span class="_list-enable" data-enable="#{entry.doc.slug}">Enable</span>"""
else
"""<span class="_list-reveal" data-reset-list title="Reveal in list"></span>"""
addons += """<span class="_list-count">#{entry.doc.short_version}</span>""" if entry.doc.version and not entry.isIndex()
"""<a href="#{entry.fullPath()}" class="_list-item _list-hover _list-result _icon-#{entry.doc.icon}" tabindex="-1">#{addons}<span class="_list-text">#{$.escape entry.name}</span></a>"""
templates.sidebarResult = function(entry) {
let addons = entry.isIndex() && app.disabledDocs.contains(entry.doc) ?
`<span class="_list-enable" data-enable="${entry.doc.slug}">Enable</span>`
:
"<span class=\"_list-reveal\" data-reset-list title=\"Reveal in list\"></span>";
if (entry.doc.version && !entry.isIndex()) { addons += `<span class="_list-count">${entry.doc.short_version}</span>`; }
return `<a href="${entry.fullPath()}" class="_list-item _list-hover _list-result _icon-${entry.doc.icon}" tabindex="-1">${addons}<span class="_list-text">${$.escape(entry.name)}</span></a>`;
};
templates.sidebarNoResults = ->
html = """ <div class="_list-note">No results.</div> """
html += """
<div class="_list-note">Note: documentations must be <a href="/settings" class="_list-note-link">enabled</a> to appear in the search.</div>
""" unless app.isSingleDoc() or app.disabledDocs.isEmpty()
html
templates.sidebarNoResults = function() {
let html = " <div class=\"_list-note\">No results.</div> ";
if (!app.isSingleDoc() && !app.disabledDocs.isEmpty()) { html += `\
<div class="_list-note">Note: documentations must be <a href="/settings" class="_list-note-link">enabled</a> to appear in the search.</div>\
`; }
return html;
};
templates.sidebarPageLink = (count) ->
"""<span role="link" class="_list-item _list-pagelink">Show more\u2026 (#{count})</span>"""
templates.sidebarPageLink = count => `<span role="link" class="_list-item _list-pagelink">Show more\u2026 (${count})</span>`;
templates.sidebarLabel = (doc, options = {}) ->
label = """<label class="_list-item"""
label += " _icon-#{doc.icon}" unless doc.version
label += """"><input type="checkbox" name="#{doc.slug}" class="_list-checkbox" """
label += "checked" if options.checked
label + """><span class="_list-text">#{doc.fullName}</span></label>"""
templates.sidebarLabel = function(doc, options) {
if (options == null) { options = {}; }
let label = "<label class=\"_list-item";
if (!doc.version) { label += ` _icon-${doc.icon}`; }
label += `"><input type="checkbox" name="${doc.slug}" class="_list-checkbox" `;
if (options.checked) { label += "checked"; }
return label + `><span class="_list-text">${doc.fullName}</span></label>`;
};
templates.sidebarVersionedDoc = (doc, versions, options = {}) ->
html = """<div class="_list-item _list-dir _list-rdir _icon-#{doc.icon}"""
html += " open" if options.open
html + """" tabindex="0">#{arrow}#{doc.name}</div><div class="_list _list-sub">#{versions}</div>"""
templates.sidebarVersionedDoc = function(doc, versions, options) {
if (options == null) { options = {}; }
let html = `<div class="_list-item _list-dir _list-rdir _icon-${doc.icon}`;
if (options.open) { html += " open"; }
return html + `" tabindex="0">${arrow}${doc.name}</div><div class="_list _list-sub">${versions}</div>`;
};
templates.sidebarDisabled = (options) ->
"""<h6 class="_list-title">#{arrow}Disabled (#{options.count}) <a href="/settings" class="_list-title-link" tabindex="-1">Customize</a></h6>"""
templates.sidebarDisabled = options => `<h6 class="_list-title">${arrow}Disabled (${options.count}) <a href="/settings" class="_list-title-link" tabindex="-1">Customize</a></h6>`;
templates.sidebarDisabledList = (html) ->
"""<div class="_disabled-list">#{html}</div>"""
templates.sidebarDisabledList = html => `<div class="_disabled-list">${html}</div>`;
templates.sidebarDisabledVersionedDoc = (doc, versions) ->
"""<a class="_list-item _list-dir _icon-#{doc.icon} _list-disabled" data-slug="#{doc.slug_without_version}" tabindex="-1">#{arrow}#{doc.name}</a><div class="_list _list-sub">#{versions}</div>"""
templates.sidebarDisabledVersionedDoc = (doc, versions) => `<a class="_list-item _list-dir _icon-${doc.icon} _list-disabled" data-slug="${doc.slug_without_version}" tabindex="-1">${arrow}${doc.name}</a><div class="_list _list-sub">${versions}</div>`;
templates.docPickerHeader = """<div class="_list-picker-head"><span>Documentation</span> <span>Enable</span></div>"""
templates.docPickerHeader = "<div class=\"_list-picker-head\"><span>Documentation</span> <span>Enable</span></div>";
templates.docPickerNote = """
<div class="_list-note">Tip: for faster and better search results, select only the docs you need.</div>
<a href="https://trello.com/b/6BmTulfx/devdocs-documentation" class="_list-link" target="_blank" rel="noopener">Vote for new documentation</a>
"""
templates.docPickerNote = `\
<div class="_list-note">Tip: for faster and better search results, select only the docs you need.</div>
<a href="https://trello.com/b/6BmTulfx/devdocs-documentation" class="_list-link" target="_blank" rel="noopener">Vote for new documentation</a>\
`;

@ -1,10 +1,15 @@
app.templates.tipKeyNav = () -> """
<p class="_notif-text">
<strong>ProTip</strong>
<span class="_notif-info">(click to dismiss)</span>
<p class="_notif-text">
Hit #{if app.settings.get('arrowScroll') then '<code class="_label">shift</code> +' else ''} <code class="_label">&darr;</code> <code class="_label">&uarr;</code> <code class="_label">&larr;</code> <code class="_label">&rarr;</code> to navigate the sidebar.<br>
Hit <code class="_label">space / shift space</code>#{if app.settings.get('arrowScroll') then ' or <code class="_label">&darr;/&uarr;</code>' else ', <code class="_label">alt &darr;/&uarr;</code> or <code class="_label">shift &darr;/&uarr;</code>'} to scroll the page.
<p class="_notif-text">
<a href="/help#shortcuts" class="_notif-link">See all keyboard shortcuts</a>
"""
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
app.templates.tipKeyNav = () => `\
<p class="_notif-text">
<strong>ProTip</strong>
<span class="_notif-info">(click to dismiss)</span>
<p class="_notif-text">
Hit ${app.settings.get('arrowScroll') ? '<code class="_label">shift</code> +' : ''} <code class="_label">&darr;</code> <code class="_label">&uarr;</code> <code class="_label">&larr;</code> <code class="_label">&rarr;</code> to navigate the sidebar.<br>
Hit <code class="_label">space / shift space</code>${app.settings.get('arrowScroll') ? ' or <code class="_label">&darr;/&uarr;</code>' : ', <code class="_label">alt &darr;/&uarr;</code> or <code class="_label">shift &darr;/&uarr;</code>'} to scroll the page.
<p class="_notif-text">
<a href="/help#shortcuts" class="_notif-link">See all keyboard shortcuts</a>\
`;

@ -1,195 +1,259 @@
class app.views.Content extends app.View
@el: '._content'
@loadingClass: '_content-loading'
@events:
click: 'onClick'
@shortcuts:
altUp: 'scrollStepUp'
altDown: 'scrollStepDown'
pageUp: 'scrollPageUp'
pageDown: 'scrollPageDown'
pageTop: 'scrollToTop'
pageBottom: 'scrollToBottom'
altF: 'onAltF'
@routes:
before: 'beforeRoute'
after: 'afterRoute'
init: ->
@scrollEl = if app.isMobile()
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS104: Avoid inline assignments
* DS204: Change includes calls to have a more natural evaluation order
* DS205: Consider reworking code to avoid use of IIFEs
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.Content = class Content extends app.View {
constructor(...args) {
this.scrollToTop = this.scrollToTop.bind(this);
this.scrollToBottom = this.scrollToBottom.bind(this);
this.scrollStepUp = this.scrollStepUp.bind(this);
this.scrollStepDown = this.scrollStepDown.bind(this);
this.scrollPageUp = this.scrollPageUp.bind(this);
this.scrollPageDown = this.scrollPageDown.bind(this);
this.onReady = this.onReady.bind(this);
this.onBootError = this.onBootError.bind(this);
this.onEntryLoading = this.onEntryLoading.bind(this);
this.onEntryLoaded = this.onEntryLoaded.bind(this);
this.beforeRoute = this.beforeRoute.bind(this);
this.afterRoute = this.afterRoute.bind(this);
this.onClick = this.onClick.bind(this);
this.onAltF = this.onAltF.bind(this);
super(...args);
}
static initClass() {
this.el = '._content';
this.loadingClass = '_content-loading';
this.events =
{click: 'onClick'};
this.shortcuts = {
altUp: 'scrollStepUp',
altDown: 'scrollStepDown',
pageUp: 'scrollPageUp',
pageDown: 'scrollPageDown',
pageTop: 'scrollToTop',
pageBottom: 'scrollToBottom',
altF: 'onAltF'
};
this.routes = {
before: 'beforeRoute',
after: 'afterRoute'
};
}
init() {
this.scrollEl = app.isMobile() ?
(document.scrollingElement || document.body)
else
@el
@scrollMap = {}
@scrollStack = []
@rootPage = new app.views.RootPage
@staticPage = new app.views.StaticPage
@settingsPage = new app.views.SettingsPage
@offlinePage = new app.views.OfflinePage
@typePage = new app.views.TypePage
@entryPage = new app.views.EntryPage
@entryPage
.on 'loading', @onEntryLoading
.on 'loaded', @onEntryLoaded
:
this.el;
this.scrollMap = {};
this.scrollStack = [];
this.rootPage = new app.views.RootPage;
this.staticPage = new app.views.StaticPage;
this.settingsPage = new app.views.SettingsPage;
this.offlinePage = new app.views.OfflinePage;
this.typePage = new app.views.TypePage;
this.entryPage = new app.views.EntryPage;
this.entryPage
.on('loading', this.onEntryLoading)
.on('loaded', this.onEntryLoaded);
app
.on 'ready', @onReady
.on 'bootError', @onBootError
return
show: (view) ->
@hideLoading()
unless view is @view
@view?.deactivate()
@html @view = view
@view.activate()
return
showLoading: ->
@addClass @constructor.loadingClass
return
isLoading: ->
@el.classList.contains @constructor.loadingClass
hideLoading: ->
@removeClass @constructor.loadingClass
return
scrollTo: (value) ->
@scrollEl.scrollTop = value or 0
return
smoothScrollTo: (value) ->
if app.settings.get('fastScroll')
@scrollTo value
else
$.smoothScroll @scrollEl, value or 0
return
scrollBy: (n) ->
@smoothScrollTo @scrollEl.scrollTop + n
return
scrollToTop: =>
@smoothScrollTo 0
return
scrollToBottom: =>
@smoothScrollTo @scrollEl.scrollHeight
return
scrollStepUp: =>
@scrollBy -80
return
scrollStepDown: =>
@scrollBy 80
return
scrollPageUp: =>
@scrollBy 40 - @scrollEl.clientHeight
return
scrollPageDown: =>
@scrollBy @scrollEl.clientHeight - 40
return
scrollToTarget: ->
if @routeCtx.hash and el = @findTargetByHash @routeCtx.hash
$.scrollToWithImageLock el, @scrollEl, 'top',
margin: if @scrollEl is @el then 0 else $.offset(@el).top
$.highlight el, className: '_highlight'
else
@scrollTo @scrollMap[@routeCtx.state.id]
return
onReady: =>
@hideLoading()
return
onBootError: =>
@hideLoading()
@html @tmpl('bootError')
return
onEntryLoading: =>
@showLoading()
if @scrollToTargetTimeout
clearTimeout @scrollToTargetTimeout
@scrollToTargetTimeout = null
return
onEntryLoaded: =>
@hideLoading()
if @scrollToTargetTimeout
clearTimeout @scrollToTargetTimeout
@scrollToTargetTimeout = null
@scrollToTarget()
return
beforeRoute: (context) =>
@cacheScrollPosition()
@routeCtx = context
@scrollToTargetTimeout = @delay @scrollToTarget
return
cacheScrollPosition: ->
return if not @routeCtx or @routeCtx.hash
return if @routeCtx.path is '/'
unless @scrollMap[@routeCtx.state.id]?
@scrollStack.push @routeCtx.state.id
while @scrollStack.length > app.config.history_cache_size
delete @scrollMap[@scrollStack.shift()]
@scrollMap[@routeCtx.state.id] = @scrollEl.scrollTop
return
afterRoute: (route, context) =>
if route != 'entry' and route != 'type'
resetFavicon()
switch route
when 'root'
@show @rootPage
when 'entry'
@show @entryPage
when 'type'
@show @typePage
when 'settings'
@show @settingsPage
when 'offline'
@show @offlinePage
else
@show @staticPage
@view.onRoute(context)
app.document.setTitle @view.getTitle?()
return
onClick: (event) =>
link = $.closestLink $.eventTarget(event), @el
if link and @isExternalUrl link.getAttribute('href')
$.stopEvent(event)
$.popup(link)
return
onAltF: (event) =>
unless document.activeElement and $.hasChild @el, document.activeElement
@find('a:not(:empty)')?.focus()
$.stopEvent(event)
findTargetByHash: (hash) ->
el = try $.id decodeURIComponent(hash) catch
el or= try $.id(hash) catch
el
isExternalUrl: (url) ->
url?[0..5] in ['http:/', 'https:']
.on('ready', this.onReady)
.on('bootError', this.onBootError);
}
show(view) {
this.hideLoading();
if (view !== this.view) {
if (this.view != null) {
this.view.deactivate();
}
this.html(this.view = view);
this.view.activate();
}
}
showLoading() {
this.addClass(this.constructor.loadingClass);
}
isLoading() {
return this.el.classList.contains(this.constructor.loadingClass);
}
hideLoading() {
this.removeClass(this.constructor.loadingClass);
}
scrollTo(value) {
this.scrollEl.scrollTop = value || 0;
}
smoothScrollTo(value) {
if (app.settings.get('fastScroll')) {
this.scrollTo(value);
} else {
$.smoothScroll(this.scrollEl, value || 0);
}
}
scrollBy(n) {
this.smoothScrollTo(this.scrollEl.scrollTop + n);
}
scrollToTop() {
this.smoothScrollTo(0);
}
scrollToBottom() {
this.smoothScrollTo(this.scrollEl.scrollHeight);
}
scrollStepUp() {
this.scrollBy(-80);
}
scrollStepDown() {
this.scrollBy(80);
}
scrollPageUp() {
this.scrollBy(40 - this.scrollEl.clientHeight);
}
scrollPageDown() {
this.scrollBy(this.scrollEl.clientHeight - 40);
}
scrollToTarget() {
let el;
if (this.routeCtx.hash && (el = this.findTargetByHash(this.routeCtx.hash))) {
$.scrollToWithImageLock(el, this.scrollEl, 'top',
{margin: this.scrollEl === this.el ? 0 : $.offset(this.el).top});
$.highlight(el, {className: '_highlight'});
} else {
this.scrollTo(this.scrollMap[this.routeCtx.state.id]);
}
}
onReady() {
this.hideLoading();
}
onBootError() {
this.hideLoading();
this.html(this.tmpl('bootError'));
}
onEntryLoading() {
this.showLoading();
if (this.scrollToTargetTimeout) {
clearTimeout(this.scrollToTargetTimeout);
this.scrollToTargetTimeout = null;
}
}
onEntryLoaded() {
this.hideLoading();
if (this.scrollToTargetTimeout) {
clearTimeout(this.scrollToTargetTimeout);
this.scrollToTargetTimeout = null;
}
this.scrollToTarget();
}
beforeRoute(context) {
this.cacheScrollPosition();
this.routeCtx = context;
this.scrollToTargetTimeout = this.delay(this.scrollToTarget);
}
cacheScrollPosition() {
if (!this.routeCtx || this.routeCtx.hash) { return; }
if (this.routeCtx.path === '/') { return; }
if (this.scrollMap[this.routeCtx.state.id] == null) {
this.scrollStack.push(this.routeCtx.state.id);
while (this.scrollStack.length > app.config.history_cache_size) {
delete this.scrollMap[this.scrollStack.shift()];
}
}
this.scrollMap[this.routeCtx.state.id] = this.scrollEl.scrollTop;
}
afterRoute(route, context) {
if ((route !== 'entry') && (route !== 'type')) {
resetFavicon();
}
switch (route) {
case 'root':
this.show(this.rootPage);
break;
case 'entry':
this.show(this.entryPage);
break;
case 'type':
this.show(this.typePage);
break;
case 'settings':
this.show(this.settingsPage);
break;
case 'offline':
this.show(this.offlinePage);
break;
default:
this.show(this.staticPage);
}
this.view.onRoute(context);
app.document.setTitle(typeof this.view.getTitle === 'function' ? this.view.getTitle() : undefined);
}
onClick(event) {
const link = $.closestLink($.eventTarget(event), this.el);
if (link && this.isExternalUrl(link.getAttribute('href'))) {
$.stopEvent(event);
$.popup(link);
}
}
onAltF(event) {
if (!document.activeElement || !$.hasChild(this.el, document.activeElement)) {
__guard__(this.find('a:not(:empty)'), x => x.focus());
return $.stopEvent(event);
}
}
findTargetByHash(hash) {
let el = (() => { try { return $.id(decodeURIComponent(hash)); } catch (error) {} })();
if (!el) { el = (() => { try { return $.id(hash); } catch (error1) {} })(); }
return el;
}
isExternalUrl(url) {
let needle;
return (needle = __guard__(url, x => x.slice(0, 6)), ['http:/', 'https:'].includes(needle));
}
});
Cls.initClass();
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,166 +1,225 @@
class app.views.EntryPage extends app.View
@className: '_page'
@errorClass: '_page-error'
@events:
click: 'onClick'
@shortcuts:
altC: 'onAltC'
altO: 'onAltO'
@routes:
before: 'beforeRoute'
init: ->
@cacheMap = {}
@cacheStack = []
return
deactivate: ->
if super
@empty()
@entry = null
return
loading: ->
@empty()
@trigger 'loading'
return
render: (content = '', fromCache = false) ->
return unless @activated
@empty()
@subview = new (@subViewClass()) @el, @entry
$.batchUpdate @el, =>
@subview.render(content, fromCache)
@addCopyButtons() unless fromCache
return
if app.disabledDocs.findBy 'slug', @entry.doc.slug
@hiddenView = new app.views.HiddenPage @el, @entry
setFaviconForDoc(@entry.doc)
@delay @polyfillMathML
@trigger 'loaded'
return
addCopyButtons: ->
unless @copyButton
@copyButton = document.createElement('button')
@copyButton.innerHTML = '<svg><use xlink:href="#icon-copy"/></svg>'
@copyButton.type = 'button'
@copyButton.className = '_pre-clip'
@copyButton.title = 'Copy to clipboard'
@copyButton.setAttribute 'aria-label', 'Copy to clipboard'
el.appendChild @copyButton.cloneNode(true) for el in @findAllByTag('pre')
return
polyfillMathML: ->
return unless window.supportsMathML is false and !@polyfilledMathML and @findByTag('math')
@polyfilledMathML = true
$.append document.head, """<link rel="stylesheet" href="#{app.config.mathml_stylesheet}">"""
return
LINKS =
home: 'Homepage'
code: 'Source code'
prepareContent: (content) ->
return content unless @entry.isIndex() and @entry.doc.links
links = for link, url of @entry.doc.links
"""<a href="#{url}" class="_links-link">#{LINKS[link]}</a>"""
"""<p class="_links">#{links.join('')}</p>#{content}"""
empty: ->
@subview?.deactivate()
@subview = null
@hiddenView?.deactivate()
@hiddenView = null
@resetClass()
super
return
subViewClass: ->
app.views["#{$.classify(@entry.doc.type)}Page"] or app.views.BasePage
getTitle: ->
@entry.doc.fullName + if @entry.isIndex() then ' documentation' else " / #{@entry.name}"
beforeRoute: =>
@cache()
@abort()
return
onRoute: (context) ->
isSameFile = context.entry.filePath() is @entry?.filePath()
@entry = context.entry
@restore() or @load() unless isSameFile
return
load: ->
@loading()
@xhr = @entry.loadFile @onSuccess, @onError
return
abort: ->
if @xhr
@xhr.abort()
@xhr = @entry = null
return
onSuccess: (response) =>
return unless @activated
@xhr = null
@render @prepareContent(response)
return
onError: =>
@xhr = null
@render @tmpl('pageLoadError')
@resetClass()
@addClass @constructor.errorClass
app.serviceWorker?.update()
return
cache: ->
return if @xhr or not @entry or @cacheMap[path = @entry.filePath()]
@cacheMap[path] = @el.innerHTML
@cacheStack.push(path)
while @cacheStack.length > app.config.history_cache_size
delete @cacheMap[@cacheStack.shift()]
return
restore: ->
if @cacheMap[path = @entry.filePath()]
@render @cacheMap[path], true
true
onClick: (event) =>
target = $.eventTarget(event)
if target.hasAttribute 'data-retry'
$.stopEvent(event)
@load()
else if target.classList.contains '_pre-clip'
$.stopEvent(event)
target.classList.add if $.copyToClipboard(target.parentNode.textContent) then '_pre-clip-success' else '_pre-clip-error'
setTimeout (-> target.className = '_pre-clip'), 2000
return
onAltC: =>
return unless link = @find('._attribution:last-child ._attribution-link')
console.log(link.href + location.hash)
navigator.clipboard.writeText(link.href + location.hash)
return
onAltO: =>
return unless link = @find('._attribution:last-child ._attribution-link')
@delay -> $.popup(link.href + location.hash)
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let LINKS = undefined;
const Cls = (app.views.EntryPage = class EntryPage extends app.View {
constructor(...args) {
this.beforeRoute = this.beforeRoute.bind(this);
this.onSuccess = this.onSuccess.bind(this);
this.onError = this.onError.bind(this);
this.onClick = this.onClick.bind(this);
this.onAltC = this.onAltC.bind(this);
this.onAltO = this.onAltO.bind(this);
super(...args);
}
static initClass() {
this.className = '_page';
this.errorClass = '_page-error';
this.events =
{click: 'onClick'};
this.shortcuts = {
altC: 'onAltC',
altO: 'onAltO'
};
this.routes =
{before: 'beforeRoute'};
LINKS = {
home: 'Homepage',
code: 'Source code'
};
}
init() {
this.cacheMap = {};
this.cacheStack = [];
}
deactivate() {
if (super.deactivate(...arguments)) {
this.empty();
this.entry = null;
}
}
loading() {
this.empty();
this.trigger('loading');
}
render(content, fromCache) {
if (content == null) { content = ''; }
if (fromCache == null) { fromCache = false; }
if (!this.activated) { return; }
this.empty();
this.subview = new (this.subViewClass())(this.el, this.entry);
$.batchUpdate(this.el, () => {
this.subview.render(content, fromCache);
if (!fromCache) { this.addCopyButtons(); }
});
if (app.disabledDocs.findBy('slug', this.entry.doc.slug)) {
this.hiddenView = new app.views.HiddenPage(this.el, this.entry);
}
setFaviconForDoc(this.entry.doc);
this.delay(this.polyfillMathML);
this.trigger('loaded');
}
addCopyButtons() {
if (!this.copyButton) {
this.copyButton = document.createElement('button');
this.copyButton.innerHTML = '<svg><use xlink:href="#icon-copy"/></svg>';
this.copyButton.type = 'button';
this.copyButton.className = '_pre-clip';
this.copyButton.title = 'Copy to clipboard';
this.copyButton.setAttribute('aria-label', 'Copy to clipboard');
}
for (var el of Array.from(this.findAllByTag('pre'))) { el.appendChild(this.copyButton.cloneNode(true)); }
}
polyfillMathML() {
if ((window.supportsMathML !== false) || !!this.polyfilledMathML || !this.findByTag('math')) { return; }
this.polyfilledMathML = true;
$.append(document.head, `<link rel="stylesheet" href="${app.config.mathml_stylesheet}">`);
}
prepareContent(content) {
if (!this.entry.isIndex() || !this.entry.doc.links) { return content; }
const links = (() => {
const result = [];
for (var link in this.entry.doc.links) {
var url = this.entry.doc.links[link];
result.push(`<a href="${url}" class="_links-link">${LINKS[link]}</a>`);
}
return result;
})();
return `<p class="_links">${links.join('')}</p>${content}`;
}
empty() {
if (this.subview != null) {
this.subview.deactivate();
}
this.subview = null;
if (this.hiddenView != null) {
this.hiddenView.deactivate();
}
this.hiddenView = null;
this.resetClass();
super.empty(...arguments);
}
subViewClass() {
return app.views[`${$.classify(this.entry.doc.type)}Page`] || app.views.BasePage;
}
getTitle() {
return this.entry.doc.fullName + (this.entry.isIndex() ? ' documentation' : ` / ${this.entry.name}`);
}
beforeRoute() {
this.cache();
this.abort();
}
onRoute(context) {
const isSameFile = context.entry.filePath() === (this.entry != null ? this.entry.filePath() : undefined);
this.entry = context.entry;
if (!isSameFile) { this.restore() || this.load(); }
}
load() {
this.loading();
this.xhr = this.entry.loadFile(this.onSuccess, this.onError);
}
abort() {
if (this.xhr) {
this.xhr.abort();
this.xhr = (this.entry = null);
}
}
onSuccess(response) {
if (!this.activated) { return; }
this.xhr = null;
this.render(this.prepareContent(response));
}
onError() {
this.xhr = null;
this.render(this.tmpl('pageLoadError'));
this.resetClass();
this.addClass(this.constructor.errorClass);
if (app.serviceWorker != null) {
app.serviceWorker.update();
}
}
cache() {
let path;
if (this.xhr || !this.entry || this.cacheMap[(path = this.entry.filePath())]) { return; }
this.cacheMap[path] = this.el.innerHTML;
this.cacheStack.push(path);
while (this.cacheStack.length > app.config.history_cache_size) {
delete this.cacheMap[this.cacheStack.shift()];
}
}
restore() {
let path;
if (this.cacheMap[(path = this.entry.filePath())]) {
this.render(this.cacheMap[path], true);
return true;
}
}
onClick(event) {
const target = $.eventTarget(event);
if (target.hasAttribute('data-retry')) {
$.stopEvent(event);
this.load();
} else if (target.classList.contains('_pre-clip')) {
$.stopEvent(event);
target.classList.add($.copyToClipboard(target.parentNode.textContent) ? '_pre-clip-success' : '_pre-clip-error');
setTimeout((() => target.className = '_pre-clip'), 2000);
}
}
onAltC() {
let link;
if (!(link = this.find('._attribution:last-child ._attribution-link'))) { return; }
console.log(link.href + location.hash);
navigator.clipboard.writeText(link.href + location.hash);
}
onAltO() {
let link;
if (!(link = this.find('._attribution:last-child ._attribution-link'))) { return; }
this.delay(() => $.popup(link.href + location.hash));
}
});
Cls.initClass();
return Cls;
})();

@ -1,92 +1,128 @@
class app.views.OfflinePage extends app.View
@className: '_static'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.OfflinePage = class OfflinePage extends app.View {
constructor(...args) {
this.onClick = this.onClick.bind(this);
super(...args);
}
@events:
click: 'onClick'
change: 'onChange'
static initClass() {
this.className = '_static';
this.events = {
click: 'onClick',
change: 'onChange'
};
}
deactivate: ->
if super
@empty()
return
deactivate() {
if (super.deactivate(...arguments)) {
this.empty();
}
}
render: ->
if app.cookieBlocked
@html @tmpl('offlineError', 'cookie_blocked')
return
render() {
if (app.cookieBlocked) {
this.html(this.tmpl('offlineError', 'cookie_blocked'));
return;
}
app.docs.getInstallStatuses (statuses) =>
return unless @activated
if statuses is false
@html @tmpl('offlineError', app.db.reason, app.db.error)
else
html = ''
html += @renderDoc(doc, statuses[doc.slug]) for doc in app.docs.all()
@html @tmpl('offlinePage', html)
@refreshLinks()
return
return
app.docs.getInstallStatuses(statuses => {
if (!this.activated) { return; }
if (statuses === false) {
this.html(this.tmpl('offlineError', app.db.reason, app.db.error));
} else {
let html = '';
for (var doc of Array.from(app.docs.all())) { html += this.renderDoc(doc, statuses[doc.slug]); }
this.html(this.tmpl('offlinePage', html));
this.refreshLinks();
}
});
}
renderDoc: (doc, status) ->
app.templates.render('offlineDoc', doc, status)
renderDoc(doc, status) {
return app.templates.render('offlineDoc', doc, status);
}
getTitle: ->
'Offline'
getTitle() {
return 'Offline';
}
refreshLinks: ->
for action in ['install', 'update', 'uninstall']
@find("[data-action-all='#{action}']").classList[if @find("[data-action='#{action}']") then 'add' else 'remove']('_show')
return
refreshLinks() {
for (var action of ['install', 'update', 'uninstall']) {
this.find(`[data-action-all='${action}']`).classList[this.find(`[data-action='${action}']`) ? 'add' : 'remove']('_show');
}
}
docByEl: (el) ->
el = el.parentNode until slug = el.getAttribute('data-slug')
app.docs.findBy('slug', slug)
docByEl(el) {
let slug;
while (!(slug = el.getAttribute('data-slug'))) { el = el.parentNode; }
return app.docs.findBy('slug', slug);
}
docEl: (doc) ->
@find("[data-slug='#{doc.slug}']")
docEl(doc) {
return this.find(`[data-slug='${doc.slug}']`);
}
onRoute: (context) ->
@render()
return
onRoute(context) {
this.render();
}
onClick: (event) =>
el = $.eventTarget(event)
if action = el.getAttribute('data-action')
doc = @docByEl(el)
action = 'install' if action is 'update'
doc[action](@onInstallSuccess.bind(@, doc), @onInstallError.bind(@, doc), @onInstallProgress.bind(@, doc))
el.parentNode.innerHTML = "#{el.textContent.replace(/e$/, '')}ing…"
else if action = el.getAttribute('data-action-all') || el.parentElement.getAttribute('data-action-all')
return unless action isnt 'uninstall' or window.confirm('Uninstall all docs?')
app.db.migrate()
$.click(el) for el in @findAll("[data-action='#{action}']")
return
onClick(event) {
let action;
let el = $.eventTarget(event);
if (action = el.getAttribute('data-action')) {
const doc = this.docByEl(el);
if (action === 'update') { action = 'install'; }
doc[action](this.onInstallSuccess.bind(this, doc), this.onInstallError.bind(this, doc), this.onInstallProgress.bind(this, doc));
el.parentNode.innerHTML = `${el.textContent.replace(/e$/, '')}ing…`;
} else if (action = el.getAttribute('data-action-all') || el.parentElement.getAttribute('data-action-all')) {
if ((action === 'uninstall') && !window.confirm('Uninstall all docs?')) { return; }
app.db.migrate();
for (el of Array.from(this.findAll(`[data-action='${action}']`))) { $.click(el); }
}
}
onInstallSuccess: (doc) ->
return unless @activated
doc.getInstallStatus (status) =>
return unless @activated
if el = @docEl(doc)
el.outerHTML = @renderDoc(doc, status)
$.highlight el, className: '_highlight'
@refreshLinks()
return
return
onInstallSuccess(doc) {
if (!this.activated) { return; }
doc.getInstallStatus(status => {
let el;
if (!this.activated) { return; }
if (el = this.docEl(doc)) {
el.outerHTML = this.renderDoc(doc, status);
$.highlight(el, {className: '_highlight'});
this.refreshLinks();
}
});
}
onInstallError: (doc) ->
return unless @activated
if el = @docEl(doc)
el.lastElementChild.textContent = 'Error'
return
onInstallError(doc) {
let el;
if (!this.activated) { return; }
if (el = this.docEl(doc)) {
el.lastElementChild.textContent = 'Error';
}
}
onInstallProgress: (doc, event) ->
return unless @activated and event.lengthComputable
if el = @docEl(doc)
percentage = Math.round event.loaded * 100 / event.total
el.lastElementChild.textContent = el.lastElementChild.textContent.replace(/(\s.+)?$/, " (#{percentage}%)")
return
onInstallProgress(doc, event) {
let el;
if (!this.activated || !event.lengthComputable) { return; }
if (el = this.docEl(doc)) {
const percentage = Math.round((event.loaded * 100) / event.total);
el.lastElementChild.textContent = el.lastElementChild.textContent.replace(/(\s.+)?$/, ` (${percentage}%)`);
}
}
onChange: (event) ->
if event.target.name is 'autoUpdate'
app.settings.set 'manualUpdate', !event.target.checked
return
onChange(event) {
if (event.target.name === 'autoUpdate') {
app.settings.set('manualUpdate', !event.target.checked);
}
}
});
Cls.initClass();

@ -1,43 +1,61 @@
class app.views.RootPage extends app.View
@events:
click: 'onClick'
init: ->
@setHidden false unless @isHidden() # reserve space in local storage
@render()
return
render: ->
@empty()
tmpl = if app.isAndroidWebview()
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.RootPage = class RootPage extends app.View {
constructor(...args) {
this.onClick = this.onClick.bind(this);
super(...args);
}
static initClass() {
this.events =
{click: 'onClick'};
}
init() {
if (!this.isHidden()) { this.setHidden(false); } // reserve space in local storage
this.render();
}
render() {
this.empty();
const tmpl = app.isAndroidWebview() ?
'androidWarning'
else if @isHidden()
: this.isHidden() ?
'splash'
else if app.isMobile()
: app.isMobile() ?
'mobileIntro'
else
'intro'
@append @tmpl(tmpl)
return
hideIntro: ->
@setHidden true
@render()
return
setHidden: (value) ->
app.settings.set 'hideIntro', value
return
isHidden: ->
app.isSingleDoc() or app.settings.get 'hideIntro'
onRoute: ->
onClick: (event) =>
if $.eventTarget(event).hasAttribute 'data-hide-intro'
$.stopEvent(event)
@hideIntro()
return
:
'intro';
this.append(this.tmpl(tmpl));
}
hideIntro() {
this.setHidden(true);
this.render();
}
setHidden(value) {
app.settings.set('hideIntro', value);
}
isHidden() {
return app.isSingleDoc() || app.settings.get('hideIntro');
}
onRoute() {}
onClick(event) {
if ($.eventTarget(event).hasAttribute('data-hide-intro')) {
$.stopEvent(event);
this.hideIntro();
}
}
});
Cls.initClass();

@ -1,116 +1,151 @@
class app.views.SettingsPage extends app.View
@className: '_static'
@events:
click: 'onClick'
change: 'onChange'
render: ->
@html @tmpl('settingsPage', @currentSettings())
return
currentSettings: ->
settings = {}
settings.theme = app.settings.get('theme')
settings.smoothScroll = !app.settings.get('fastScroll')
settings.arrowScroll = app.settings.get('arrowScroll')
settings.noAutofocus = app.settings.get('noAutofocus')
settings.autoInstall = app.settings.get('autoInstall')
settings.analyticsConsent = app.settings.get('analyticsConsent')
settings.spaceScroll = app.settings.get('spaceScroll')
settings.spaceTimeout = app.settings.get('spaceTimeout')
settings.autoSupported = app.settings.autoSupported
settings[layout] = app.settings.hasLayout(layout) for layout in app.settings.LAYOUTS
settings
getTitle: ->
'Preferences'
setTheme: (value) ->
app.settings.set('theme', value)
return
toggleLayout: (layout, enable) ->
app.settings.setLayout(layout, enable)
return
toggleSmoothScroll: (enable) ->
app.settings.set('fastScroll', !enable)
return
toggleAnalyticsConsent: (enable) ->
app.settings.set('analyticsConsent', if enable then '1' else '0')
resetAnalytics() unless enable
return
toggleSpaceScroll: (enable) ->
app.settings.set('spaceScroll', if enable then 1 else 0)
return
setScrollTimeout: (value) ->
app.settings.set('spaceTimeout', value)
toggle: (name, enable) ->
app.settings.set(name, enable)
return
export: ->
data = new Blob([JSON.stringify(app.settings.export())], type: 'application/json')
link = document.createElement('a')
link.href = URL.createObjectURL(data)
link.download = 'devdocs.json'
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
return
import: (file, input) ->
unless file and file.type is 'application/json'
new app.views.Notif 'ImportInvalid', autoHide: false
return
reader = new FileReader()
reader.onloadend = ->
data = try JSON.parse(reader.result)
unless data and data.constructor is Object
new app.views.Notif 'ImportInvalid', autoHide: false
return
app.settings.import(data)
$.trigger input.form, 'import'
return
reader.readAsText(file)
return
onChange: (event) =>
input = event.target
switch input.name
when 'theme'
@setTheme input.value
when 'layout'
@toggleLayout input.value, input.checked
when 'smoothScroll'
@toggleSmoothScroll input.checked
when 'import'
@import input.files[0], input
when 'analyticsConsent'
@toggleAnalyticsConsent input.checked
when 'spaceScroll'
@toggleSpaceScroll input.checked
when 'spaceTimeout'
@setScrollTimeout input.value
else
@toggle input.name, input.checked
return
onClick: (event) =>
target = $.eventTarget(event)
switch target.getAttribute('data-action')
when 'export'
$.stopEvent(event)
@export()
return
onRoute: (context) ->
@render()
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.SettingsPage = class SettingsPage extends app.View {
constructor(...args) {
this.onChange = this.onChange.bind(this);
this.onClick = this.onClick.bind(this);
super(...args);
}
static initClass() {
this.className = '_static';
this.events = {
click: 'onClick',
change: 'onChange'
};
}
render() {
this.html(this.tmpl('settingsPage', this.currentSettings()));
}
currentSettings() {
const settings = {};
settings.theme = app.settings.get('theme');
settings.smoothScroll = !app.settings.get('fastScroll');
settings.arrowScroll = app.settings.get('arrowScroll');
settings.noAutofocus = app.settings.get('noAutofocus');
settings.autoInstall = app.settings.get('autoInstall');
settings.analyticsConsent = app.settings.get('analyticsConsent');
settings.spaceScroll = app.settings.get('spaceScroll');
settings.spaceTimeout = app.settings.get('spaceTimeout');
settings.autoSupported = app.settings.autoSupported;
for (var layout of Array.from(app.settings.LAYOUTS)) { settings[layout] = app.settings.hasLayout(layout); }
return settings;
}
getTitle() {
return 'Preferences';
}
setTheme(value) {
app.settings.set('theme', value);
}
toggleLayout(layout, enable) {
app.settings.setLayout(layout, enable);
}
toggleSmoothScroll(enable) {
app.settings.set('fastScroll', !enable);
}
toggleAnalyticsConsent(enable) {
app.settings.set('analyticsConsent', enable ? '1' : '0');
if (!enable) { resetAnalytics(); }
}
toggleSpaceScroll(enable) {
app.settings.set('spaceScroll', enable ? 1 : 0);
}
setScrollTimeout(value) {
return app.settings.set('spaceTimeout', value);
}
toggle(name, enable) {
app.settings.set(name, enable);
}
export() {
const data = new Blob([JSON.stringify(app.settings.export())], {type: 'application/json'});
const link = document.createElement('a');
link.href = URL.createObjectURL(data);
link.download = 'devdocs.json';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
import(file, input) {
if (!file || (file.type !== 'application/json')) {
new app.views.Notif('ImportInvalid', {autoHide: false});
return;
}
const reader = new FileReader();
reader.onloadend = function() {
const data = (() => { try { return JSON.parse(reader.result); } catch (error) {} })();
if (!data || (data.constructor !== Object)) {
new app.views.Notif('ImportInvalid', {autoHide: false});
return;
}
app.settings.import(data);
$.trigger(input.form, 'import');
};
reader.readAsText(file);
}
onChange(event) {
const input = event.target;
switch (input.name) {
case 'theme':
this.setTheme(input.value);
break;
case 'layout':
this.toggleLayout(input.value, input.checked);
break;
case 'smoothScroll':
this.toggleSmoothScroll(input.checked);
break;
case 'import':
this.import(input.files[0], input);
break;
case 'analyticsConsent':
this.toggleAnalyticsConsent(input.checked);
break;
case 'spaceScroll':
this.toggleSpaceScroll(input.checked);
break;
case 'spaceTimeout':
this.setScrollTimeout(input.value);
break;
default:
this.toggle(input.name, input.checked);
}
}
onClick(event) {
const target = $.eventTarget(event);
switch (target.getAttribute('data-action')) {
case 'export':
$.stopEvent(event);
this.export();
break;
}
}
onRoute(context) {
this.render();
}
});
Cls.initClass();

@ -1,26 +1,39 @@
class app.views.StaticPage extends app.View
@className: '_static'
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.StaticPage = class StaticPage extends app.View {
static initClass() {
this.className = '_static';
this.titles = {
about: 'About',
news: 'News',
help: 'User Guide',
notFound: '404'
};
}
@titles:
about: 'About'
news: 'News'
help: 'User Guide'
notFound: '404'
deactivate() {
if (super.deactivate(...arguments)) {
this.empty();
this.page = null;
}
}
deactivate: ->
if super
@empty()
@page = null
return
render(page) {
this.page = page;
this.html(this.tmpl(`${this.page}Page`));
}
render: (page) ->
@page = page
@html @tmpl("#{@page}Page")
return
getTitle() {
return this.constructor.titles[this.page];
}
getTitle: ->
@constructor.titles[@page]
onRoute: (context) ->
@render context.page or 'notFound'
return
onRoute(context) {
this.render(context.page || 'notFound');
}
});
Cls.initClass();

@ -1,20 +1,33 @@
class app.views.TypePage extends app.View
@className: '_page'
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.TypePage = class TypePage extends app.View {
static initClass() {
this.className = '_page';
}
deactivate: ->
if super
@empty()
@type = null
return
deactivate() {
if (super.deactivate(...arguments)) {
this.empty();
this.type = null;
}
}
render: (@type) ->
@html @tmpl('typePage', @type)
setFaviconForDoc(@type.doc)
return
render(type) {
this.type = type;
this.html(this.tmpl('typePage', this.type));
setFaviconForDoc(this.type.doc);
}
getTitle: ->
"#{@type.doc.fullName} / #{@type.name}"
getTitle() {
return `${this.type.doc.fullName} / ${this.type.name}`;
}
onRoute: (context) ->
@render context.type
return
onRoute(context) {
this.render(context.type);
}
});
Cls.initClass();

@ -1,85 +1,111 @@
class app.views.Document extends app.View
@el: document
@events:
visibilitychange: 'onVisibilityChange'
@shortcuts:
help: 'onHelp'
preferences: 'onPreferences'
escape: 'onEscape'
superLeft: 'onBack'
superRight: 'onForward'
@routes:
after: 'afterRoute'
init: ->
@addSubview @menu = new app.views.Menu,
@addSubview @sidebar = new app.views.Sidebar
@addSubview @resizer = new app.views.Resizer if app.views.Resizer.isSupported()
@addSubview @content = new app.views.Content
@addSubview @path = new app.views.Path unless app.isSingleDoc() or app.isMobile()
@settings = new app.views.Settings unless app.isSingleDoc()
$.on document.body, 'click', @onClick
@activate()
return
setTitle: (title) ->
@el.title = if title then "#{title} — DevDocs" else 'DevDocs API Documentation'
afterRoute: (route) =>
if route is 'settings'
@settings?.activate()
else
@settings?.deactivate()
return
onVisibilityChange: =>
return unless @el.visibilityState is 'visible'
@delay ->
location.reload() if app.isMobile() isnt app.views.Mobile.detect()
return
, 300
return
onHelp: ->
app.router.show '/help#shortcuts'
return
onPreferences: ->
app.router.show '/settings'
return
onEscape: ->
path = if !app.isSingleDoc() or location.pathname is app.doc.fullPath()
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.Document = class Document extends app.View {
constructor(...args) {
this.afterRoute = this.afterRoute.bind(this);
this.onVisibilityChange = this.onVisibilityChange.bind(this);
super(...args);
}
static initClass() {
this.el = document;
this.events =
{visibilitychange: 'onVisibilityChange'};
this.shortcuts = {
help: 'onHelp',
preferences: 'onPreferences',
escape: 'onEscape',
superLeft: 'onBack',
superRight: 'onForward'
};
this.routes =
{after: 'afterRoute'};
}
init() {
this.addSubview((this.menu = new app.views.Menu),
this.addSubview(this.sidebar = new app.views.Sidebar));
if (app.views.Resizer.isSupported()) { this.addSubview(this.resizer = new app.views.Resizer); }
this.addSubview(this.content = new app.views.Content);
if (!app.isSingleDoc() && !app.isMobile()) { this.addSubview(this.path = new app.views.Path); }
if (!app.isSingleDoc()) { this.settings = new app.views.Settings; }
$.on(document.body, 'click', this.onClick);
this.activate();
}
setTitle(title) {
return this.el.title = title ? `${title} — DevDocs` : 'DevDocs API Documentation';
}
afterRoute(route) {
if (route === 'settings') {
if (this.settings != null) {
this.settings.activate();
}
} else {
if (this.settings != null) {
this.settings.deactivate();
}
}
}
onVisibilityChange() {
if (this.el.visibilityState !== 'visible') { return; }
this.delay(function() {
if (app.isMobile() !== app.views.Mobile.detect()) { location.reload(); }
}
, 300);
}
onHelp() {
app.router.show('/help#shortcuts');
}
onPreferences() {
app.router.show('/settings');
}
onEscape() {
const path = !app.isSingleDoc() || (location.pathname === app.doc.fullPath()) ?
'/'
else
app.doc.fullPath()
app.router.show(path)
return
onBack: ->
history.back()
return
onForward: ->
history.forward()
return
onClick: (event) ->
target = $.eventTarget(event)
return unless target.hasAttribute('data-behavior')
$.stopEvent(event)
switch target.getAttribute('data-behavior')
when 'back' then history.back()
when 'reload' then window.location.reload()
when 'reboot' then app.reboot()
when 'hard-reload' then app.reload()
when 'reset' then app.reset() if confirm('Are you sure you want to reset DevDocs?')
when 'accept-analytics' then Cookies.set('analyticsConsent', '1', expires: 1e8) && app.reboot()
when 'decline-analytics' then Cookies.set('analyticsConsent', '0', expires: 1e8) && app.reboot()
return
:
app.doc.fullPath();
app.router.show(path);
}
onBack() {
history.back();
}
onForward() {
history.forward();
}
onClick(event) {
const target = $.eventTarget(event);
if (!target.hasAttribute('data-behavior')) { return; }
$.stopEvent(event);
switch (target.getAttribute('data-behavior')) {
case 'back': history.back(); break;
case 'reload': window.location.reload(); break;
case 'reboot': app.reboot(); break;
case 'hard-reload': app.reload(); break;
case 'reset': if (confirm('Are you sure you want to reset DevDocs?')) { app.reset(); } break;
case 'accept-analytics': Cookies.set('analyticsConsent', '1', {expires: 1e8}) && app.reboot(); break;
case 'decline-analytics': Cookies.set('analyticsConsent', '0', {expires: 1e8}) && app.reboot(); break;
}
}
});
Cls.initClass();

@ -1,23 +1,39 @@
class app.views.Menu extends app.View
@el: '._menu'
@activeClass: 'active'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.Menu = class Menu extends app.View {
constructor(...args) {
this.onGlobalClick = this.onGlobalClick.bind(this);
super(...args);
}
@events:
click: 'onClick'
static initClass() {
this.el = '._menu';
this.activeClass = 'active';
this.events =
{click: 'onClick'};
}
init: ->
$.on document.body, 'click', @onGlobalClick
return
init() {
$.on(document.body, 'click', this.onGlobalClick);
}
onClick: (event) ->
target = $.eventTarget(event)
target.blur() if target.tagName is 'A'
return
onClick(event) {
const target = $.eventTarget(event);
if (target.tagName === 'A') { target.blur(); }
}
onGlobalClick: (event) =>
return if event.which isnt 1
if event.target.hasAttribute?('data-toggle-menu')
@toggleClass @constructor.activeClass
else if @hasClass @constructor.activeClass
@removeClass @constructor.activeClass
return
onGlobalClick(event) {
if (event.which !== 1) { return; }
if (typeof event.target.hasAttribute === 'function' ? event.target.hasAttribute('data-toggle-menu') : undefined) {
this.toggleClass(this.constructor.activeClass);
} else if (this.hasClass(this.constructor.activeClass)) {
this.removeClass(this.constructor.activeClass);
}
}
});
Cls.initClass();

@ -1,155 +1,195 @@
class app.views.Mobile extends app.View
@className: '_mobile'
@elements:
body: 'body'
content: '._container'
sidebar: '._sidebar'
docPicker: '._settings ._sidebar'
@shortcuts:
escape: 'onEscape'
@routes:
after: 'afterRoute'
@detect: ->
if Cookies.get('override-mobile-detect')?
return JSON.parse Cookies.get('override-mobile-detect')
try
(window.matchMedia('(max-width: 480px)').matches) or
(window.matchMedia('(max-width: 767px)').matches) or
(window.matchMedia('(max-height: 767px) and (max-width: 1024px)').matches) or
# Need to sniff the user agent because some Android and Windows Phone devices don't take
# resolution (dpi) into account when reporting device width/height.
(navigator.userAgent.indexOf('Android') isnt -1 and navigator.userAgent.indexOf('Mobile') isnt -1) or
(navigator.userAgent.indexOf('IEMobile') isnt -1)
catch
false
@detectAndroidWebview: ->
try
/(Android).*( Version\/.\.. ).*(Chrome)/.test(navigator.userAgent)
catch
false
constructor: ->
@el = document.documentElement
super
init: ->
$.on $('._search'), 'touchend', @onTapSearch
@toggleSidebar = $('button[data-toggle-sidebar]')
@toggleSidebar.removeAttribute('hidden')
$.on @toggleSidebar, 'click', @onClickToggleSidebar
@back = $('button[data-back]')
@back.removeAttribute('hidden')
$.on @back, 'click', @onClickBack
@forward = $('button[data-forward]')
@forward.removeAttribute('hidden')
$.on @forward, 'click', @onClickForward
@docPickerTab = $('button[data-tab="doc-picker"]')
@docPickerTab.removeAttribute('hidden')
$.on @docPickerTab, 'click', @onClickDocPickerTab
@settingsTab = $('button[data-tab="settings"]')
@settingsTab.removeAttribute('hidden')
$.on @settingsTab, 'click', @onClickSettingsTab
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.Mobile = class Mobile extends app.View {
static initClass() {
this.className = '_mobile';
this.elements = {
body: 'body',
content: '._container',
sidebar: '._sidebar',
docPicker: '._settings ._sidebar'
};
this.shortcuts =
{escape: 'onEscape'};
this.routes =
{after: 'afterRoute'};
}
static detect() {
if (Cookies.get('override-mobile-detect') != null) {
return JSON.parse(Cookies.get('override-mobile-detect'));
}
try {
return (window.matchMedia('(max-width: 480px)').matches) ||
(window.matchMedia('(max-width: 767px)').matches) ||
(window.matchMedia('(max-height: 767px) and (max-width: 1024px)').matches) ||
// Need to sniff the user agent because some Android and Windows Phone devices don't take
// resolution (dpi) into account when reporting device width/height.
((navigator.userAgent.indexOf('Android') !== -1) && (navigator.userAgent.indexOf('Mobile') !== -1)) ||
(navigator.userAgent.indexOf('IEMobile') !== -1);
} catch (error) {
return false;
}
}
static detectAndroidWebview() {
try {
return /(Android).*( Version\/.\.. ).*(Chrome)/.test(navigator.userAgent);
} catch (error) {
return false;
}
}
constructor() {
this.showSidebar = this.showSidebar.bind(this);
this.hideSidebar = this.hideSidebar.bind(this);
this.onClickBack = this.onClickBack.bind(this);
this.onClickForward = this.onClickForward.bind(this);
this.onClickToggleSidebar = this.onClickToggleSidebar.bind(this);
this.onClickDocPickerTab = this.onClickDocPickerTab.bind(this);
this.onClickSettingsTab = this.onClickSettingsTab.bind(this);
this.onTapSearch = this.onTapSearch.bind(this);
this.onEscape = this.onEscape.bind(this);
this.afterRoute = this.afterRoute.bind(this);
this.el = document.documentElement;
super(...arguments);
}
init() {
$.on($('._search'), 'touchend', this.onTapSearch);
this.toggleSidebar = $('button[data-toggle-sidebar]');
this.toggleSidebar.removeAttribute('hidden');
$.on(this.toggleSidebar, 'click', this.onClickToggleSidebar);
this.back = $('button[data-back]');
this.back.removeAttribute('hidden');
$.on(this.back, 'click', this.onClickBack);
this.forward = $('button[data-forward]');
this.forward.removeAttribute('hidden');
$.on(this.forward, 'click', this.onClickForward);
this.docPickerTab = $('button[data-tab="doc-picker"]');
this.docPickerTab.removeAttribute('hidden');
$.on(this.docPickerTab, 'click', this.onClickDocPickerTab);
this.settingsTab = $('button[data-tab="settings"]');
this.settingsTab.removeAttribute('hidden');
$.on(this.settingsTab, 'click', this.onClickSettingsTab);
app.document.sidebar.search
.on 'searching', @showSidebar
@activate()
return
showSidebar: =>
if @isSidebarShown()
window.scrollTo 0, 0
return
@contentTop = window.scrollY
@content.style.display = 'none'
@sidebar.style.display = 'block'
if selection = @findByClass app.views.ListSelect.activeClass
scrollContainer = if window.scrollY is @body.scrollTop then @body else document.documentElement
$.scrollTo selection, scrollContainer, 'center'
else
window.scrollTo 0, @findByClass(app.views.ListFold.activeClass) and @sidebarTop or 0
return
hideSidebar: =>
return unless @isSidebarShown()
@sidebarTop = window.scrollY
@sidebar.style.display = 'none'
@content.style.display = 'block'
window.scrollTo 0, @contentTop or 0
return
isSidebarShown: ->
@sidebar.style.display isnt 'none'
onClickBack: =>
history.back()
onClickForward: =>
history.forward()
onClickToggleSidebar: =>
if @isSidebarShown() then @hideSidebar() else @showSidebar()
return
onClickDocPickerTab: (event) =>
$.stopEvent(event)
@showDocPicker()
return
onClickSettingsTab: (event) =>
$.stopEvent(event)
@showSettings()
return
showDocPicker: ->
window.scrollTo 0, 0
@docPickerTab.classList.add 'active'
@settingsTab.classList.remove 'active'
@docPicker.style.display = 'block'
@content.style.display = 'none'
return
showSettings: ->
window.scrollTo 0, 0
@docPickerTab.classList.remove 'active'
@settingsTab.classList.add 'active'
@docPicker.style.display = 'none'
@content.style.display = 'block'
return
onTapSearch: =>
window.scrollTo 0, 0
onEscape: =>
@hideSidebar()
afterRoute: (route) =>
@hideSidebar()
if route is 'settings'
@showDocPicker()
else
@content.style.display = 'block'
if page.canGoBack()
@back.removeAttribute('disabled')
else
@back.setAttribute('disabled', 'disabled')
if page.canGoForward()
@forward.removeAttribute('disabled')
else
@forward.setAttribute('disabled', 'disabled')
return
.on('searching', this.showSidebar);
this.activate();
}
showSidebar() {
let selection;
if (this.isSidebarShown()) {
window.scrollTo(0, 0);
return;
}
this.contentTop = window.scrollY;
this.content.style.display = 'none';
this.sidebar.style.display = 'block';
if (selection = this.findByClass(app.views.ListSelect.activeClass)) {
const scrollContainer = window.scrollY === this.body.scrollTop ? this.body : document.documentElement;
$.scrollTo(selection, scrollContainer, 'center');
} else {
window.scrollTo(0, (this.findByClass(app.views.ListFold.activeClass) && this.sidebarTop) || 0);
}
}
hideSidebar() {
if (!this.isSidebarShown()) { return; }
this.sidebarTop = window.scrollY;
this.sidebar.style.display = 'none';
this.content.style.display = 'block';
window.scrollTo(0, this.contentTop || 0);
}
isSidebarShown() {
return this.sidebar.style.display !== 'none';
}
onClickBack() {
return history.back();
}
onClickForward() {
return history.forward();
}
onClickToggleSidebar() {
if (this.isSidebarShown()) { this.hideSidebar(); } else { this.showSidebar(); }
}
onClickDocPickerTab(event) {
$.stopEvent(event);
this.showDocPicker();
}
onClickSettingsTab(event) {
$.stopEvent(event);
this.showSettings();
}
showDocPicker() {
window.scrollTo(0, 0);
this.docPickerTab.classList.add('active');
this.settingsTab.classList.remove('active');
this.docPicker.style.display = 'block';
this.content.style.display = 'none';
}
showSettings() {
window.scrollTo(0, 0);
this.docPickerTab.classList.remove('active');
this.settingsTab.classList.add('active');
this.docPicker.style.display = 'none';
this.content.style.display = 'block';
}
onTapSearch() {
return window.scrollTo(0, 0);
}
onEscape() {
return this.hideSidebar();
}
afterRoute(route) {
this.hideSidebar();
if (route === 'settings') {
this.showDocPicker();
} else {
this.content.style.display = 'block';
}
if (page.canGoBack()) {
this.back.removeAttribute('disabled');
} else {
this.back.setAttribute('disabled', 'disabled');
}
if (page.canGoForward()) {
this.forward.removeAttribute('disabled');
} else {
this.forward.setAttribute('disabled', 'disabled');
}
}
});
Cls.initClass();

@ -1,43 +1,64 @@
class app.views.Path extends app.View
@className: '_path'
@attributes:
role: 'complementary'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.Path = class Path extends app.View {
constructor(...args) {
this.onClick = this.onClick.bind(this);
this.afterRoute = this.afterRoute.bind(this);
super(...args);
}
@events:
click: 'onClick'
static initClass() {
this.className = '_path';
this.attributes =
{role: 'complementary'};
this.events =
{click: 'onClick'};
this.routes =
{after: 'afterRoute'};
}
@routes:
after: 'afterRoute'
render(...args) {
this.html(this.tmpl('path', ...Array.from(args)));
this.show();
}
render: (args...) ->
@html @tmpl 'path', args...
@show()
return
show() {
if (!this.el.parentNode) { this.prependTo(app.el); }
}
show: ->
@prependTo app.el unless @el.parentNode
return
hide() {
if (this.el.parentNode) { $.remove(this.el); }
}
hide: ->
$.remove @el if @el.parentNode
return
onClick(event) {
let link;
if (link = $.closestLink(event.target, this.el)) { this.clicked = true; }
}
onClick: (event) =>
@clicked = true if link = $.closestLink event.target, @el
return
afterRoute(route, context) {
if (context.type) {
this.render(context.doc, context.type);
} else if (context.entry) {
if (context.entry.isIndex()) {
this.render(context.doc);
} else {
this.render(context.doc, context.entry.getType(), context.entry);
}
} else {
this.hide();
}
afterRoute: (route, context) =>
if context.type
@render context.doc, context.type
else if context.entry
if context.entry.isIndex()
@render context.doc
else
@render context.doc, context.entry.getType(), context.entry
else
@hide()
if @clicked
@clicked = null
app.document.sidebar.reset()
return
if (this.clicked) {
this.clicked = null;
app.document.sidebar.reset();
}
}
});
Cls.initClass();

@ -1,49 +1,75 @@
class app.views.Resizer extends app.View
@className: '_resizer'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let MIN = undefined;
let MAX = undefined;
const Cls = (app.views.Resizer = class Resizer extends app.View {
constructor(...args) {
this.onDragStart = this.onDragStart.bind(this);
this.onDrag = this.onDrag.bind(this);
this.onDragEnd = this.onDragEnd.bind(this);
super(...args);
}
@events:
dragstart: 'onDragStart'
dragend: 'onDragEnd'
static initClass() {
this.className = '_resizer';
this.events = {
dragstart: 'onDragStart',
dragend: 'onDragEnd'
};
MIN = 260;
MAX = 600;
}
@isSupported: ->
'ondragstart' of document.createElement('div') and !app.isMobile()
static isSupported() {
return 'ondragstart' in document.createElement('div') && !app.isMobile();
}
init: ->
@el.setAttribute('draggable', 'true')
@appendTo $('._app')
return
init() {
this.el.setAttribute('draggable', 'true');
this.appendTo($('._app'));
}
MIN = 260
MAX = 600
resize(value, save) {
value -= app.el.offsetLeft;
if (!(value > 0)) { return; }
value = Math.min(Math.max(Math.round(value), MIN), MAX);
const newSize = `${value}px`;
document.documentElement.style.setProperty('--sidebarWidth', newSize);
if (save) { app.settings.setSize(value); }
}
resize: (value, save) ->
value -= app.el.offsetLeft
return unless value > 0
value = Math.min(Math.max(Math.round(value), MIN), MAX)
newSize = "#{value}px"
document.documentElement.style.setProperty('--sidebarWidth', newSize)
app.settings.setSize(value) if save
return
onDragStart(event) {
event.dataTransfer.effectAllowed = 'link';
event.dataTransfer.setData('Text', '');
$.on(window, 'dragover', this.onDrag);
}
onDragStart: (event) =>
event.dataTransfer.effectAllowed = 'link'
event.dataTransfer.setData('Text', '')
$.on(window, 'dragover', @onDrag)
return
onDrag(event) {
const value = event.pageX;
if (!(value > 0)) { return; }
this.lastDragValue = value;
if (this.lastDrag && (this.lastDrag > (Date.now() - 50))) { return; }
this.lastDrag = Date.now();
this.resize(value, false);
}
onDrag: (event) =>
value = event.pageX
return unless value > 0
@lastDragValue = value
return if @lastDrag and @lastDrag > Date.now() - 50
@lastDrag = Date.now()
@resize(value, false)
return
onDragEnd: (event) =>
$.off(window, 'dragover', @onDrag)
value = event.pageX or (event.screenX - window.screenX)
if @lastDragValue and not (@lastDragValue - 5 < value < @lastDragValue + 5) # https://github.com/freeCodeCamp/devdocs/issues/265
value = @lastDragValue
@resize(value, true)
return
onDragEnd(event) {
$.off(window, 'dragover', this.onDrag);
let value = event.pageX || (event.screenX - window.screenX);
if (this.lastDragValue && !(this.lastDragValue - 5 < value && value < this.lastDragValue + 5)) { // https://github.com/freeCodeCamp/devdocs/issues/265
value = this.lastDragValue;
}
this.resize(value, true);
}
});
Cls.initClass();
return Cls;
})();

@ -1,83 +1,127 @@
class app.views.Settings extends app.View
SIDEBAR_HIDDEN_LAYOUT = '_sidebar-hidden'
@el: '._settings'
@elements:
sidebar: '._sidebar'
saveBtn: 'button[type="submit"]'
backBtn: 'button[data-back]'
@events:
import: 'onImport'
change: 'onChange'
submit: 'onSubmit'
click: 'onClick'
@shortcuts:
enter: 'onEnter'
init: ->
@addSubview @docPicker = new app.views.DocPicker
return
activate: ->
if super
@render()
document.body.classList.remove(SIDEBAR_HIDDEN_LAYOUT)
return
deactivate: ->
if super
@resetClass()
@docPicker.detach()
document.body.classList.add(SIDEBAR_HIDDEN_LAYOUT) if app.settings.hasLayout(SIDEBAR_HIDDEN_LAYOUT)
return
render: ->
@docPicker.appendTo @sidebar
@refreshElements()
@addClass '_in'
return
save: (options = {}) ->
unless @saving
@saving = true
if options.import
docs = app.settings.getDocs()
else
docs = @docPicker.getSelectedDocs()
app.settings.setDocs(docs)
@saveBtn.textContent = 'Saving\u2026'
disabledDocs = new app.collections.Docs(doc for doc in app.docs.all() when docs.indexOf(doc.slug) is -1)
disabledDocs.uninstall ->
app.db.migrate()
app.reload()
return
onChange: =>
@addClass('_dirty')
return
onEnter: =>
@save()
return
onSubmit: (event) =>
event.preventDefault()
@save()
return
onImport: =>
@addClass('_dirty')
@save(import: true)
return
onClick: (event) =>
return if event.which isnt 1
if event.target is @backBtn
$.stopEvent(event)
app.router.show '/'
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let SIDEBAR_HIDDEN_LAYOUT = undefined;
const Cls = (app.views.Settings = class Settings extends app.View {
constructor(...args) {
this.onChange = this.onChange.bind(this);
this.onEnter = this.onEnter.bind(this);
this.onSubmit = this.onSubmit.bind(this);
this.onImport = this.onImport.bind(this);
this.onClick = this.onClick.bind(this);
super(...args);
}
static initClass() {
SIDEBAR_HIDDEN_LAYOUT = '_sidebar-hidden';
this.el = '._settings';
this.elements = {
sidebar: '._sidebar',
saveBtn: 'button[type="submit"]',
backBtn: 'button[data-back]'
};
this.events = {
import: 'onImport',
change: 'onChange',
submit: 'onSubmit',
click: 'onClick'
};
this.shortcuts =
{enter: 'onEnter'};
}
init() {
this.addSubview(this.docPicker = new app.views.DocPicker);
}
activate() {
if (super.activate(...arguments)) {
this.render();
document.body.classList.remove(SIDEBAR_HIDDEN_LAYOUT);
}
}
deactivate() {
if (super.deactivate(...arguments)) {
this.resetClass();
this.docPicker.detach();
if (app.settings.hasLayout(SIDEBAR_HIDDEN_LAYOUT)) { document.body.classList.add(SIDEBAR_HIDDEN_LAYOUT); }
}
}
render() {
this.docPicker.appendTo(this.sidebar);
this.refreshElements();
this.addClass('_in');
}
save(options) {
if (options == null) { options = {}; }
if (!this.saving) {
let docs;
this.saving = true;
if (options.import) {
docs = app.settings.getDocs();
} else {
docs = this.docPicker.getSelectedDocs();
app.settings.setDocs(docs);
}
this.saveBtn.textContent = 'Saving\u2026';
const disabledDocs = new app.collections.Docs((() => {
const result = [];
for (var doc of Array.from(app.docs.all())) { if (docs.indexOf(doc.slug) === -1) {
result.push(doc);
}
}
return result;
})());
disabledDocs.uninstall(function() {
app.db.migrate();
return app.reload();
});
}
}
onChange() {
this.addClass('_dirty');
}
onEnter() {
this.save();
}
onSubmit(event) {
event.preventDefault();
this.save();
}
onImport() {
this.addClass('_dirty');
this.save({import: true});
}
onClick(event) {
if (event.which !== 1) { return; }
if (event.target === this.backBtn) {
$.stopEvent(event);
app.router.show('/');
}
}
});
Cls.initClass();
return Cls;
})();

@ -1,124 +1,177 @@
class app.views.ListFocus extends app.View
@activeClass: 'focus'
@events:
click: 'onClick'
@shortcuts:
up: 'onUp'
down: 'onDown'
left: 'onLeft'
enter: 'onEnter'
superEnter: 'onSuperEnter'
escape: 'blur'
constructor: (@el) ->
super
@focusOnNextFrame = $.framify(@focus, @)
focus: (el, options = {}) ->
if el and not el.classList.contains @constructor.activeClass
@blur()
el.classList.add @constructor.activeClass
$.trigger el, 'focus' unless options.silent is true
return
blur: =>
if cursor = @getCursor()
cursor.classList.remove @constructor.activeClass
$.trigger cursor, 'blur'
return
getCursor: ->
@findByClass(@constructor.activeClass) or @findByClass(app.views.ListSelect.activeClass)
findNext: (cursor) ->
if next = cursor.nextSibling
if next.tagName is 'A'
next
else if next.tagName is 'SPAN' # pagination link
$.click(next)
@findNext cursor
else if next.tagName is 'DIV' # sub-list
if cursor.className.indexOf(' open') >= 0
@findFirst(next) or @findNext(next)
else
@findNext(next)
else if next.tagName is 'H6' # title
@findNext(next)
else if cursor.parentNode isnt @el
@findNext cursor.parentNode
findFirst: (cursor) ->
return unless first = cursor.firstChild
if first.tagName is 'A'
first
else if first.tagName is 'SPAN' # pagination link
$.click(first)
@findFirst cursor
findPrev: (cursor) ->
if prev = cursor.previousSibling
if prev.tagName is 'A'
prev
else if prev.tagName is 'SPAN' # pagination link
$.click(prev)
@findPrev cursor
else if prev.tagName is 'DIV' # sub-list
if prev.previousSibling.className.indexOf('open') >= 0
@findLast(prev) or @findPrev(prev)
else
@findPrev(prev)
else if prev.tagName is 'H6' # title
@findPrev(prev)
else if cursor.parentNode isnt @el
@findPrev cursor.parentNode
findLast: (cursor) ->
return unless last = cursor.lastChild
if last.tagName is 'A'
last
else if last.tagName is 'SPAN' or last.tagName is 'H6' # pagination link or title
@findPrev last
else if last.tagName is 'DIV' # sub-list
@findLast last
onDown: =>
if cursor = @getCursor()
@focusOnNextFrame @findNext(cursor)
else
@focusOnNextFrame @findByTag('a')
return
onUp: =>
if cursor = @getCursor()
@focusOnNextFrame @findPrev(cursor)
else
@focusOnNextFrame @findLastByTag('a')
return
onLeft: =>
cursor = @getCursor()
if cursor and not cursor.classList.contains(app.views.ListFold.activeClass) and cursor.parentNode isnt @el
prev = cursor.parentNode.previousSibling
@focusOnNextFrame cursor.parentNode.previousSibling if prev and prev.classList.contains(app.views.ListFold.targetClass)
return
onEnter: =>
if cursor = @getCursor()
$.click(cursor)
return
onSuperEnter: =>
if cursor = @getCursor()
$.popup(cursor)
return
onClick: (event) =>
return if event.which isnt 1 or event.metaKey or event.ctrlKey
target = $.eventTarget(event)
if target.tagName is 'A'
@focus target, silent: true
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.ListFocus = class ListFocus extends app.View {
static initClass() {
this.activeClass = 'focus';
this.events =
{click: 'onClick'};
this.shortcuts = {
up: 'onUp',
down: 'onDown',
left: 'onLeft',
enter: 'onEnter',
superEnter: 'onSuperEnter',
escape: 'blur'
};
}
constructor(el) {
this.blur = this.blur.bind(this);
this.onDown = this.onDown.bind(this);
this.onUp = this.onUp.bind(this);
this.onLeft = this.onLeft.bind(this);
this.onEnter = this.onEnter.bind(this);
this.onSuperEnter = this.onSuperEnter.bind(this);
this.onClick = this.onClick.bind(this);
this.el = el;
super(...arguments);
this.focusOnNextFrame = $.framify(this.focus, this);
}
focus(el, options) {
if (options == null) { options = {}; }
if (el && !el.classList.contains(this.constructor.activeClass)) {
this.blur();
el.classList.add(this.constructor.activeClass);
if (options.silent !== true) { $.trigger(el, 'focus'); }
}
}
blur() {
let cursor;
if (cursor = this.getCursor()) {
cursor.classList.remove(this.constructor.activeClass);
$.trigger(cursor, 'blur');
}
}
getCursor() {
return this.findByClass(this.constructor.activeClass) || this.findByClass(app.views.ListSelect.activeClass);
}
findNext(cursor) {
let next;
if (next = cursor.nextSibling) {
if (next.tagName === 'A') {
return next;
} else if (next.tagName === 'SPAN') { // pagination link
$.click(next);
return this.findNext(cursor);
} else if (next.tagName === 'DIV') { // sub-list
if (cursor.className.indexOf(' open') >= 0) {
return this.findFirst(next) || this.findNext(next);
} else {
return this.findNext(next);
}
} else if (next.tagName === 'H6') { // title
return this.findNext(next);
}
} else if (cursor.parentNode !== this.el) {
return this.findNext(cursor.parentNode);
}
}
findFirst(cursor) {
let first;
if (!(first = cursor.firstChild)) { return; }
if (first.tagName === 'A') {
return first;
} else if (first.tagName === 'SPAN') { // pagination link
$.click(first);
return this.findFirst(cursor);
}
}
findPrev(cursor) {
let prev;
if (prev = cursor.previousSibling) {
if (prev.tagName === 'A') {
return prev;
} else if (prev.tagName === 'SPAN') { // pagination link
$.click(prev);
return this.findPrev(cursor);
} else if (prev.tagName === 'DIV') { // sub-list
if (prev.previousSibling.className.indexOf('open') >= 0) {
return this.findLast(prev) || this.findPrev(prev);
} else {
return this.findPrev(prev);
}
} else if (prev.tagName === 'H6') { // title
return this.findPrev(prev);
}
} else if (cursor.parentNode !== this.el) {
return this.findPrev(cursor.parentNode);
}
}
findLast(cursor) {
let last;
if (!(last = cursor.lastChild)) { return; }
if (last.tagName === 'A') {
return last;
} else if ((last.tagName === 'SPAN') || (last.tagName === 'H6')) { // pagination link or title
return this.findPrev(last);
} else if (last.tagName === 'DIV') { // sub-list
return this.findLast(last);
}
}
onDown() {
let cursor;
if ((cursor = this.getCursor())) {
this.focusOnNextFrame(this.findNext(cursor));
} else {
this.focusOnNextFrame(this.findByTag('a'));
}
}
onUp() {
let cursor;
if ((cursor = this.getCursor())) {
this.focusOnNextFrame(this.findPrev(cursor));
} else {
this.focusOnNextFrame(this.findLastByTag('a'));
}
}
onLeft() {
const cursor = this.getCursor();
if (cursor && !cursor.classList.contains(app.views.ListFold.activeClass) && (cursor.parentNode !== this.el)) {
const prev = cursor.parentNode.previousSibling;
if (prev && prev.classList.contains(app.views.ListFold.targetClass)) { this.focusOnNextFrame(cursor.parentNode.previousSibling); }
}
}
onEnter() {
let cursor;
if (cursor = this.getCursor()) {
$.click(cursor);
}
}
onSuperEnter() {
let cursor;
if (cursor = this.getCursor()) {
$.popup(cursor);
}
}
onClick(event) {
if ((event.which !== 1) || event.metaKey || event.ctrlKey) { return; }
const target = $.eventTarget(event);
if (target.tagName === 'A') {
this.focus(target, {silent: true});
}
}
});
Cls.initClass();

@ -1,71 +1,95 @@
class app.views.ListFold extends app.View
@targetClass: '_list-dir'
@handleClass: '_list-arrow'
@activeClass: 'open'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.ListFold = class ListFold extends app.View {
static initClass() {
this.targetClass = '_list-dir';
this.handleClass = '_list-arrow';
this.activeClass = 'open';
this.events =
{click: 'onClick'};
this.shortcuts = {
left: 'onLeft',
right: 'onRight'
};
}
@events:
click: 'onClick'
constructor(el) { this.onLeft = this.onLeft.bind(this); this.onRight = this.onRight.bind(this); this.onClick = this.onClick.bind(this); this.el = el; super(...arguments); }
@shortcuts:
left: 'onLeft'
right: 'onRight'
open(el) {
if (el && !el.classList.contains(this.constructor.activeClass)) {
el.classList.add(this.constructor.activeClass);
$.trigger(el, 'open');
}
}
constructor: (@el) -> super
close(el) {
if (el && el.classList.contains(this.constructor.activeClass)) {
el.classList.remove(this.constructor.activeClass);
$.trigger(el, 'close');
}
}
open: (el) ->
if el and not el.classList.contains @constructor.activeClass
el.classList.add @constructor.activeClass
$.trigger el, 'open'
return
toggle(el) {
if (el.classList.contains(this.constructor.activeClass)) {
this.close(el);
} else {
this.open(el);
}
}
close: (el) ->
if el and el.classList.contains @constructor.activeClass
el.classList.remove @constructor.activeClass
$.trigger el, 'close'
return
reset() {
let el;
while ((el = this.findByClass(this.constructor.activeClass))) {
this.close(el);
}
}
toggle: (el) ->
if el.classList.contains @constructor.activeClass
@close el
else
@open el
return
getCursor() {
return this.findByClass(app.views.ListFocus.activeClass) || this.findByClass(app.views.ListSelect.activeClass);
}
reset: ->
while el = @findByClass @constructor.activeClass
@close el
return
onLeft() {
const cursor = this.getCursor();
if (cursor != null ? cursor.classList.contains(this.constructor.activeClass) : undefined) {
this.close(cursor);
}
}
getCursor: ->
@findByClass(app.views.ListFocus.activeClass) or @findByClass(app.views.ListSelect.activeClass)
onRight() {
const cursor = this.getCursor();
if (cursor != null ? cursor.classList.contains(this.constructor.targetClass) : undefined) {
this.open(cursor);
}
}
onLeft: =>
cursor = @getCursor()
if cursor?.classList.contains @constructor.activeClass
@close cursor
return
onClick(event) {
if ((event.which !== 1) || event.metaKey || event.ctrlKey) { return; }
if (!event.pageY) { return; } // ignore fabricated clicks
let el = $.eventTarget(event);
if (el.parentNode.tagName.toUpperCase() === 'SVG') { el = el.parentNode; }
onRight: =>
cursor = @getCursor()
if cursor?.classList.contains @constructor.targetClass
@open cursor
return
onClick: (event) =>
return if event.which isnt 1 or event.metaKey or event.ctrlKey
return unless event.pageY # ignore fabricated clicks
el = $.eventTarget(event)
el = el.parentNode if el.parentNode.tagName.toUpperCase() is 'SVG'
if el.classList.contains @constructor.handleClass
$.stopEvent(event)
@toggle el.parentNode
else if el.classList.contains @constructor.targetClass
if el.hasAttribute('href')
if el.classList.contains(@constructor.activeClass)
@close(el) if el.classList.contains(app.views.ListSelect.activeClass)
else
@open(el)
else
@toggle(el)
return
if (el.classList.contains(this.constructor.handleClass)) {
$.stopEvent(event);
this.toggle(el.parentNode);
} else if (el.classList.contains(this.constructor.targetClass)) {
if (el.hasAttribute('href')) {
if (el.classList.contains(this.constructor.activeClass)) {
if (el.classList.contains(app.views.ListSelect.activeClass)) { this.close(el); }
} else {
this.open(el);
}
} else {
this.toggle(el);
}
}
}
});
Cls.initClass();

@ -1,43 +1,65 @@
class app.views.ListSelect extends app.View
@activeClass: 'active'
@events:
click: 'onClick'
constructor: (@el) -> super
deactivate: ->
@deselect() if super
return
select: (el) ->
@deselect()
if el
el.classList.add @constructor.activeClass
$.trigger el, 'select'
return
deselect: ->
if selection = @getSelection()
selection.classList.remove @constructor.activeClass
$.trigger selection, 'deselect'
return
selectByHref: (href) ->
unless @getSelection()?.getAttribute('href') is href
@select @find("a[href='#{href}']")
return
selectCurrent: ->
@selectByHref location.pathname + location.hash
return
getSelection: ->
@findByClass @constructor.activeClass
onClick: (event) =>
return if event.which isnt 1 or event.metaKey or event.ctrlKey
target = $.eventTarget(event)
if target.tagName is 'A'
@select target
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.ListSelect = class ListSelect extends app.View {
static initClass() {
this.activeClass = 'active';
this.events =
{click: 'onClick'};
}
constructor(el) { this.onClick = this.onClick.bind(this); this.el = el; super(...arguments); }
deactivate() {
if (super.deactivate(...arguments)) { this.deselect(); }
}
select(el) {
this.deselect();
if (el) {
el.classList.add(this.constructor.activeClass);
$.trigger(el, 'select');
}
}
deselect() {
let selection;
if (selection = this.getSelection()) {
selection.classList.remove(this.constructor.activeClass);
$.trigger(selection, 'deselect');
}
}
selectByHref(href) {
if (__guard__(this.getSelection(), x => x.getAttribute('href')) !== href) {
this.select(this.find(`a[href='${href}']`));
}
}
selectCurrent() {
this.selectByHref(location.pathname + location.hash);
}
getSelection() {
return this.findByClass(this.constructor.activeClass);
}
onClick(event) {
if ((event.which !== 1) || event.metaKey || event.ctrlKey) { return; }
const target = $.eventTarget(event);
if (target.tagName === 'A') {
this.select(target);
}
}
});
Cls.initClass();
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,90 +1,121 @@
class app.views.PaginatedList extends app.View
PER_PAGE = app.config.max_results
constructor: (@data) ->
(@constructor.events or= {}).click ?= 'onClick'
super
renderPaginated: ->
@page = 0
if @totalPages() > 1
@paginateNext()
else
@html @renderAll()
return
# render: (dataSlice) -> implemented by subclass
renderAll: ->
@render @data
renderPage: (page) ->
@render @data[((page - 1) * PER_PAGE)...(page * PER_PAGE)]
renderPageLink: (count) ->
@tmpl 'sidebarPageLink', count
renderPrevLink: (page) ->
@renderPageLink (page - 1) * PER_PAGE
renderNextLink: (page) ->
@renderPageLink @data.length - page * PER_PAGE
totalPages: ->
Math.ceil @data.length / PER_PAGE
paginate: (link) ->
$.lockScroll link.nextSibling or link.previousSibling, =>
$.batchUpdate @el, =>
if link.nextSibling then @paginatePrev link else @paginateNext link
return
return
return
paginateNext: ->
@remove @el.lastChild if @el.lastChild # remove link
@hideTopPage() if @page >= 2 # keep previous page into view
@page++
@append @renderPage(@page)
@append @renderNextLink(@page) if @page < @totalPages()
return
paginatePrev: ->
@remove @el.firstChild # remove link
@hideBottomPage()
@page--
@prepend @renderPage(@page - 1) # previous page is offset by one
@prepend @renderPrevLink(@page - 1) if @page >= 3
return
paginateTo: (object) ->
index = @data.indexOf(object)
if index >= PER_PAGE
@paginateNext() for [0...(index // PER_PAGE)]
return
hideTopPage: ->
n = if @page <= 2
PER_PAGE
else
PER_PAGE + 1 # remove link
@remove @el.firstChild for [0...n]
@prepend @renderPrevLink(@page)
return
hideBottomPage: ->
n = if @page is @totalPages()
@data.length % PER_PAGE or PER_PAGE
else
PER_PAGE + 1 # remove link
@remove @el.lastChild for [0...n]
@append @renderNextLink(@page - 1)
return
onClick: (event) =>
target = $.eventTarget(event)
if target.tagName is 'SPAN' # link
$.stopEvent(event)
@paginate target
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS104: Avoid inline assignments
* DS202: Simplify dynamic range loops
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let PER_PAGE = undefined;
const Cls = (app.views.PaginatedList = class PaginatedList extends app.View {
static initClass() {
PER_PAGE = app.config.max_results;
}
constructor(data) {
let base;
this.onClick = this.onClick.bind(this);
this.data = data;
if (((base = this.constructor.events || (this.constructor.events = {}))).click == null) { base.click = 'onClick'; }
super(...arguments);
}
renderPaginated() {
this.page = 0;
if (this.totalPages() > 1) {
this.paginateNext();
} else {
this.html(this.renderAll());
}
}
// render: (dataSlice) -> implemented by subclass
renderAll() {
return this.render(this.data);
}
renderPage(page) {
return this.render(this.data.slice(((page - 1) * PER_PAGE), (page * PER_PAGE)));
}
renderPageLink(count) {
return this.tmpl('sidebarPageLink', count);
}
renderPrevLink(page) {
return this.renderPageLink((page - 1) * PER_PAGE);
}
renderNextLink(page) {
return this.renderPageLink(this.data.length - (page * PER_PAGE));
}
totalPages() {
return Math.ceil(this.data.length / PER_PAGE);
}
paginate(link) {
$.lockScroll(link.nextSibling || link.previousSibling, () => {
$.batchUpdate(this.el, () => {
if (link.nextSibling) { this.paginatePrev(link); } else { this.paginateNext(link); }
});
});
}
paginateNext() {
if (this.el.lastChild) { this.remove(this.el.lastChild); } // remove link
if (this.page >= 2) { this.hideTopPage(); } // keep previous page into view
this.page++;
this.append(this.renderPage(this.page));
if (this.page < this.totalPages()) { this.append(this.renderNextLink(this.page)); }
}
paginatePrev() {
this.remove(this.el.firstChild); // remove link
this.hideBottomPage();
this.page--;
this.prepend(this.renderPage(this.page - 1)); // previous page is offset by one
if (this.page >= 3) { this.prepend(this.renderPrevLink(this.page - 1)); }
}
paginateTo(object) {
const index = this.data.indexOf(object);
if (index >= PER_PAGE) {
for (let i = 0, end = Math.floor(index / PER_PAGE), asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) { this.paginateNext(); }
}
}
hideTopPage() {
const n = this.page <= 2 ?
PER_PAGE
:
PER_PAGE + 1; // remove link
for (let i = 0, end = n, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) { this.remove(this.el.firstChild); }
this.prepend(this.renderPrevLink(this.page));
}
hideBottomPage() {
const n = this.page === this.totalPages() ?
(this.data.length % PER_PAGE) || PER_PAGE
:
PER_PAGE + 1; // remove link
for (let i = 0, end = n, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) { this.remove(this.el.lastChild); }
this.append(this.renderNextLink(this.page - 1));
}
onClick(event) {
const target = $.eventTarget(event);
if (target.tagName === 'SPAN') { // link
$.stopEvent(event);
this.paginate(target);
}
}
});
Cls.initClass();
return Cls;
})();

@ -1,34 +1,55 @@
#= require views/misc/notif
class app.views.News extends app.views.Notif
@className += ' _notif-news'
@defautOptions:
autoHide: 30000
init: ->
@unreadNews = @getUnreadNews()
@show() if @unreadNews.length
@markAllAsRead()
return
render: ->
@html app.templates.notifNews(@unreadNews)
return
getUnreadNews: ->
return [] unless time = @getLastReadTime()
for news in app.news
break if new Date(news[0]).getTime() <= time
news
getLastNewsTime: ->
new Date(app.news[0][0]).getTime()
getLastReadTime: ->
app.settings.get 'news'
markAllAsRead: ->
app.settings.set 'news', @getLastNewsTime()
return
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require views/misc/notif
const Cls = (app.views.News = class News extends app.views.Notif {
static initClass() {
this.className += ' _notif-news';
this.defautOptions =
{autoHide: 30000};
}
init() {
this.unreadNews = this.getUnreadNews();
if (this.unreadNews.length) { this.show(); }
this.markAllAsRead();
}
render() {
this.html(app.templates.notifNews(this.unreadNews));
}
getUnreadNews() {
let time;
if (!(time = this.getLastReadTime())) { return []; }
return (() => {
const result = [];
for (var news of Array.from(app.news)) {
if (new Date(news[0]).getTime() <= time) { break; }
result.push(news);
}
return result;
})();
}
getLastNewsTime() {
return new Date(app.news[0][0]).getTime();
}
getLastReadTime() {
return app.settings.get('news');
}
markAllAsRead() {
app.settings.set('news', this.getLastNewsTime());
}
});
Cls.initClass();

@ -1,27 +1,38 @@
class app.views.Notice extends app.View
@className: '_notice'
@attributes:
role: 'alert'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.Notice = class Notice extends app.View {
static initClass() {
this.className = '_notice';
this.attributes =
{role: 'alert'};
}
constructor: (@type, @args...) -> super
constructor(type, ...rest) { this.type = type; [...this.args] = Array.from(rest); super(...arguments); }
init: ->
@activate()
return
init() {
this.activate();
}
activate: ->
@show() if super
return
activate() {
if (super.activate(...arguments)) { this.show(); }
}
deactivate: ->
@hide() if super
return
deactivate() {
if (super.deactivate(...arguments)) { this.hide(); }
}
show: ->
@html @tmpl("#{@type}Notice", @args...)
@prependTo app.el
return
show() {
this.html(this.tmpl(`${this.type}Notice`, ...Array.from(this.args)));
this.prependTo(app.el);
}
hide: ->
$.remove @el
return
hide() {
$.remove(this.el);
}
});
Cls.initClass();

@ -1,59 +1,78 @@
class app.views.Notif extends app.View
@className: '_notif'
@activeClass: '_in'
@attributes:
role: 'alert'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.Notif = class Notif extends app.View {
static initClass() {
this.className = '_notif';
this.activeClass = '_in';
this.attributes =
{role: 'alert'};
this.defautOptions =
{autoHide: 15000};
this.events =
{click: 'onClick'};
}
@defautOptions:
autoHide: 15000
constructor(type, options) {
this.onClick = this.onClick.bind(this);
this.type = type;
if (options == null) { options = {}; }
this.options = options;
this.options = $.extend({}, this.constructor.defautOptions, this.options);
super(...arguments);
}
@events:
click: 'onClick'
init() {
this.show();
}
constructor: (@type, @options = {}) ->
@options = $.extend {}, @constructor.defautOptions, @options
super
show() {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = this.delay(this.hide, this.options.autoHide);
} else {
this.render();
this.position();
this.activate();
this.appendTo(document.body);
this.el.offsetWidth; // force reflow
this.addClass(this.constructor.activeClass);
if (this.options.autoHide) { this.timeout = this.delay(this.hide, this.options.autoHide); }
}
}
init: ->
@show()
return
hide() {
clearTimeout(this.timeout);
this.timeout = null;
this.detach();
}
show: ->
if @timeout
clearTimeout @timeout
@timeout = @delay @hide, @options.autoHide
else
@render()
@position()
@activate()
@appendTo document.body
@el.offsetWidth # force reflow
@addClass @constructor.activeClass
@timeout = @delay @hide, @options.autoHide if @options.autoHide
return
render() {
this.html(this.tmpl(`notif${this.type}`));
}
hide: ->
clearTimeout @timeout
@timeout = null
@detach()
return
position() {
const notifications = $$(`.${app.views.Notif.className}`);
if (notifications.length) {
const lastNotif = notifications[notifications.length - 1];
this.el.style.top = lastNotif.offsetTop + lastNotif.offsetHeight + 16 + 'px';
}
}
render: ->
@html @tmpl("notif#{@type}")
return
position: ->
notifications = $$ ".#{app.views.Notif.className}"
if notifications.length
lastNotif = notifications[notifications.length - 1]
@el.style.top = lastNotif.offsetTop + lastNotif.offsetHeight + 16 + 'px'
return
onClick: (event) =>
return if event.which isnt 1
target = $.eventTarget(event)
return if target.hasAttribute('data-behavior')
if target.tagName isnt 'A' or target.classList.contains('_notif-close')
$.stopEvent(event)
@hide()
return
onClick(event) {
if (event.which !== 1) { return; }
const target = $.eventTarget(event);
if (target.hasAttribute('data-behavior')) { return; }
if ((target.tagName !== 'A') || target.classList.contains('_notif-close')) {
$.stopEvent(event);
this.hide();
}
}
});
Cls.initClass();

@ -1,11 +1,20 @@
#= require views/misc/notif
/*
* decaffeinate suggestions:
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require views/misc/notif
class app.views.Tip extends app.views.Notif
@className: '_notif _notif-tip'
const Cls = (app.views.Tip = class Tip extends app.views.Notif {
static initClass() {
this.className = '_notif _notif-tip';
this.defautOptions =
{autoHide: false};
}
@defautOptions:
autoHide: false
render: ->
@html @tmpl("tip#{@type}")
return
render() {
this.html(this.tmpl(`tip${this.type}`));
}
});
Cls.initClass();

@ -1,34 +1,56 @@
#= require views/misc/notif
class app.views.Updates extends app.views.Notif
@className += ' _notif-news'
@defautOptions:
autoHide: 30000
init: ->
@lastUpdateTime = @getLastUpdateTime()
@updatedDocs = @getUpdatedDocs()
@updatedDisabledDocs = @getUpdatedDisabledDocs()
@show() if @updatedDocs.length > 0 or @updatedDisabledDocs.length > 0
@markAllAsRead()
return
render: ->
@html app.templates.notifUpdates(@updatedDocs, @updatedDisabledDocs)
return
getUpdatedDocs: ->
return [] unless @lastUpdateTime
doc for doc in app.docs.all() when doc.mtime > @lastUpdateTime
getUpdatedDisabledDocs: ->
return [] unless @lastUpdateTime
doc for doc in app.disabledDocs.all() when doc.mtime > @lastUpdateTime and app.docs.findBy('slug_without_version', doc.slug_without_version)
getLastUpdateTime: ->
app.settings.get 'version'
markAllAsRead: ->
app.settings.set 'version', if app.config.env is 'production' then app.config.version else Math.floor(Date.now() / 1000)
return
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require views/misc/notif
const Cls = (app.views.Updates = class Updates extends app.views.Notif {
static initClass() {
this.className += ' _notif-news';
this.defautOptions =
{autoHide: 30000};
}
init() {
this.lastUpdateTime = this.getLastUpdateTime();
this.updatedDocs = this.getUpdatedDocs();
this.updatedDisabledDocs = this.getUpdatedDisabledDocs();
if ((this.updatedDocs.length > 0) || (this.updatedDisabledDocs.length > 0)) { this.show(); }
this.markAllAsRead();
}
render() {
this.html(app.templates.notifUpdates(this.updatedDocs, this.updatedDisabledDocs));
}
getUpdatedDocs() {
if (!this.lastUpdateTime) { return []; }
return Array.from(app.docs.all()).filter((doc) => doc.mtime > this.lastUpdateTime);
}
getUpdatedDisabledDocs() {
if (!this.lastUpdateTime) { return []; }
return (() => {
const result = [];
for (var doc of Array.from(app.disabledDocs.all())) { if ((doc.mtime > this.lastUpdateTime) && app.docs.findBy('slug_without_version', doc.slug_without_version)) {
result.push(doc);
}
}
return result;
})();
}
getLastUpdateTime() {
return app.settings.get('version');
}
markAllAsRead() {
app.settings.set('version', app.config.env === 'production' ? app.config.version : Math.floor(Date.now() / 1000));
}
});
Cls.initClass();

@ -1,43 +1,61 @@
class app.views.BasePage extends app.View
constructor: (@el, @entry) -> super
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
app.views.BasePage = class BasePage extends app.View {
constructor(el, entry) { this.paintCode = this.paintCode.bind(this); this.el = el; this.entry = entry; super(...arguments); }
deactivate: ->
if super
@highlightNodes = []
deactivate() {
if (super.deactivate(...arguments)) {
return this.highlightNodes = [];
}
}
render: (content, fromCache = false) ->
@highlightNodes = []
@previousTiming = null
@addClass "_#{@entry.doc.type}" unless @constructor.className
@html content
@highlightCode() unless fromCache
@activate()
@delay @afterRender if @afterRender
if @highlightNodes.length > 0
$.requestAnimationFrame => $.requestAnimationFrame(@paintCode)
return
render(content, fromCache) {
if (fromCache == null) { fromCache = false; }
this.highlightNodes = [];
this.previousTiming = null;
if (!this.constructor.className) { this.addClass(`_${this.entry.doc.type}`); }
this.html(content);
if (!fromCache) { this.highlightCode(); }
this.activate();
if (this.afterRender) { this.delay(this.afterRender); }
if (this.highlightNodes.length > 0) {
$.requestAnimationFrame(() => $.requestAnimationFrame(this.paintCode));
}
}
highlightCode: ->
for el in @findAll('pre[data-language]')
language = el.getAttribute('data-language')
el.classList.add("language-#{language}")
@highlightNodes.push(el)
return
highlightCode() {
for (var el of Array.from(this.findAll('pre[data-language]'))) {
var language = el.getAttribute('data-language');
el.classList.add(`language-${language}`);
this.highlightNodes.push(el);
}
}
paintCode: (timing) =>
if @previousTiming
if Math.round(1000 / (timing - @previousTiming)) > 50 # fps
@nodesPerFrame = Math.round(Math.min(@nodesPerFrame * 1.25, 50))
else
@nodesPerFrame = Math.round(Math.max(@nodesPerFrame * .8, 10))
else
@nodesPerFrame = 10
paintCode(timing) {
if (this.previousTiming) {
if (Math.round(1000 / (timing - this.previousTiming)) > 50) { // fps
this.nodesPerFrame = Math.round(Math.min(this.nodesPerFrame * 1.25, 50));
} else {
this.nodesPerFrame = Math.round(Math.max(this.nodesPerFrame * .8, 10));
}
} else {
this.nodesPerFrame = 10;
}
for el in @highlightNodes.splice(0, @nodesPerFrame)
$.remove(clipEl) if clipEl = el.lastElementChild
Prism.highlightElement(el)
$.append(el, clipEl) if clipEl
for (var el of Array.from(this.highlightNodes.splice(0, this.nodesPerFrame))) {
var clipEl;
if (clipEl = el.lastElementChild) { $.remove(clipEl); }
Prism.highlightElement(el);
if (clipEl) { $.append(el, clipEl); }
}
$.requestAnimationFrame(@paintCode) if @highlightNodes.length > 0
@previousTiming = timing
return
if (this.highlightNodes.length > 0) { $.requestAnimationFrame(this.paintCode); }
this.previousTiming = timing;
}
};

@ -1,16 +1,28 @@
class app.views.HiddenPage extends app.View
@events:
click: 'onClick'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.HiddenPage = class HiddenPage extends app.View {
static initClass() {
this.events =
{click: 'onClick'};
}
constructor: (@el, @entry) -> super
constructor(el, entry) { this.onClick = this.onClick.bind(this); this.el = el; this.entry = entry; super(...arguments); }
init: ->
@addSubview @notice = new app.views.Notice 'disabledDoc'
@activate()
return
init() {
this.addSubview(this.notice = new app.views.Notice('disabledDoc'));
this.activate();
}
onClick: (event) =>
if link = $.closestLink(event.target, @el)
$.stopEvent(event)
$.popup(link)
return
onClick(event) {
let link;
if (link = $.closestLink(event.target, this.el)) {
$.stopEvent(event);
$.popup(link);
}
}
});
Cls.initClass();

@ -1,57 +1,81 @@
#= require views/pages/base
class app.views.JqueryPage extends app.views.BasePage
@demoClassName: '_jquery-demo'
afterRender: ->
# Prevent jQuery Mobile's demo iframes from scrolling the page
for iframe in @findAllByTag 'iframe'
iframe.style.display = 'none'
$.on iframe, 'load', @onIframeLoaded
@runExamples()
onIframeLoaded: (event) =>
event.target.style.display = ''
$.off event.target, 'load', @onIframeLoaded
return
runExamples: ->
for el in @findAllByClass 'entry-example'
try @runExample el catch
return
runExample: (el) ->
source = el.getElementsByClassName('syntaxhighlighter')[0]
return unless source and source.innerHTML.indexOf('!doctype') isnt -1
unless iframe = el.getElementsByClassName(@constructor.demoClassName)[0]
iframe = document.createElement 'iframe'
iframe.className = @constructor.demoClassName
iframe.width = '100%'
iframe.height = 200
el.appendChild(iframe)
doc = iframe.contentDocument
doc.write @fixIframeSource(source.textContent)
doc.close()
return
fixIframeSource: (source) ->
source = source.replace '"/resources/', '"https://api.jquery.com/resources/' # attr(), keydown()
source = source.replace '</head>', """
<style>
html, body { border: 0; margin: 0; padding: 0; }
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
</style>
<script>
$.ajaxPrefilter(function(opt, opt2, xhr) {
if (opt.url.indexOf('http') !== 0) {
xhr.abort();
document.body.innerHTML = "<p><strong>This demo cannot run inside DevDocs.</strong></p>";
}
});
</script>
</head>
"""
source.replace /<script>/gi, '<script nonce="devdocs">'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require views/pages/base
const Cls = (app.views.JqueryPage = class JqueryPage extends app.views.BasePage {
constructor(...args) {
this.onIframeLoaded = this.onIframeLoaded.bind(this);
super(...args);
}
static initClass() {
this.demoClassName = '_jquery-demo';
}
afterRender() {
// Prevent jQuery Mobile's demo iframes from scrolling the page
for (var iframe of Array.from(this.findAllByTag('iframe'))) {
iframe.style.display = 'none';
$.on(iframe, 'load', this.onIframeLoaded);
}
return this.runExamples();
}
onIframeLoaded(event) {
event.target.style.display = '';
$.off(event.target, 'load', this.onIframeLoaded);
}
runExamples() {
for (var el of Array.from(this.findAllByClass('entry-example'))) {
try { this.runExample(el); } catch (error) {}
}
}
runExample(el) {
let iframe;
const source = el.getElementsByClassName('syntaxhighlighter')[0];
if (!source || (source.innerHTML.indexOf('!doctype') === -1)) { return; }
if (!(iframe = el.getElementsByClassName(this.constructor.demoClassName)[0])) {
iframe = document.createElement('iframe');
iframe.className = this.constructor.demoClassName;
iframe.width = '100%';
iframe.height = 200;
el.appendChild(iframe);
}
const doc = iframe.contentDocument;
doc.write(this.fixIframeSource(source.textContent));
doc.close();
}
fixIframeSource(source) {
source = source.replace('"/resources/', '"https://api.jquery.com/resources/'); // attr(), keydown()
source = source.replace('</head>', `\
<style>
html, body { border: 0; margin: 0; padding: 0; }
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
</style>
<script>
$.ajaxPrefilter(function(opt, opt2, xhr) {
if (opt.url.indexOf('http') !== 0) {
xhr.abort();
document.body.innerHTML = "<p><strong>This demo cannot run inside DevDocs.</strong></p>";
}
});
</script>
</head>\
`
);
return source.replace(/<script>/gi, '<script nonce="devdocs">');
}
});
Cls.initClass();

@ -1,15 +1,26 @@
#= require views/pages/base
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require views/pages/base
class app.views.RdocPage extends app.views.BasePage
@events:
click: 'onClick'
const Cls = (app.views.RdocPage = class RdocPage extends app.views.BasePage {
static initClass() {
this.events =
{click: 'onClick'};
}
onClick: (event) ->
return unless event.target.classList.contains 'method-click-advice'
$.stopEvent(event)
onClick(event) {
if (!event.target.classList.contains('method-click-advice')) { return; }
$.stopEvent(event);
source = $ '.method-source-code', event.target.closest('.method-detail')
isShown = source.style.display is 'block'
const source = $('.method-source-code', event.target.closest('.method-detail'));
const isShown = source.style.display === 'block';
source.style.display = if isShown then 'none' else 'block'
event.target.textContent = if isShown then 'Show source' else 'Hide source'
source.style.display = isShown ? 'none' : 'block';
return event.target.textContent = isShown ? 'Show source' : 'Hide source';
}
});
Cls.initClass();

@ -1,17 +1,34 @@
#= require views/pages/base
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require views/pages/base
class app.views.SqlitePage extends app.views.BasePage
@events:
click: 'onClick'
const Cls = (app.views.SqlitePage = class SqlitePage extends app.views.BasePage {
constructor(...args) {
this.onClick = this.onClick.bind(this);
super(...args);
}
onClick: (event) =>
return unless id = event.target.getAttribute('data-toggle')
return unless el = @find("##{id}")
$.stopEvent(event)
if el.style.display == 'none'
el.style.display = 'block'
event.target.textContent = 'hide'
else
el.style.display = 'none'
event.target.textContent = 'show'
return
static initClass() {
this.events =
{click: 'onClick'};
}
onClick(event) {
let el, id;
if (!(id = event.target.getAttribute('data-toggle'))) { return; }
if (!(el = this.find(`#${id}`))) { return; }
$.stopEvent(event);
if (el.style.display === 'none') {
el.style.display = 'block';
event.target.textContent = 'hide';
} else {
el.style.display = 'none';
event.target.textContent = 'show';
}
}
});
Cls.initClass();

@ -1,14 +1,23 @@
#= require views/pages/base
/*
* decaffeinate suggestions:
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require views/pages/base
class app.views.SupportTablesPage extends app.views.BasePage
@events:
click: 'onClick'
const Cls = (app.views.SupportTablesPage = class SupportTablesPage extends app.views.BasePage {
static initClass() {
this.events =
{click: 'onClick'};
}
onClick: (event) ->
return unless event.target.classList.contains 'show-all'
$.stopEvent(event)
onClick(event) {
if (!event.target.classList.contains('show-all')) { return; }
$.stopEvent(event);
el = event.target
el = el.parentNode until el.tagName is 'TABLE'
el.classList.add 'show-all'
return
let el = event.target;
while (el.tagName !== 'TABLE') { el = el.parentNode; }
el.classList.add('show-all');
}
});
Cls.initClass();

@ -1,168 +1,225 @@
class app.views.Search extends app.View
SEARCH_PARAM = app.config.search_param
@el: '._search'
@activeClass: '_search-active'
@elements:
input: '._search-input'
resetLink: '._search-clear'
@events:
input: 'onInput'
click: 'onClick'
submit: 'onSubmit'
@shortcuts:
typing: 'focus'
altG: 'google'
altS: 'stackoverflow'
altD: 'duckduckgo'
@routes:
after: 'afterRoute'
init: ->
@addSubview @scope = new app.views.SearchScope @el
@searcher = new app.Searcher
@searcher
.on 'results', @onResults
.on 'end', @onEnd
@scope
.on 'change', @onScopeChange
app.on 'ready', @onReady
$.on window, 'hashchange', @searchUrl
$.on window, 'focus', @onWindowFocus
return
focus: =>
return if document.activeElement is @input
return if app.settings.get('noAutofocus')
@input.focus()
return
autoFocus: =>
return if app.isMobile() or $.isAndroid() or $.isIOS()
return if document.activeElement?.tagName is 'INPUT'
return if app.settings.get('noAutofocus')
@input.focus()
return
onWindowFocus: (event) =>
@autoFocus() if event.target is window
getScopeDoc: ->
@scope.getScope() if @scope.isActive()
reset: (force) ->
@scope.reset() if force or not @input.value
@el.reset()
@onInput()
@autoFocus()
return
onReady: =>
@value = ''
@delay @onInput
return
onInput: =>
return if not @value? or # ignore events pre-"ready"
@value is @input.value
@value = @input.value
if @value.length
@search()
else
@clear()
return
search: (url = false) ->
@addClass @constructor.activeClass
@trigger 'searching'
@hasResults = null
@flags = urlSearch: url, initialResults: true
@searcher.find @scope.getScope().entries.all(), 'text', @value
return
searchUrl: =>
if location.pathname is '/'
@scope.searchUrl()
else if not app.router.isIndex()
return
return unless value = @extractHashValue()
@input.value = @value = value
@input.setSelectionRange(value.length, value.length)
@search true
true
clear: ->
@removeClass @constructor.activeClass
@trigger 'clear'
return
externalSearch: (url) ->
if value = @value
value = "#{@scope.name()} #{value}" if @scope.name()
$.popup "#{url}#{encodeURIComponent value}"
@reset()
return
google: =>
@externalSearch "https://www.google.com/search?q="
return
stackoverflow: =>
@externalSearch "https://stackoverflow.com/search?q="
return
duckduckgo: =>
@externalSearch "https://duckduckgo.com/?t=devdocs&q="
return
onResults: (results) =>
@hasResults = true if results.length
@trigger 'results', results, @flags
@flags.initialResults = false
return
onEnd: =>
@trigger 'noresults' unless @hasResults
return
onClick: (event) =>
if event.target is @resetLink
$.stopEvent(event)
@reset()
return
onSubmit: (event) ->
$.stopEvent(event)
return
onScopeChange: =>
@value = ''
@onInput()
return
afterRoute: (name, context) =>
return if app.shortcuts.eventInProgress?.name is 'escape'
@reset(true) if not context.init and app.router.isIndex()
@delay @searchUrl if context.hash
$.requestAnimationFrame @autoFocus
return
extractHashValue: ->
if (value = @getHashValue())?
app.router.replaceHash()
value
HASH_RGX = new RegExp "^##{SEARCH_PARAM}=(.*)"
getHashValue: ->
try HASH_RGX.exec($.urlDecode location.hash)?[1] catch
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let SEARCH_PARAM = undefined;
let HASH_RGX = undefined;
const Cls = (app.views.Search = class Search extends app.View {
constructor(...args) {
this.focus = this.focus.bind(this);
this.autoFocus = this.autoFocus.bind(this);
this.onWindowFocus = this.onWindowFocus.bind(this);
this.onReady = this.onReady.bind(this);
this.onInput = this.onInput.bind(this);
this.searchUrl = this.searchUrl.bind(this);
this.google = this.google.bind(this);
this.stackoverflow = this.stackoverflow.bind(this);
this.duckduckgo = this.duckduckgo.bind(this);
this.onResults = this.onResults.bind(this);
this.onEnd = this.onEnd.bind(this);
this.onClick = this.onClick.bind(this);
this.onScopeChange = this.onScopeChange.bind(this);
this.afterRoute = this.afterRoute.bind(this);
super(...args);
}
static initClass() {
SEARCH_PARAM = app.config.search_param;
this.el = '._search';
this.activeClass = '_search-active';
this.elements = {
input: '._search-input',
resetLink: '._search-clear'
};
this.events = {
input: 'onInput',
click: 'onClick',
submit: 'onSubmit'
};
this.shortcuts = {
typing: 'focus',
altG: 'google',
altS: 'stackoverflow',
altD: 'duckduckgo'
};
this.routes =
{after: 'afterRoute'};
HASH_RGX = new RegExp(`^#${SEARCH_PARAM}=(.*)`);
}
init() {
this.addSubview(this.scope = new app.views.SearchScope(this.el));
this.searcher = new app.Searcher;
this.searcher
.on('results', this.onResults)
.on('end', this.onEnd);
this.scope
.on('change', this.onScopeChange);
app.on('ready', this.onReady);
$.on(window, 'hashchange', this.searchUrl);
$.on(window, 'focus', this.onWindowFocus);
}
focus() {
if (document.activeElement === this.input) { return; }
if (app.settings.get('noAutofocus')) { return; }
this.input.focus();
}
autoFocus() {
if (app.isMobile() || $.isAndroid() || $.isIOS()) { return; }
if ((document.activeElement != null ? document.activeElement.tagName : undefined) === 'INPUT') { return; }
if (app.settings.get('noAutofocus')) { return; }
this.input.focus();
}
onWindowFocus(event) {
if (event.target === window) { return this.autoFocus(); }
}
getScopeDoc() {
if (this.scope.isActive()) { return this.scope.getScope(); }
}
reset(force) {
if (force || !this.input.value) { this.scope.reset(); }
this.el.reset();
this.onInput();
this.autoFocus();
}
onReady() {
this.value = '';
this.delay(this.onInput);
}
onInput() {
if ((this.value == null) || // ignore events pre-"ready"
(this.value === this.input.value)) { return; }
this.value = this.input.value;
if (this.value.length) {
this.search();
} else {
this.clear();
}
}
search(url) {
if (url == null) { url = false; }
this.addClass(this.constructor.activeClass);
this.trigger('searching');
this.hasResults = null;
this.flags = {urlSearch: url, initialResults: true};
this.searcher.find(this.scope.getScope().entries.all(), 'text', this.value);
}
searchUrl() {
let value;
if (location.pathname === '/') {
this.scope.searchUrl();
} else if (!app.router.isIndex()) {
return;
}
if (!(value = this.extractHashValue())) { return; }
this.input.value = (this.value = value);
this.input.setSelectionRange(value.length, value.length);
this.search(true);
return true;
}
clear() {
this.removeClass(this.constructor.activeClass);
this.trigger('clear');
}
externalSearch(url) {
let value;
if (value = this.value) {
if (this.scope.name()) { value = `${this.scope.name()} ${value}`; }
$.popup(`${url}${encodeURIComponent(value)}`);
this.reset();
}
}
google() {
this.externalSearch("https://www.google.com/search?q=");
}
stackoverflow() {
this.externalSearch("https://stackoverflow.com/search?q=");
}
duckduckgo() {
this.externalSearch("https://duckduckgo.com/?t=devdocs&q=");
}
onResults(results) {
if (results.length) { this.hasResults = true; }
this.trigger('results', results, this.flags);
this.flags.initialResults = false;
}
onEnd() {
if (!this.hasResults) { this.trigger('noresults'); }
}
onClick(event) {
if (event.target === this.resetLink) {
$.stopEvent(event);
this.reset();
}
}
onSubmit(event) {
$.stopEvent(event);
}
onScopeChange() {
this.value = '';
this.onInput();
}
afterRoute(name, context) {
if ((app.shortcuts.eventInProgress != null ? app.shortcuts.eventInProgress.name : undefined) === 'escape') { return; }
if (!context.init && app.router.isIndex()) { this.reset(true); }
if (context.hash) { this.delay(this.searchUrl); }
$.requestAnimationFrame(this.autoFocus);
}
extractHashValue() {
let value;
if ((value = this.getHashValue()) != null) {
app.router.replaceHash();
return value;
}
}
getHashValue() {
try { return __guard__(HASH_RGX.exec($.urlDecode(location.hash)), x => x[1]); } catch (error) {}
}
});
Cls.initClass();
return Cls;
})();
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,135 +1,180 @@
class app.views.SearchScope extends app.View
SEARCH_PARAM = app.config.search_param
@elements:
input: '._search-input'
tag: '._search-tag'
@events:
click: 'onClick'
keydown: 'onKeydown'
textInput: 'onTextInput'
@routes:
after: 'afterRoute'
constructor: (@el) -> super
init: ->
@placeholder = @input.getAttribute 'placeholder'
@searcher = new app.SynchronousSearcher
fuzzy_min_length: 2
max_results: 1
@searcher.on 'results', @onResults
return
getScope: ->
@doc or app
isActive: ->
!!@doc
name: ->
@doc?.name
search: (value, searchDisabled = false) ->
return if @doc
@searcher.find app.docs.all(), 'text', value
@searcher.find app.disabledDocs.all(), 'text', value if not @doc and searchDisabled
return
searchUrl: ->
if value = @extractHashValue()
@search value, true
return
onResults: (results) =>
return unless doc = results[0]
if app.docs.contains(doc)
@selectDoc(doc)
else
@redirectToDoc(doc)
return
selectDoc: (doc) ->
previousDoc = @doc
return if doc is previousDoc
@doc = doc
@tag.textContent = doc.fullName
@tag.style.display = 'block'
@input.removeAttribute 'placeholder'
@input.value = @input.value[@input.selectionStart..]
@input.style.paddingLeft = @tag.offsetWidth + 10 + 'px'
$.trigger @input, 'input'
@trigger 'change', @doc, previousDoc
return
redirectToDoc: (doc) ->
hash = location.hash
app.router.replaceHash('')
location.assign doc.fullPath() + hash
return
reset: =>
return unless @doc
previousDoc = @doc
@doc = null
@tag.textContent = ''
@tag.style.display = 'none'
@input.setAttribute 'placeholder', @placeholder
@input.style.paddingLeft = ''
@trigger 'change', null, previousDoc
return
doScopeSearch: (event) =>
@search @input.value[0...@input.selectionStart]
$.stopEvent(event) if @doc
return
onClick: (event) =>
if event.target is @tag
@reset()
$.stopEvent(event)
return
onKeydown: (event) =>
if event.which is 8 # backspace
if @doc and @input.selectionEnd is 0
@reset()
$.stopEvent(event)
else if not @doc and @input.value and not $.isChromeForAndroid()
return if event.ctrlKey or event.metaKey or event.altKey or event.shiftKey
if event.which is 9 or # tab
(event.which is 32 and app.isMobile()) # space
@doScopeSearch(event)
return
onTextInput: (event) =>
return unless $.isChromeForAndroid()
if not @doc and @input.value and event.data == ' '
@doScopeSearch(event)
return
extractHashValue: ->
if value = @getHashValue()
newHash = $.urlDecode(location.hash).replace "##{SEARCH_PARAM}=#{value} ", "##{SEARCH_PARAM}="
app.router.replaceHash(newHash)
value
HASH_RGX = new RegExp "^##{SEARCH_PARAM}=(.+?) ."
getHashValue: ->
try HASH_RGX.exec($.urlDecode location.hash)?[1] catch
afterRoute: (name, context) =>
if !app.isSingleDoc() and context.init and context.doc
@selectDoc(context.doc)
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
(function() {
let SEARCH_PARAM = undefined;
let HASH_RGX = undefined;
const Cls = (app.views.SearchScope = class SearchScope extends app.View {
static initClass() {
SEARCH_PARAM = app.config.search_param;
this.elements = {
input: '._search-input',
tag: '._search-tag'
};
this.events = {
click: 'onClick',
keydown: 'onKeydown',
textInput: 'onTextInput'
};
this.routes =
{after: 'afterRoute'};
HASH_RGX = new RegExp(`^#${SEARCH_PARAM}=(.+?) .`);
}
constructor(el) { this.onResults = this.onResults.bind(this); this.reset = this.reset.bind(this); this.doScopeSearch = this.doScopeSearch.bind(this); this.onClick = this.onClick.bind(this); this.onKeydown = this.onKeydown.bind(this); this.onTextInput = this.onTextInput.bind(this); this.afterRoute = this.afterRoute.bind(this); this.el = el; super(...arguments); }
init() {
this.placeholder = this.input.getAttribute('placeholder');
this.searcher = new app.SynchronousSearcher({
fuzzy_min_length: 2,
max_results: 1
});
this.searcher.on('results', this.onResults);
}
getScope() {
return this.doc || app;
}
isActive() {
return !!this.doc;
}
name() {
return (this.doc != null ? this.doc.name : undefined);
}
search(value, searchDisabled) {
if (searchDisabled == null) { searchDisabled = false; }
if (this.doc) { return; }
this.searcher.find(app.docs.all(), 'text', value);
if (!this.doc && searchDisabled) { this.searcher.find(app.disabledDocs.all(), 'text', value); }
}
searchUrl() {
let value;
if (value = this.extractHashValue()) {
this.search(value, true);
}
}
onResults(results) {
let doc;
if (!(doc = results[0])) { return; }
if (app.docs.contains(doc)) {
this.selectDoc(doc);
} else {
this.redirectToDoc(doc);
}
}
selectDoc(doc) {
const previousDoc = this.doc;
if (doc === previousDoc) { return; }
this.doc = doc;
this.tag.textContent = doc.fullName;
this.tag.style.display = 'block';
this.input.removeAttribute('placeholder');
this.input.value = this.input.value.slice(this.input.selectionStart);
this.input.style.paddingLeft = this.tag.offsetWidth + 10 + 'px';
$.trigger(this.input, 'input');
this.trigger('change', this.doc, previousDoc);
}
redirectToDoc(doc) {
const {
hash
} = location;
app.router.replaceHash('');
location.assign(doc.fullPath() + hash);
}
reset() {
if (!this.doc) { return; }
const previousDoc = this.doc;
this.doc = null;
this.tag.textContent = '';
this.tag.style.display = 'none';
this.input.setAttribute('placeholder', this.placeholder);
this.input.style.paddingLeft = '';
this.trigger('change', null, previousDoc);
}
doScopeSearch(event) {
this.search(this.input.value.slice(0, this.input.selectionStart));
if (this.doc) { $.stopEvent(event); }
}
onClick(event) {
if (event.target === this.tag) {
this.reset();
$.stopEvent(event);
}
}
onKeydown(event) {
if (event.which === 8) { // backspace
if (this.doc && (this.input.selectionEnd === 0)) {
this.reset();
$.stopEvent(event);
}
} else if (!this.doc && this.input.value && !$.isChromeForAndroid()) {
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) { return; }
if ((event.which === 9) || // tab
((event.which === 32) && app.isMobile())) { // space
this.doScopeSearch(event);
}
}
}
onTextInput(event) {
if (!$.isChromeForAndroid()) { return; }
if (!this.doc && this.input.value && (event.data === ' ')) {
this.doScopeSearch(event);
}
}
extractHashValue() {
let value;
if (value = this.getHashValue()) {
const newHash = $.urlDecode(location.hash).replace(`#${SEARCH_PARAM}=${value} `, `#${SEARCH_PARAM}=`);
app.router.replaceHash(newHash);
return value;
}
}
getHashValue() {
try { return __guard__(HASH_RGX.exec($.urlDecode(location.hash)), x => x[1]); } catch (error) {}
}
afterRoute(name, context) {
if (!app.isSingleDoc() && context.init && context.doc) {
this.selectDoc(context.doc);
}
}
});
Cls.initClass();
return Cls;
})();
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,187 +1,234 @@
class app.views.DocList extends app.View
@className: '_list'
@attributes:
role: 'navigation'
@events:
open: 'onOpen'
close: 'onClose'
click: 'onClick'
@routes:
after: 'afterRoute'
@elements:
disabledTitle: '._list-title'
disabledList: '._disabled-list'
init: ->
@lists = {}
@addSubview @listFocus = new app.views.ListFocus @el
@addSubview @listFold = new app.views.ListFold @el
@addSubview @listSelect = new app.views.ListSelect @el
app.on 'ready', @render
return
activate: ->
if super
list.activate() for slug, list of @lists
@listSelect.selectCurrent()
return
deactivate: ->
if super
list.deactivate() for slug, list of @lists
return
render: =>
html = ''
for doc in app.docs.all()
html += @tmpl('sidebarDoc', doc, fullName: app.docs.countAllBy('name', doc.name) > 1)
@html html
@renderDisabled() unless app.isSingleDoc() or app.disabledDocs.size() is 0
return
renderDisabled: ->
@append @tmpl('sidebarDisabled', count: app.disabledDocs.size())
@refreshElements()
@renderDisabledList()
return
renderDisabledList: ->
if app.settings.get('hideDisabled')
@removeDisabledList()
else
@appendDisabledList()
return
appendDisabledList: ->
html = ''
docs = [].concat(app.disabledDocs.all()...)
while doc = docs.shift()
if doc.version?
versions = ''
loop
versions += @tmpl('sidebarDoc', doc, disabled: true)
break if docs[0]?.name isnt doc.name
doc = docs.shift()
html += @tmpl('sidebarDisabledVersionedDoc', doc, versions)
else
html += @tmpl('sidebarDoc', doc, disabled: true)
@append @tmpl('sidebarDisabledList', html)
@disabledTitle.classList.add('open-title')
@refreshElements()
return
removeDisabledList: ->
$.remove @disabledList if @disabledList
@disabledTitle.classList.remove('open-title')
@refreshElements()
return
reset: (options = {}) ->
@listSelect.deselect()
@listFocus?.blur()
@listFold.reset()
@revealCurrent() if options.revealCurrent || app.isSingleDoc()
return
onOpen: (event) =>
$.stopEvent(event)
doc = app.docs.findBy 'slug', event.target.getAttribute('data-slug')
if doc and not @lists[doc.slug]
@lists[doc.slug] = if doc.types.isEmpty()
new app.views.EntryList doc.entries.all()
else
new app.views.TypeList doc
$.after event.target, @lists[doc.slug].el
return
onClose: (event) =>
$.stopEvent(event)
doc = app.docs.findBy 'slug', event.target.getAttribute('data-slug')
if doc and @lists[doc.slug]
@lists[doc.slug].detach()
delete @lists[doc.slug]
return
select: (model) ->
@listSelect.selectByHref model?.fullPath()
return
reveal: (model) ->
@openDoc model.doc
@openType model.getType() if model.type
@focus model
@paginateTo model
@scrollTo model
return
focus: (model) ->
@listFocus?.focus @find("a[href='#{model.fullPath()}']")
return
revealCurrent: ->
if model = app.router.context.type or app.router.context.entry
@reveal model
@select model
return
openDoc: (doc) ->
@listFold.open @find("[data-slug='#{doc.slug_without_version}']") if app.disabledDocs.contains(doc) and doc.version
@listFold.open @find("[data-slug='#{doc.slug}']")
return
closeDoc: (doc) ->
@listFold.close @find("[data-slug='#{doc.slug}']")
return
openType: (type) ->
@listFold.open @lists[type.doc.slug].find("[data-slug='#{type.slug}']")
return
paginateTo: (model) ->
@lists[model.doc.slug]?.paginateTo(model)
return
scrollTo: (model) ->
$.scrollTo @find("a[href='#{model.fullPath()}']"), null, 'top', margin: if app.isMobile() then 48 else 0
return
toggleDisabled: ->
if @disabledTitle.classList.contains('open-title')
@removeDisabledList()
app.settings.set 'hideDisabled', true
else
@appendDisabledList()
app.settings.set 'hideDisabled', false
return
onClick: (event) =>
target = $.eventTarget(event)
if @disabledTitle and $.hasChild(@disabledTitle, target) and target.tagName isnt 'A'
$.stopEvent(event)
@toggleDisabled()
else if slug = target.getAttribute('data-enable')
$.stopEvent(event)
doc = app.disabledDocs.findBy('slug', slug)
app.enableDoc(doc, @onEnabled, @onEnabled) if doc
return
onEnabled: =>
@reset()
@render()
return
afterRoute: (route, context) =>
if context.init
@reset revealCurrent: true if @activated
else
@select context.type or context.entry
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.DocList = class DocList extends app.View {
constructor(...args) {
this.render = this.render.bind(this);
this.onOpen = this.onOpen.bind(this);
this.onClose = this.onClose.bind(this);
this.onClick = this.onClick.bind(this);
this.onEnabled = this.onEnabled.bind(this);
this.afterRoute = this.afterRoute.bind(this);
super(...args);
}
static initClass() {
this.className = '_list';
this.attributes =
{role: 'navigation'};
this.events = {
open: 'onOpen',
close: 'onClose',
click: 'onClick'
};
this.routes =
{after: 'afterRoute'};
this.elements = {
disabledTitle: '._list-title',
disabledList: '._disabled-list'
};
}
init() {
this.lists = {};
this.addSubview(this.listFocus = new app.views.ListFocus(this.el));
this.addSubview(this.listFold = new app.views.ListFold(this.el));
this.addSubview(this.listSelect = new app.views.ListSelect(this.el));
app.on('ready', this.render);
}
activate() {
if (super.activate(...arguments)) {
for (var slug in this.lists) { var list = this.lists[slug]; list.activate(); }
this.listSelect.selectCurrent();
}
}
deactivate() {
if (super.deactivate(...arguments)) {
for (var slug in this.lists) { var list = this.lists[slug]; list.deactivate(); }
}
}
render() {
let html = '';
for (var doc of Array.from(app.docs.all())) {
html += this.tmpl('sidebarDoc', doc, {fullName: app.docs.countAllBy('name', doc.name) > 1});
}
this.html(html);
if (!app.isSingleDoc() && (app.disabledDocs.size() !== 0)) { this.renderDisabled(); }
}
renderDisabled() {
this.append(this.tmpl('sidebarDisabled', {count: app.disabledDocs.size()}));
this.refreshElements();
this.renderDisabledList();
}
renderDisabledList() {
if (app.settings.get('hideDisabled')) {
this.removeDisabledList();
} else {
this.appendDisabledList();
}
}
appendDisabledList() {
let doc;
let html = '';
const docs = [].concat(...Array.from(app.disabledDocs.all() || []));
while ((doc = docs.shift())) {
if (doc.version != null) {
var versions = '';
while (true) {
versions += this.tmpl('sidebarDoc', doc, {disabled: true});
if ((docs[0] != null ? docs[0].name : undefined) !== doc.name) { break; }
doc = docs.shift();
}
html += this.tmpl('sidebarDisabledVersionedDoc', doc, versions);
} else {
html += this.tmpl('sidebarDoc', doc, {disabled: true});
}
}
this.append(this.tmpl('sidebarDisabledList', html));
this.disabledTitle.classList.add('open-title');
this.refreshElements();
}
removeDisabledList() {
if (this.disabledList) { $.remove(this.disabledList); }
this.disabledTitle.classList.remove('open-title');
this.refreshElements();
}
reset(options) {
if (options == null) { options = {}; }
this.listSelect.deselect();
if (this.listFocus != null) {
this.listFocus.blur();
}
this.listFold.reset();
if (options.revealCurrent || app.isSingleDoc()) { this.revealCurrent(); }
}
onOpen(event) {
$.stopEvent(event);
const doc = app.docs.findBy('slug', event.target.getAttribute('data-slug'));
if (doc && !this.lists[doc.slug]) {
this.lists[doc.slug] = doc.types.isEmpty() ?
new app.views.EntryList(doc.entries.all())
:
new app.views.TypeList(doc);
$.after(event.target, this.lists[doc.slug].el);
}
}
onClose(event) {
$.stopEvent(event);
const doc = app.docs.findBy('slug', event.target.getAttribute('data-slug'));
if (doc && this.lists[doc.slug]) {
this.lists[doc.slug].detach();
delete this.lists[doc.slug];
}
}
select(model) {
this.listSelect.selectByHref(model != null ? model.fullPath() : undefined);
}
reveal(model) {
this.openDoc(model.doc);
if (model.type) { this.openType(model.getType()); }
this.focus(model);
this.paginateTo(model);
this.scrollTo(model);
}
focus(model) {
if (this.listFocus != null) {
this.listFocus.focus(this.find(`a[href='${model.fullPath()}']`));
}
}
revealCurrent() {
let model;
if (model = app.router.context.type || app.router.context.entry) {
this.reveal(model);
this.select(model);
}
}
openDoc(doc) {
if (app.disabledDocs.contains(doc) && doc.version) { this.listFold.open(this.find(`[data-slug='${doc.slug_without_version}']`)); }
this.listFold.open(this.find(`[data-slug='${doc.slug}']`));
}
closeDoc(doc) {
this.listFold.close(this.find(`[data-slug='${doc.slug}']`));
}
openType(type) {
this.listFold.open(this.lists[type.doc.slug].find(`[data-slug='${type.slug}']`));
}
paginateTo(model) {
if (this.lists[model.doc.slug] != null) {
this.lists[model.doc.slug].paginateTo(model);
}
}
scrollTo(model) {
$.scrollTo(this.find(`a[href='${model.fullPath()}']`), null, 'top', {margin: app.isMobile() ? 48 : 0});
}
toggleDisabled() {
if (this.disabledTitle.classList.contains('open-title')) {
this.removeDisabledList();
app.settings.set('hideDisabled', true);
} else {
this.appendDisabledList();
app.settings.set('hideDisabled', false);
}
}
onClick(event) {
let slug;
const target = $.eventTarget(event);
if (this.disabledTitle && $.hasChild(this.disabledTitle, target) && (target.tagName !== 'A')) {
$.stopEvent(event);
this.toggleDisabled();
} else if (slug = target.getAttribute('data-enable')) {
$.stopEvent(event);
const doc = app.disabledDocs.findBy('slug', slug);
if (doc) { app.enableDoc(doc, this.onEnabled, this.onEnabled); }
}
}
onEnabled() {
this.reset();
this.render();
}
afterRoute(route, context) {
if (context.init) {
if (this.activated) { this.reset({revealCurrent: true}); }
} else {
this.select(context.type || context.entry);
}
}
});
Cls.initClass();

@ -1,88 +1,130 @@
class app.views.DocPicker extends app.View
@className: '_list _list-picker'
@events:
mousedown: 'onMouseDown'
mouseup: 'onMouseUp'
init: ->
@addSubview @listFold = new app.views.ListFold(@el)
return
activate: ->
if super
@render()
$.on @el, 'focus', @onDOMFocus, true
return
deactivate: ->
if super
@empty()
$.off @el, 'focus', @onDOMFocus, true
@focusEl = null
return
render: ->
html = @tmpl('docPickerHeader')
docs = app.docs.all().concat(app.disabledDocs.all()...)
while doc = docs.shift()
if doc.version?
[docs, versions] = @extractVersions(docs, doc)
html += @tmpl('sidebarVersionedDoc', doc, @renderVersions(versions), open: app.docs.contains(doc))
else
html += @tmpl('sidebarLabel', doc, checked: app.docs.contains(doc))
@html html + @tmpl('docPickerNote')
$.requestAnimationFrame => @findByTag('input')?.focus()
return
renderVersions: (docs) ->
html = ''
html += @tmpl('sidebarLabel', doc, checked: app.docs.contains(doc)) for doc in docs
html
extractVersions: (originalDocs, version) ->
docs = []
versions = [version]
for doc in originalDocs
(if doc.name is version.name then versions else docs).push(doc)
[docs, versions]
empty: ->
@resetClass()
super
return
getSelectedDocs: ->
for input in @findAllByTag 'input' when input?.checked
input.name
onMouseDown: =>
@mouseDown = Date.now()
return
onMouseUp: =>
@mouseUp = Date.now()
return
onDOMFocus: (event) =>
target = event.target
if target.tagName is 'INPUT'
unless (@mouseDown and Date.now() < @mouseDown + 100) or (@mouseUp and Date.now() < @mouseUp + 100)
$.scrollTo target.parentNode, null, 'continuous'
else if target.classList.contains(app.views.ListFold.targetClass)
target.blur()
unless @mouseDown and Date.now() < @mouseDown + 100
if @focusEl is $('input', target.nextElementSibling)
@listFold.close(target) if target.classList.contains(app.views.ListFold.activeClass)
prev = target.previousElementSibling
prev = prev.previousElementSibling until prev.tagName is 'LABEL' or prev.classList.contains(app.views.ListFold.targetClass)
prev = $.makeArray($$('input', prev.nextElementSibling)).pop() if prev.classList.contains(app.views.ListFold.activeClass)
@delay -> prev.focus()
else
@listFold.open(target) unless target.classList.contains(app.views.ListFold.activeClass)
@delay -> $('input', target.nextElementSibling).focus()
@focusEl = target
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.DocPicker = class DocPicker extends app.View {
constructor(...args) {
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.onDOMFocus = this.onDOMFocus.bind(this);
super(...args);
}
static initClass() {
this.className = '_list _list-picker';
this.events = {
mousedown: 'onMouseDown',
mouseup: 'onMouseUp'
};
}
init() {
this.addSubview(this.listFold = new app.views.ListFold(this.el));
}
activate() {
if (super.activate(...arguments)) {
this.render();
$.on(this.el, 'focus', this.onDOMFocus, true);
}
}
deactivate() {
if (super.deactivate(...arguments)) {
this.empty();
$.off(this.el, 'focus', this.onDOMFocus, true);
this.focusEl = null;
}
}
render() {
let doc;
let html = this.tmpl('docPickerHeader');
let docs = app.docs.all().concat(...Array.from(app.disabledDocs.all() || []));
while ((doc = docs.shift())) {
if (doc.version != null) {
var versions;
[docs, versions] = Array.from(this.extractVersions(docs, doc));
html += this.tmpl('sidebarVersionedDoc', doc, this.renderVersions(versions), {open: app.docs.contains(doc)});
} else {
html += this.tmpl('sidebarLabel', doc, {checked: app.docs.contains(doc)});
}
}
this.html(html + this.tmpl('docPickerNote'));
$.requestAnimationFrame(() => __guard__(this.findByTag('input'), x => x.focus()));
}
renderVersions(docs) {
let html = '';
for (var doc of Array.from(docs)) { html += this.tmpl('sidebarLabel', doc, {checked: app.docs.contains(doc)}); }
return html;
}
extractVersions(originalDocs, version) {
const docs = [];
const versions = [version];
for (var doc of Array.from(originalDocs)) {
(doc.name === version.name ? versions : docs).push(doc);
}
return [docs, versions];
}
empty() {
this.resetClass();
super.empty(...arguments);
}
getSelectedDocs() {
return Array.from(this.findAllByTag('input')).filter((input) => (input != null ? input.checked : undefined)).map((input) =>
input.name);
}
onMouseDown() {
this.mouseDown = Date.now();
}
onMouseUp() {
this.mouseUp = Date.now();
}
onDOMFocus(event) {
const {
target
} = event;
if (target.tagName === 'INPUT') {
if ((!this.mouseDown || !(Date.now() < (this.mouseDown + 100))) && (!this.mouseUp || !(Date.now() < (this.mouseUp + 100)))) {
$.scrollTo(target.parentNode, null, 'continuous');
}
} else if (target.classList.contains(app.views.ListFold.targetClass)) {
target.blur();
if (!this.mouseDown || !(Date.now() < (this.mouseDown + 100))) {
if (this.focusEl === $('input', target.nextElementSibling)) {
if (target.classList.contains(app.views.ListFold.activeClass)) { this.listFold.close(target); }
let prev = target.previousElementSibling;
while ((prev.tagName !== 'LABEL') && !prev.classList.contains(app.views.ListFold.targetClass)) { prev = prev.previousElementSibling; }
if (prev.classList.contains(app.views.ListFold.activeClass)) { prev = $.makeArray($$('input', prev.nextElementSibling)).pop(); }
this.delay(() => prev.focus());
} else {
if (!target.classList.contains(app.views.ListFold.activeClass)) { this.listFold.open(target); }
this.delay(() => $('input', target.nextElementSibling).focus());
}
}
}
this.focusEl = target;
}
});
Cls.initClass();
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,15 +1,27 @@
#= require views/list/paginated_list
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
//= require views/list/paginated_list
class app.views.EntryList extends app.views.PaginatedList
@tagName: 'div'
@className: '_list _list-sub'
const Cls = (app.views.EntryList = class EntryList extends app.views.PaginatedList {
static initClass() {
this.tagName = 'div';
this.className = '_list _list-sub';
}
constructor: (@entries) -> super
constructor(entries) { this.entries = entries; super(...arguments); }
init: ->
@renderPaginated()
@activate()
return
init() {
this.renderPaginated();
this.activate();
}
render: (entries) ->
@tmpl 'sidebarEntry', entries
render(entries) {
return this.tmpl('sidebarEntry', entries);
}
});
Cls.initClass();

@ -1,68 +1,93 @@
class app.views.Results extends app.View
@className: '_list'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.Results = class Results extends app.View {
static initClass() {
this.className = '_list';
this.events =
{click: 'onClick'};
this.routes =
{after: 'afterRoute'};
}
@events:
click: 'onClick'
constructor(sidebar, search) { this.onResults = this.onResults.bind(this); this.onNoResults = this.onNoResults.bind(this); this.onClear = this.onClear.bind(this); this.afterRoute = this.afterRoute.bind(this); this.onClick = this.onClick.bind(this); this.sidebar = sidebar; this.search = search; super(...arguments); }
@routes:
after: 'afterRoute'
deactivate() {
if (super.deactivate(...arguments)) {
this.empty();
}
}
constructor: (@sidebar, @search) -> super
init() {
this.addSubview(this.listFocus = new app.views.ListFocus(this.el));
this.addSubview(this.listSelect = new app.views.ListSelect(this.el));
deactivate: ->
if super
@empty()
return
this.search
.on('results', this.onResults)
.on('noresults', this.onNoResults)
.on('clear', this.onClear);
}
init: ->
@addSubview @listFocus = new app.views.ListFocus @el
@addSubview @listSelect = new app.views.ListSelect @el
onResults(entries, flags) {
if (flags.initialResults) { if (this.listFocus != null) {
this.listFocus.blur();
} }
if (flags.initialResults) { this.empty(); }
this.append(this.tmpl('sidebarResult', entries));
@search
.on 'results', @onResults
.on 'noresults', @onNoResults
.on 'clear', @onClear
return
if (flags.initialResults) {
if (flags.urlSearch) { this.openFirst(); } else { this.focusFirst(); }
}
}
onResults: (entries, flags) =>
@listFocus?.blur() if flags.initialResults
@empty() if flags.initialResults
@append @tmpl('sidebarResult', entries)
onNoResults() {
this.html(this.tmpl('sidebarNoResults'));
}
if flags.initialResults
if flags.urlSearch then @openFirst() else @focusFirst()
return
onClear() {
this.empty();
}
onNoResults: =>
@html @tmpl('sidebarNoResults')
return
focusFirst() {
if (!app.isMobile()) { if (this.listFocus != null) {
this.listFocus.focusOnNextFrame(this.el.firstElementChild);
} }
}
onClear: =>
@empty()
return
openFirst() {
if (this.el.firstElementChild != null) {
this.el.firstElementChild.click();
}
}
focusFirst: ->
@listFocus?.focusOnNextFrame @el.firstElementChild unless app.isMobile()
return
onDocEnabled(doc) {
app.router.show(doc.fullPath());
return this.sidebar.onDocEnabled();
}
openFirst: ->
@el.firstElementChild?.click()
return
afterRoute(route, context) {
if (route === 'entry') {
this.listSelect.selectByHref(context.entry.fullPath());
} else {
this.listSelect.deselect();
}
}
onDocEnabled: (doc) ->
app.router.show(doc.fullPath())
@sidebar.onDocEnabled()
afterRoute: (route, context) =>
if route is 'entry'
@listSelect.selectByHref context.entry.fullPath()
else
@listSelect.deselect()
return
onClick: (event) =>
return if event.which isnt 1
if slug = $.eventTarget(event).getAttribute('data-enable')
$.stopEvent(event)
doc = app.disabledDocs.findBy('slug', slug)
app.enableDoc(doc, @onDocEnabled.bind(@, doc), $.noop) if doc
onClick(event) {
let slug;
if (event.which !== 1) { return; }
if (slug = $.eventTarget(event).getAttribute('data-enable')) {
$.stopEvent(event);
const doc = app.disabledDocs.findBy('slug', slug);
if (doc) { return app.enableDoc(doc, this.onDocEnabled.bind(this, doc), $.noop); }
}
}
});
Cls.initClass();

@ -1,164 +1,217 @@
class app.views.Sidebar extends app.View
@el: '._sidebar'
@events:
focus: 'onFocus'
select: 'onSelect'
click: 'onClick'
@routes:
after: 'afterRoute'
@shortcuts:
altR: 'onAltR'
escape: 'onEscape'
init: ->
@addSubview @hover = new app.views.SidebarHover @el unless app.isMobile()
@addSubview @search = new app.views.Search
@search
.on 'searching', @onSearching
.on 'clear', @onSearchClear
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.Sidebar = class Sidebar extends app.View {
constructor(...args) {
this.resetHoverOnMouseMove = this.resetHoverOnMouseMove.bind(this);
this.resetHover = this.resetHover.bind(this);
this.showResults = this.showResults.bind(this);
this.onReady = this.onReady.bind(this);
this.onScopeChange = this.onScopeChange.bind(this);
this.onSearching = this.onSearching.bind(this);
this.onSearchClear = this.onSearchClear.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onSelect = this.onSelect.bind(this);
this.onClick = this.onClick.bind(this);
this.onAltR = this.onAltR.bind(this);
this.onEscape = this.onEscape.bind(this);
this.afterRoute = this.afterRoute.bind(this);
super(...args);
}
static initClass() {
this.el = '._sidebar';
this.events = {
focus: 'onFocus',
select: 'onSelect',
click: 'onClick'
};
this.routes =
{after: 'afterRoute'};
this.shortcuts = {
altR: 'onAltR',
escape: 'onEscape'
};
}
init() {
if (!app.isMobile()) { this.addSubview(this.hover = new app.views.SidebarHover(this.el)); }
this.addSubview(this.search = new app.views.Search);
this.search
.on('searching', this.onSearching)
.on('clear', this.onSearchClear)
.scope
.on 'change', @onScopeChange
@results = new app.views.Results @, @search
@docList = new app.views.DocList
app.on 'ready', @onReady
$.on document.documentElement, 'mouseleave', => @hide()
$.on document.documentElement, 'mouseenter', => @resetDisplay(forceNoHover: false)
return
hide: ->
@removeClass 'show'
return
display: ->
@addClass 'show'
return
resetDisplay: (options = {}) ->
return unless @hasClass 'show'
@removeClass 'show'
unless options.forceNoHover is false or @hasClass 'no-hover'
@addClass 'no-hover'
$.on window, 'mousemove', @resetHoverOnMouseMove
return
resetHoverOnMouseMove: =>
$.off window, 'mousemove', @resetHoverOnMouseMove
$.requestAnimationFrame @resetHover
resetHover: =>
@removeClass 'no-hover'
showView: (view) ->
unless @view is view
@hover?.hide()
@saveScrollPosition()
@view?.deactivate()
@view = view
@render()
@view.activate()
@restoreScrollPosition()
return
render: ->
@html @view
return
showDocList: ->
@showView @docList
return
showResults: =>
@display()
@showView @results
return
reset: ->
@display()
@showDocList()
@docList.reset()
@search.reset()
return
onReady: =>
@view = @docList
@render()
@view.activate()
return
onScopeChange: (newDoc, previousDoc) =>
@docList.closeDoc(previousDoc) if previousDoc
if newDoc then @docList.reveal(newDoc.toEntry()) else @scrollToTop()
return
saveScrollPosition: ->
if @view is @docList
@scrollTop = @el.scrollTop
return
restoreScrollPosition: ->
if @view is @docList and @scrollTop
@el.scrollTop = @scrollTop
@scrollTop = null
else
@scrollToTop()
return
scrollToTop: ->
@el.scrollTop = 0
return
onSearching: =>
@showResults()
return
onSearchClear: =>
@resetDisplay()
@showDocList()
return
onFocus: (event) =>
@display()
$.scrollTo event.target, @el, 'continuous', bottomGap: 2 unless event.target is @el
return
onSelect: =>
@resetDisplay()
return
onClick: (event) =>
return if event.which isnt 1
if $.eventTarget(event).hasAttribute? 'data-reset-list'
$.stopEvent(event)
@onAltR()
return
onAltR: =>
@reset()
@docList.reset(revealCurrent: true)
@display()
return
onEscape: =>
@reset()
@resetDisplay()
if doc = @search.getScopeDoc() then @docList.reveal(doc.toEntry()) else @scrollToTop()
return
onDocEnabled: ->
@docList.onEnabled()
@reset()
return
afterRoute: (name, context) =>
return if app.shortcuts.eventInProgress?.name is 'escape'
@reset() if not context.init and app.router.isIndex()
@resetDisplay()
return
.on('change', this.onScopeChange);
this.results = new app.views.Results(this, this.search);
this.docList = new app.views.DocList;
app.on('ready', this.onReady);
$.on(document.documentElement, 'mouseleave', () => this.hide());
$.on(document.documentElement, 'mouseenter', () => this.resetDisplay({forceNoHover: false}));
}
hide() {
this.removeClass('show');
}
display() {
this.addClass('show');
}
resetDisplay(options) {
if (options == null) { options = {}; }
if (!this.hasClass('show')) { return; }
this.removeClass('show');
if ((options.forceNoHover !== false) && !this.hasClass('no-hover')) {
this.addClass('no-hover');
$.on(window, 'mousemove', this.resetHoverOnMouseMove);
}
}
resetHoverOnMouseMove() {
$.off(window, 'mousemove', this.resetHoverOnMouseMove);
return $.requestAnimationFrame(this.resetHover);
}
resetHover() {
return this.removeClass('no-hover');
}
showView(view) {
if (this.view !== view) {
if (this.hover != null) {
this.hover.hide();
}
this.saveScrollPosition();
if (this.view != null) {
this.view.deactivate();
}
this.view = view;
this.render();
this.view.activate();
this.restoreScrollPosition();
}
}
render() {
this.html(this.view);
}
showDocList() {
this.showView(this.docList);
}
showResults() {
this.display();
this.showView(this.results);
}
reset() {
this.display();
this.showDocList();
this.docList.reset();
this.search.reset();
}
onReady() {
this.view = this.docList;
this.render();
this.view.activate();
}
onScopeChange(newDoc, previousDoc) {
if (previousDoc) { this.docList.closeDoc(previousDoc); }
if (newDoc) { this.docList.reveal(newDoc.toEntry()); } else { this.scrollToTop(); }
}
saveScrollPosition() {
if (this.view === this.docList) {
this.scrollTop = this.el.scrollTop;
}
}
restoreScrollPosition() {
if ((this.view === this.docList) && this.scrollTop) {
this.el.scrollTop = this.scrollTop;
this.scrollTop = null;
} else {
this.scrollToTop();
}
}
scrollToTop() {
this.el.scrollTop = 0;
}
onSearching() {
this.showResults();
}
onSearchClear() {
this.resetDisplay();
this.showDocList();
}
onFocus(event) {
this.display();
if (event.target !== this.el) { $.scrollTo(event.target, this.el, 'continuous', {bottomGap: 2}); }
}
onSelect() {
this.resetDisplay();
}
onClick(event) {
if (event.which !== 1) { return; }
if (__guardMethod__($.eventTarget(event), 'hasAttribute', o => o.hasAttribute('data-reset-list'))) {
$.stopEvent(event);
this.onAltR();
}
}
onAltR() {
this.reset();
this.docList.reset({revealCurrent: true});
this.display();
}
onEscape() {
let doc;
this.reset();
this.resetDisplay();
if ((doc = this.search.getScopeDoc())) { this.docList.reveal(doc.toEntry()); } else { this.scrollToTop(); }
}
onDocEnabled() {
this.docList.onEnabled();
this.reset();
}
afterRoute(name, context) {
if ((app.shortcuts.eventInProgress != null ? app.shortcuts.eventInProgress.name : undefined) === 'escape') { return; }
if (!context.init && app.router.isIndex()) { this.reset(); }
this.resetDisplay();
}
});
Cls.initClass();
function __guardMethod__(obj, methodName, transform) {
if (typeof obj !== 'undefined' && obj !== null && typeof obj[methodName] === 'function') {
return transform(obj, methodName);
} else {
return undefined;
}
}

@ -1,100 +1,143 @@
class app.views.SidebarHover extends app.View
@itemClass: '_list-hover'
@events:
focus: 'onFocus'
blur: 'onBlur'
mouseover: 'onMouseover'
mouseout: 'onMouseout'
scroll: 'onScroll'
click: 'onClick'
@routes:
after: 'onRoute'
constructor: (@el) ->
unless isPointerEventsSupported()
delete @constructor.events.mouseover
super
show: (el) ->
unless el is @cursor
@hide()
if @isTarget(el) and @isTruncated(el.lastElementChild or el)
@cursor = el
@clone = @makeClone @cursor
$.append document.body, @clone
@offsetTop ?= @el.offsetTop
@position()
return
hide: ->
if @cursor
$.remove @clone
@cursor = @clone = null
return
position: =>
if @cursor
rect = $.rect(@cursor)
if rect.top >= @offsetTop
@clone.style.top = rect.top + 'px'
@clone.style.left = rect.left + 'px'
else
@hide()
return
makeClone: (el) ->
clone = el.cloneNode(true)
clone.classList.add 'clone'
clone
isTarget: (el) ->
el?.classList?.contains @constructor.itemClass
isSelected: (el) ->
el.classList.contains 'active'
isTruncated: (el) ->
el.scrollWidth > el.offsetWidth
onFocus: (event) =>
@focusTime = Date.now()
@show event.target
return
onBlur: =>
@hide()
return
onMouseover: (event) =>
if @isTarget(event.target) and not @isSelected(event.target) and @mouseActivated()
@show event.target
return
onMouseout: (event) =>
if @isTarget(event.target) and @mouseActivated()
@hide()
return
mouseActivated: ->
# Skip mouse events caused by focus events scrolling the sidebar.
not @focusTime or Date.now() - @focusTime > 500
onScroll: =>
@position()
return
onClick: (event) =>
if event.target is @clone
$.click @cursor
return
onRoute: =>
@hide()
return
isPointerEventsSupported = ->
el = document.createElement 'div'
el.style.cssText = 'pointer-events: auto'
el.style.pointerEvents is 'auto'
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.SidebarHover = class SidebarHover extends app.View {
static initClass() {
this.itemClass = '_list-hover';
this.events = {
focus: 'onFocus',
blur: 'onBlur',
mouseover: 'onMouseover',
mouseout: 'onMouseout',
scroll: 'onScroll',
click: 'onClick'
};
this.routes =
{after: 'onRoute'};
}
constructor(el) {
this.position = this.position.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onMouseover = this.onMouseover.bind(this);
this.onMouseout = this.onMouseout.bind(this);
this.onScroll = this.onScroll.bind(this);
this.onClick = this.onClick.bind(this);
this.onRoute = this.onRoute.bind(this);
this.el = el;
if (!isPointerEventsSupported()) {
delete this.constructor.events.mouseover;
}
super(...arguments);
}
show(el) {
if (el !== this.cursor) {
this.hide();
if (this.isTarget(el) && this.isTruncated(el.lastElementChild || el)) {
this.cursor = el;
this.clone = this.makeClone(this.cursor);
$.append(document.body, this.clone);
if (this.offsetTop == null) { this.offsetTop = this.el.offsetTop; }
this.position();
}
}
}
hide() {
if (this.cursor) {
$.remove(this.clone);
this.cursor = (this.clone = null);
}
}
position() {
if (this.cursor) {
const rect = $.rect(this.cursor);
if (rect.top >= this.offsetTop) {
this.clone.style.top = rect.top + 'px';
this.clone.style.left = rect.left + 'px';
} else {
this.hide();
}
}
}
makeClone(el) {
const clone = el.cloneNode(true);
clone.classList.add('clone');
return clone;
}
isTarget(el) {
return __guard__(el != null ? el.classList : undefined, x => x.contains(this.constructor.itemClass));
}
isSelected(el) {
return el.classList.contains('active');
}
isTruncated(el) {
return el.scrollWidth > el.offsetWidth;
}
onFocus(event) {
this.focusTime = Date.now();
this.show(event.target);
}
onBlur() {
this.hide();
}
onMouseover(event) {
if (this.isTarget(event.target) && !this.isSelected(event.target) && this.mouseActivated()) {
this.show(event.target);
}
}
onMouseout(event) {
if (this.isTarget(event.target) && this.mouseActivated()) {
this.hide();
}
}
mouseActivated() {
// Skip mouse events caused by focus events scrolling the sidebar.
return !this.focusTime || ((Date.now() - this.focusTime) > 500);
}
onScroll() {
this.position();
}
onClick(event) {
if (event.target === this.clone) {
$.click(this.cursor);
}
}
onRoute() {
this.hide();
}
});
Cls.initClass();
var isPointerEventsSupported = function() {
const el = document.createElement('div');
el.style.cssText = 'pointer-events: auto';
return el.style.pointerEvents === 'auto';
};
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,53 +1,77 @@
class app.views.TypeList extends app.View
@tagName: 'div'
@className: '_list _list-sub'
@events:
open: 'onOpen'
close: 'onClose'
constructor: (@doc) -> super
init: ->
@lists = {}
@render()
@activate()
return
activate: ->
if super
list.activate() for slug, list of @lists
return
deactivate: ->
if super
list.deactivate() for slug, list of @lists
return
render: ->
html = ''
html += @tmpl('sidebarType', group) for group in @doc.types.groups()
@html(html)
onOpen: (event) =>
$.stopEvent(event)
type = @doc.types.findBy 'slug', event.target.getAttribute('data-slug')
if type and not @lists[type.slug]
@lists[type.slug] = new app.views.EntryList(type.entries())
$.after event.target, @lists[type.slug].el
return
onClose: (event) =>
$.stopEvent(event)
type = @doc.types.findBy 'slug', event.target.getAttribute('data-slug')
if type and @lists[type.slug]
@lists[type.slug].detach()
delete @lists[type.slug]
return
paginateTo: (model) ->
if model.type
@lists[model.getType().slug]?.paginateTo(model)
return
/*
* decaffeinate suggestions:
* DS002: Fix invalid constructor
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.views.TypeList = class TypeList extends app.View {
static initClass() {
this.tagName = 'div';
this.className = '_list _list-sub';
this.events = {
open: 'onOpen',
close: 'onClose'
};
}
constructor(doc) { this.onOpen = this.onOpen.bind(this); this.onClose = this.onClose.bind(this); this.doc = doc; super(...arguments); }
init() {
this.lists = {};
this.render();
this.activate();
}
activate() {
if (super.activate(...arguments)) {
for (var slug in this.lists) { var list = this.lists[slug]; list.activate(); }
}
}
deactivate() {
if (super.deactivate(...arguments)) {
for (var slug in this.lists) { var list = this.lists[slug]; list.deactivate(); }
}
}
render() {
let html = '';
for (var group of Array.from(this.doc.types.groups())) { html += this.tmpl('sidebarType', group); }
return this.html(html);
}
onOpen(event) {
$.stopEvent(event);
const type = this.doc.types.findBy('slug', event.target.getAttribute('data-slug'));
if (type && !this.lists[type.slug]) {
this.lists[type.slug] = new app.views.EntryList(type.entries());
$.after(event.target, this.lists[type.slug].el);
}
}
onClose(event) {
$.stopEvent(event);
const type = this.doc.types.findBy('slug', event.target.getAttribute('data-slug'));
if (type && this.lists[type.slug]) {
this.lists[type.slug].detach();
delete this.lists[type.slug];
}
}
paginateTo(model) {
if (model.type) {
__guard__(this.lists[model.getType().slug], x => x.paginateTo(model));
}
}
});
Cls.initClass();
function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
}

@ -1,172 +1,214 @@
class app.View
$.extend @prototype, Events
constructor: ->
@setupElement()
@originalClassName = @el.className if @el.className
@resetClass() if @constructor.className
@refreshElements()
@init?()
@refreshElements()
setupElement: ->
@el ?= if typeof @constructor.el is 'string'
$ @constructor.el
else if @constructor.el
@constructor.el
else
document.createElement @constructor.tagName or 'div'
if @constructor.attributes
for key, value of @constructor.attributes
@el.setAttribute(key, value)
return
refreshElements: ->
if @constructor.elements
@[name] = @find selector for name, selector of @constructor.elements
return
addClass: (name) ->
@el.classList.add(name)
return
removeClass: (name) ->
@el.classList.remove(name)
return
toggleClass: (name) ->
@el.classList.toggle(name)
return
hasClass: (name) ->
@el.classList.contains(name)
resetClass: ->
@el.className = @originalClassName or ''
if @constructor.className
@addClass name for name in @constructor.className.split ' '
return
find: (selector) ->
$ selector, @el
findAll: (selector) ->
$$ selector, @el
findByClass: (name) ->
@findAllByClass(name)[0]
findLastByClass: (name) ->
all = @findAllByClass(name)[0]
all[all.length - 1]
findAllByClass: (name) ->
@el.getElementsByClassName(name)
findByTag: (tag) ->
@findAllByTag(tag)[0]
findLastByTag: (tag) ->
all = @findAllByTag(tag)
all[all.length - 1]
findAllByTag: (tag) ->
@el.getElementsByTagName(tag)
append: (value) ->
$.append @el, value.el or value
return
appendTo: (value) ->
$.append value.el or value, @el
return
prepend: (value) ->
$.prepend @el, value.el or value
return
prependTo: (value) ->
$.prepend value.el or value, @el
return
before: (value) ->
$.before @el, value.el or value
return
after: (value) ->
$.after @el, value.el or value
return
remove: (value) ->
$.remove value.el or value
return
empty: ->
$.empty @el
@refreshElements()
return
html: (value) ->
@empty()
@append value
return
tmpl: (args...) ->
app.templates.render(args...)
delay: (fn, args...) ->
delay = if typeof args[args.length - 1] is 'number' then args.pop() else 0
setTimeout fn.bind(@, args...), delay
onDOM: (event, callback) ->
$.on @el, event, callback
return
offDOM: (event, callback) ->
$.off @el, event, callback
return
bindEvents: ->
if @constructor.events
@onDOM name, @[method] for name, method of @constructor.events
if @constructor.routes
app.router.on name, @[method] for name, method of @constructor.routes
if @constructor.shortcuts
app.shortcuts.on name, @[method] for name, method of @constructor.shortcuts
return
unbindEvents: ->
if @constructor.events
@offDOM name, @[method] for name, method of @constructor.events
if @constructor.routes
app.router.off name, @[method] for name, method of @constructor.routes
if @constructor.shortcuts
app.shortcuts.off name, @[method] for name, method of @constructor.shortcuts
return
addSubview: (view) ->
(@subviews or= []).push(view)
activate: ->
return if @activated
@bindEvents()
view.activate() for view in @subviews if @subviews
@activated = true
true
deactivate: ->
return unless @activated
@unbindEvents()
view.deactivate() for view in @subviews if @subviews
@activated = false
true
detach: ->
@deactivate()
$.remove @el
return
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
const Cls = (app.View = class View {
static initClass() {
$.extend(this.prototype, Events);
}
constructor() {
this.setupElement();
if (this.el.className) { this.originalClassName = this.el.className; }
if (this.constructor.className) { this.resetClass(); }
this.refreshElements();
if (typeof this.init === 'function') {
this.init();
}
this.refreshElements();
}
setupElement() {
if (this.el == null) { this.el = typeof this.constructor.el === 'string' ?
$(this.constructor.el)
: this.constructor.el ?
this.constructor.el
:
document.createElement(this.constructor.tagName || 'div'); }
if (this.constructor.attributes) {
for (var key in this.constructor.attributes) {
var value = this.constructor.attributes[key];
this.el.setAttribute(key, value);
}
}
}
refreshElements() {
if (this.constructor.elements) {
for (var name in this.constructor.elements) { var selector = this.constructor.elements[name]; this[name] = this.find(selector); }
}
}
addClass(name) {
this.el.classList.add(name);
}
removeClass(name) {
this.el.classList.remove(name);
}
toggleClass(name) {
this.el.classList.toggle(name);
}
hasClass(name) {
return this.el.classList.contains(name);
}
resetClass() {
this.el.className = this.originalClassName || '';
if (this.constructor.className) {
for (var name of Array.from(this.constructor.className.split(' '))) { this.addClass(name); }
}
}
find(selector) {
return $(selector, this.el);
}
findAll(selector) {
return $$(selector, this.el);
}
findByClass(name) {
return this.findAllByClass(name)[0];
}
findLastByClass(name) {
const all = this.findAllByClass(name)[0];
return all[all.length - 1];
}
findAllByClass(name) {
return this.el.getElementsByClassName(name);
}
findByTag(tag) {
return this.findAllByTag(tag)[0];
}
findLastByTag(tag) {
const all = this.findAllByTag(tag);
return all[all.length - 1];
}
findAllByTag(tag) {
return this.el.getElementsByTagName(tag);
}
append(value) {
$.append(this.el, value.el || value);
}
appendTo(value) {
$.append(value.el || value, this.el);
}
prepend(value) {
$.prepend(this.el, value.el || value);
}
prependTo(value) {
$.prepend(value.el || value, this.el);
}
before(value) {
$.before(this.el, value.el || value);
}
after(value) {
$.after(this.el, value.el || value);
}
remove(value) {
$.remove(value.el || value);
}
empty() {
$.empty(this.el);
this.refreshElements();
}
html(value) {
this.empty();
this.append(value);
}
tmpl(...args) {
return app.templates.render(...Array.from(args || []));
}
delay(fn, ...args) {
const delay = typeof args[args.length - 1] === 'number' ? args.pop() : 0;
return setTimeout(fn.bind(this, ...Array.from(args)), delay);
}
onDOM(event, callback) {
$.on(this.el, event, callback);
}
offDOM(event, callback) {
$.off(this.el, event, callback);
}
bindEvents() {
let method, name;
if (this.constructor.events) {
for (name in this.constructor.events) { method = this.constructor.events[name]; this.onDOM(name, this[method]); }
}
if (this.constructor.routes) {
for (name in this.constructor.routes) { method = this.constructor.routes[name]; app.router.on(name, this[method]); }
}
if (this.constructor.shortcuts) {
for (name in this.constructor.shortcuts) { method = this.constructor.shortcuts[name]; app.shortcuts.on(name, this[method]); }
}
}
unbindEvents() {
let method, name;
if (this.constructor.events) {
for (name in this.constructor.events) { method = this.constructor.events[name]; this.offDOM(name, this[method]); }
}
if (this.constructor.routes) {
for (name in this.constructor.routes) { method = this.constructor.routes[name]; app.router.off(name, this[method]); }
}
if (this.constructor.shortcuts) {
for (name in this.constructor.shortcuts) { method = this.constructor.shortcuts[name]; app.shortcuts.off(name, this[method]); }
}
}
addSubview(view) {
return (this.subviews || (this.subviews = [])).push(view);
}
activate() {
if (this.activated) { return; }
this.bindEvents();
if (this.subviews) { for (var view of Array.from(this.subviews)) { view.activate(); } }
this.activated = true;
return true;
}
deactivate() {
if (!this.activated) { return; }
this.unbindEvents();
if (this.subviews) { for (var view of Array.from(this.subviews)) { view.deactivate(); } }
this.activated = false;
return true;
}
detach() {
this.deactivate();
$.remove(this.el);
}
});
Cls.initClass();

Loading…
Cancel
Save