Merge pull request #1441 from freeCodeCamp/decaffeinate

Migrate CoffeeScript to JavaScript
pull/2073/head
Simon Legner 1 year ago committed by GitHub
commit 92b009800d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -14,7 +14,6 @@ gem 'yajl-ruby', require: false
group :app do
gem 'browser'
gem 'chunky_png'
gem 'coffee-script'
gem 'erubi'
gem 'image_optim_pack', platforms: :ruby
gem 'image_optim'

@ -14,10 +14,6 @@ GEM
byebug (11.1.3)
chunky_png (1.4.0)
coderay (1.1.3)
coffee-script (2.4.1)
coffee-script-source
execjs
coffee-script-source (1.12.2)
concurrent-ruby (1.1.10)
daemons (1.4.1)
erubi (1.12.0)
@ -146,7 +142,6 @@ DEPENDENCIES
better_errors
browser
chunky_png
coffee-script
erubi
html-pipeline
image_optim

@ -1,283 +0,0 @@
@app =
_$: $
_$$: $$
_page: page
collections: {}
models: {}
templates: {}
views: {}
init: ->
try @initErrorTracking() catch
return unless @browserCheck()
@el = $('._app')
@localStorage = new LocalStorageStore
@serviceWorker = new app.ServiceWorker if app.ServiceWorker.isEnabled()
@settings = new app.Settings
@db = new app.DB()
@settings.initLayout()
@docs = new app.collections.Docs
@disabledDocs = new app.collections.Docs
@entries = new app.collections.Entries
@router = new app.Router
@shortcuts = new app.Shortcuts
@document = new app.views.Document
@mobile = new app.views.Mobile if @isMobile()
if document.body.hasAttribute('data-doc')
@DOC = JSON.parse(document.body.getAttribute('data-doc'))
@bootOne()
else if @DOCS
@bootAll()
else
@onBootError()
return
browserCheck: ->
return true if @isSupportedBrowser()
document.body.innerHTML = app.templates.unsupportedBrowser
@hideLoadingScreen()
false
initErrorTracking: ->
# Show a warning message and don't track errors when the app is loaded
# from a domain other than our own, because things are likely to break.
# (e.g. cross-domain requests)
if @isInvalidLocation()
new app.views.Notif 'InvalidLocation'
else
if @config.sentry_dsn
Raven.config @config.sentry_dsn,
release: @config.release
whitelistUrls: [/devdocs/]
includePaths: [/devdocs/]
ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/]
tags:
mode: if @isSingleDoc() then 'single' else 'full'
iframe: (window.top isnt window).toString()
electron: (!!window.process?.versions?.electron).toString()
shouldSendCallback: =>
try
if @isInjectionError()
@onInjectionError()
return false
if @isAndroidWebview()
return false
true
dataCallback: (data) ->
try
$.extend(data.user ||= {}, app.settings.dump())
data.user.docs = data.user.docs.split('/') if data.user.docs
data.user.lastIDBTransaction = app.lastIDBTransaction if app.lastIDBTransaction
data.tags.scriptCount = document.scripts.length
data
.install()
@previousErrorHandler = onerror
window.onerror = @onWindowError.bind(@)
CookiesStore.onBlocked = @onCookieBlocked
return
bootOne: ->
@doc = new app.models.Doc @DOC
@docs.reset [@doc]
@doc.load @start.bind(@), @onBootError.bind(@), readCache: true
new app.views.Notice 'singleDoc', @doc
delete @DOC
return
bootAll: ->
docs = @settings.getDocs()
for doc in @DOCS
(if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc)
@migrateDocs()
@docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true
delete @DOCS
return
start: ->
@entries.add doc.toEntry() for doc in @docs.all()
@entries.add doc.toEntry() for doc in @disabledDocs.all()
@initDoc(doc) for doc in @docs.all()
@trigger 'ready'
@router.start()
@hideLoadingScreen()
setTimeout =>
@welcomeBack() unless @doc
@removeEvent 'ready bootError'
, 50
return
initDoc: (doc) ->
doc.entries.add type.toEntry() for type in doc.types.all()
@entries.add doc.entries.all()
return
migrateDocs: ->
for slug in @settings.getDocs() when not @docs.findBy('slug', slug)
needsSaving = true
doc = @disabledDocs.findBy('slug', 'webpack') if slug == 'webpack~2'
doc = @disabledDocs.findBy('slug', 'angular') if slug == 'angular~4_typescript'
doc = @disabledDocs.findBy('slug', 'angular~2') if slug == 'angular~2_typescript'
doc ||= @disabledDocs.findBy('slug_without_version', slug)
if doc
@disabledDocs.remove(doc)
@docs.add(doc)
@saveDocs() if needsSaving
return
enableDoc: (doc, _onSuccess, onError) ->
return if @docs.contains(doc)
onSuccess = =>
return if @docs.contains(doc)
@disabledDocs.remove(doc)
@docs.add(doc)
@docs.sort()
@initDoc(doc)
@saveDocs()
if app.settings.get('autoInstall')
doc.install(_onSuccess, onError)
else
_onSuccess()
return
doc.load onSuccess, onError, writeCache: true
return
saveDocs: ->
@settings.setDocs(doc.slug for doc in @docs.all())
@db.migrate()
@serviceWorker?.updateInBackground()
welcomeBack: ->
visitCount = @settings.get('count')
@settings.set 'count', ++visitCount
new app.views.Notif 'Share', autoHide: null if visitCount is 5
new app.views.News()
new app.views.Updates()
@updateChecker = new app.UpdateChecker()
reboot: ->
if location.pathname isnt '/' and location.pathname isnt '/settings'
window.location = "/##{location.pathname}"
else
window.location = '/'
return
reload: ->
@docs.clearCache()
@disabledDocs.clearCache()
if @serviceWorker then @serviceWorker.reload() else @reboot()
return
reset: ->
@localStorage.reset()
@settings.reset()
@db?.reset()
@serviceWorker?.update()
window.location = '/'
return
showTip: (tip) ->
return if @isSingleDoc()
tips = @settings.getTips()
if tips.indexOf(tip) is -1
tips.push(tip)
@settings.setTips(tips)
new app.views.Tip(tip)
return
hideLoadingScreen: ->
document.body.classList.add '_overlay-scrollbars' if $.overlayScrollbarsEnabled()
document.documentElement.classList.remove '_booting'
return
indexHost: ->
# Can't load the index files from the host/CDN when service worker is
# enabled because it doesn't support caching URLs that use CORS.
@config[if @serviceWorker and @settings.hasDocs() then 'index_path' else 'docs_origin']
onBootError: (args...) ->
@trigger 'bootError'
@hideLoadingScreen()
return
onQuotaExceeded: ->
return if @quotaExceeded
@quotaExceeded = true
new app.views.Notif 'QuotaExceeded', autoHide: null
return
onCookieBlocked: (key, value, actual) ->
return if @cookieBlocked
@cookieBlocked = true
new app.views.Notif 'CookieBlocked', autoHide: null
Raven.captureMessage "CookieBlocked/#{key}", level: 'warning', extra: {value, actual}
return
onWindowError: (args...) ->
return if @cookieBlocked
if @isInjectionError args...
@onInjectionError()
else if @isAppError args...
@previousErrorHandler? args...
@hideLoadingScreen()
@errorNotif or= new app.views.Notif 'Error'
@errorNotif.show()
return
onInjectionError: ->
unless @injectionError
@injectionError = true
alert """
JavaScript code has been injected in the page which prevents DevDocs from running correctly.
Please check your browser extensions/addons. """
Raven.captureMessage 'injection error', level: 'info'
return
isInjectionError: ->
# Some browser extensions expect the entire web to use jQuery.
# I gave up trying to fight back.
window.$ isnt app._$ or window.$$ isnt app._$$ or window.page isnt app._page or typeof $.empty isnt 'function' or typeof page.show isnt 'function'
isAppError: (error, file) ->
# Ignore errors from external scripts.
file and file.indexOf('devdocs') isnt -1 and file.indexOf('.js') is file.length - 3
isSupportedBrowser: ->
try
features =
bind: !!Function::bind
pushState: !!history.pushState
matchMedia: !!window.matchMedia
insertAdjacentHTML: !!document.body.insertAdjacentHTML
defaultPrevented: document.createEvent('CustomEvent').defaultPrevented is false
cssVariables: !!CSS?.supports?('(--t: 0)')
for key, value of features when not value
Raven.captureMessage "unsupported/#{key}", level: 'info'
return false
true
catch error
Raven.captureMessage 'unsupported/exception', level: 'info', extra: { error: error }
false
isSingleDoc: ->
document.body.hasAttribute('data-doc')
isMobile: ->
@_isMobile ?= app.views.Mobile.detect()
isAndroidWebview: ->
@_isAndroidWebview ?= app.views.Mobile.detectAndroidWebview()
isInvalidLocation: ->
@config.env is 'production' and location.host.indexOf(app.config.production_host) isnt 0
$.extend app, Events

@ -0,0 +1,419 @@
class App extends Events {
_$ = $;
_$$ = $$;
_page = page;
collections = {};
models = {};
templates = {};
views = {};
init() {
try {
this.initErrorTracking();
} catch (error) {}
if (!this.browserCheck()) {
return;
}
this.el = $("._app");
this.localStorage = new LocalStorageStore();
if (app.ServiceWorker.isEnabled()) {
this.serviceWorker = new app.ServiceWorker();
}
this.settings = new app.Settings();
this.db = new app.DB();
this.settings.initLayout();
this.docs = new app.collections.Docs();
this.disabledDocs = new app.collections.Docs();
this.entries = new app.collections.Entries();
this.router = new app.Router();
this.shortcuts = new app.Shortcuts();
this.document = new app.views.Document();
if (this.isMobile()) {
this.mobile = new app.views.Mobile();
}
if (document.body.hasAttribute("data-doc")) {
this.DOC = JSON.parse(document.body.getAttribute("data-doc"));
this.bootOne();
} else if (this.DOCS) {
this.bootAll();
} else {
this.onBootError();
}
}
browserCheck() {
if (this.isSupportedBrowser()) {
return true;
}
document.body.innerHTML = app.templates.unsupportedBrowser;
this.hideLoadingScreen();
return false;
}
initErrorTracking() {
// Show a warning message and don't track errors when the app is loaded
// from a domain other than our own, because things are likely to break.
// (e.g. cross-domain requests)
if (this.isInvalidLocation()) {
new app.views.Notif("InvalidLocation");
} else {
if (this.config.sentry_dsn) {
Raven.config(this.config.sentry_dsn, {
release: this.config.release,
whitelistUrls: [/devdocs/],
includePaths: [/devdocs/],
ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/],
tags: {
mode: this.isSingleDoc() ? "single" : "full",
iframe: (window.top !== window).toString(),
electron: (!!window.process?.versions?.electron).toString(),
},
shouldSendCallback: () => {
try {
if (this.isInjectionError()) {
this.onInjectionError();
return false;
}
if (this.isAndroidWebview()) {
return false;
}
} catch (error) {}
return true;
},
dataCallback(data) {
try {
data.user ||= {};
Object.assign(data.user, app.settings.dump());
if (data.user.docs) {
data.user.docs = data.user.docs.split("/");
}
if (app.lastIDBTransaction) {
data.user.lastIDBTransaction = app.lastIDBTransaction;
}
data.tags.scriptCount = document.scripts.length;
} catch (error) {}
return data;
},
}).install();
}
this.previousErrorHandler = onerror;
window.onerror = this.onWindowError.bind(this);
CookiesStore.onBlocked = this.onCookieBlocked;
}
}
bootOne() {
this.doc = new app.models.Doc(this.DOC);
this.docs.reset([this.doc]);
this.doc.load(this.start.bind(this), this.onBootError.bind(this), {
readCache: true,
});
new app.views.Notice("singleDoc", this.doc);
delete this.DOC;
}
bootAll() {
const docs = this.settings.getDocs();
for (var doc of this.DOCS) {
(docs.includes(doc.slug) ? this.docs : this.disabledDocs).add(doc);
}
this.migrateDocs();
this.docs.load(this.start.bind(this), this.onBootError.bind(this), {
readCache: true,
writeCache: true,
});
delete this.DOCS;
}
start() {
let doc;
for (doc of this.docs.all()) {
this.entries.add(doc.toEntry());
}
for (doc of this.disabledDocs.all()) {
this.entries.add(doc.toEntry());
}
for (doc of this.docs.all()) {
this.initDoc(doc);
}
this.trigger("ready");
this.router.start();
this.hideLoadingScreen();
setTimeout(() => {
if (!this.doc) {
this.welcomeBack();
}
return this.removeEvent("ready bootError");
}, 50);
}
initDoc(doc) {
for (var type of doc.types.all()) {
doc.entries.add(type.toEntry());
}
this.entries.add(doc.entries.all());
}
migrateDocs() {
let needsSaving;
for (var slug of this.settings.getDocs()) {
if (!this.docs.findBy("slug", slug)) {
var doc;
needsSaving = true;
if (slug === "webpack~2") {
doc = this.disabledDocs.findBy("slug", "webpack");
}
if (slug === "angular~4_typescript") {
doc = this.disabledDocs.findBy("slug", "angular");
}
if (slug === "angular~2_typescript") {
doc = this.disabledDocs.findBy("slug", "angular~2");
}
if (!doc) {
doc = this.disabledDocs.findBy("slug_without_version", slug);
}
if (doc) {
this.disabledDocs.remove(doc);
this.docs.add(doc);
}
}
}
if (needsSaving) {
this.saveDocs();
}
}
enableDoc(doc, _onSuccess, onError) {
if (this.docs.contains(doc)) {
return;
}
const onSuccess = () => {
if (this.docs.contains(doc)) {
return;
}
this.disabledDocs.remove(doc);
this.docs.add(doc);
this.docs.sort();
this.initDoc(doc);
this.saveDocs();
if (app.settings.get("autoInstall")) {
doc.install(_onSuccess, onError);
} else {
_onSuccess();
}
};
doc.load(onSuccess, onError, { writeCache: true });
}
saveDocs() {
this.settings.setDocs(this.docs.all().map((doc) => doc.slug));
this.db.migrate();
return this.serviceWorker != null
? this.serviceWorker.updateInBackground()
: undefined;
}
welcomeBack() {
let visitCount = this.settings.get("count");
this.settings.set("count", ++visitCount);
if (visitCount === 5) {
new app.views.Notif("Share", { autoHide: null });
}
new app.views.News();
new app.views.Updates();
return (this.updateChecker = new app.UpdateChecker());
}
reboot() {
if (location.pathname !== "/" && location.pathname !== "/settings") {
window.location = `/#${location.pathname}`;
} else {
window.location = "/";
}
}
reload() {
this.docs.clearCache();
this.disabledDocs.clearCache();
if (this.serviceWorker) {
this.serviceWorker.reload();
} else {
this.reboot();
}
}
reset() {
this.localStorage.reset();
this.settings.reset();
if (this.db != null) {
this.db.reset();
}
if (this.serviceWorker != null) {
this.serviceWorker.update();
}
window.location = "/";
}
showTip(tip) {
if (this.isSingleDoc()) {
return;
}
const tips = this.settings.getTips();
if (!tips.includes(tip)) {
tips.push(tip);
this.settings.setTips(tips);
new app.views.Tip(tip);
}
}
hideLoadingScreen() {
if ($.overlayScrollbarsEnabled()) {
document.body.classList.add("_overlay-scrollbars");
}
document.documentElement.classList.remove("_booting");
}
indexHost() {
// Can't load the index files from the host/CDN when service worker is
// enabled because it doesn't support caching URLs that use CORS.
return this.config[
this.serviceWorker && this.settings.hasDocs()
? "index_path"
: "docs_origin"
];
}
onBootError(...args) {
this.trigger("bootError");
this.hideLoadingScreen();
}
onQuotaExceeded() {
if (this.quotaExceeded) {
return;
}
this.quotaExceeded = true;
new app.views.Notif("QuotaExceeded", { autoHide: null });
}
onCookieBlocked(key, value, actual) {
if (this.cookieBlocked) {
return;
}
this.cookieBlocked = true;
new app.views.Notif("CookieBlocked", { autoHide: null });
Raven.captureMessage(`CookieBlocked/${key}`, {
level: "warning",
extra: { value, actual },
});
}
onWindowError(...args) {
if (this.cookieBlocked) {
return;
}
if (this.isInjectionError(...args)) {
this.onInjectionError();
} else if (this.isAppError(...args)) {
if (typeof this.previousErrorHandler === "function") {
this.previousErrorHandler(...args);
}
this.hideLoadingScreen();
if (!this.errorNotif) {
this.errorNotif = new app.views.Notif("Error");
}
this.errorNotif.show();
}
}
onInjectionError() {
if (!this.injectionError) {
this.injectionError = true;
alert(`\
JavaScript code has been injected in the page which prevents DevDocs from running correctly.
Please check your browser extensions/addons. `);
Raven.captureMessage("injection error", { level: "info" });
}
}
isInjectionError() {
// Some browser extensions expect the entire web to use jQuery.
// I gave up trying to fight back.
return (
window.$ !== app._$ ||
window.$$ !== app._$$ ||
window.page !== app._page ||
typeof $.empty !== "function" ||
typeof page.show !== "function"
);
}
isAppError(error, file) {
// Ignore errors from external scripts.
return file && file.includes("devdocs") && file.endsWith(".js");
}
isSupportedBrowser() {
try {
const features = {
bind: !!Function.prototype.bind,
pushState: !!history.pushState,
matchMedia: !!window.matchMedia,
insertAdjacentHTML: !!document.body.insertAdjacentHTML,
defaultPrevented:
document.createEvent("CustomEvent").defaultPrevented === false,
cssVariables: !!CSS.supports?.("(--t: 0)"),
};
for (var key in features) {
var value = features[key];
if (!value) {
Raven.captureMessage(`unsupported/${key}`, { level: "info" });
return false;
}
}
return true;
} catch (error) {
Raven.captureMessage("unsupported/exception", {
level: "info",
extra: { error },
});
return false;
}
}
isSingleDoc() {
return document.body.hasAttribute("data-doc");
}
isMobile() {
return this._isMobile != null
? this._isMobile
: (this._isMobile = app.views.Mobile.detect());
}
isAndroidWebview() {
return this._isAndroidWebview != null
? this._isAndroidWebview
: (this._isAndroidWebview = app.views.Mobile.detectAndroidWebview());
}
isInvalidLocation() {
return (
this.config.env === "production" &&
!location.host.startsWith(app.config.production_host)
);
}
}
this.app = new App();

@ -1,18 +0,0 @@
app.config =
db_filename: 'db.json'
default_docs: <%= App.default_docs.to_json %>
docs_origin: '<%= App.docs_origin %>'
env: '<%= App.environment %>'
history_cache_size: 10
index_filename: 'index.json'
index_path: '/<%= App.docs_prefix %>'
max_results: 50
production_host: 'devdocs.io'
search_param: 'q'
sentry_dsn: '<%= App.sentry_dsn %>'
version: <%= Time.now.to_i %>
release: <%= Time.now.utc.httpdate.to_json %>
mathml_stylesheet: '/mathml.css'
favicon_spritesheet: '<%= image_path('sprites/docs.png') %>'
service_worker_path: '/service-worker.js'
service_worker_enabled: <%= App.environment == :production || ENV['ENABLE_SERVICE_WORKER'] == 'true' %>

@ -0,0 +1,19 @@
app.config = {
db_filename: 'db.json',
default_docs: <%= App.default_docs.to_json %>,
docs_origin: '<%= App.docs_origin %>',
env: '<%= App.environment %>',
history_cache_size: 10,
index_filename: 'index.json',
index_path: '/<%= App.docs_prefix %>',
max_results: 50,
production_host: 'devdocs.io',
search_param: 'q',
sentry_dsn: '<%= App.sentry_dsn %>',
version: <%= Time.now.to_i %>,
release: <%= Time.now.utc.httpdate.to_json %>,
mathml_stylesheet: '/mathml.css',
favicon_spritesheet: '<%= image_path('sprites/docs.png') %>',
service_worker_path: '/service-worker.js',
service_worker_enabled: <%= App.environment == :production || ENV['ENABLE_SERVICE_WORKER'] == 'true' %>,
}

@ -1,382 +0,0 @@
class app.DB
NAME = 'docs'
VERSION = 15
constructor: ->
@versionMultipler = if $.isIE() then 1e5 else 1e9
@useIndexedDB = @useIndexedDB()
@callbacks = []
db: (fn) ->
return fn() unless @useIndexedDB
@callbacks.push(fn) if fn
return if @open
try
@open = true
req = indexedDB.open(NAME, VERSION * @versionMultipler + @userVersion())
req.onsuccess = @onOpenSuccess
req.onerror = @onOpenError
req.onupgradeneeded = @onUpgradeNeeded
catch error
@fail 'exception', error
return
onOpenSuccess: (event) =>
db = event.target.result
if db.objectStoreNames.length is 0
try db.close()
@open = false
@fail 'empty'
else if error = @buggyIDB(db)
try db.close()
@open = false
@fail 'buggy', error
else
@runCallbacks(db)
@open = false
db.close()
return
onOpenError: (event) =>
event.preventDefault()
@open = false
error = event.target.error
switch error.name
when 'QuotaExceededError'
@onQuotaExceededError()
when 'VersionError'
@onVersionError()
when 'InvalidStateError'
@fail 'private_mode'
else
@fail 'cant_open', error
return
fail: (reason, error) ->
@cachedDocs = null
@useIndexedDB = false
@reason or= reason
@error or= error
console.error? 'IDB error', error if error
@runCallbacks()
if error and reason is 'cant_open'
Raven.captureMessage "#{error.name}: #{error.message}", level: 'warning', fingerprint: [error.name]
return
onQuotaExceededError: ->
@reset()
@db()
app.onQuotaExceeded()
Raven.captureMessage 'QuotaExceededError', level: 'warning'
return
onVersionError: ->
req = indexedDB.open(NAME)
req.onsuccess = (event) =>
@handleVersionMismatch event.target.result.version
req.onerror = (event) ->
event.preventDefault()
@fail 'cant_open', error
return
handleVersionMismatch: (actualVersion) ->
if Math.floor(actualVersion / @versionMultipler) isnt VERSION
@fail 'version'
else
@setUserVersion actualVersion - VERSION * @versionMultipler
@db()
return
buggyIDB: (db) ->
return if @checkedBuggyIDB
@checkedBuggyIDB = true
try
@idbTransaction(db, stores: $.makeArray(db.objectStoreNames)[0..1], mode: 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937
return
catch error
return error
runCallbacks: (db) ->
fn(db) while fn = @callbacks.shift()
return
onUpgradeNeeded: (event) ->
return unless db = event.target.result
objectStoreNames = $.makeArray(db.objectStoreNames)
unless $.arrayDelete(objectStoreNames, 'docs')
try db.createObjectStore('docs')
for doc in app.docs.all() when not $.arrayDelete(objectStoreNames, doc.slug)
try db.createObjectStore(doc.slug)
for name in objectStoreNames
try db.deleteObjectStore(name)
return
store: (doc, data, onSuccess, onError, _retry = true) ->
@db (db) =>
unless db
onError()
return
txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false
txn.oncomplete = =>
@cachedDocs?[doc.slug] = doc.mtime
onSuccess()
return
txn.onerror = (event) =>
event.preventDefault()
if txn.error?.name is 'NotFoundError' and _retry
@migrate()
setTimeout =>
@store(doc, data, onSuccess, onError, false)
, 0
else
onError(event)
return
store = txn.objectStore(doc.slug)
store.clear()
store.add(content, path) for path, content of data
store = txn.objectStore('docs')
store.put(doc.mtime, doc.slug)
return
return
unstore: (doc, onSuccess, onError, _retry = true) ->
@db (db) =>
unless db
onError()
return
txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false
txn.oncomplete = =>
delete @cachedDocs?[doc.slug]
onSuccess()
return
txn.onerror = (event) ->
event.preventDefault()
if txn.error?.name is 'NotFoundError' and _retry
@migrate()
setTimeout =>
@unstore(doc, onSuccess, onError, false)
, 0
else
onError(event)
return
store = txn.objectStore('docs')
store.delete(doc.slug)
store = txn.objectStore(doc.slug)
store.clear()
return
return
version: (doc, fn) ->
if (version = @cachedVersion(doc))?
fn(version)
return
@db (db) =>
unless db
fn(false)
return
txn = @idbTransaction db, stores: ['docs'], mode: 'readonly'
store = txn.objectStore('docs')
req = store.get(doc.slug)
req.onsuccess = ->
fn(req.result)
return
req.onerror = (event) ->
event.preventDefault()
fn(false)
return
return
return
cachedVersion: (doc) ->
return unless @cachedDocs
@cachedDocs[doc.slug] or false
versions: (docs, fn) ->
if versions = @cachedVersions(docs)
fn(versions)
return
@db (db) =>
unless db
fn(false)
return
txn = @idbTransaction db, stores: ['docs'], mode: 'readonly'
txn.oncomplete = ->
fn(result)
return
store = txn.objectStore('docs')
result = {}
docs.forEach (doc) ->
req = store.get(doc.slug)
req.onsuccess = ->
result[doc.slug] = req.result
return
req.onerror = (event) ->
event.preventDefault()
result[doc.slug] = false
return
return
return
cachedVersions: (docs) ->
return unless @cachedDocs
result = {}
result[doc.slug] = @cachedVersion(doc) for doc in docs
result
load: (entry, onSuccess, onError) ->
if @shouldLoadWithIDB(entry)
onError = @loadWithXHR.bind(@, entry, onSuccess, onError)
@loadWithIDB entry, onSuccess, onError
else
@loadWithXHR entry, onSuccess, onError
loadWithXHR: (entry, onSuccess, onError) ->
ajax
url: entry.fileUrl()
dataType: 'html'
success: onSuccess
error: onError
loadWithIDB: (entry, onSuccess, onError) ->
@db (db) =>
unless db
onError()
return
unless db.objectStoreNames.contains(entry.doc.slug)
onError()
@loadDocsCache(db)
return
txn = @idbTransaction db, stores: [entry.doc.slug], mode: 'readonly'
store = txn.objectStore(entry.doc.slug)
req = store.get(entry.dbPath())
req.onsuccess = ->
if req.result then onSuccess(req.result) else onError()
return
req.onerror = (event) ->
event.preventDefault()
onError()
return
@loadDocsCache(db)
return
loadDocsCache: (db) ->
return if @cachedDocs
@cachedDocs = {}
txn = @idbTransaction db, stores: ['docs'], mode: 'readonly'
txn.oncomplete = =>
setTimeout(@checkForCorruptedDocs, 50)
return
req = txn.objectStore('docs').openCursor()
req.onsuccess = (event) =>
return unless cursor = event.target.result
@cachedDocs[cursor.key] = cursor.value
cursor.continue()
return
req.onerror = (event) ->
event.preventDefault()
return
return
checkForCorruptedDocs: =>
@db (db) =>
@corruptedDocs = []
docs = (key for key, value of @cachedDocs when value)
return if docs.length is 0
for slug in docs when not app.docs.findBy('slug', slug)
@corruptedDocs.push(slug)
for slug in @corruptedDocs
$.arrayDelete(docs, slug)
if docs.length is 0
setTimeout(@deleteCorruptedDocs, 0)
return
txn = @idbTransaction(db, stores: docs, mode: 'readonly', ignoreError: false)
txn.oncomplete = =>
setTimeout(@deleteCorruptedDocs, 0) if @corruptedDocs.length > 0
return
for doc in docs
txn.objectStore(doc).get('index').onsuccess = (event) =>
@corruptedDocs.push(event.target.source.name) unless event.target.result
return
return
return
deleteCorruptedDocs: =>
@db (db) =>
txn = @idbTransaction(db, stores: ['docs'], mode: 'readwrite', ignoreError: false)
store = txn.objectStore('docs')
while doc = @corruptedDocs.pop()
@cachedDocs[doc] = false
store.delete(doc)
return
Raven.captureMessage 'corruptedDocs', level: 'info', extra: { docs: @corruptedDocs.join(',') }
return
shouldLoadWithIDB: (entry) ->
@useIndexedDB and (not @cachedDocs or @cachedDocs[entry.doc.slug])
idbTransaction: (db, options) ->
app.lastIDBTransaction = [options.stores, options.mode]
txn = db.transaction(options.stores, options.mode)
unless options.ignoreError is false
txn.onerror = (event) ->
event.preventDefault()
return
unless options.ignoreAbort is false
txn.onabort = (event) ->
event.preventDefault()
return
txn
reset: ->
try indexedDB?.deleteDatabase(NAME) catch
return
useIndexedDB: ->
try
if !app.isSingleDoc() and window.indexedDB
true
else
@reason = 'not_supported'
false
catch
false
migrate: ->
app.settings.set('schema', @userVersion() + 1)
return
setUserVersion: (version) ->
app.settings.set('schema', version)
return
userVersion: ->
app.settings.get('schema')

@ -0,0 +1,559 @@
app.DB = class DB {
static NAME = "docs";
static VERSION = 15;
constructor() {
this.versionMultipler = $.isIE() ? 1e5 : 1e9;
this.useIndexedDB = this.useIndexedDB();
this.callbacks = [];
}
db(fn) {
if (!this.useIndexedDB) {
return fn();
}
if (fn) {
this.callbacks.push(fn);
}
if (this.open) {
return;
}
try {
this.open = true;
const req = indexedDB.open(
DB.NAME,
DB.VERSION * this.versionMultipler + this.userVersion(),
);
req.onsuccess = (event) => this.onOpenSuccess(event);
req.onerror = (event) => this.onOpenError(event);
req.onupgradeneeded = (event) => this.onUpgradeNeeded(event);
} catch (error) {
this.fail("exception", error);
}
}
onOpenSuccess(event) {
let error;
const db = event.target.result;
if (db.objectStoreNames.length === 0) {
try {
db.close();
} catch (error1) {}
this.open = false;
this.fail("empty");
} else if ((error = this.buggyIDB(db))) {
try {
db.close();
} catch (error2) {}
this.open = false;
this.fail("buggy", error);
} else {
this.runCallbacks(db);
this.open = false;
db.close();
}
}
onOpenError(event) {
event.preventDefault();
this.open = false;
const { error } = event.target;
switch (error.name) {
case "QuotaExceededError":
this.onQuotaExceededError();
break;
case "VersionError":
this.onVersionError();
break;
case "InvalidStateError":
this.fail("private_mode");
break;
default:
this.fail("cant_open", error);
}
}
fail(reason, error) {
this.cachedDocs = null;
this.useIndexedDB = false;
if (!this.reason) {
this.reason = reason;
}
if (!this.error) {
this.error = error;
}
if (error) {
if (typeof console.error === "function") {
console.error("IDB error", error);
}
}
this.runCallbacks();
if (error && reason === "cant_open") {
Raven.captureMessage(`${error.name}: ${error.message}`, {
level: "warning",
fingerprint: [error.name],
});
}
}
onQuotaExceededError() {
this.reset();
this.db();
app.onQuotaExceeded();
Raven.captureMessage("QuotaExceededError", { level: "warning" });
}
onVersionError() {
const req = indexedDB.open(DB.NAME);
req.onsuccess = (event) => {
return this.handleVersionMismatch(event.target.result.version);
};
req.onerror = function (event) {
event.preventDefault();
return this.fail("cant_open", error);
};
}
handleVersionMismatch(actualVersion) {
if (Math.floor(actualVersion / this.versionMultipler) !== DB.VERSION) {
this.fail("version");
} else {
this.setUserVersion(actualVersion - DB.VERSION * this.versionMultipler);
this.db();
}
}
buggyIDB(db) {
if (this.checkedBuggyIDB) {
return;
}
this.checkedBuggyIDB = true;
try {
this.idbTransaction(db, {
stores: $.makeArray(db.objectStoreNames).slice(0, 2),
mode: "readwrite",
}).abort(); // https://bugs.webkit.org/show_bug.cgi?id=136937
return;
} catch (error) {
return error;
}
}
runCallbacks(db) {
let fn;
while ((fn = this.callbacks.shift())) {
fn(db);
}
}
onUpgradeNeeded(event) {
let db;
if (!(db = event.target.result)) {
return;
}
const objectStoreNames = $.makeArray(db.objectStoreNames);
if (!$.arrayDelete(objectStoreNames, "docs")) {
try {
db.createObjectStore("docs");
} catch (error) {}
}
for (var doc of app.docs.all()) {
if (!$.arrayDelete(objectStoreNames, doc.slug)) {
try {
db.createObjectStore(doc.slug);
} catch (error1) {}
}
}
for (var name of objectStoreNames) {
try {
db.deleteObjectStore(name);
} catch (error2) {}
}
}
store(doc, data, onSuccess, onError, _retry) {
if (_retry == null) {
_retry = true;
}
this.db((db) => {
if (!db) {
onError();
return;
}
const txn = this.idbTransaction(db, {
stores: ["docs", doc.slug],
mode: "readwrite",
ignoreError: false,
});
txn.oncomplete = () => {
if (this.cachedDocs != null) {
this.cachedDocs[doc.slug] = doc.mtime;
}
onSuccess();
};
txn.onerror = (event) => {
event.preventDefault();
if (txn.error?.name === "NotFoundError" && _retry) {
this.migrate();
setTimeout(() => {
return this.store(doc, data, onSuccess, onError, false);
}, 0);
} else {
onError(event);
}
};
let store = txn.objectStore(doc.slug);
store.clear();
for (var path in data) {
var content = data[path];
store.add(content, path);
}
store = txn.objectStore("docs");
store.put(doc.mtime, doc.slug);
});
}
unstore(doc, onSuccess, onError, _retry) {
if (_retry == null) {
_retry = true;
}
this.db((db) => {
if (!db) {
onError();
return;
}
const txn = this.idbTransaction(db, {
stores: ["docs", doc.slug],
mode: "readwrite",
ignoreError: false,
});
txn.oncomplete = () => {
if (this.cachedDocs != null) {
delete this.cachedDocs[doc.slug];
}
onSuccess();
};
txn.onerror = function (event) {
event.preventDefault();
if (txn.error?.name === "NotFoundError" && _retry) {
this.migrate();
setTimeout(() => {
return this.unstore(doc, onSuccess, onError, false);
}, 0);
} else {
onError(event);
}
};
let store = txn.objectStore("docs");
store.delete(doc.slug);
store = txn.objectStore(doc.slug);
store.clear();
});
}
version(doc, fn) {
const version = this.cachedVersion(doc);
if (version != null) {
fn(version);
return;
}
this.db((db) => {
if (!db) {
fn(false);
return;
}
const txn = this.idbTransaction(db, {
stores: ["docs"],
mode: "readonly",
});
const store = txn.objectStore("docs");
const req = store.get(doc.slug);
req.onsuccess = function () {
fn(req.result);
};
req.onerror = function (event) {
event.preventDefault();
fn(false);
};
});
}
cachedVersion(doc) {
if (!this.cachedDocs) {
return;
}
return this.cachedDocs[doc.slug] || false;
}
versions(docs, fn) {
const versions = this.cachedVersions(docs);
if (versions) {
fn(versions);
return;
}
return this.db((db) => {
if (!db) {
fn(false);
return;
}
const txn = this.idbTransaction(db, {
stores: ["docs"],
mode: "readonly",
});
txn.oncomplete = function () {
fn(result);
};
const store = txn.objectStore("docs");
var result = {};
docs.forEach((doc) => {
const req = store.get(doc.slug);
req.onsuccess = function () {
result[doc.slug] = req.result;
};
req.onerror = function (event) {
event.preventDefault();
result[doc.slug] = false;
};
});
});
}
cachedVersions(docs) {
if (!this.cachedDocs) {
return;
}
const result = {};
for (var doc of docs) {
result[doc.slug] = this.cachedVersion(doc);
}
return result;
}
load(entry, onSuccess, onError) {
if (this.shouldLoadWithIDB(entry)) {
return this.loadWithIDB(entry, onSuccess, () =>
this.loadWithXHR(entry, onSuccess, onError),
);
} else {
return this.loadWithXHR(entry, onSuccess, onError);
}
}
loadWithXHR(entry, onSuccess, onError) {
return ajax({
url: entry.fileUrl(),
dataType: "html",
success: onSuccess,
error: onError,
});
}
loadWithIDB(entry, onSuccess, onError) {
return this.db((db) => {
if (!db) {
onError();
return;
}
if (!db.objectStoreNames.contains(entry.doc.slug)) {
onError();
this.loadDocsCache(db);
return;
}
const txn = this.idbTransaction(db, {
stores: [entry.doc.slug],
mode: "readonly",
});
const store = txn.objectStore(entry.doc.slug);
const req = store.get(entry.dbPath());
req.onsuccess = function () {
if (req.result) {
onSuccess(req.result);
} else {
onError();
}
};
req.onerror = function (event) {
event.preventDefault();
onError();
};
this.loadDocsCache(db);
});
}
loadDocsCache(db) {
if (this.cachedDocs) {
return;
}
this.cachedDocs = {};
const txn = this.idbTransaction(db, {
stores: ["docs"],
mode: "readonly",
});
txn.oncomplete = () => {
setTimeout(() => this.checkForCorruptedDocs(), 50);
};
const req = txn.objectStore("docs").openCursor();
req.onsuccess = (event) => {
let cursor;
if (!(cursor = event.target.result)) {
return;
}
this.cachedDocs[cursor.key] = cursor.value;
cursor.continue();
};
req.onerror = function (event) {
event.preventDefault();
};
}
checkForCorruptedDocs() {
this.db((db) => {
let slug;
this.corruptedDocs = [];
const docs = (() => {
const result = [];
for (var key in this.cachedDocs) {
var value = this.cachedDocs[key];
if (value) {
result.push(key);
}
}
return result;
})();
if (docs.length === 0) {
return;
}
for (slug of docs) {
if (!app.docs.findBy("slug", slug)) {
this.corruptedDocs.push(slug);
}
}
for (slug of this.corruptedDocs) {
$.arrayDelete(docs, slug);
}
if (docs.length === 0) {
setTimeout(() => this.deleteCorruptedDocs(), 0);
return;
}
const txn = this.idbTransaction(db, {
stores: docs,
mode: "readonly",
ignoreError: false,
});
txn.oncomplete = () => {
if (this.corruptedDocs.length > 0) {
setTimeout(() => this.deleteCorruptedDocs(), 0);
}
};
for (var doc of docs) {
txn.objectStore(doc).get("index").onsuccess = (event) => {
if (!event.target.result) {
this.corruptedDocs.push(event.target.source.name);
}
};
}
});
}
deleteCorruptedDocs() {
this.db((db) => {
let doc;
const txn = this.idbTransaction(db, {
stores: ["docs"],
mode: "readwrite",
ignoreError: false,
});
const store = txn.objectStore("docs");
while ((doc = this.corruptedDocs.pop())) {
this.cachedDocs[doc] = false;
store.delete(doc);
}
});
Raven.captureMessage("corruptedDocs", {
level: "info",
extra: { docs: this.corruptedDocs.join(",") },
});
}
shouldLoadWithIDB(entry) {
return (
this.useIndexedDB && (!this.cachedDocs || this.cachedDocs[entry.doc.slug])
);
}
idbTransaction(db, options) {
app.lastIDBTransaction = [options.stores, options.mode];
const txn = db.transaction(options.stores, options.mode);
if (options.ignoreError !== false) {
txn.onerror = function (event) {
event.preventDefault();
};
}
if (options.ignoreAbort !== false) {
txn.onabort = function (event) {
event.preventDefault();
};
}
return txn;
}
reset() {
try {
indexedDB?.deleteDatabase(DB.NAME);
} catch (error) {}
}
useIndexedDB() {
try {
if (!app.isSingleDoc() && window.indexedDB) {
return true;
} else {
this.reason = "not_supported";
return false;
}
} catch (error) {
return false;
}
}
migrate() {
app.settings.set("schema", this.userVersion() + 1);
}
setUserVersion(version) {
app.settings.set("schema", version);
}
userVersion() {
return app.settings.get("schema");
}
};

@ -1,154 +0,0 @@
class app.Router
$.extend @prototype, Events
@routes: [
['*', 'before' ]
['/', 'root' ]
['/settings', 'settings' ]
['/offline', 'offline' ]
['/about', 'about' ]
['/news', 'news' ]
['/help', 'help' ]
['/:doc-:type/', 'type' ]
['/:doc/', 'doc' ]
['/:doc/:path(*)', 'entry' ]
['*', 'notFound' ]
]
constructor: ->
for [path, method] in @constructor.routes
page path, @[method].bind(@)
@setInitialPath()
start: ->
page.start()
return
show: (path) ->
page.show(path)
return
triggerRoute: (name) ->
@trigger name, @context
@trigger 'after', name, @context
return
before: (context, next) ->
previousContext = @context
@context = context
@trigger 'before', context
if res = next()
@context = previousContext
return res
else
return
doc: (context, next) ->
if doc = app.docs.findBySlug(context.params.doc) or app.disabledDocs.findBySlug(context.params.doc)
context.doc = doc
context.entry = doc.toEntry()
@triggerRoute 'entry'
return
else
return next()
type: (context, next) ->
doc = app.docs.findBySlug(context.params.doc)
if type = doc?.types.findBy 'slug', context.params.type
context.doc = doc
context.type = type
@triggerRoute 'type'
return
else
return next()
entry: (context, next) ->
doc = app.docs.findBySlug(context.params.doc)
return next() unless doc
path = context.params.path
hash = context.hash
if entry = doc.findEntryByPathAndHash(path, hash)
context.doc = doc
context.entry = entry
@triggerRoute 'entry'
return
else if path.slice(-6) is '/index'
path = path.substr(0, path.length - 6)
return entry.fullPath() if entry = doc.findEntryByPathAndHash(path, hash)
else
path = "#{path}/index"
return entry.fullPath() if entry = doc.findEntryByPathAndHash(path, hash)
return next()
root: ->
return '/' if app.isSingleDoc()
@triggerRoute 'root'
return
settings: (context) ->
return "/#/#{context.path}" if app.isSingleDoc()
@triggerRoute 'settings'
return
offline: (context)->
return "/#/#{context.path}" if app.isSingleDoc()
@triggerRoute 'offline'
return
about: (context) ->
return "/#/#{context.path}" if app.isSingleDoc()
context.page = 'about'
@triggerRoute 'page'
return
news: (context) ->
return "/#/#{context.path}" if app.isSingleDoc()
context.page = 'news'
@triggerRoute 'page'
return
help: (context) ->
return "/#/#{context.path}" if app.isSingleDoc()
context.page = 'help'
@triggerRoute 'page'
return
notFound: (context) ->
@triggerRoute 'notFound'
return
isIndex: ->
@context?.path is '/' or (app.isSingleDoc() and @context?.entry?.isIndex())
isSettings: ->
@context?.path is '/settings'
setInitialPath: ->
# Remove superfluous forward slashes at the beginning of the path
if (path = location.pathname.replace /^\/{2,}/g, '/') isnt location.pathname
page.replace path + location.search + location.hash, null, true
if location.pathname is '/'
if path = @getInitialPathFromHash()
page.replace path + location.search, null, true
else if path = @getInitialPathFromCookie()
page.replace path + location.search + location.hash, null, true
return
getInitialPathFromHash: ->
try
(new RegExp "#/(.+)").exec(decodeURIComponent location.hash)?[1]
catch
getInitialPathFromCookie: ->
if path = Cookies.get('initial_path')
Cookies.expire('initial_path')
path
replaceHash: (hash) ->
page.replace location.pathname + location.search + (hash or ''), null, true
return

@ -0,0 +1,209 @@
app.Router = class Router extends Events {
static routes = [
["*", "before"],
["/", "root"],
["/settings", "settings"],
["/offline", "offline"],
["/about", "about"],
["/news", "news"],
["/help", "help"],
["/:doc-:type/", "type"],
["/:doc/", "doc"],
["/:doc/:path(*)", "entry"],
["*", "notFound"],
];
constructor() {
super();
for (var [path, method] of this.constructor.routes) {
page(path, this[method].bind(this));
}
this.setInitialPath();
}
start() {
page.start();
}
show(path) {
page.show(path);
}
triggerRoute(name) {
this.trigger(name, this.context);
this.trigger("after", name, this.context);
}
before(context, next) {
let res;
const previousContext = this.context;
this.context = context;
this.trigger("before", context);
if ((res = next())) {
this.context = previousContext;
return res;
} else {
return;
}
}
doc(context, next) {
let doc;
if (
(doc =
app.docs.findBySlug(context.params.doc) ||
app.disabledDocs.findBySlug(context.params.doc))
) {
context.doc = doc;
context.entry = doc.toEntry();
this.triggerRoute("entry");
return;
} else {
return next();
}
}
type(context, next) {
const doc = app.docs.findBySlug(context.params.doc);
const type = doc?.types?.findBy("slug", context.params.type);
if (type) {
context.doc = doc;
context.type = type;
this.triggerRoute("type");
return;
} else {
return next();
}
}
entry(context, next) {
let entry;
const doc = app.docs.findBySlug(context.params.doc);
if (!doc) {
return next();
}
let { path } = context.params;
const { hash } = context;
if ((entry = doc.findEntryByPathAndHash(path, hash))) {
context.doc = doc;
context.entry = entry;
this.triggerRoute("entry");
return;
} else if (path.slice(-6) === "/index") {
path = path.substr(0, path.length - 6);
if ((entry = doc.findEntryByPathAndHash(path, hash))) {
return entry.fullPath();
}
} else {
path = `${path}/index`;
if ((entry = doc.findEntryByPathAndHash(path, hash))) {
return entry.fullPath();
}
}
return next();
}
root() {
if (app.isSingleDoc()) {
return "/";
}
this.triggerRoute("root");
}
settings(context) {
if (app.isSingleDoc()) {
return `/#/${context.path}`;
}
this.triggerRoute("settings");
}
offline(context) {
if (app.isSingleDoc()) {
return `/#/${context.path}`;
}
this.triggerRoute("offline");
}
about(context) {
if (app.isSingleDoc()) {
return `/#/${context.path}`;
}
context.page = "about";
this.triggerRoute("page");
}
news(context) {
if (app.isSingleDoc()) {
return `/#/${context.path}`;
}
context.page = "news";
this.triggerRoute("page");
}
help(context) {
if (app.isSingleDoc()) {
return `/#/${context.path}`;
}
context.page = "help";
this.triggerRoute("page");
}
notFound(context) {
this.triggerRoute("notFound");
}
isIndex() {
return (
this.context?.path === "/" ||
(app.isSingleDoc() && this.context?.entry?.isIndex())
);
}
isSettings() {
return this.context?.path === "/settings";
}
setInitialPath() {
// Remove superfluous forward slashes at the beginning of the path
let path;
if (
(path = location.pathname.replace(/^\/{2,}/g, "/")) !== location.pathname
) {
page.replace(path + location.search + location.hash, null, true);
}
if (location.pathname === "/") {
if ((path = this.getInitialPathFromHash())) {
page.replace(path + location.search, null, true);
} else if ((path = this.getInitialPathFromCookie())) {
page.replace(path + location.search + location.hash, null, true);
}
}
}
getInitialPathFromHash() {
try {
return new RegExp("#/(.+)").exec(decodeURIComponent(location.hash))?.[1];
} catch (error) {}
}
getInitialPathFromCookie() {
let path;
if ((path = Cookies.get("initial_path"))) {
Cookies.expire("initial_path");
return path;
}
}
replaceHash(hash) {
page.replace(
location.pathname + location.search + (hash || ""),
null,
true,
);
}
};

@ -1,292 +0,0 @@
#
# Match functions
#
SEPARATOR = '.'
query =
queryLength =
value =
valueLength =
matcher = # current match function
fuzzyRegexp = # query fuzzy regexp
index = # position of the query in the string being matched
lastIndex = # last position of the query in the string being matched
match = # regexp match data
matchIndex =
matchLength =
score = # score for the current match
separators = # counter
i = null # cursor
`function exactMatch() {`
index = value.indexOf(query)
return unless index >= 0
lastIndex = value.lastIndexOf(query)
if index isnt lastIndex
return Math.max(scoreExactMatch(), ((index = lastIndex) and scoreExactMatch()) or 0)
else
return scoreExactMatch()
`}`
`function scoreExactMatch() {`
# Remove one point for each unmatched character.
score = 100 - (valueLength - queryLength)
if index > 0
# If the character preceding the query is a dot, assign the same score
# as if the query was found at the beginning of the string, minus one.
if value.charAt(index - 1) is SEPARATOR
score += index - 1
# Don't match a single-character query unless it's found at the beginning
# of the string or is preceded by a dot.
else if queryLength is 1
return
# (1) Remove one point for each unmatched character up to the nearest
# preceding dot or the beginning of the string.
# (2) Remove one point for each unmatched character following the query.
else
i = index - 2
i-- while i >= 0 and value.charAt(i) isnt SEPARATOR
score -= (index - i) + # (1)
(valueLength - queryLength - index) # (2)
# Remove one point for each dot preceding the query, except for the one
# immediately before the query.
separators = 0
i = index - 2
while i >= 0
separators++ if value.charAt(i) is SEPARATOR
i--
score -= separators
# Remove five points for each dot following the query.
separators = 0
i = valueLength - queryLength - index - 1
while i >= 0
separators++ if value.charAt(index + queryLength + i) is SEPARATOR
i--
score -= separators * 5
return Math.max 1, score
`}`
`function fuzzyMatch() {`
return if valueLength <= queryLength or value.indexOf(query) >= 0
return unless match = fuzzyRegexp.exec(value)
matchIndex = match.index
matchLength = match[0].length
score = scoreFuzzyMatch()
if match = fuzzyRegexp.exec(value.slice(i = value.lastIndexOf(SEPARATOR) + 1))
matchIndex = i + match.index
matchLength = match[0].length
return Math.max(score, scoreFuzzyMatch())
else
return score
`}`
`function scoreFuzzyMatch() {`
# When the match is at the beginning of the string or preceded by a dot.
if matchIndex is 0 or value.charAt(matchIndex - 1) is SEPARATOR
return Math.max 66, 100 - matchLength
# When the match is at the end of the string.
else if matchIndex + matchLength is valueLength
return Math.max 33, 67 - matchLength
# When the match is in the middle of the string.
else
return Math.max 1, 34 - matchLength
`}`
#
# Searchers
#
class app.Searcher
$.extend @prototype, Events
CHUNK_SIZE = 20000
DEFAULTS =
max_results: app.config.max_results
fuzzy_min_length: 3
SEPARATORS_REGEXP = /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g
EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/
INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/
EMPTY_PARANTHESES_REGEXP = /\(\)/
EVENT_REGEXP = /\ event$/
DOT_REGEXP = /\.+/g
WHITESPACE_REGEXP = /\s/g
EMPTY_STRING = ''
ELLIPSIS = '...'
STRING = 'string'
@normalizeString: (string) ->
string
.toLowerCase()
.replace ELLIPSIS, EMPTY_STRING
.replace EVENT_REGEXP, EMPTY_STRING
.replace INFO_PARANTHESES_REGEXP, EMPTY_STRING
.replace SEPARATORS_REGEXP, SEPARATOR
.replace DOT_REGEXP, SEPARATOR
.replace EMPTY_PARANTHESES_REGEXP, EMPTY_STRING
.replace WHITESPACE_REGEXP, EMPTY_STRING
@normalizeQuery: (string) ->
string = @normalizeString(string)
string.replace EOS_SEPARATORS_REGEXP, '$1.'
constructor: (options = {}) ->
@options = $.extend {}, DEFAULTS, options
find: (data, attr, q) ->
@kill()
@data = data
@attr = attr
@query = q
@setup()
if @isValid() then @match() else @end()
return
setup: ->
query = @query = @constructor.normalizeQuery(@query)
queryLength = query.length
@dataLength = @data.length
@matchers = [exactMatch]
@totalResults = 0
@setupFuzzy()
return
setupFuzzy: ->
if queryLength >= @options.fuzzy_min_length
fuzzyRegexp = @queryToFuzzyRegexp(query)
@matchers.push(fuzzyMatch)
else
fuzzyRegexp = null
return
isValid: ->
queryLength > 0 and query isnt SEPARATOR
end: ->
@triggerResults [] unless @totalResults
@trigger 'end'
@free()
return
kill: ->
if @timeout
clearTimeout @timeout
@free()
return
free: ->
@data = @attr = @dataLength = @matchers = @matcher = @query =
@totalResults = @scoreMap = @cursor = @timeout = null
return
match: =>
if not @foundEnough() and @matcher = @matchers.shift()
@setupMatcher()
@matchChunks()
else
@end()
return
setupMatcher: ->
@cursor = 0
@scoreMap = new Array(101)
return
matchChunks: =>
@matchChunk()
if @cursor is @dataLength or @scoredEnough()
@delay @match
@sendResults()
else
@delay @matchChunks
return
matchChunk: ->
matcher = @matcher
for [0...@chunkSize()]
value = @data[@cursor][@attr]
if value.split # string
valueLength = value.length
@addResult(@data[@cursor], score) if score = matcher()
else # array
score = 0
for value in @data[@cursor][@attr]
valueLength = value.length
score = Math.max(score, matcher() || 0)
@addResult(@data[@cursor], score) if score > 0
@cursor++
return
chunkSize: ->
if @cursor + CHUNK_SIZE > @dataLength
@dataLength % CHUNK_SIZE
else
CHUNK_SIZE
scoredEnough: ->
@scoreMap[100]?.length >= @options.max_results
foundEnough: ->
@totalResults >= @options.max_results
addResult: (object, score) ->
(@scoreMap[Math.round(score)] or= []).push(object)
@totalResults++
return
getResults: ->
results = []
for objects in @scoreMap by -1 when objects
results.push.apply results, objects
results[0...@options.max_results]
sendResults: ->
results = @getResults()
@triggerResults results if results.length
return
triggerResults: (results) ->
@trigger 'results', results
return
delay: (fn) ->
@timeout = setTimeout(fn, 1)
queryToFuzzyRegexp: (string) ->
chars = string.split ''
chars[i] = $.escapeRegexp(char) for char, i in chars
new RegExp chars.join('.*?') # abc -> /a.*?b.*?c.*?/
class app.SynchronousSearcher extends app.Searcher
match: =>
if @matcher
@allResults or= []
@allResults.push.apply @allResults, @getResults()
super
free: ->
@allResults = null
super
end: ->
@sendResults true
super
sendResults: (end) ->
if end and @allResults?.length
@triggerResults @allResults
delay: (fn) ->
fn()

@ -0,0 +1,396 @@
//
// Match functions
//
let fuzzyRegexp,
i,
index,
lastIndex,
match,
matcher,
matchIndex,
matchLength,
queryLength,
score,
separators,
value,
valueLength;
const SEPARATOR = ".";
let query =
(queryLength =
value =
valueLength =
matcher = // current match function
fuzzyRegexp = // query fuzzy regexp
index = // position of the query in the string being matched
lastIndex = // last position of the query in the string being matched
match = // regexp match data
matchIndex =
matchLength =
score = // score for the current match
separators = // counter
i =
null); // cursor
function exactMatch() {
index = value.indexOf(query);
if (!(index >= 0)) {
return;
}
lastIndex = value.lastIndexOf(query);
if (index !== lastIndex) {
return Math.max(
scoreExactMatch(),
((index = lastIndex) && scoreExactMatch()) || 0,
);
} else {
return scoreExactMatch();
}
}
function scoreExactMatch() {
// Remove one point for each unmatched character.
score = 100 - (valueLength - queryLength);
if (index > 0) {
// If the character preceding the query is a dot, assign the same score
// as if the query was found at the beginning of the string, minus one.
if (value.charAt(index - 1) === SEPARATOR) {
score += index - 1;
// Don't match a single-character query unless it's found at the beginning
// of the string or is preceded by a dot.
} else if (queryLength === 1) {
return;
// (1) Remove one point for each unmatched character up to the nearest
// preceding dot or the beginning of the string.
// (2) Remove one point for each unmatched character following the query.
} else {
i = index - 2;
while (i >= 0 && value.charAt(i) !== SEPARATOR) {
i--;
}
score -=
index -
i + // (1)
(valueLength - queryLength - index); // (2)
}
// Remove one point for each dot preceding the query, except for the one
// immediately before the query.
separators = 0;
i = index - 2;
while (i >= 0) {
if (value.charAt(i) === SEPARATOR) {
separators++;
}
i--;
}
score -= separators;
}
// Remove five points for each dot following the query.
separators = 0;
i = valueLength - queryLength - index - 1;
while (i >= 0) {
if (value.charAt(index + queryLength + i) === SEPARATOR) {
separators++;
}
i--;
}
score -= separators * 5;
return Math.max(1, score);
}
function fuzzyMatch() {
if (valueLength <= queryLength || value.includes(query)) {
return;
}
if (!(match = fuzzyRegexp.exec(value))) {
return;
}
matchIndex = match.index;
matchLength = match[0].length;
score = scoreFuzzyMatch();
if (
(match = fuzzyRegexp.exec(
value.slice((i = value.lastIndexOf(SEPARATOR) + 1)),
))
) {
matchIndex = i + match.index;
matchLength = match[0].length;
return Math.max(score, scoreFuzzyMatch());
} else {
return score;
}
}
function scoreFuzzyMatch() {
// When the match is at the beginning of the string or preceded by a dot.
if (matchIndex === 0 || value.charAt(matchIndex - 1) === SEPARATOR) {
return Math.max(66, 100 - matchLength);
// When the match is at the end of the string.
} else if (matchIndex + matchLength === valueLength) {
return Math.max(33, 67 - matchLength);
// When the match is in the middle of the string.
} else {
return Math.max(1, 34 - matchLength);
}
}
//
// Searchers
//
app.Searcher = class Searcher extends Events {
static CHUNK_SIZE = 20000;
static DEFAULTS = {
max_results: app.config.max_results,
fuzzy_min_length: 3,
};
static SEPARATORS_REGEXP =
/#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g;
static EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/;
static INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/;
static EMPTY_PARANTHESES_REGEXP = /\(\)/;
static EVENT_REGEXP = /\ event$/;
static DOT_REGEXP = /\.+/g;
static WHITESPACE_REGEXP = /\s/g;
static EMPTY_STRING = "";
static ELLIPSIS = "...";
static STRING = "string";
static normalizeString(string) {
return string
.toLowerCase()
.replace(Searcher.ELLIPSIS, Searcher.EMPTY_STRING)
.replace(Searcher.EVENT_REGEXP, Searcher.EMPTY_STRING)
.replace(Searcher.INFO_PARANTHESES_REGEXP, Searcher.EMPTY_STRING)
.replace(Searcher.SEPARATORS_REGEXP, SEPARATOR)
.replace(Searcher.DOT_REGEXP, SEPARATOR)
.replace(Searcher.EMPTY_PARANTHESES_REGEXP, Searcher.EMPTY_STRING)
.replace(Searcher.WHITESPACE_REGEXP, Searcher.EMPTY_STRING);
}
static normalizeQuery(string) {
string = this.normalizeString(string);
return string.replace(Searcher.EOS_SEPARATORS_REGEXP, "$1.");
}
constructor(options) {
super();
this.options = { ...Searcher.DEFAULTS, ...(options || {}) };
}
find(data, attr, q) {
this.kill();
this.data = data;
this.attr = attr;
this.query = q;
this.setup();
if (this.isValid()) {
this.match();
} else {
this.end();
}
}
setup() {
query = this.query = this.constructor.normalizeQuery(this.query);
queryLength = query.length;
this.dataLength = this.data.length;
this.matchers = [exactMatch];
this.totalResults = 0;
this.setupFuzzy();
}
setupFuzzy() {
if (queryLength >= this.options.fuzzy_min_length) {
fuzzyRegexp = this.queryToFuzzyRegexp(query);
this.matchers.push(fuzzyMatch);
} else {
fuzzyRegexp = null;
}
}
isValid() {
return queryLength > 0 && query !== SEPARATOR;
}
end() {
if (!this.totalResults) {
this.triggerResults([]);
}
this.trigger("end");
this.free();
}
kill() {
if (this.timeout) {
clearTimeout(this.timeout);
this.free();
}
}
free() {
this.data = null;
this.attr = null;
this.dataLength = null;
this.matchers = null;
this.matcher = null;
this.query = null;
this.totalResults = null;
this.scoreMap = null;
this.cursor = null;
this.timeout = null;
}
match() {
if (!this.foundEnough() && (this.matcher = this.matchers.shift())) {
this.setupMatcher();
this.matchChunks();
} else {
this.end();
}
}
setupMatcher() {
this.cursor = 0;
this.scoreMap = new Array(101);
}
matchChunks() {
this.matchChunk();
if (this.cursor === this.dataLength || this.scoredEnough()) {
this.delay(() => this.match());
this.sendResults();
} else {
this.delay(() => this.matchChunks());
}
}
matchChunk() {
({ matcher } = this);
for (let j = 0, end = this.chunkSize(); j < end; j++) {
value = this.data[this.cursor][this.attr];
if (value.split) {
// string
valueLength = value.length;
if ((score = matcher())) {
this.addResult(this.data[this.cursor], score);
}
} else {
// array
score = 0;
for (value of Array.from(this.data[this.cursor][this.attr])) {
valueLength = value.length;
score = Math.max(score, matcher() || 0);
}
if (score > 0) {
this.addResult(this.data[this.cursor], score);
}
}
this.cursor++;
}
}
chunkSize() {
if (this.cursor + Searcher.CHUNK_SIZE > this.dataLength) {
return this.dataLength % Searcher.CHUNK_SIZE;
} else {
return Searcher.CHUNK_SIZE;
}
}
scoredEnough() {
return this.scoreMap[100]?.length >= this.options.max_results;
}
foundEnough() {
return this.totalResults >= this.options.max_results;
}
addResult(object, score) {
let name;
(
this.scoreMap[(name = Math.round(score))] || (this.scoreMap[name] = [])
).push(object);
this.totalResults++;
}
getResults() {
const results = [];
for (let j = this.scoreMap.length - 1; j >= 0; j--) {
var objects = this.scoreMap[j];
if (objects) {
results.push(...objects);
}
}
return results.slice(0, this.options.max_results);
}
sendResults() {
const results = this.getResults();
if (results.length) {
this.triggerResults(results);
}
}
triggerResults(results) {
this.trigger("results", results);
}
delay(fn) {
return (this.timeout = setTimeout(fn, 1));
}
queryToFuzzyRegexp(string) {
const chars = string.split("");
for (i = 0; i < chars.length; i++) {
var char = chars[i];
chars[i] = $.escapeRegexp(char);
}
return new RegExp(chars.join(".*?")); // abc -> /a.*?b.*?c.*?/
}
};
app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher {
match() {
if (this.matcher) {
if (!this.allResults) {
this.allResults = [];
}
this.allResults.push(...this.getResults());
}
return super.match(...arguments);
}
free() {
this.allResults = null;
return super.free(...arguments);
}
end() {
this.sendResults(true);
return super.end(...arguments);
}
sendResults(end) {
if (end && this.allResults?.length) {
return this.triggerResults(this.allResults);
}
}
delay(fn) {
return fn();
}
};

@ -1,49 +0,0 @@
class app.ServiceWorker
$.extend @prototype, Events
@isEnabled: ->
!!navigator.serviceWorker and app.config.service_worker_enabled
constructor: ->
@registration = null
@notifyUpdate = true
navigator.serviceWorker.register(app.config.service_worker_path, {scope: '/'})
.then(
(registration) => @updateRegistration(registration),
(error) -> console.error('Could not register service worker:', error)
)
update: ->
return unless @registration
@notifyUpdate = true
return @registration.update().catch(->)
updateInBackground: ->
return unless @registration
@notifyUpdate = false
return @registration.update().catch(->)
reload: ->
return @updateInBackground().then(() -> app.reboot())
updateRegistration: (registration) ->
@registration = registration
$.on @registration, 'updatefound', @onUpdateFound
return
onUpdateFound: =>
$.off @installingRegistration, 'statechange', @onStateChange() if @installingRegistration
@installingRegistration = @registration.installing
$.on @installingRegistration, 'statechange', @onStateChange
return
onStateChange: =>
if @installingRegistration and @installingRegistration.state == 'installed' and navigator.serviceWorker.controller
@installingRegistration = null
@onUpdateReady()
return
onUpdateReady: ->
@trigger 'updateready' if @notifyUpdate
return

@ -0,0 +1,69 @@
app.ServiceWorker = class ServiceWorker extends Events {
static isEnabled() {
return !!navigator.serviceWorker && app.config.service_worker_enabled;
}
constructor() {
super();
this.onStateChange = this.onStateChange.bind(this);
this.registration = null;
this.notifyUpdate = true;
navigator.serviceWorker
.register(app.config.service_worker_path, { scope: "/" })
.then(
(registration) => this.updateRegistration(registration),
(error) => console.error("Could not register service worker:", error),
);
}
update() {
if (!this.registration) {
return;
}
this.notifyUpdate = true;
return this.registration.update().catch(() => {});
}
updateInBackground() {
if (!this.registration) {
return;
}
this.notifyUpdate = false;
return this.registration.update().catch(() => {});
}
reload() {
return this.updateInBackground().then(() => app.reboot());
}
updateRegistration(registration) {
this.registration = registration;
$.on(this.registration, "updatefound", () => this.onUpdateFound());
}
onUpdateFound() {
if (this.installingRegistration) {
$.off(this.installingRegistration, "statechange", this.onStateChange);
}
this.installingRegistration = this.registration.installing;
$.on(this.installingRegistration, "statechange", this.onStateChange);
}
onStateChange() {
if (
this.installingRegistration &&
this.installingRegistration.state === "installed" &&
navigator.serviceWorker.controller
) {
this.installingRegistration = null;
this.onUpdateReady();
}
}
onUpdateReady() {
if (this.notifyUpdate) {
this.trigger("updateready");
}
}
};

@ -1,170 +0,0 @@
class app.Settings
PREFERENCE_KEYS = [
'hideDisabled'
'hideIntro'
'manualUpdate'
'fastScroll'
'arrowScroll'
'analyticsConsent'
'docs'
'dark' # legacy
'theme'
'layout'
'size'
'tips'
'noAutofocus'
'autoInstall'
'spaceScroll'
'spaceTimeout'
]
INTERNAL_KEYS = [
'count'
'schema'
'version'
'news'
]
LAYOUTS: [
'_max-width'
'_sidebar-hidden'
'_native-scrollbars'
'_text-justify-hyphenate'
]
@defaults:
count: 0
hideDisabled: false
hideIntro: false
news: 0
manualUpdate: false
schema: 1
analyticsConsent: false
theme: 'auto'
spaceScroll: 1
spaceTimeout: 0.5
constructor: ->
@store = new CookiesStore
@cache = {}
@autoSupported = window.matchMedia('(prefers-color-scheme)').media != 'not all'
if @autoSupported
@darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
@darkModeQuery.addListener => @setTheme(@get('theme'))
get: (key) ->
return @cache[key] if @cache.hasOwnProperty(key)
@cache[key] = @store.get(key) ? @constructor.defaults[key]
if key == 'theme' and @cache[key] == 'auto' and !@darkModeQuery
@cache[key] = 'default'
else
@cache[key]
set: (key, value) ->
@store.set(key, value)
delete @cache[key]
@setTheme(value) if key == 'theme'
return
del: (key) ->
@store.del(key)
delete @cache[key]
return
hasDocs: ->
try !!@store.get('docs')
getDocs: ->
@store.get('docs')?.split('/') or app.config.default_docs
setDocs: (docs) ->
@set 'docs', docs.join('/')
return
getTips: ->
@store.get('tips')?.split('/') or []
setTips: (tips) ->
@set 'tips', tips.join('/')
return
setLayout: (name, enable) ->
@toggleLayout(name, enable)
layout = (@store.get('layout') || '').split(' ')
$.arrayDelete(layout, '')
if enable
layout.push(name) if layout.indexOf(name) is -1
else
$.arrayDelete(layout, name)
if layout.length > 0
@set 'layout', layout.join(' ')
else
@del 'layout'
return
hasLayout: (name) ->
layout = (@store.get('layout') || '').split(' ')
layout.indexOf(name) isnt -1
setSize: (value) ->
@set 'size', value
return
dump: ->
@store.dump()
export: ->
data = @dump()
delete data[key] for key in INTERNAL_KEYS
data
import: (data) ->
for key, value of @export()
@del key unless data.hasOwnProperty(key)
for key, value of data
@set key, value if PREFERENCE_KEYS.indexOf(key) isnt -1
return
reset: ->
@store.reset()
@cache = {}
return
initLayout: ->
if @get('dark') is 1
@set('theme', 'dark')
@del 'dark'
@setTheme(@get('theme'))
@toggleLayout(layout, @hasLayout(layout)) for layout in @LAYOUTS
@initSidebarWidth()
return
setTheme: (theme) ->
if theme is 'auto'
theme = if @darkModeQuery.matches then 'dark' else 'default'
classList = document.documentElement.classList
classList.remove('_theme-default', '_theme-dark')
classList.add('_theme-' + theme)
@updateColorMeta()
return
updateColorMeta: ->
color = getComputedStyle(document.documentElement).getPropertyValue('--headerBackground').trim()
$('meta[name=theme-color]').setAttribute('content', color)
return
toggleLayout: (layout, enable) ->
classList = document.body.classList
# sidebar is always shown for settings; its state is updated in app.views.Settings
classList.toggle(layout, enable) unless layout is '_sidebar-hidden' and app.router?.isSettings
classList.toggle('_overlay-scrollbars', $.overlayScrollbarsEnabled())
return
initSidebarWidth: ->
size = @get('size')
document.documentElement.style.setProperty('--sidebarWidth', size + 'px') if size
return

@ -0,0 +1,213 @@
app.Settings = class Settings {
static PREFERENCE_KEYS = [
"hideDisabled",
"hideIntro",
"manualUpdate",
"fastScroll",
"arrowScroll",
"analyticsConsent",
"docs",
"dark", // legacy
"theme",
"layout",
"size",
"tips",
"noAutofocus",
"autoInstall",
"spaceScroll",
"spaceTimeout",
];
static INTERNAL_KEYS = ["count", "schema", "version", "news"];
static LAYOUTS = [
"_max-width",
"_sidebar-hidden",
"_native-scrollbars",
"_text-justify-hyphenate",
];
static defaults = {
count: 0,
hideDisabled: false,
hideIntro: false,
news: 0,
manualUpdate: false,
schema: 1,
analyticsConsent: false,
theme: "auto",
spaceScroll: 1,
spaceTimeout: 0.5,
};
constructor() {
this.store = new CookiesStore();
this.cache = {};
this.autoSupported =
window.matchMedia("(prefers-color-scheme)").media !== "not all";
if (this.autoSupported) {
this.darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
this.darkModeQuery.addListener(() => this.setTheme(this.get("theme")));
}
}
get(key) {
let left;
if (this.cache.hasOwnProperty(key)) {
return this.cache[key];
}
this.cache[key] =
(left = this.store.get(key)) != null
? left
: this.constructor.defaults[key];
if (key === "theme" && this.cache[key] === "auto" && !this.darkModeQuery) {
return (this.cache[key] = "default");
} else {
return this.cache[key];
}
}
set(key, value) {
this.store.set(key, value);
delete this.cache[key];
if (key === "theme") {
this.setTheme(value);
}
}
del(key) {
this.store.del(key);
delete this.cache[key];
}
hasDocs() {
try {
return !!this.store.get("docs");
} catch (error) {}
}
getDocs() {
return this.store.get("docs")?.split("/") || app.config.default_docs;
}
setDocs(docs) {
this.set("docs", docs.join("/"));
}
getTips() {
return this.store.get("tips")?.split("/") || [];
}
setTips(tips) {
this.set("tips", tips.join("/"));
}
setLayout(name, enable) {
this.toggleLayout(name, enable);
const layout = (this.store.get("layout") || "").split(" ");
$.arrayDelete(layout, "");
if (enable) {
if (!layout.includes(name)) {
layout.push(name);
}
} else {
$.arrayDelete(layout, name);
}
if (layout.length > 0) {
this.set("layout", layout.join(" "));
} else {
this.del("layout");
}
}
hasLayout(name) {
const layout = (this.store.get("layout") || "").split(" ");
return layout.includes(name);
}
setSize(value) {
this.set("size", value);
}
dump() {
return this.store.dump();
}
export() {
const data = this.dump();
for (var key of Settings.INTERNAL_KEYS) {
delete data[key];
}
return data;
}
import(data) {
let key, value;
const object = this.export();
for (key in object) {
value = object[key];
if (!data.hasOwnProperty(key)) {
this.del(key);
}
}
for (key in data) {
value = data[key];
if (Settings.PREFERENCE_KEYS.includes(key)) {
this.set(key, value);
}
}
}
reset() {
this.store.reset();
this.cache = {};
}
initLayout() {
if (this.get("dark") === 1) {
this.set("theme", "dark");
this.del("dark");
}
this.setTheme(this.get("theme"));
for (var layout of app.Settings.LAYOUTS) {
this.toggleLayout(layout, this.hasLayout(layout));
}
this.initSidebarWidth();
}
setTheme(theme) {
if (theme === "auto") {
theme = this.darkModeQuery.matches ? "dark" : "default";
}
const { classList } = document.documentElement;
classList.remove("_theme-default", "_theme-dark");
classList.add("_theme-" + theme);
this.updateColorMeta();
}
updateColorMeta() {
const color = getComputedStyle(document.documentElement)
.getPropertyValue("--headerBackground")
.trim();
$("meta[name=theme-color]").setAttribute("content", color);
}
toggleLayout(layout, enable) {
const { classList } = document.body;
// sidebar is always shown for settings; its state is updated in app.views.Settings
if (layout !== "_sidebar-hidden" || !app.router?.isSettings) {
classList.toggle(layout, enable);
}
classList.toggle("_overlay-scrollbars", $.overlayScrollbarsEnabled());
}
initSidebarWidth() {
const size = this.get("size");
if (size) {
document.documentElement.style.setProperty("--sidebarWidth", size + "px");
}
}
};

@ -1,193 +0,0 @@
class app.Shortcuts
$.extend @prototype, Events
constructor: ->
@isMac = $.isMac()
@start()
start: ->
$.on document, 'keydown', @onKeydown
$.on document, 'keypress', @onKeypress
return
stop: ->
$.off document, 'keydown', @onKeydown
$.off document, 'keypress', @onKeypress
return
swapArrowKeysBehavior: ->
app.settings.get('arrowScroll')
spaceScroll: ->
app.settings.get('spaceScroll')
showTip: ->
app.showTip('KeyNav')
@showTip = null
spaceTimeout: ->
app.settings.get('spaceTimeout')
onKeydown: (event) =>
return if @buggyEvent(event)
result = if event.ctrlKey or event.metaKey
@handleKeydownSuperEvent event unless event.altKey or event.shiftKey
else if event.shiftKey
@handleKeydownShiftEvent event unless event.altKey
else if event.altKey
@handleKeydownAltEvent event
else
@handleKeydownEvent event
event.preventDefault() if result is false
return
onKeypress: (event) =>
return if @buggyEvent(event) or (event.charCode == 63 and document.activeElement.tagName == 'INPUT')
unless event.ctrlKey or event.metaKey
result = @handleKeypressEvent event
event.preventDefault() if result is false
return
handleKeydownEvent: (event, _force) ->
return @handleKeydownAltEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior()
if not event.target.form and (48 <= event.which <= 57 or 65 <= event.which <= 90)
@trigger 'typing'
return
switch event.which
when 8
@trigger 'typing' unless event.target.form
when 13
@trigger 'enter'
when 27
@trigger 'escape'
false
when 32
if event.target.type is 'search' and @spaceScroll() and (not @lastKeypress or @lastKeypress < Date.now() - (@spaceTimeout() * 1000))
@trigger 'pageDown'
false
when 33
@trigger 'pageUp'
when 34
@trigger 'pageDown'
when 35
@trigger 'pageBottom' unless event.target.form
when 36
@trigger 'pageTop' unless event.target.form
when 37
@trigger 'left' unless event.target.value
when 38
@trigger 'up'
@showTip?()
false
when 39
@trigger 'right' unless event.target.value
when 40
@trigger 'down'
@showTip?()
false
when 191
unless event.target.form
@trigger 'typing'
false
handleKeydownSuperEvent: (event) ->
switch event.which
when 13
@trigger 'superEnter'
when 37
if @isMac
@trigger 'superLeft'
false
when 38
@trigger 'pageTop'
false
when 39
if @isMac
@trigger 'superRight'
false
when 40
@trigger 'pageBottom'
false
when 188
@trigger 'preferences'
false
handleKeydownShiftEvent: (event, _force) ->
return @handleKeydownEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior()
if not event.target.form and 65 <= event.which <= 90
@trigger 'typing'
return
switch event.which
when 32
@trigger 'pageUp'
false
when 38
unless getSelection()?.toString()
@trigger 'altUp'
false
when 40
unless getSelection()?.toString()
@trigger 'altDown'
false
handleKeydownAltEvent: (event, _force) ->
return @handleKeydownEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior()
switch event.which
when 9
@trigger 'altRight', event
when 37
unless @isMac
@trigger 'superLeft'
false
when 38
@trigger 'altUp'
false
when 39
unless @isMac
@trigger 'superRight'
false
when 40
@trigger 'altDown'
false
when 67
@trigger 'altC'
false
when 68
@trigger 'altD'
false
when 70
@trigger 'altF', event
when 71
@trigger 'altG'
false
when 79
@trigger 'altO'
false
when 82
@trigger 'altR'
false
when 83
@trigger 'altS'
false
handleKeypressEvent: (event) ->
if event.which is 63 and not event.target.value
@trigger 'help'
false
else
@lastKeypress = Date.now()
buggyEvent: (event) ->
try
event.target
event.ctrlKey
event.which
return false
catch
return true

@ -0,0 +1,295 @@
app.Shortcuts = class Shortcuts extends Events {
constructor() {
super();
this.onKeydown = this.onKeydown.bind(this);
this.onKeypress = this.onKeypress.bind(this);
this.isMac = $.isMac();
this.start();
}
start() {
$.on(document, "keydown", this.onKeydown);
$.on(document, "keypress", this.onKeypress);
}
stop() {
$.off(document, "keydown", this.onKeydown);
$.off(document, "keypress", this.onKeypress);
}
swapArrowKeysBehavior() {
return app.settings.get("arrowScroll");
}
spaceScroll() {
return app.settings.get("spaceScroll");
}
showTip() {
app.showTip("KeyNav");
return (this.showTip = null);
}
spaceTimeout() {
return app.settings.get("spaceTimeout");
}
onKeydown(event) {
if (this.buggyEvent(event)) {
return;
}
const result = (() => {
if (event.ctrlKey || event.metaKey) {
if (!event.altKey && !event.shiftKey) {
return this.handleKeydownSuperEvent(event);
}
} else if (event.shiftKey) {
if (!event.altKey) {
return this.handleKeydownShiftEvent(event);
}
} else if (event.altKey) {
return this.handleKeydownAltEvent(event);
} else {
return this.handleKeydownEvent(event);
}
})();
if (result === false) {
event.preventDefault();
}
}
onKeypress(event) {
if (
this.buggyEvent(event) ||
(event.charCode === 63 && document.activeElement.tagName === "INPUT")
) {
return;
}
if (!event.ctrlKey && !event.metaKey) {
const result = this.handleKeypressEvent(event);
if (result === false) {
event.preventDefault();
}
}
}
handleKeydownEvent(event, _force) {
if (
!_force &&
[37, 38, 39, 40].includes(event.which) &&
this.swapArrowKeysBehavior()
) {
return this.handleKeydownAltEvent(event, true);
}
if (
!event.target.form &&
((48 <= event.which && event.which <= 57) ||
(65 <= event.which && event.which <= 90))
) {
this.trigger("typing");
return;
}
switch (event.which) {
case 8:
if (!event.target.form) {
return this.trigger("typing");
}
break;
case 13:
return this.trigger("enter");
case 27:
this.trigger("escape");
return false;
case 32:
if (
event.target.type === "search" &&
this.spaceScroll() &&
(!this.lastKeypress ||
this.lastKeypress < Date.now() - this.spaceTimeout() * 1000)
) {
this.trigger("pageDown");
return false;
}
break;
case 33:
return this.trigger("pageUp");
case 34:
return this.trigger("pageDown");
case 35:
if (!event.target.form) {
return this.trigger("pageBottom");
}
break;
case 36:
if (!event.target.form) {
return this.trigger("pageTop");
}
break;
case 37:
if (!event.target.value) {
return this.trigger("left");
}
break;
case 38:
this.trigger("up");
if (typeof this.showTip === "function") {
this.showTip();
}
return false;
case 39:
if (!event.target.value) {
return this.trigger("right");
}
break;
case 40:
this.trigger("down");
if (typeof this.showTip === "function") {
this.showTip();
}
return false;
case 191:
if (!event.target.form) {
this.trigger("typing");
return false;
}
break;
}
}
handleKeydownSuperEvent(event) {
switch (event.which) {
case 13:
return this.trigger("superEnter");
case 37:
if (this.isMac) {
this.trigger("superLeft");
return false;
}
break;
case 38:
this.trigger("pageTop");
return false;
case 39:
if (this.isMac) {
this.trigger("superRight");
return false;
}
break;
case 40:
this.trigger("pageBottom");
return false;
case 188:
this.trigger("preferences");
return false;
}
}
handleKeydownShiftEvent(event, _force) {
if (
!_force &&
[37, 38, 39, 40].includes(event.which) &&
this.swapArrowKeysBehavior()
) {
return this.handleKeydownEvent(event, true);
}
if (!event.target.form && 65 <= event.which && event.which <= 90) {
this.trigger("typing");
return;
}
switch (event.which) {
case 32:
this.trigger("pageUp");
return false;
case 38:
if (!getSelection()?.toString()) {
this.trigger("altUp");
return false;
}
break;
case 40:
if (!getSelection()?.toString()) {
this.trigger("altDown");
return false;
}
break;
}
}
handleKeydownAltEvent(event, _force) {
if (
!_force &&
[37, 38, 39, 40].includes(event.which) &&
this.swapArrowKeysBehavior()
) {
return this.handleKeydownEvent(event, true);
}
switch (event.which) {
case 9:
return this.trigger("altRight", event);
case 37:
if (!this.isMac) {
this.trigger("superLeft");
return false;
}
break;
case 38:
this.trigger("altUp");
return false;
case 39:
if (!this.isMac) {
this.trigger("superRight");
return false;
}
break;
case 40:
this.trigger("altDown");
return false;
case 67:
this.trigger("altC");
return false;
case 68:
this.trigger("altD");
return false;
case 70:
return this.trigger("altF", event);
case 71:
this.trigger("altG");
return false;
case 79:
this.trigger("altO");
return false;
case 82:
this.trigger("altR");
return false;
case 83:
this.trigger("altS");
return false;
}
}
handleKeypressEvent(event) {
if (event.which === 63 && !event.target.value) {
this.trigger("help");
return false;
} else {
return (this.lastKeypress = Date.now());
}
}
buggyEvent(event) {
try {
event.target;
event.ctrlKey;
event.which;
return false;
} catch (error) {
return true;
}
}
};

@ -1,39 +0,0 @@
class app.UpdateChecker
constructor: ->
@lastCheck = Date.now()
$.on window, 'focus', @onFocus
app.serviceWorker?.on 'updateready', @onUpdateReady
setTimeout @checkDocs, 0
check: ->
if app.serviceWorker
app.serviceWorker.update()
else
ajax
url: $('script[src*="application"]').getAttribute('src')
dataType: 'application/javascript'
error: (_, xhr) => @onUpdateReady() if xhr.status is 404
return
onUpdateReady: ->
new app.views.Notif 'UpdateReady', autoHide: null
return
checkDocs: =>
unless app.settings.get('manualUpdate')
app.docs.updateInBackground()
else
app.docs.checkForUpdates (i) => @onDocsUpdateReady() if i > 0
return
onDocsUpdateReady: ->
new app.views.Notif 'UpdateDocs', autoHide: null
return
onFocus: =>
if Date.now() - @lastCheck > 21600e3
@lastCheck = Date.now()
@check()
return

@ -0,0 +1,55 @@
app.UpdateChecker = class UpdateChecker {
constructor() {
this.lastCheck = Date.now();
$.on(window, "focus", () => this.onFocus());
if (app.serviceWorker) {
app.serviceWorker.on("updateready", () => this.onUpdateReady());
}
setTimeout(() => this.checkDocs(), 0);
}
check() {
if (app.serviceWorker) {
app.serviceWorker.update();
} else {
ajax({
url: $('script[src*="application"]').getAttribute("src"),
dataType: "application/javascript",
error: (_, xhr) => {
if (xhr.status === 404) {
return this.onUpdateReady();
}
},
});
}
}
onUpdateReady() {
new app.views.Notif("UpdateReady", { autoHide: null });
}
checkDocs() {
if (!app.settings.get("manualUpdate")) {
app.docs.updateInBackground();
} else {
app.docs.checkForUpdates((i) => {
if (i > 0) {
return this.onDocsUpdateReady();
}
});
}
}
onDocsUpdateReady() {
new app.views.Notif("UpdateDocs", { autoHide: null });
}
onFocus() {
if (Date.now() - this.lastCheck > 21600e3) {
this.lastCheck = Date.now();
this.check();
}
}
};

@ -0,0 +1,33 @@
//= require_tree ./vendor
//= require lib/license
//= require_tree ./lib
//= require app/app
//= require app/config
//= require_tree ./app
//= require collections/collection
//= require_tree ./collections
//= require models/model
//= require_tree ./models
//= require views/view
//= require_tree ./views
//= require_tree ./templates
//= require tracking
var init = function () {
document.removeEventListener("DOMContentLoaded", init, false);
if (document.body) {
return app.init();
} else {
return setTimeout(init, 42);
}
};
document.addEventListener("DOMContentLoaded", init, false);

@ -1,31 +0,0 @@
#= require_tree ./vendor
#= require lib/license
#= require_tree ./lib
#= require app/app
#= require app/config
#= require_tree ./app
#= require collections/collection
#= require_tree ./collections
#= require models/model
#= require_tree ./models
#= require views/view
#= require_tree ./views
#= require_tree ./templates
#= require tracking
init = ->
document.removeEventListener 'DOMContentLoaded', init, false
if document.body
app.init()
else
setTimeout(init, 42)
document.addEventListener 'DOMContentLoaded', init, false

@ -1,55 +0,0 @@
class app.Collection
constructor: (objects = []) ->
@reset objects
model: ->
app.models[@constructor.model]
reset: (objects = []) ->
@models = []
@add object for object in objects
return
add: (object) ->
if object instanceof app.Model
@models.push object
else if object instanceof Array
@add obj for obj in object
else if object instanceof app.Collection
@models.push object.all()...
else
@models.push new (@model())(object)
return
remove: (model) ->
@models.splice @models.indexOf(model), 1
return
size: ->
@models.length
isEmpty: ->
@models.length is 0
each: (fn) ->
fn(model) for model in @models
return
all: ->
@models
contains: (model) ->
@models.indexOf(model) >= 0
findBy: (attr, value) ->
for model in @models
return model if model[attr] is value
return
findAllBy: (attr, value) ->
model for model in @models when model[attr] is value
countAllBy: (attr, value) ->
i = 0
i += 1 for model in @models when model[attr] is value
i

@ -0,0 +1,80 @@
app.Collection = class Collection {
constructor(objects) {
if (objects == null) {
objects = [];
}
this.reset(objects);
}
model() {
return app.models[this.constructor.model];
}
reset(objects) {
if (objects == null) {
objects = [];
}
this.models = [];
for (var object of objects) {
this.add(object);
}
}
add(object) {
if (object instanceof app.Model) {
this.models.push(object);
} else if (object instanceof Array) {
for (var obj of object) {
this.add(obj);
}
} else if (object instanceof app.Collection) {
this.models.push(...(object.all() || []));
} else {
this.models.push(new (this.model())(object));
}
}
remove(model) {
this.models.splice(this.models.indexOf(model), 1);
}
size() {
return this.models.length;
}
isEmpty() {
return this.models.length === 0;
}
each(fn) {
for (var model of this.models) {
fn(model);
}
}
all() {
return this.models;
}
contains(model) {
return this.models.includes(model);
}
findBy(attr, value) {
return this.models.find((model) => model[attr] === value);
}
findAllBy(attr, value) {
return this.models.filter((model) => model[attr] === value);
}
countAllBy(attr, value) {
let i = 0;
for (var model of this.models) {
if (model[attr] === value) {
i += 1;
}
}
return i;
}
};

@ -1,85 +0,0 @@
class app.collections.Docs extends app.Collection
@model: 'Doc'
findBySlug: (slug) ->
@findBy('slug', slug) or @findBy('slug_without_version', slug)
NORMALIZE_VERSION_RGX = /\.(\d)$/
NORMALIZE_VERSION_SUB = '.0$1'
sort: ->
@models.sort (a, b) ->
if a.name is b.name
if not a.version or a.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB) > b.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB)
-1
else
1
else if a.name.toLowerCase() > b.name.toLowerCase()
1
else
-1
# Load models concurrently.
# It's not pretty but I didn't want to import a promise library only for this.
CONCURRENCY = 3
load: (onComplete, onError, options) ->
i = 0
next = =>
if i < @models.length
@models[i].load(next, fail, options)
else if i is @models.length + CONCURRENCY - 1
onComplete()
i++
return
fail = (args...) ->
if onError
onError(args...)
onError = null
next()
return
next() for [0...CONCURRENCY]
return
clearCache: ->
doc.clearCache() for doc in @models
return
uninstall: (callback) ->
i = 0
next = =>
if i < @models.length
@models[i++].uninstall(next, next)
else
callback()
return
next()
return
getInstallStatuses: (callback) ->
app.db.versions @models, (statuses) ->
if statuses
for key, value of statuses
statuses[key] = installed: !!value, mtime: value
callback(statuses)
return
return
checkForUpdates: (callback) ->
@getInstallStatuses (statuses) =>
i = 0
if statuses
i += 1 for slug, status of statuses when @findBy('slug', slug).isOutdated(status)
callback(i)
return
return
updateInBackground: ->
@getInstallStatuses (statuses) =>
return unless statuses
for slug, status of statuses
doc = @findBy 'slug', slug
doc.install($.noop, $.noop) if doc.isOutdated(status)
return
return

@ -0,0 +1,124 @@
app.collections.Docs = class Docs extends app.Collection {
static model = "Doc";
static NORMALIZE_VERSION_RGX = /\.(\d)$/;
static NORMALIZE_VERSION_SUB = ".0$1";
// Load models concurrently.
// It's not pretty but I didn't want to import a promise library only for this.
static CONCURRENCY = 3;
findBySlug(slug) {
return (
this.findBy("slug", slug) || this.findBy("slug_without_version", slug)
);
}
sort() {
return this.models.sort((a, b) => {
if (a.name === b.name) {
if (
!a.version ||
a.version.replace(
Docs.NORMALIZE_VERSION_RGX,
Docs.NORMALIZE_VERSION_SUB,
) >
b.version.replace(
Docs.NORMALIZE_VERSION_RGX,
Docs.NORMALIZE_VERSION_SUB,
)
) {
return -1;
} else {
return 1;
}
} else if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
} else {
return -1;
}
});
}
load(onComplete, onError, options) {
let i = 0;
var next = () => {
if (i < this.models.length) {
this.models[i].load(next, fail, options);
} else if (i === this.models.length + Docs.CONCURRENCY - 1) {
onComplete();
}
i++;
};
var fail = function (...args) {
if (onError) {
onError(args);
onError = null;
}
next();
};
for (let j = 0, end = Docs.CONCURRENCY; j < end; j++) {
next();
}
}
clearCache() {
for (var doc of this.models) {
doc.clearCache();
}
}
uninstall(callback) {
let i = 0;
var next = () => {
if (i < this.models.length) {
this.models[i++].uninstall(next, next);
} else {
callback();
}
};
next();
}
getInstallStatuses(callback) {
app.db.versions(this.models, (statuses) => {
if (statuses) {
for (var key in statuses) {
var value = statuses[key];
statuses[key] = { installed: !!value, mtime: value };
}
}
callback(statuses);
});
}
checkForUpdates(callback) {
this.getInstallStatuses((statuses) => {
let i = 0;
if (statuses) {
for (var slug in statuses) {
var status = statuses[slug];
if (this.findBy("slug", slug).isOutdated(status)) {
i += 1;
}
}
}
callback(i);
});
}
updateInBackground() {
this.getInstallStatuses((statuses) => {
if (!statuses) {
return;
}
for (var slug in statuses) {
var status = statuses[slug];
var doc = this.findBy("slug", slug);
if (doc.isOutdated(status)) {
doc.install($.noop, $.noop);
}
}
});
}
};

@ -1,2 +0,0 @@
class app.collections.Entries extends app.Collection
@model: 'Entry'

@ -0,0 +1,3 @@
app.collections.Entries = class Entries extends app.Collection {
static model = "Entry";
};

@ -1,19 +0,0 @@
class app.collections.Types extends app.Collection
@model: 'Type'
groups: ->
result = []
for type in @models
(result[@_groupFor(type)] ||= []).push(type)
result.filter (e) -> e.length > 0
GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i
APPENDIX_RGX = /appendix/i
_groupFor: (type) ->
if GUIDES_RGX.test(type.name)
0
else if APPENDIX_RGX.test(type.name)
2
else
1

@ -0,0 +1,26 @@
app.collections.Types = class Types extends app.Collection {
static model = "Type";
static GUIDES_RGX =
/(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i;
static APPENDIX_RGX = /appendix/i;
groups() {
const result = [];
for (var type of this.models) {
const name = this._groupFor(type);
result[name] ||= [];
result[name].push(type);
}
return result.filter((e) => e.length > 0);
}
_groupFor(type) {
if (Types.GUIDES_RGX.test(type.name)) {
return 0;
} else if (Types.APPENDIX_RGX.test(type.name)) {
return 2;
} else {
return 1;
}
}
};

@ -0,0 +1,105 @@
//
// App
//
const _init = app.init;
app.init = function () {
console.time("Init");
_init.call(app);
console.timeEnd("Init");
return console.time("Load");
};
const _start = app.start;
app.start = function () {
console.timeEnd("Load");
console.time("Start");
_start.call(app, ...arguments);
return console.timeEnd("Start");
};
//
// Searcher
//
app.Searcher = class TimingSearcher extends app.Searcher {
setup() {
console.groupCollapsed(`Search: ${this.query}`);
console.time("Total");
return super.setup();
}
match() {
if (this.matcher) {
console.timeEnd(this.matcher.name);
}
return super.match();
}
setupMatcher() {
console.time(this.matcher.name);
return super.setupMatcher();
}
end() {
console.log(`Results: ${this.totalResults}`);
console.timeEnd("Total");
console.groupEnd();
return super.end();
}
kill() {
if (this.timeout) {
if (this.matcher) {
console.timeEnd(this.matcher.name);
}
console.groupEnd();
console.timeEnd("Total");
console.warn("Killed");
}
return super.kill();
}
};
//
// View tree
//
this.viewTree = function (view, level, visited) {
if (view == null) {
view = app.document;
}
if (level == null) {
level = 0;
}
if (visited == null) {
visited = [];
}
if (visited.includes(view)) {
return;
}
visited.push(view);
console.log(
`%c ${Array(level + 1).join(" ")}${
view.constructor.name
}: ${!!view.activated}`,
"color:" + ((view.activated && "green") || "red"),
);
for (var key of Object.keys(view || {})) {
var value = view[key];
if (key !== "view" && value) {
if (typeof value === "object" && value.setupElement) {
this.viewTree(value, level + 1, visited);
} else if (value.constructor.toString().match(/Object\(\)/)) {
for (var k of Object.keys(value || {})) {
var v = value[k];
if (v && typeof v === "object" && v.setupElement) {
this.viewTree(v, level + 1, visited);
}
}
}
}
}
};

@ -1,85 +0,0 @@
return unless console?.time and console.groupCollapsed
#
# App
#
_init = app.init
app.init = ->
console.time 'Init'
_init.call(app)
console.timeEnd 'Init'
console.time 'Load'
_start = app.start
app.start = ->
console.timeEnd 'Load'
console.time 'Start'
_start.call(app, arguments...)
console.timeEnd 'Start'
#
# Searcher
#
_super = app.Searcher
_proto = app.Searcher.prototype
app.Searcher = ->
_super.apply @, arguments
_setup = @setup.bind(@)
@setup = ->
console.groupCollapsed "Search: #{@query}"
console.time 'Total'
_setup()
_match = @match.bind(@)
@match = =>
console.timeEnd @matcher.name if @matcher
_match()
_setupMatcher = @setupMatcher.bind(@)
@setupMatcher = ->
console.time @matcher.name
_setupMatcher()
_end = @end.bind(@)
@end = ->
console.log "Results: #{@totalResults}"
console.timeEnd 'Total'
console.groupEnd()
_end()
_kill = @kill.bind(@)
@kill = ->
if @timeout
console.timeEnd @matcher.name if @matcher
console.groupEnd()
console.timeEnd 'Total'
console.warn 'Killed'
_kill()
return
$.extend(app.Searcher, _super)
_proto.constructor = app.Searcher
app.Searcher.prototype = _proto
#
# View tree
#
@viewTree = (view = app.document, level = 0, visited = []) ->
return if visited.indexOf(view) >= 0
visited.push(view)
console.log "%c #{Array(level + 1).join(' ')}#{view.constructor.name}: #{!!view.activated}",
'color:' + (view.activated and 'green' or 'red')
for own key, value of view when key isnt 'view' and value
if typeof value is 'object' and value.setupElement
@viewTree(value, level + 1, visited)
else if value.constructor.toString().match(/Object\(\)/)
@viewTree(v, level + 1, visited) for own k, v of value when v and typeof v is 'object' and v.setupElement
return

@ -1,118 +0,0 @@
MIME_TYPES =
json: 'application/json'
html: 'text/html'
@ajax = (options) ->
applyDefaults(options)
serializeData(options)
xhr = new XMLHttpRequest()
xhr.open(options.type, options.url, options.async)
applyCallbacks(xhr, options)
applyHeaders(xhr, options)
xhr.send(options.data)
if options.async
abort: abort.bind(undefined, xhr)
else
parseResponse(xhr, options)
ajax.defaults =
async: true
dataType: 'json'
timeout: 30
type: 'GET'
# contentType
# context
# data
# error
# headers
# progress
# success
# url
applyDefaults = (options) ->
for key of ajax.defaults
options[key] ?= ajax.defaults[key]
return
serializeData = (options) ->
return unless options.data
if options.type is 'GET'
options.url += '?' + serializeParams(options.data)
options.data = null
else
options.data = serializeParams(options.data)
return
serializeParams = (params) ->
("#{encodeURIComponent key}=#{encodeURIComponent value}" for key, value of params).join '&'
applyCallbacks = (xhr, options) ->
return unless options.async
xhr.timer = setTimeout onTimeout.bind(undefined, xhr, options), options.timeout * 1000
xhr.onprogress = options.progress if options.progress
xhr.onreadystatechange = ->
if xhr.readyState is 4
clearTimeout(xhr.timer)
onComplete(xhr, options)
return
return
applyHeaders = (xhr, options) ->
options.headers or= {}
if options.contentType
options.headers['Content-Type'] = options.contentType
if not options.headers['Content-Type'] and options.data and options.type isnt 'GET'
options.headers['Content-Type'] = 'application/x-www-form-urlencoded'
if options.dataType
options.headers['Accept'] = MIME_TYPES[options.dataType] or options.dataType
for key, value of options.headers
xhr.setRequestHeader(key, value)
return
onComplete = (xhr, options) ->
if 200 <= xhr.status < 300
if (response = parseResponse(xhr, options))?
onSuccess response, xhr, options
else
onError 'invalid', xhr, options
else
onError 'error', xhr, options
return
onSuccess = (response, xhr, options) ->
options.success?.call options.context, response, xhr, options
return
onError = (type, xhr, options) ->
options.error?.call options.context, type, xhr, options
return
onTimeout = (xhr, options) ->
xhr.abort()
onError 'timeout', xhr, options
return
abort = (xhr) ->
clearTimeout(xhr.timer)
xhr.onreadystatechange = null
xhr.abort()
return
parseResponse = (xhr, options) ->
if options.dataType is 'json'
parseJSON(xhr.responseText)
else
xhr.responseText
parseJSON = (json) ->
try JSON.parse(json) catch

@ -0,0 +1,166 @@
const MIME_TYPES = {
json: "application/json",
html: "text/html",
};
function ajax(options) {
applyDefaults(options);
serializeData(options);
const xhr = new XMLHttpRequest();
xhr.open(options.type, options.url, options.async);
applyCallbacks(xhr, options);
applyHeaders(xhr, options);
xhr.send(options.data);
if (options.async) {
return { abort: abort.bind(undefined, xhr) };
} else {
return parseResponse(xhr, options);
}
function applyDefaults(options) {
for (var key in ajax.defaults) {
if (options[key] == null) {
options[key] = ajax.defaults[key];
}
}
}
function serializeData(options) {
if (!options.data) {
return;
}
if (options.type === "GET") {
options.url += "?" + serializeParams(options.data);
options.data = null;
} else {
options.data = serializeParams(options.data);
}
}
function serializeParams(params) {
return Object.entries(params)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
)
.join("&");
}
function applyCallbacks(xhr, options) {
if (!options.async) {
return;
}
xhr.timer = setTimeout(
onTimeout.bind(undefined, xhr, options),
options.timeout * 1000,
);
if (options.progress) {
xhr.onprogress = options.progress;
}
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
clearTimeout(xhr.timer);
onComplete(xhr, options);
}
};
}
function applyHeaders(xhr, options) {
if (!options.headers) {
options.headers = {};
}
if (options.contentType) {
options.headers["Content-Type"] = options.contentType;
}
if (
!options.headers["Content-Type"] &&
options.data &&
options.type !== "GET"
) {
options.headers["Content-Type"] = "application/x-www-form-urlencoded";
}
if (options.dataType) {
options.headers["Accept"] =
MIME_TYPES[options.dataType] || options.dataType;
}
for (var key in options.headers) {
var value = options.headers[key];
xhr.setRequestHeader(key, value);
}
}
function onComplete(xhr, options) {
if (200 <= xhr.status && xhr.status < 300) {
let response;
if ((response = parseResponse(xhr, options)) != null) {
onSuccess(response, xhr, options);
} else {
onError("invalid", xhr, options);
}
} else {
onError("error", xhr, options);
}
}
function onSuccess(response, xhr, options) {
if (options.success != null) {
options.success.call(options.context, response, xhr, options);
}
}
function onError(type, xhr, options) {
if (options.error != null) {
options.error.call(options.context, type, xhr, options);
}
}
function onTimeout(xhr, options) {
xhr.abort();
onError("timeout", xhr, options);
}
function abort(xhr) {
clearTimeout(xhr.timer);
xhr.onreadystatechange = null;
xhr.abort();
}
function parseResponse(xhr, options) {
if (options.dataType === "json") {
return parseJSON(xhr.responseText);
} else {
return xhr.responseText;
}
}
function parseJSON(json) {
try {
return JSON.parse(json);
} catch (error) {}
}
}
ajax.defaults = {
async: true,
dataType: "json",
timeout: 30,
type: "GET",
// contentType
// context
// data
// error
// headers
// progress
// success
// url
};

@ -1,42 +0,0 @@
class @CookiesStore
# Intentionally called CookiesStore instead of CookieStore
# Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome
# Related issue: https://github.com/freeCodeCamp/devdocs/issues/932
INT = /^\d+$/
@onBlocked: ->
get: (key) ->
value = Cookies.get(key)
value = parseInt(value, 10) if value? and INT.test(value)
value
set: (key, value) ->
if value == false
@del(key)
return
value = 1 if value == true
value = parseInt(value, 10) if value and INT.test?(value)
Cookies.set(key, '' + value, path: '/', expires: 1e8)
@constructor.onBlocked(key, value, @get(key)) if @get(key) != value
return
del: (key) ->
Cookies.expire(key)
return
reset: ->
try
for cookie in document.cookie.split(/;\s?/)
Cookies.expire(cookie.split('=')[0])
return
catch
dump: ->
result = {}
for cookie in document.cookie.split(/;\s?/) when cookie[0] isnt '_'
cookie = cookie.split('=')
result[cookie[0]] = cookie[1]
result

@ -0,0 +1,63 @@
// Intentionally called CookiesStore instead of CookieStore
// Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome
// Related issue: https://github.com/freeCodeCamp/devdocs/issues/932
class CookiesStore {
static INT = /^\d+$/;
static onBlocked() {}
get(key) {
let value = Cookies.get(key);
if (value != null && CookiesStore.INT.test(value)) {
value = parseInt(value, 10);
}
return value;
}
set(key, value) {
if (value === false) {
this.del(key);
return;
}
if (value === true) {
value = 1;
}
if (
value &&
(typeof CookiesStore.INT.test === "function"
? CookiesStore.INT.test(value)
: undefined)
) {
value = parseInt(value, 10);
}
Cookies.set(key, "" + value, { path: "/", expires: 1e8 });
if (this.get(key) !== value) {
CookiesStore.onBlocked(key, value, this.get(key));
}
}
del(key) {
Cookies.expire(key);
}
reset() {
try {
for (var cookie of document.cookie.split(/;\s?/)) {
Cookies.expire(cookie.split("=")[0]);
}
return;
} catch (error) {}
}
dump() {
const result = {};
for (var cookie of document.cookie.split(/;\s?/)) {
if (cookie[0] !== "_") {
cookie = cookie.split("=");
result[cookie[0]] = cookie[1];
}
}
return result;
}
}

@ -1,28 +0,0 @@
@Events =
on: (event, callback) ->
if event.indexOf(' ') >= 0
@on name, callback for name in event.split(' ')
else
((@_callbacks ?= {})[event] ?= []).push callback
@
off: (event, callback) ->
if event.indexOf(' ') >= 0
@off name, callback for name in event.split(' ')
else if (callbacks = @_callbacks?[event]) and (index = callbacks.indexOf callback) >= 0
callbacks.splice index, 1
delete @_callbacks[event] unless callbacks.length
@
trigger: (event, args...) ->
@eventInProgress = { name: event, args: args }
if callbacks = @_callbacks?[event]
callback? args... for callback in callbacks.slice(0)
@eventInProgress = null
@trigger 'all', event, args... unless event is 'all'
@
removeEvent: (event) ->
if @_callbacks?
delete @_callbacks[name] for name in event.split(' ')
@

@ -0,0 +1,58 @@
class Events {
on(event, callback) {
if (event.includes(" ")) {
for (var name of event.split(" ")) {
this.on(name, callback);
}
} else {
this._callbacks ||= {};
this._callbacks[event] ||= [];
this._callbacks[event].push(callback);
}
return this;
}
off(event, callback) {
let callbacks, index;
if (event.includes(" ")) {
for (var name of event.split(" ")) {
this.off(name, callback);
}
} else if (
(callbacks = this._callbacks?.[event]) &&
(index = callbacks.indexOf(callback)) >= 0
) {
callbacks.splice(index, 1);
if (!callbacks.length) {
delete this._callbacks[event];
}
}
return this;
}
trigger(event, ...args) {
this.eventInProgress = { name: event, args };
const callbacks = this._callbacks?.[event];
if (callbacks) {
for (const callback of callbacks.slice(0)) {
if (typeof callback === "function") {
callback(...args);
}
}
}
this.eventInProgress = null;
if (event !== "all") {
this.trigger("all", event, ...args);
}
return this;
}
removeEvent(event) {
if (this._callbacks != null) {
for (var name of event.split(" ")) {
delete this._callbacks[name];
}
}
return this;
}
}

@ -1,76 +0,0 @@
defaultUrl = null
currentSlug = null
imageCache = {}
urlCache = {}
withImage = (url, action) ->
if imageCache[url]
action(imageCache[url])
else
img = new Image()
img.crossOrigin = 'anonymous'
img.src = url
img.onload = () =>
imageCache[url] = img
action(img)
@setFaviconForDoc = (doc) ->
return if currentSlug == doc.slug
favicon = $('link[rel="icon"]')
if defaultUrl == null
defaultUrl = favicon.href
if urlCache[doc.slug]
favicon.href = urlCache[doc.slug]
currentSlug = doc.slug
return
iconEl = $("._icon-#{doc.slug.split('~')[0]}")
return if iconEl == null
styles = window.getComputedStyle(iconEl, ':before')
backgroundPositionX = styles['background-position-x']
backgroundPositionY = styles['background-position-y']
return if backgroundPositionX == undefined || backgroundPositionY == undefined
bgUrl = app.config.favicon_spritesheet
sourceSize = 16
sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2)))
sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2)))
withImage(bgUrl, (docImg) ->
withImage(defaultUrl, (defaultImg) ->
size = defaultImg.width
canvas = document.createElement('canvas')
ctx = canvas.getContext('2d')
canvas.width = size
canvas.height = size
ctx.drawImage(defaultImg, 0, 0)
docIconPercentage = 65
destinationCoords = size / 100 * (100 - docIconPercentage)
destinationSize = size / 100 * docIconPercentage
ctx.drawImage(docImg, sourceX, sourceY, sourceSize, sourceSize, destinationCoords, destinationCoords, destinationSize, destinationSize)
try
urlCache[doc.slug] = canvas.toDataURL()
favicon.href = urlCache[doc.slug]
currentSlug = doc.slug
catch error
Raven.captureException error, { level: 'info' }
@resetFavicon()
)
)
@resetFavicon = () ->
if defaultUrl != null and currentSlug != null
$('link[rel="icon"]').href = defaultUrl
currentSlug = null

@ -0,0 +1,101 @@
let defaultUrl = null;
let currentSlug = null;
const imageCache = {};
const urlCache = {};
const withImage = function (url, action) {
if (imageCache[url]) {
return action(imageCache[url]);
} else {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = url;
return (img.onload = () => {
imageCache[url] = img;
return action(img);
});
}
};
this.setFaviconForDoc = function (doc) {
if (currentSlug === doc.slug) {
return;
}
const favicon = $('link[rel="icon"]');
if (defaultUrl === null) {
defaultUrl = favicon.href;
}
if (urlCache[doc.slug]) {
favicon.href = urlCache[doc.slug];
currentSlug = doc.slug;
return;
}
const iconEl = $(`._icon-${doc.slug.split("~")[0]}`);
if (iconEl === null) {
return;
}
const styles = window.getComputedStyle(iconEl, ":before");
const backgroundPositionX = styles["background-position-x"];
const backgroundPositionY = styles["background-position-y"];
if (backgroundPositionX === undefined || backgroundPositionY === undefined) {
return;
}
const bgUrl = app.config.favicon_spritesheet;
const sourceSize = 16;
const sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2)));
const sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2)));
return withImage(bgUrl, (docImg) =>
withImage(defaultUrl, function (defaultImg) {
const size = defaultImg.width;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = size;
canvas.height = size;
ctx.drawImage(defaultImg, 0, 0);
const docIconPercentage = 65;
const destinationCoords = (size / 100) * (100 - docIconPercentage);
const destinationSize = (size / 100) * docIconPercentage;
ctx.drawImage(
docImg,
sourceX,
sourceY,
sourceSize,
sourceSize,
destinationCoords,
destinationCoords,
destinationSize,
destinationSize,
);
try {
urlCache[doc.slug] = canvas.toDataURL();
favicon.href = urlCache[doc.slug];
return (currentSlug = doc.slug);
} catch (error) {
Raven.captureException(error, { level: "info" });
return this.resetFavicon();
}
}),
);
};
this.resetFavicon = function () {
if (defaultUrl !== null && currentSlug !== null) {
$('link[rel="icon"]').href = defaultUrl;
return (currentSlug = null);
}
};

@ -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 +0,0 @@
class @LocalStorageStore
get: (key) ->
try
JSON.parse localStorage.getItem(key)
catch
set: (key, value) ->
try
localStorage.setItem(key, JSON.stringify(value))
true
catch
del: (key) ->
try
localStorage.removeItem(key)
true
catch
reset: ->
try
localStorage.clear()
true
catch

@ -0,0 +1,28 @@
this.LocalStorageStore = class LocalStorageStore {
get(key) {
try {
return JSON.parse(localStorage.getItem(key));
} catch (error) {}
}
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {}
}
del(key) {
try {
localStorage.removeItem(key);
return true;
} catch (error) {}
}
reset() {
try {
localStorage.clear();
return true;
} catch (error) {}
}
};

@ -1,223 +0,0 @@
###
* 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
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

@ -0,0 +1,338 @@
/*
* Based on github.com/visionmedia/page.js
* Licensed under the MIT license
* Copyright 2012 TJ Holowaychuk <tj@vision-media.ca>
*/
let running = false;
let currentState = null;
const callbacks = [];
this.page = function (value, fn) {
if (typeof value === "function") {
page("*", value);
} else if (typeof fn === "function") {
const route = new Route(value);
callbacks.push(route.middleware(fn));
} else if (typeof value === "string") {
page.show(value, fn);
} else {
page.start(value);
}
};
page.start = function (options) {
if (options == null) {
options = {};
}
if (!running) {
running = true;
addEventListener("popstate", onpopstate);
addEventListener("click", onclick);
page.replace(currentPath(), null, null, true);
}
};
page.stop = function () {
if (running) {
running = false;
removeEventListener("click", onclick);
removeEventListener("popstate", onpopstate);
}
};
page.show = function (path, state) {
let res;
if (path === currentState?.path) {
return;
}
const context = new Context(path, state);
const previousState = currentState;
currentState = context.state;
if ((res = page.dispatch(context))) {
currentState = previousState;
location.assign(res);
} else {
context.pushState();
updateCanonicalLink();
track();
}
return context;
};
page.replace = function (path, state, skipDispatch, init) {
let result;
let context = new Context(path, state || currentState);
context.init = init;
currentState = context.state;
if (!skipDispatch) {
result = page.dispatch(context);
}
if (result) {
context = new Context(result);
context.init = init;
currentState = context.state;
page.dispatch(context);
}
context.replaceState();
updateCanonicalLink();
if (!skipDispatch) {
track();
}
return context;
};
page.dispatch = function (context) {
let i = 0;
var next = function () {
let fn, res;
if ((fn = callbacks[i++])) {
res = fn(context, next);
}
return res;
};
return next();
};
page.canGoBack = () => !Context.isIntialState(currentState);
page.canGoForward = () => !Context.isLastState(currentState);
const currentPath = () => location.pathname + location.search + location.hash;
class Context {
static isIntialState(state) {
return state.id === 0;
}
static isLastState(state) {
return state.id === this.stateId - 1;
}
static isInitialPopState(state) {
return state.path === this.initialPath && this.stateId === 1;
}
static isSameSession(state) {
return state.sessionId === this.sessionId;
}
constructor(path, state) {
this.initialPath = currentPath();
this.sessionId = Date.now();
this.stateId = 0;
if (path == null) {
path = "/";
}
this.path = path;
if (state == null) {
state = {};
}
this.state = state;
this.pathname = this.path.replace(
/(?:\?([^#]*))?(?:#(.*))?$/,
(_, query, hash) => {
this.query = query;
this.hash = hash;
return "";
},
);
if (this.state.id == null) {
this.state.id = this.constructor.stateId++;
}
if (this.state.sessionId == null) {
this.state.sessionId = this.constructor.sessionId;
}
this.state.path = this.path;
}
pushState() {
history.pushState(this.state, "", this.path);
}
replaceState() {
try {
history.replaceState(this.state, "", this.path);
} catch (error) {} // NS_ERROR_FAILURE in Firefox
}
}
class Route {
constructor(path, options) {
this.path = path;
if (options == null) {
options = {};
}
this.keys = [];
this.regexp = pathToRegexp(this.path, this.keys);
}
middleware(fn) {
return (context, next) => {
let params;
if (this.match(context.pathname, (params = []))) {
context.params = params;
return fn(context, next);
} else {
return next();
}
};
}
match(path, params) {
let matchData;
if (!(matchData = this.regexp.exec(path))) {
return;
}
const iterable = matchData.slice(1);
for (let i = 0; i < iterable.length; i++) {
var key;
var value = iterable[i];
if (typeof value === "string") {
value = decodeURIComponent(value);
}
if ((key = this.keys[i])) {
params[key.name] = value;
} else {
params.push(value);
}
}
return true;
}
}
var pathToRegexp = function (path, keys) {
if (path instanceof RegExp) {
return path;
}
if (path instanceof Array) {
path = `(${path.join("|")})`;
}
path = path
.replace(/\/\(/g, "(?:/")
.replace(
/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g,
(_, slash, format, key, capture, optional) => {
if (slash == null) {
slash = "";
}
if (format == null) {
format = "";
}
keys.push({ name: key, optional: !!optional });
let str = optional ? "" : slash;
str += "(?:";
if (optional) {
str += slash;
}
str += format;
str += capture || (format ? "([^/.]+?)" : "([^/]+?)");
str += ")";
if (optional) {
str += optional;
}
return str;
},
)
.replace(/([\/.])/g, "\\$1")
.replace(/\*/g, "(.*)");
return new RegExp(`^${path}$`);
};
var onpopstate = function (event) {
if (!event.state || Context.isInitialPopState(event.state)) {
return;
}
if (Context.isSameSession(event.state)) {
page.replace(event.state.path, event.state);
} else {
location.reload();
}
};
var onclick = function (event) {
try {
if (
event.which !== 1 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.defaultPrevented
) {
return;
}
} catch (error) {
return;
}
let link = $.eventTarget(event);
while (link && link.tagName !== "A") {
link = link.parentNode;
}
if (link && !link.target && isSameOrigin(link.href)) {
event.preventDefault();
let path = link.pathname + link.search + link.hash;
path = path.replace(/^\/\/+/, "/"); // IE11 bug
page.show(path);
}
};
var isSameOrigin = (url) =>
url.startsWith(`${location.protocol}//${location.hostname}`);
var updateCanonicalLink = function () {
if (!this.canonicalLink) {
this.canonicalLink = document.head.querySelector('link[rel="canonical"]');
}
return this.canonicalLink.setAttribute(
"href",
`https://${location.host}${location.pathname}`,
);
};
const trackers = [];
page.track = function (fn) {
trackers.push(fn);
};
var track = function () {
if (app.config.env !== "production") {
return;
}
if (navigator.doNotTrack === "1") {
return;
}
if (navigator.globalPrivacyControl) {
return;
}
const consentGiven = Cookies.get("analyticsConsent");
const consentAsked = Cookies.get("analyticsConsentAsked");
if (consentGiven === "1") {
for (var tracker of trackers) {
tracker.call();
}
} else if (consentGiven === undefined && consentAsked === undefined) {
// Only ask for consent once per browser session
Cookies.set("analyticsConsentAsked", "1");
new app.views.Notif("AnalyticsConsent", { autoHide: null });
}
};
this.resetAnalytics = function () {
for (var cookie of document.cookie.split(/;\s?/)) {
var name = cookie.split("=")[0];
if (name[0] === "_" && name[1] !== "_") {
Cookies.expire(name);
}
}
};

@ -1,399 +0,0 @@
#
# Traversing
#
@$ = (selector, el = document) ->
try el.querySelector(selector) catch
@$$ = (selector, el = document) ->
try el.querySelectorAll(selector) catch
$.id = (id) ->
document.getElementById(id)
$.hasChild = (parent, el) ->
return unless parent
while el
return true if el is parent
return if el is document.body
el = el.parentNode
$.closestLink = (el, parent = document.body) ->
while el
return el if el.tagName is 'A'
return if el is parent
el = el.parentNode
#
# Events
#
$.on = (el, event, callback, useCapture = false) ->
if event.indexOf(' ') >= 0
$.on el, name, callback for name in event.split(' ')
else
el.addEventListener(event, callback, useCapture)
return
$.off = (el, event, callback, useCapture = false) ->
if event.indexOf(' ') >= 0
$.off el, name, callback for name in event.split(' ')
else
el.removeEventListener(event, callback, useCapture)
return
$.trigger = (el, type, canBubble = true, cancelable = true) ->
event = document.createEvent 'Event'
event.initEvent(type, canBubble, cancelable)
el.dispatchEvent(event)
return
$.click = (el) ->
event = document.createEvent 'MouseEvent'
event.initMouseEvent 'click', true, true, window, null, 0, 0, 0, 0, false, false, false, false, 0, null
el.dispatchEvent(event)
return
$.stopEvent = (event) ->
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
return
$.eventTarget = (event) ->
event.target.correspondingUseElement || event.target
#
# Manipulation
#
buildFragment = (value) ->
fragment = document.createDocumentFragment()
if $.isCollection(value)
fragment.appendChild(child) for child in $.makeArray(value)
else
fragment.innerHTML = value
fragment
$.append = (el, value) ->
if typeof value is 'string'
el.insertAdjacentHTML 'beforeend', value
else
value = buildFragment(value) if $.isCollection(value)
el.appendChild(value)
return
$.prepend = (el, value) ->
if not el.firstChild
$.append(value)
else if typeof value is 'string'
el.insertAdjacentHTML 'afterbegin', value
else
value = buildFragment(value) if $.isCollection(value)
el.insertBefore(value, el.firstChild)
return
$.before = (el, value) ->
if typeof value is 'string' or $.isCollection(value)
value = buildFragment(value)
el.parentNode.insertBefore(value, el)
return
$.after = (el, value) ->
if typeof value is 'string' or $.isCollection(value)
value = buildFragment(value)
if el.nextSibling
el.parentNode.insertBefore(value, el.nextSibling)
else
el.parentNode.appendChild(value)
return
$.remove = (value) ->
if $.isCollection(value)
el.parentNode?.removeChild(el) for el in $.makeArray(value)
else
value.parentNode?.removeChild(value)
return
$.empty = (el) ->
el.removeChild(el.firstChild) while el.firstChild
return
# Calls the function while the element is off the DOM to avoid triggering
# unnecessary reflows and repaints.
$.batchUpdate = (el, fn) ->
parent = el.parentNode
sibling = el.nextSibling
parent.removeChild(el)
fn(el)
if (sibling)
parent.insertBefore(el, sibling)
else
parent.appendChild(el)
return
#
# Offset
#
$.rect = (el) ->
el.getBoundingClientRect()
$.offset = (el, container = document.body) ->
top = 0
left = 0
while el and el isnt container
top += el.offsetTop
left += el.offsetLeft
el = el.offsetParent
top: top
left: left
$.scrollParent = (el) ->
while (el = el.parentNode) and el.nodeType is 1
break if el.scrollTop > 0
break if getComputedStyle(el)?.overflowY in ['auto', 'scroll']
el
$.scrollTo = (el, parent, position = 'center', options = {}) ->
return unless el
parent ?= $.scrollParent(el)
return unless parent
parentHeight = parent.clientHeight
parentScrollHeight = parent.scrollHeight
return unless parentScrollHeight > parentHeight
top = $.offset(el, parent).top
offsetTop = parent.firstElementChild.offsetTop
switch position
when 'top'
parent.scrollTop = top - offsetTop - (if options.margin? then options.margin else 0)
when 'center'
parent.scrollTop = top - Math.round(parentHeight / 2 - el.offsetHeight / 2)
when 'continuous'
scrollTop = parent.scrollTop
height = el.offsetHeight
lastElementOffset = parent.lastElementChild.offsetTop + parent.lastElementChild.offsetHeight
offsetBottom = if lastElementOffset > 0 then parentScrollHeight - lastElementOffset else 0
# If the target element is above the visible portion of its scrollable
# ancestor, move it near the top with a gap = options.topGap * target's height.
if top - offsetTop <= scrollTop + height * (options.topGap or 1)
parent.scrollTop = top - offsetTop - height * (options.topGap or 1)
# If the target element is below the visible portion of its scrollable
# ancestor, move it near the bottom with a gap = options.bottomGap * target's height.
else if top + offsetBottom >= scrollTop + parentHeight - height * ((options.bottomGap or 1) + 1)
parent.scrollTop = top + offsetBottom - parentHeight + height * ((options.bottomGap or 1) + 1)
return
$.scrollToWithImageLock = (el, parent, args...) ->
parent ?= $.scrollParent(el)
return unless parent
$.scrollTo el, parent, args...
# Lock the scroll position on the target element for up to 3 seconds while
# nearby images are loaded and rendered.
for image in parent.getElementsByTagName('img') when not image.complete
do ->
onLoad = (event) ->
clearTimeout(timeout)
unbind(event.target)
$.scrollTo el, parent, args...
unbind = (target) ->
$.off target, 'load', onLoad
$.on image, 'load', onLoad
timeout = setTimeout unbind.bind(null, image), 3000
return
# Calls the function while locking the element's position relative to the window.
$.lockScroll = (el, fn) ->
if parent = $.scrollParent(el)
top = $.rect(el).top
top -= $.rect(parent).top unless parent in [document.body, document.documentElement]
fn()
parent.scrollTop = $.offset(el, parent).top - top
else
fn()
return
smoothScroll = smoothStart = smoothEnd = smoothDistance = smoothDuration = null
$.smoothScroll = (el, end) ->
unless window.requestAnimationFrame
el.scrollTop = end
return
smoothEnd = end
if smoothScroll
newDistance = smoothEnd - smoothStart
smoothDuration += Math.min 300, Math.abs(smoothDistance - newDistance)
smoothDistance = newDistance
return
smoothStart = el.scrollTop
smoothDistance = smoothEnd - smoothStart
smoothDuration = Math.min 300, Math.abs(smoothDistance)
startTime = Date.now()
smoothScroll = ->
p = Math.min 1, (Date.now() - startTime) / smoothDuration
y = Math.max 0, Math.floor(smoothStart + smoothDistance * (if p < 0.5 then 2 * p * p else p * (4 - p * 2) - 1))
el.scrollTop = y
if p is 1
smoothScroll = null
else
requestAnimationFrame(smoothScroll)
requestAnimationFrame(smoothScroll)
#
# Utilities
#
$.extend = (target, objects...) ->
for object in objects when object
for key, value of object
target[key] = value
target
$.makeArray = (object) ->
if Array.isArray(object)
object
else
Array::slice.apply(object)
$.arrayDelete = (array, object) ->
index = array.indexOf(object)
if index >= 0
array.splice(index, 1)
true
else
false
# Returns true if the object is an array or a collection of DOM elements.
$.isCollection = (object) ->
Array.isArray(object) or typeof object?.item is 'function'
ESCAPE_HTML_MAP =
'&': '&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'
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

@ -0,0 +1,546 @@
//
// Traversing
//
let smoothDistance, smoothDuration, smoothEnd, smoothStart;
this.$ = function (selector, el) {
if (el == null) {
el = document;
}
try {
return el.querySelector(selector);
} catch (error) {}
};
this.$$ = function (selector, el) {
if (el == null) {
el = document;
}
try {
return el.querySelectorAll(selector);
} catch (error) {}
};
$.id = (id) => document.getElementById(id);
$.hasChild = function (parent, el) {
if (!parent) {
return;
}
while (el) {
if (el === parent) {
return true;
}
if (el === document.body) {
return;
}
el = el.parentNode;
}
};
$.closestLink = function (el, parent) {
if (parent == null) {
parent = document.body;
}
while (el) {
if (el.tagName === "A") {
return el;
}
if (el === parent) {
return;
}
el = el.parentNode;
}
};
//
// Events
//
$.on = function (el, event, callback, useCapture) {
if (useCapture == null) {
useCapture = false;
}
if (event.includes(" ")) {
for (var name of event.split(" ")) {
$.on(el, name, callback);
}
} else {
el.addEventListener(event, callback, useCapture);
}
};
$.off = function (el, event, callback, useCapture) {
if (useCapture == null) {
useCapture = false;
}
if (event.includes(" ")) {
for (var name of event.split(" ")) {
$.off(el, name, callback);
}
} else {
el.removeEventListener(event, callback, useCapture);
}
};
$.trigger = function (el, type, canBubble, cancelable) {
const event = new Event(type, {
bubbles: canBubble ?? true,
cancelable: cancelable ?? true,
});
el.dispatchEvent(event);
};
$.click = function (el) {
const event = new MouseEvent("click", {
bubbles: true,
cancelable: true,
});
el.dispatchEvent(event);
};
$.stopEvent = function (event) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
$.eventTarget = (event) => event.target.correspondingUseElement || event.target;
//
// Manipulation
//
const buildFragment = function (value) {
const fragment = document.createDocumentFragment();
if ($.isCollection(value)) {
for (var child of $.makeArray(value)) {
fragment.appendChild(child);
}
} else {
fragment.innerHTML = value;
}
return fragment;
};
$.append = function (el, value) {
if (typeof value === "string") {
el.insertAdjacentHTML("beforeend", value);
} else {
if ($.isCollection(value)) {
value = buildFragment(value);
}
el.appendChild(value);
}
};
$.prepend = function (el, value) {
if (!el.firstChild) {
$.append(value);
} else if (typeof value === "string") {
el.insertAdjacentHTML("afterbegin", value);
} else {
if ($.isCollection(value)) {
value = buildFragment(value);
}
el.insertBefore(value, el.firstChild);
}
};
$.before = function (el, value) {
if (typeof value === "string" || $.isCollection(value)) {
value = buildFragment(value);
}
el.parentNode.insertBefore(value, el);
};
$.after = function (el, value) {
if (typeof value === "string" || $.isCollection(value)) {
value = buildFragment(value);
}
if (el.nextSibling) {
el.parentNode.insertBefore(value, el.nextSibling);
} else {
el.parentNode.appendChild(value);
}
};
$.remove = function (value) {
if ($.isCollection(value)) {
for (var el of $.makeArray(value)) {
if (el.parentNode != null) {
el.parentNode.removeChild(el);
}
}
} else {
if (value.parentNode != null) {
value.parentNode.removeChild(value);
}
}
};
$.empty = function (el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
};
// Calls the function while the element is off the DOM to avoid triggering
// unnecessary reflows and repaints.
$.batchUpdate = function (el, fn) {
const parent = el.parentNode;
const sibling = el.nextSibling;
parent.removeChild(el);
fn(el);
if (sibling) {
parent.insertBefore(el, sibling);
} else {
parent.appendChild(el);
}
};
//
// Offset
//
$.rect = (el) => el.getBoundingClientRect();
$.offset = function (el, container) {
if (container == null) {
container = document.body;
}
let top = 0;
let left = 0;
while (el && el !== container) {
top += el.offsetTop;
left += el.offsetLeft;
el = el.offsetParent;
}
return {
top,
left,
};
};
$.scrollParent = function (el) {
while ((el = el.parentNode) && el.nodeType === 1) {
if (el.scrollTop > 0) {
break;
}
if (["auto", "scroll"].includes(getComputedStyle(el)?.overflowY ?? "")) {
break;
}
}
return el;
};
$.scrollTo = function (el, parent, position, options) {
if (position == null) {
position = "center";
}
if (options == null) {
options = {};
}
if (!el) {
return;
}
if (parent == null) {
parent = $.scrollParent(el);
}
if (!parent) {
return;
}
const parentHeight = parent.clientHeight;
const parentScrollHeight = parent.scrollHeight;
if (!(parentScrollHeight > parentHeight)) {
return;
}
const { top } = $.offset(el, parent);
const { offsetTop } = parent.firstElementChild;
switch (position) {
case "top":
parent.scrollTop = top - offsetTop - (options.margin || 0);
break;
case "center":
parent.scrollTop =
top - Math.round(parentHeight / 2 - el.offsetHeight / 2);
break;
case "continuous":
var { scrollTop } = parent;
var height = el.offsetHeight;
var lastElementOffset =
parent.lastElementChild.offsetTop +
parent.lastElementChild.offsetHeight;
var offsetBottom =
lastElementOffset > 0 ? parentScrollHeight - lastElementOffset : 0;
// If the target element is above the visible portion of its scrollable
// ancestor, move it near the top with a gap = options.topGap * target's height.
if (top - offsetTop <= scrollTop + height * (options.topGap || 1)) {
parent.scrollTop = top - offsetTop - height * (options.topGap || 1);
// If the target element is below the visible portion of its scrollable
// ancestor, move it near the bottom with a gap = options.bottomGap * target's height.
} else if (
top + offsetBottom >=
scrollTop + parentHeight - height * ((options.bottomGap || 1) + 1)
) {
parent.scrollTop =
top +
offsetBottom -
parentHeight +
height * ((options.bottomGap || 1) + 1);
}
break;
}
};
$.scrollToWithImageLock = function (el, parent, ...args) {
if (parent == null) {
parent = $.scrollParent(el);
}
if (!parent) {
return;
}
$.scrollTo(el, parent, ...args);
// Lock the scroll position on the target element for up to 3 seconds while
// nearby images are loaded and rendered.
for (var image of parent.getElementsByTagName("img")) {
if (!image.complete) {
(function () {
let timeout;
const onLoad = function (event) {
clearTimeout(timeout);
unbind(event.target);
return $.scrollTo(el, parent, ...args);
};
var unbind = (target) => $.off(target, "load", onLoad);
$.on(image, "load", onLoad);
return (timeout = setTimeout(unbind.bind(null, image), 3000));
})();
}
}
};
// Calls the function while locking the element's position relative to the window.
$.lockScroll = function (el, fn) {
let parent;
if ((parent = $.scrollParent(el))) {
let { top } = $.rect(el);
if (![document.body, document.documentElement].includes(parent)) {
top -= $.rect(parent).top;
}
fn();
parent.scrollTop = $.offset(el, parent).top - top;
} else {
fn();
}
};
let smoothScroll =
(smoothStart =
smoothEnd =
smoothDistance =
smoothDuration =
null);
$.smoothScroll = function (el, end) {
smoothEnd = end;
if (smoothScroll) {
const newDistance = smoothEnd - smoothStart;
smoothDuration += Math.min(300, Math.abs(smoothDistance - newDistance));
smoothDistance = newDistance;
return;
}
smoothStart = el.scrollTop;
smoothDistance = smoothEnd - smoothStart;
smoothDuration = Math.min(300, Math.abs(smoothDistance));
const startTime = Date.now();
smoothScroll = function () {
const p = Math.min(1, (Date.now() - startTime) / smoothDuration);
const y = Math.max(
0,
Math.floor(
smoothStart +
smoothDistance * (p < 0.5 ? 2 * p * p : p * (4 - p * 2) - 1),
),
);
el.scrollTop = y;
if (p === 1) {
return (smoothScroll = null);
} else {
return requestAnimationFrame(smoothScroll);
}
};
return requestAnimationFrame(smoothScroll);
};
//
// Utilities
//
$.makeArray = function (object) {
if (Array.isArray(object)) {
return object;
} else {
return Array.prototype.slice.apply(object);
}
};
$.arrayDelete = function (array, object) {
const index = array.indexOf(object);
if (index >= 0) {
array.splice(index, 1);
return true;
} else {
return false;
}
};
// Returns true if the object is an array or a collection of DOM elements.
$.isCollection = (object) =>
Array.isArray(object) || typeof object?.item === "function";
const ESCAPE_HTML_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#x27;",
"/": "&#x2F;",
};
const ESCAPE_HTML_REGEXP = /[&<>"'\/]/g;
$.escape = (string) =>
string.replace(ESCAPE_HTML_REGEXP, (match) => ESCAPE_HTML_MAP[match]);
const ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g;
$.escapeRegexp = (string) => string.replace(ESCAPE_REGEXP, "\\$1");
$.urlDecode = (string) => decodeURIComponent(string.replace(/\+/g, "%20"));
$.classify = function (string) {
string = string.split("_");
for (let i = 0; i < string.length; i++) {
var substr = string[i];
string[i] = substr[0].toUpperCase() + substr.slice(1);
}
return string.join("");
};
//
// Miscellaneous
//
$.noop = function () {};
$.popup = function (value) {
try {
const win = window.open();
if (win.opener) {
win.opener = null;
}
win.location = value.href || value;
} catch (error) {
window.open(value.href || value, "_blank");
}
};
let isMac = null;
$.isMac = () =>
isMac != null ? isMac : (isMac = navigator.userAgent.includes("Mac"));
let isIE = null;
$.isIE = () =>
isIE != null
? isIE
: (isIE =
navigator.userAgent.includes("MSIE") ||
navigator.userAgent.includes("rv:11.0"));
let isChromeForAndroid = null;
$.isChromeForAndroid = () =>
isChromeForAndroid != null
? isChromeForAndroid
: (isChromeForAndroid =
navigator.userAgent.includes("Android") &&
/Chrome\/([.0-9])+ Mobile/.test(navigator.userAgent));
let isAndroid = null;
$.isAndroid = () =>
isAndroid != null
? isAndroid
: (isAndroid = navigator.userAgent.includes("Android"));
let isIOS = null;
$.isIOS = () =>
isIOS != null
? isIOS
: (isIOS =
navigator.userAgent.includes("iPhone") ||
navigator.userAgent.includes("iPad"));
$.overlayScrollbarsEnabled = function () {
if (!$.isMac()) {
return false;
}
const div = document.createElement("div");
div.setAttribute(
"style",
"width: 100px; height: 100px; overflow: scroll; position: absolute",
);
document.body.appendChild(div);
const result = div.offsetWidth === div.clientWidth;
document.body.removeChild(div);
return result;
};
const HIGHLIGHT_DEFAULTS = {
className: "highlight",
delay: 1000,
};
$.highlight = function (el, options) {
options = { ...HIGHLIGHT_DEFAULTS, ...(options || {}) };
el.classList.add(options.className);
setTimeout(() => el.classList.remove(options.className), options.delay);
};
$.copyToClipboard = function (string) {
let result;
const textarea = document.createElement("textarea");
textarea.style.position = "fixed";
textarea.style.opacity = 0;
textarea.value = string;
document.body.appendChild(textarea);
try {
textarea.select();
result = !!document.execCommand("copy");
} catch (error) {
result = false;
} finally {
document.body.removeChild(textarea);
}
return result;
};

@ -1,147 +0,0 @@
class app.models.Doc extends app.Model
# Attributes: name, slug, type, version, release, db_size, mtime, links
constructor: ->
super
@reset @
@slug_without_version = @slug.split('~')[0]
@fullName = "#{@name}" + if @version then " #{@version}" else ''
@icon = @slug_without_version
@short_version = @version.split(' ')[0] if @version
@text = @toEntry().text
reset: (data) ->
@resetEntries data.entries
@resetTypes data.types
return
resetEntries: (entries) ->
@entries = new app.collections.Entries(entries)
@entries.each (entry) => entry.doc = @
return
resetTypes: (types) ->
@types = new app.collections.Types(types)
@types.each (type) => type.doc = @
return
fullPath: (path = '') ->
path = "/#{path}" unless path[0] is '/'
"/#{@slug}#{path}"
fileUrl: (path) ->
"#{app.config.docs_origin}#{@fullPath(path)}?#{@mtime}"
dbUrl: ->
"#{app.config.docs_origin}/#{@slug}/#{app.config.db_filename}?#{@mtime}"
indexUrl: ->
"#{app.indexHost()}/#{@slug}/#{app.config.index_filename}?#{@mtime}"
toEntry: ->
return @entry if @entry
@entry = new app.models.Entry
doc: @
name: @fullName
path: 'index'
@entry.addAlias(@name) if @version
@entry
findEntryByPathAndHash: (path, hash) ->
if hash and entry = @entries.findBy 'path', "#{path}##{hash}"
entry
else if path is 'index'
@toEntry()
else
@entries.findBy 'path', path
load: (onSuccess, onError, options = {}) ->
return if options.readCache and @_loadFromCache(onSuccess)
callback = (data) =>
@reset data
onSuccess()
@_setCache data if options.writeCache
return
ajax
url: @indexUrl()
success: callback
error: onError
clearCache: ->
app.localStorage.del @slug
return
_loadFromCache: (onSuccess) ->
return unless data = @_getCache()
callback = =>
@reset data
onSuccess()
return
setTimeout callback, 0
true
_getCache: ->
return unless data = app.localStorage.get @slug
if data[0] is @mtime
return data[1]
else
@clearCache()
return
_setCache: (data) ->
app.localStorage.set @slug, [@mtime, data]
return
install: (onSuccess, onError, onProgress) ->
return if @installing
@installing = true
error = =>
@installing = null
onError()
return
success = (data) =>
@installing = null
app.db.store @, data, onSuccess, error
return
ajax
url: @dbUrl()
success: success
error: error
progress: onProgress
timeout: 3600
return
uninstall: (onSuccess, onError) ->
return if @installing
@installing = true
success = =>
@installing = null
onSuccess()
return
error = =>
@installing = null
onError()
return
app.db.unstore @, success, error
return
getInstallStatus: (callback) ->
app.db.version @, (value) ->
callback installed: !!value, mtime: value
return
isOutdated: (status) ->
return false if not status
isInstalled = status.installed or app.settings.get('autoInstall')
isInstalled and @mtime isnt status.mtime

@ -0,0 +1,202 @@
app.models.Doc = class Doc extends app.Model {
// Attributes: name, slug, type, version, release, db_size, mtime, links
constructor() {
super(...arguments);
this.reset(this);
this.slug_without_version = this.slug.split("~")[0];
this.fullName = `${this.name}` + (this.version ? ` ${this.version}` : "");
this.icon = this.slug_without_version;
if (this.version) {
this.short_version = this.version.split(" ")[0];
}
this.text = this.toEntry().text;
}
reset(data) {
this.resetEntries(data.entries);
this.resetTypes(data.types);
}
resetEntries(entries) {
this.entries = new app.collections.Entries(entries);
this.entries.each((entry) => {
return (entry.doc = this);
});
}
resetTypes(types) {
this.types = new app.collections.Types(types);
this.types.each((type) => {
return (type.doc = this);
});
}
fullPath(path) {
if (path == null) {
path = "";
}
if (path[0] !== "/") {
path = `/${path}`;
}
return `/${this.slug}${path}`;
}
fileUrl(path) {
return `${app.config.docs_origin}${this.fullPath(path)}?${this.mtime}`;
}
dbUrl() {
return `${app.config.docs_origin}/${this.slug}/${app.config.db_filename}?${this.mtime}`;
}
indexUrl() {
return `${app.indexHost()}/${this.slug}/${app.config.index_filename}?${
this.mtime
}`;
}
toEntry() {
if (this.entry) {
return this.entry;
}
this.entry = new app.models.Entry({
doc: this,
name: this.fullName,
path: "index",
});
if (this.version) {
this.entry.addAlias(this.name);
}
return this.entry;
}
findEntryByPathAndHash(path, hash) {
let entry;
if (hash && (entry = this.entries.findBy("path", `${path}#${hash}`))) {
return entry;
} else if (path === "index") {
return this.toEntry();
} else {
return this.entries.findBy("path", path);
}
}
load(onSuccess, onError, options) {
if (options == null) {
options = {};
}
if (options.readCache && this._loadFromCache(onSuccess)) {
return;
}
const callback = (data) => {
this.reset(data);
onSuccess();
if (options.writeCache) {
this._setCache(data);
}
};
return ajax({
url: this.indexUrl(),
success: callback,
error: onError,
});
}
clearCache() {
app.localStorage.del(this.slug);
}
_loadFromCache(onSuccess) {
let data;
if (!(data = this._getCache())) {
return;
}
const callback = () => {
this.reset(data);
onSuccess();
};
setTimeout(callback, 0);
return true;
}
_getCache() {
let data;
if (!(data = app.localStorage.get(this.slug))) {
return;
}
if (data[0] === this.mtime) {
return data[1];
} else {
this.clearCache();
return;
}
}
_setCache(data) {
app.localStorage.set(this.slug, [this.mtime, data]);
}
install(onSuccess, onError, onProgress) {
if (this.installing) {
return;
}
this.installing = true;
const error = () => {
this.installing = null;
onError();
};
const success = (data) => {
this.installing = null;
app.db.store(this, data, onSuccess, error);
};
ajax({
url: this.dbUrl(),
success,
error,
progress: onProgress,
timeout: 3600,
});
}
uninstall(onSuccess, onError) {
if (this.installing) {
return;
}
this.installing = true;
const success = () => {
this.installing = null;
onSuccess();
};
const error = () => {
this.installing = null;
onError();
};
app.db.unstore(this, success, error);
}
getInstallStatus(callback) {
app.db.version(this, (value) =>
callback({ installed: !!value, mtime: value }),
);
}
isOutdated(status) {
if (!status) {
return false;
}
const isInstalled = status.installed || app.settings.get("autoInstall");
return isInstalled && this.mtime !== status.mtime;
}
};

@ -1,85 +0,0 @@
#= require app/searcher
class app.models.Entry extends app.Model
# Attributes: name, type, path
constructor: ->
super
@text = applyAliases(app.Searcher.normalizeString(@name))
addAlias: (name) ->
text = applyAliases(app.Searcher.normalizeString(name))
@text = [@text] unless Array.isArray(@text)
@text.push(if Array.isArray(text) then text[1] else text)
return
fullPath: ->
@doc.fullPath if @isIndex() then '' else @path
dbPath: ->
@path.replace /#.*/, ''
filePath: ->
@doc.fullPath @_filePath()
fileUrl: ->
@doc.fileUrl @_filePath()
_filePath: ->
result = @path.replace /#.*/, ''
result += '.html' unless result[-5..-1] is '.html'
result
isIndex: ->
@path is 'index'
getType: ->
@doc.types.findBy 'name', @type
loadFile: (onSuccess, onError) ->
app.db.load(@, onSuccess, onError)
applyAliases = (string) ->
if ALIASES.hasOwnProperty(string)
return [string, ALIASES[string]]
else
words = string.split('.')
for word, i in words when ALIASES.hasOwnProperty(word)
words[i] = ALIASES[word]
return [string, words.join('.')]
return string
@ALIASES = ALIASES =
'angular': 'ng'
'angular.js': 'ng'
'backbone.js': 'bb'
'c++': 'cpp'
'coffeescript': 'cs'
'crystal': 'cr'
'elixir': 'ex'
'javascript': 'js'
'julia': 'jl'
'jquery': '$'
'knockout.js': 'ko'
'kubernetes': 'k8s'
'less': 'ls'
'lodash': '_'
'löve': 'love'
'marionette': 'mn'
'markdown': 'md'
'matplotlib': 'mpl'
'modernizr': 'mdr'
'moment.js': 'mt'
'openjdk': 'java'
'nginx': 'ngx'
'numpy': 'np'
'pandas': 'pd'
'postgresql': 'pg'
'python': 'py'
'ruby.on.rails': 'ror'
'ruby': 'rb'
'rust': 'rs'
'sass': 'scss'
'tensorflow': 'tf'
'typescript': 'ts'
'underscore.js': '_'

@ -0,0 +1,105 @@
//= require app/searcher
app.models.Entry = class Entry extends app.Model {
static applyAliases(string) {
if (Entry.ALIASES.hasOwnProperty(string)) {
return [string, Entry.ALIASES[string]];
} else {
const words = string.split(".");
for (let i = 0; i < words.length; i++) {
var word = words[i];
if (Entry.ALIASES.hasOwnProperty(word)) {
words[i] = Entry.ALIASES[word];
return [string, words.join(".")];
}
}
}
return string;
}
static ALIASES = {
angular: "ng",
"angular.js": "ng",
"backbone.js": "bb",
"c++": "cpp",
coffeescript: "cs",
crystal: "cr",
elixir: "ex",
javascript: "js",
julia: "jl",
jquery: "$",
"knockout.js": "ko",
kubernetes: "k8s",
less: "ls",
lodash: "_",
löve: "love",
marionette: "mn",
markdown: "md",
matplotlib: "mpl",
modernizr: "mdr",
"moment.js": "mt",
openjdk: "java",
nginx: "ngx",
numpy: "np",
pandas: "pd",
postgresql: "pg",
python: "py",
"ruby.on.rails": "ror",
ruby: "rb",
rust: "rs",
sass: "scss",
tensorflow: "tf",
typescript: "ts",
"underscore.js": "_",
};
// Attributes: name, type, path
constructor() {
super(...arguments);
this.text = Entry.applyAliases(app.Searcher.normalizeString(this.name));
}
addAlias(name) {
const text = Entry.applyAliases(app.Searcher.normalizeString(name));
if (!Array.isArray(this.text)) {
this.text = [this.text];
}
this.text.push(Array.isArray(text) ? text[1] : text);
}
fullPath() {
return this.doc.fullPath(this.isIndex() ? "" : this.path);
}
dbPath() {
return this.path.replace(/#.*/, "");
}
filePath() {
return this.doc.fullPath(this._filePath());
}
fileUrl() {
return this.doc.fileUrl(this._filePath());
}
_filePath() {
let result = this.path.replace(/#.*/, "");
if (result.slice(-5) !== ".html") {
result += ".html";
}
return result;
}
isIndex() {
return this.path === "index";
}
getType() {
return this.doc.types.findBy("name", this.type);
}
loadFile(onSuccess, onError) {
return app.db.load(this, onSuccess, onError);
}
};

@ -1,3 +0,0 @@
class app.Model
constructor: (attributes) ->
@[key] = value for key, value of attributes

@ -0,0 +1,8 @@
app.Model = class Model {
constructor(attributes) {
for (var key in attributes) {
var value = attributes[key];
this[key] = value;
}
}
};

@ -1,14 +0,0 @@
class app.models.Type extends app.Model
# Attributes: name, slug, count
fullPath: ->
"/#{@doc.slug}-#{@slug}/"
entries: ->
@doc.entries.findAllBy 'type', @name
toEntry: ->
new app.models.Entry
doc: @doc
name: "#{@doc.name} / #{@name}"
path: '..' + @fullPath()

@ -0,0 +1,19 @@
app.models.Type = class Type extends app.Model {
// Attributes: name, slug, count
fullPath() {
return `/${this.doc.slug}-${this.slug}/`;
}
entries() {
return this.doc.entries.findAllBy("type", this.name);
}
toEntry() {
return new app.models.Entry({
doc: this.doc,
name: `${this.doc.name} / ${this.name}`,
path: ".." + this.fullPath(),
});
}
};

@ -1,11 +0,0 @@
app.templates.render = (name, value, args...) ->
template = app.templates[name]
if Array.isArray(value)
result = ''
result += template(val, args...) for val in value
result
else if typeof template is 'function'
template(value, args...)
else
template

@ -0,0 +1,15 @@
app.templates.render = function (name, value, ...args) {
const template = app.templates[name];
if (Array.isArray(value)) {
let result = "";
for (var val of value) {
result += template(val, ...args);
}
return result;
} else if (typeof template === "function") {
return template(value, ...args);
} else {
return template;
}
};

@ -1,73 +0,0 @@
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>"""
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.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.offlineError = (reason, exception) ->
if reason is 'cookie_blocked'
return error """ Cookies must be enabled to use offline mode. """
reason = switch reason
when 'not_supported'
""" DevDocs requires IndexedDB to cache documentations for offline access.<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>. """
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>
"""

@ -0,0 +1,95 @@
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>`;
};
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.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.offlineError = function (reason, exception) {
if (reason === "cookie_blocked") {
return error(" Cookies must be enabled to use offline mode. ");
}
reason = (() => {
switch (reason) {
case "not_supported":
return ` DevDocs requires IndexedDB to cache documentations for offline access.<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>. ';
}
})();
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>\
`;

@ -1,9 +0,0 @@
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.disabledDocNotice = ->
notice """ <strong>This documentation is disabled.</strong>
To enable it, go to <a href="/settings" class="_notice-link">Preferences</a>. """

@ -0,0 +1,9 @@
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.disabledDocNotice = () =>
notice(` <strong>This documentation is disabled.</strong>
To enable it, go to <a href="/settings" class="_notice-link">Preferences</a>. `);

@ -1,76 +0,0 @@
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>
"""
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.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.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.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.notifUpdates = (docs, disabledDocs) ->
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 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>'
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.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> """

@ -0,0 +1,110 @@
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>\
`;
};
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.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.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.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.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 of 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 of 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>";
}
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.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> `,
);

@ -1,91 +0,0 @@
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
</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="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="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="credits">Credits</h2>
<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>
<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>
<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>
"""

@ -0,0 +1,96 @@
app.templates.aboutPage = function () {
let doc;
const all_docs = app.docs.all().concat(...(app.disabledDocs.all() || []));
// de-duplicate docs by doc.name
const docs = [];
for (doc of 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>
<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="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="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="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="credits">Credits</h2>
<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>
<div class="_table">
<table class="_credits">
<tr>
<th>Documentation
<th>Copyright/License
<th>Source code
${docs
.map(
(doc) =>
`<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>`,
)
.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 +0,0 @@
app.templates.helpPage = ->
ctrlKey = if $.isMac() then 'cmd' else 'ctrl'
navKey = if $.isMac() then 'cmd' else 'alt'
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]
"""
<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>
<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="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
'<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.
<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>.
"""

@ -0,0 +1,179 @@
app.templates.helpPage = function () {
const ctrlKey = $.isMac() ? "cmd" : "ctrl";
const navKey = $.isMac() ? "cmd" : "alt";
const arrowScroll = app.settings.get("arrowScroll");
const aliases = Object.entries(app.models.Entry.ALIASES);
const middle = Math.ceil(aliases.length / 2);
const aliases_one = aliases.slice(0, middle);
const aliases_two = aliases.slice(middle);
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>
<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="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>'
: '<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.
<h2 class="_block-heading" id="aliases">Search Aliases</h2>
<div class="_aliases">
<table>
<tr>
<th>Word
<th>Alias
${aliases_one
.map(
([key, value]) =>
`<tr><td class=\"_code\">${key}<td class=\"_code\">${value}`,
)
.join("")}
</table>
<table>
<tr>
<th>Word
<th>Alias
${aliases_two
.map(
([key, value]) =>
`<tr><td class=\"_code\">${key}<td class=\"_code\">${value}`,
)
.join("")}
</table>
</div>
<p>Feel free to suggest new aliases on <a href="https://github.com/freeCodeCamp/devdocs/issues/new">GitHub</a>.\
`;
};

@ -1,36 +0,0 @@
#= depend_on news.json
app.templates.newsPage = ->
""" <h1 class="_lined-heading">Changelog</h1>
<p class="_note">
For the latest news, follow <a href="https://twitter.com/DevDocs">@DevDocs</a>.<br>
For development updates, follow the project on <a href="https://github.com/freeCodeCamp/devdocs">GitHub</a>.
<div class="_news">#{app.templates.newsList app.news}</div> """
app.templates.newsList = (news, options = {}) ->
year = new Date().getUTCFullYear()
result = ''
for value in news
date = new Date(value[0])
if options.years isnt false and year isnt date.getUTCFullYear()
year = date.getUTCFullYear()
result += """<h2 class="_block-heading">#{year}</h2>"""
result += newsItem(date, value[1..])
result
MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
newsItem = (date, news) ->
date = """<span class="_news-date">#{MONTHS[date.getUTCMonth()]} #{date.getUTCDate()}</span>"""
result = ''
for text, i in news
text = text.split "\n"
title = """<span class="_news-title">#{text.shift()}</span>"""
result += """<div class="_news-row">#{if i is 0 then date else ''} #{title} #{text.join '<br>'}</div>"""
result
app.news = <%= App.news.to_json %>

@ -0,0 +1,41 @@
//= depend_on news.json
app.templates.newsPage = () => ` <h1 class="_lined-heading">Changelog</h1>
<p class="_note">
For the latest news, follow <a href="https://twitter.com/DevDocs">@DevDocs</a>.<br>
For development updates, follow the project on <a href="https://github.com/freeCodeCamp/devdocs">GitHub</a>.
<div class="_news">${app.templates.newsList(app.news)}</div> `;
app.templates.newsList = function(news, options = {}) {
let year = new Date().getUTCFullYear();
let result = '';
for (let value of news) {
const date = new Date(value[0]);
if ((options.years !== false) && (year !== date.getUTCFullYear())) {
year = date.getUTCFullYear();
result += `<h2 class="_block-heading">${year}</h2>`;
}
result += newsItem(date, value.slice(1));
}
return result;
};
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
var newsItem = function(date, news) {
date = `<span class="_news-date">${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}</span>`;
let result = '';
for (let i = 0; i < news.length; i++) {
let text = news[i];
text = text.split("\n");
const title = `<span class="_news-title">${text.shift()}</span>`;
result += `<div class="_news-row">${i === 0 ? date : ''} ${title} ${text.join('<br>')}</div>`;
}
return result;
};
app.news = <%= App.news.to_json %>

@ -1,80 +0,0 @@
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>
<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)"
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). """
app.templates.offlineDoc = (doc, status) ->
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>
"""
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 + '</tr>'

@ -0,0 +1,88 @@
app.templates.offlinePage = (docs) => `\
<h1 class="_lined-heading">Offline Documentation</h1>
<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>\
`;
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>)";
}
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 = function (doc, status) {
const outdated = doc.isOutdated(status);
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 += !(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>\
`;
return html + "</tr>";
};

@ -1,74 +0,0 @@
app.templates.splash = """<div class="_splash-title">DevDocs</div>"""
<% if App.development? %>
app.templates.intro = """
<div class="_intro"><div class="_intro-message">
<a href="#" class="_intro-hide" data-hide-intro>Stop showing this message</a>
<h2 class="_intro-title">Hi there!</h2>
<p>Thanks for downloading DevDocs. Here are a few things you should know:
<ol class="_intro-list">
<li>Your local version of DevDocs won't self-update. Unless you're modifying the code,
we&nbsp;recommend using the hosted version at <a href="https://devdocs.io">devdocs.io</a>.
<li>Run <code>thor docs:list</code> to see all available documentations.
<li>Run <code>thor docs:download &lt;name&gt;</code> to download documentations.
<li>Run <code>thor docs:download --installed</code> to update all downloaded documentations.
<li>To be notified about new versions, don't forget to <a href="https://github.com/freeCodeCamp/devdocs/subscription">watch the repository</a> on GitHub.
<li>The <a href="https://github.com/freeCodeCamp/devdocs/issues">issue tracker</a> is the preferred channel for bug reports and
feature requests. For everything else, use <a href="https://discord.gg/PRyKn3Vbay">Discord</a>.
<li>Contributions are welcome. See the <a href="https://github.com/freeCodeCamp/devdocs/blob/main/CONTRIBUTING.md">guidelines</a>.
<li>DevDocs is licensed under the terms of the Mozilla Public License v2.0. 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.
</ol>
<p>Happy coding!
</div></div>
"""
<% else %>
app.templates.intro = """
<div class="_intro"><div class="_intro-message">
<a href="#" class="_intro-hide" data-hide-intro>Stop showing this message</a>
<h2 class="_intro-title">Welcome!</h2>
<p>DevDocs combines multiple API documentations in a fast, organized, and searchable interface.
Here's what you should know before you start:
<ol class="_intro-list">
<li>Open the <a href="/settings">Preferences</a> to enable more docs and customize the UI.
<li>You don't have to use your mouse &mdash; see the list of <a href="/help#shortcuts">keyboard shortcuts</a>.
<li>The search supports fuzzy matching (e.g. "bgcp" brings up "background-clip").
<li>To search a specific documentation, type its name (or an abbr.), then Tab.
<li>You can search using your browser's address bar &mdash; <a href="/help#browser_search">learn how</a>.
<li>DevDocs works <a href="/offline">offline</a>, on mobile, and can be installed as web app.
<li>For the latest news, follow <a href="https://twitter.com/DevDocs">@DevDocs</a>.
<li>DevDocs is free and <a href="https://github.com/freeCodeCamp/devdocs">open source</a>.
<object data="https://img.shields.io/github/stars/freeCodeCamp/devdocs.svg?style=social" type="image/svg+xml" aria-hidden="true" height="20"></object>
<li>And if you're new to coding, check out <a href="https://www.freecodecamp.org/">freeCodeCamp's open source curriculum</a>.
</ol>
<p>Happy coding!
</div></div>
"""
<% end %>
app.templates.mobileIntro = """
<div class="_mobile-intro">
<h2 class="_intro-title">Welcome!</h2>
<p>DevDocs combines multiple API documentations in a fast, organized, and searchable interface.
Here's what you should know before you start:
<ol class="_intro-list">
<li>Pick your docs in the <a href="/settings">Preferences</a>.
<li>The search supports fuzzy matching.
<li>To search a specific documentation, type its name (or an abbr.), then Space.
<li>For the latest news, follow <a href="https://twitter.com/DevDocs">@DevDocs</a>.
<li>DevDocs is <a href="https://github.com/freeCodeCamp/devdocs">open source</a>.
</ol>
<p>Happy coding!
<a class="_intro-hide" data-hide-intro>Stop showing this message</a>
</div>
"""
app.templates.androidWarning = """
<div class="_mobile-intro">
<h2 class="_intro-title">Hi there</h2>
<p>DevDocs is running inside an Android WebView. Some features may not work properly.
<p>If you downloaded an app called DevDocs on the Play Store, please uninstall it — it's made by someone who is using (and profiting from) the name DevDocs without permission.
<p>To install DevDocs on your phone, visit <a href="https://devdocs.io" target="_blank" rel="noopener">devdocs.io</a> in Chrome and select "Add to home screen" in the menu.
</div>
"""

@ -0,0 +1,74 @@
app.templates.splash = "<div class=\"_splash-title\">DevDocs</div>";
<% if App.development? %>
app.templates.intro = `\
<div class="_intro"><div class="_intro-message">
<a href="#" class="_intro-hide" data-hide-intro>Stop showing this message</a>
<h2 class="_intro-title">Hi there!</h2>
<p>Thanks for downloading DevDocs. Here are a few things you should know:
<ol class="_intro-list">
<li>Your local version of DevDocs won't self-update. Unless you're modifying the code,
we&nbsp;recommend using the hosted version at <a href="https://devdocs.io">devdocs.io</a>.
<li>Run <code>thor docs:list</code> to see all available documentations.
<li>Run <code>thor docs:download &lt;name&gt;</code> to download documentations.
<li>Run <code>thor docs:download --installed</code> to update all downloaded documentations.
<li>To be notified about new versions, don't forget to <a href="https://github.com/freeCodeCamp/devdocs/subscription">watch the repository</a> on GitHub.
<li>The <a href="https://github.com/freeCodeCamp/devdocs/issues">issue tracker</a> is the preferred channel for bug reports and
feature requests. For everything else, use <a href="https://discord.gg/PRyKn3Vbay">Discord</a>.
<li>Contributions are welcome. See the <a href="https://github.com/freeCodeCamp/devdocs/blob/main/CONTRIBUTING.md">guidelines</a>.
<li>DevDocs is licensed under the terms of the Mozilla Public License v2.0. 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.
</ol>
<p>Happy coding!
</div></div>\
`;
<% else %>
app.templates.intro = `\
<div class="_intro"><div class="_intro-message">
<a href="#" class="_intro-hide" data-hide-intro>Stop showing this message</a>
<h2 class="_intro-title">Welcome!</h2>
<p>DevDocs combines multiple API documentations in a fast, organized, and searchable interface.
Here's what you should know before you start:
<ol class="_intro-list">
<li>Open the <a href="/settings">Preferences</a> to enable more docs and customize the UI.
<li>You don't have to use your mouse &mdash; see the list of <a href="/help#shortcuts">keyboard shortcuts</a>.
<li>The search supports fuzzy matching (e.g. "bgcp" brings up "background-clip").
<li>To search a specific documentation, type its name (or an abbr.), then Tab.
<li>You can search using your browser's address bar &mdash; <a href="/help#browser_search">learn how</a>.
<li>DevDocs works <a href="/offline">offline</a>, on mobile, and can be installed as web app.
<li>For the latest news, follow <a href="https://twitter.com/DevDocs">@DevDocs</a>.
<li>DevDocs is free and <a href="https://github.com/freeCodeCamp/devdocs">open source</a>.
<object data="https://img.shields.io/github/stars/freeCodeCamp/devdocs.svg?style=social" type="image/svg+xml" aria-hidden="true" height="20"></object>
<li>And if you're new to coding, check out <a href="https://www.freecodecamp.org/">freeCodeCamp's open source curriculum</a>.
</ol>
<p>Happy coding!
</div></div>\
`;
<% end %>
app.templates.mobileIntro = `\
<div class="_mobile-intro">
<h2 class="_intro-title">Welcome!</h2>
<p>DevDocs combines multiple API documentations in a fast, organized, and searchable interface.
Here's what you should know before you start:
<ol class="_intro-list">
<li>Pick your docs in the <a href="/settings">Preferences</a>.
<li>The search supports fuzzy matching.
<li>To search a specific documentation, type its name (or an abbr.), then Space.
<li>For the latest news, follow <a href="https://twitter.com/DevDocs">@DevDocs</a>.
<li>DevDocs is <a href="https://github.com/freeCodeCamp/devdocs">open source</a>.
</ol>
<p>Happy coding!
<a class="_intro-hide" data-hide-intro>Stop showing this message</a>
</div>\
`;
app.templates.androidWarning = `\
<div class="_mobile-intro">
<h2 class="_intro-title">Hi there</h2>
<p>DevDocs is running inside an Android WebView. Some features may not work properly.
<p>If you downloaded an app called DevDocs on the Play Store, please uninstall it — it's made by someone who is using (and profiting from) the name DevDocs without permission.
<p>To install DevDocs on your phone, visit <a href="https://devdocs.io" target="_blank" rel="noopener">devdocs.io</a> in Chrome and select "Add to home screen" in the menu.
</div>\
`;

@ -1,81 +0,0 @@
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>
"""
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
""}
#{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-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>
<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>
<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>
"""

@ -0,0 +1,112 @@
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>
<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>
</div>
<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"${
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-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>
<button type="button" class="_btn-link _reset-btn" data-behavior="reset">Reset all preferences and data</button>\
`;

@ -1,6 +0,0 @@
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>"""

@ -0,0 +1,11 @@
app.templates.typePage = (type) => {
return ` <h1>${type.doc.fullName} / ${type.name}</h1>
<ul class="_entry-list">${app.templates.render(
"typePageEntry",
type.entries(),
)}</ul> `;
};
app.templates.typePageEntry = (entry) => {
return `<li><a href="${entry.fullPath()}">${$.escape(entry.name)}</a></li>`;
};

@ -1,7 +0,0 @@
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

@ -0,0 +1,15 @@
app.templates.path = function (doc, type, entry) {
const arrow = '<svg class="_path-arrow"><use xlink:href="#icon-dir"/></svg>';
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 +0,0 @@
templates = app.templates
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.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.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.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.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.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.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.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.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>
"""

@ -0,0 +1,111 @@
const { templates } = app;
const arrow = '<svg class="_list-arrow"><use xlink:href="#icon-dir"/></svg>';
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.sidebarEntry = (entry) =>
`<a href="${entry.fullPath()}" class="_list-item _list-hover" tabindex="-1">${$.escape(
entry.name,
)}</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 = 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.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 = 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.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.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>\
`;

@ -1,10 +0,0 @@
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>
"""

@ -0,0 +1,16 @@
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,32 +1,55 @@
try {
if (app.config.env === 'production') {
if (Cookies.get('analyticsConsent') === '1') {
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-5544833-12', 'devdocs.io');
page.track(function() {
ga('send', 'pageview', {
if (app.config.env === "production") {
if (Cookies.get("analyticsConsent") === "1") {
(function (i, s, o, g, r, a, m) {
i["GoogleAnalyticsObject"] = r;
(i[r] =
i[r] ||
function () {
(i[r].q = i[r].q || []).push(arguments);
}),
(i[r].l = 1 * new Date());
(a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]);
a.async = 1;
a.src = g;
m.parentNode.insertBefore(a, m);
})(
window,
document,
"script",
"https://www.google-analytics.com/analytics.js",
"ga",
);
ga("create", "UA-5544833-12", "devdocs.io");
page.track(function () {
ga("send", "pageview", {
page: location.pathname + location.search + location.hash,
dimension1: app.router.context && app.router.context.doc && app.router.context.doc.slug_without_version
dimension1:
app.router.context &&
app.router.context.doc &&
app.router.context.doc.slug_without_version,
});
});
page.track(function() {
if (window._gauges)
_gauges.push(['track']);
page.track(function () {
if (window._gauges) _gauges.push(["track"]);
else
(function() {
var _gauges=_gauges||[];!function(){var a=document.createElement("script");
a.type="text/javascript",a.async=!0,a.id="gauges-tracker",
a.setAttribute("data-site-id","51c15f82613f5d7819000067"),
a.src="https://secure.gaug.es/track.js";var b=document.getElementsByTagName("script")[0];
b.parentNode.insertBefore(a,b)}();
(function () {
var _gauges = _gauges || [];
!(function () {
var a = document.createElement("script");
(a.type = "text/javascript"),
(a.async = !0),
(a.id = "gauges-tracker"),
a.setAttribute("data-site-id", "51c15f82613f5d7819000067"),
(a.src = "https://secure.gaug.es/track.js");
var b = document.getElementsByTagName("script")[0];
b.parentNode.insertBefore(a, b);
})();
})();
});
} else {
resetAnalytics();
}
}
} catch(e) { }
} catch (e) {}

@ -5,171 +5,204 @@
* This is free and unencumbered software released into the public domain.
*/
(function (global, undefined) {
'use strict';
"use strict";
var factory = function (window) {
if (typeof window.document !== 'object') {
throw new Error('Cookies.js requires a `window` with a `document` object');
var factory = function (window) {
if (typeof window.document !== "object") {
throw new Error(
"Cookies.js requires a `window` with a `document` object",
);
}
var Cookies = function (key, value, options) {
return arguments.length === 1
? Cookies.get(key)
: Cookies.set(key, value, options);
};
// Allows for setter injection in unit tests
Cookies._document = window.document;
// Used to ensure cookie keys do not collide with
// built-in `Object` properties
Cookies._cacheKeyPrefix = "cookey."; // Hurr hurr, :)
Cookies._maxExpireDate = new Date("Fri, 31 Dec 9999 23:59:59 UTC");
Cookies.defaults = {
path: "/",
SameSite: "Strict",
secure: true,
};
Cookies.get = function (key) {
if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) {
Cookies._renewCache();
}
var value = Cookies._cache[Cookies._cacheKeyPrefix + key];
return value === undefined ? undefined : decodeURIComponent(value);
};
Cookies.set = function (key, value, options) {
options = Cookies._getExtendedOptions(options);
options.expires = Cookies._getExpiresDate(
value === undefined ? -1 : options.expires,
);
Cookies._document.cookie = Cookies._generateCookieString(
key,
value,
options,
);
return Cookies;
};
Cookies.expire = function (key, options) {
return Cookies.set(key, undefined, options);
};
Cookies._getExtendedOptions = function (options) {
return {
path: (options && options.path) || Cookies.defaults.path,
domain: (options && options.domain) || Cookies.defaults.domain,
SameSite: (options && options.SameSite) || Cookies.defaults.SameSite,
expires: (options && options.expires) || Cookies.defaults.expires,
secure:
options && options.secure !== undefined
? options.secure
: Cookies.defaults.secure,
};
};
Cookies._isValidDate = function (date) {
return (
Object.prototype.toString.call(date) === "[object Date]" &&
!isNaN(date.getTime())
);
};
Cookies._getExpiresDate = function (expires, now) {
now = now || new Date();
if (typeof expires === "number") {
expires =
expires === Infinity
? Cookies._maxExpireDate
: new Date(now.getTime() + expires * 1000);
} else if (typeof expires === "string") {
expires = new Date(expires);
}
if (expires && !Cookies._isValidDate(expires)) {
throw new Error(
"`expires` parameter cannot be converted to a valid Date instance",
);
}
return expires;
};
Cookies._generateCookieString = function (key, value, options) {
key = key.replace(/[^#$&+\^`|]/g, encodeURIComponent);
key = key.replace(/\(/g, "%28").replace(/\)/g, "%29");
value = (value + "").replace(
/[^!#$&-+\--:<-\[\]-~]/g,
encodeURIComponent,
);
options = options || {};
var cookieString = key + "=" + value;
cookieString += options.path ? ";path=" + options.path : "";
cookieString += options.domain ? ";domain=" + options.domain : "";
cookieString += options.SameSite ? ";SameSite=" + options.SameSite : "";
cookieString += options.expires
? ";expires=" + options.expires.toUTCString()
: "";
cookieString += options.secure ? ";secure" : "";
return cookieString;
};
Cookies._getCacheFromString = function (documentCookie) {
var cookieCache = {};
var cookiesArray = documentCookie ? documentCookie.split("; ") : [];
for (var i = 0; i < cookiesArray.length; i++) {
var cookieKvp = Cookies._getKeyValuePairFromCookieString(
cookiesArray[i],
);
if (
cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] === undefined
) {
cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] =
cookieKvp.value;
}
}
var Cookies = function (key, value, options) {
return arguments.length === 1 ?
Cookies.get(key) : Cookies.set(key, value, options);
};
return cookieCache;
};
// Allows for setter injection in unit tests
Cookies._document = window.document;
// Used to ensure cookie keys do not collide with
// built-in `Object` properties
Cookies._cacheKeyPrefix = 'cookey.'; // Hurr hurr, :)
Cookies._getKeyValuePairFromCookieString = function (cookieString) {
// "=" is a valid character in a cookie value according to RFC6265, so cannot `split('=')`
var separatorIndex = cookieString.indexOf("=");
// IE omits the "=" when the cookie value is an empty string
separatorIndex =
separatorIndex < 0 ? cookieString.length : separatorIndex;
var key = cookieString.substr(0, separatorIndex);
var decodedKey;
try {
decodedKey = decodeURIComponent(key);
} catch (e) {
if (console && typeof console.error === "function") {
console.error('Could not decode cookie with key "' + key + '"', e);
}
}
Cookies._maxExpireDate = new Date('Fri, 31 Dec 9999 23:59:59 UTC');
return {
key: decodedKey,
value: cookieString.substr(separatorIndex + 1), // Defer decoding value until accessed
};
};
Cookies.defaults = {
path: '/',
SameSite: 'Strict',
secure: true
};
Cookies.get = function (key) {
if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) {
Cookies._renewCache();
}
var value = Cookies._cache[Cookies._cacheKeyPrefix + key];
return value === undefined ? undefined : decodeURIComponent(value);
};
Cookies.set = function (key, value, options) {
options = Cookies._getExtendedOptions(options);
options.expires = Cookies._getExpiresDate(value === undefined ? -1 : options.expires);
Cookies._document.cookie = Cookies._generateCookieString(key, value, options);
return Cookies;
};
Cookies.expire = function (key, options) {
return Cookies.set(key, undefined, options);
};
Cookies._getExtendedOptions = function (options) {
return {
path: options && options.path || Cookies.defaults.path,
domain: options && options.domain || Cookies.defaults.domain,
SameSite: options && options.SameSite || Cookies.defaults.SameSite,
expires: options && options.expires || Cookies.defaults.expires,
secure: options && options.secure !== undefined ? options.secure : Cookies.defaults.secure
};
};
Cookies._isValidDate = function (date) {
return Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date.getTime());
};
Cookies._getExpiresDate = function (expires, now) {
now = now || new Date();
if (typeof expires === 'number') {
expires = expires === Infinity ?
Cookies._maxExpireDate : new Date(now.getTime() + expires * 1000);
} else if (typeof expires === 'string') {
expires = new Date(expires);
}
if (expires && !Cookies._isValidDate(expires)) {
throw new Error('`expires` parameter cannot be converted to a valid Date instance');
}
return expires;
};
Cookies._generateCookieString = function (key, value, options) {
key = key.replace(/[^#$&+\^`|]/g, encodeURIComponent);
key = key.replace(/\(/g, '%28').replace(/\)/g, '%29');
value = (value + '').replace(/[^!#$&-+\--:<-\[\]-~]/g, encodeURIComponent);
options = options || {};
var cookieString = key + '=' + value;
cookieString += options.path ? ';path=' + options.path : '';
cookieString += options.domain ? ';domain=' + options.domain : '';
cookieString += options.SameSite ? ';SameSite=' + options.SameSite : '';
cookieString += options.expires ? ';expires=' + options.expires.toUTCString() : '';
cookieString += options.secure ? ';secure' : '';
return cookieString;
};
Cookies._getCacheFromString = function (documentCookie) {
var cookieCache = {};
var cookiesArray = documentCookie ? documentCookie.split('; ') : [];
for (var i = 0; i < cookiesArray.length; i++) {
var cookieKvp = Cookies._getKeyValuePairFromCookieString(cookiesArray[i]);
if (cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] === undefined) {
cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] = cookieKvp.value;
}
}
return cookieCache;
};
Cookies._getKeyValuePairFromCookieString = function (cookieString) {
// "=" is a valid character in a cookie value according to RFC6265, so cannot `split('=')`
var separatorIndex = cookieString.indexOf('=');
// IE omits the "=" when the cookie value is an empty string
separatorIndex = separatorIndex < 0 ? cookieString.length : separatorIndex;
var key = cookieString.substr(0, separatorIndex);
var decodedKey;
try {
decodedKey = decodeURIComponent(key);
} catch (e) {
if (console && typeof console.error === 'function') {
console.error('Could not decode cookie with key "' + key + '"', e);
}
}
return {
key: decodedKey,
value: cookieString.substr(separatorIndex + 1) // Defer decoding value until accessed
};
};
Cookies._renewCache = function () {
Cookies._cache = Cookies._getCacheFromString(Cookies._document.cookie);
Cookies._cachedDocumentCookie = Cookies._document.cookie;
};
Cookies._areEnabled = function () {
var testKey = 'cookies.js';
var areEnabled = Cookies.set(testKey, 1).get(testKey) === '1';
Cookies.expire(testKey);
return areEnabled;
};
Cookies.enabled = Cookies._areEnabled();
return Cookies;
Cookies._renewCache = function () {
Cookies._cache = Cookies._getCacheFromString(Cookies._document.cookie);
Cookies._cachedDocumentCookie = Cookies._document.cookie;
};
var cookiesExport = (global && typeof global.document === 'object') ? factory(global) : factory;
// AMD support
if (typeof define === 'function' && define.amd) {
define(function () { return cookiesExport; });
Cookies._areEnabled = function () {
var testKey = "cookies.js";
var areEnabled = Cookies.set(testKey, 1).get(testKey) === "1";
Cookies.expire(testKey);
return areEnabled;
};
Cookies.enabled = Cookies._areEnabled();
return Cookies;
};
var cookiesExport =
global && typeof global.document === "object" ? factory(global) : factory;
// AMD support
if (typeof define === "function" && define.amd) {
define(function () {
return cookiesExport;
});
// CommonJS/Node.js support
} else if (typeof exports === 'object') {
// Support Node.js specific `module.exports` (which can be a function)
if (typeof module === 'object' && typeof module.exports === 'object') {
exports = module.exports = cookiesExport;
}
// But always support CommonJS module 1.1.1 spec (`exports` cannot be a function)
exports.Cookies = cookiesExport;
} else {
global.Cookies = cookiesExport;
} else if (typeof exports === "object") {
// Support Node.js specific `module.exports` (which can be a function)
if (typeof module === "object" && typeof module.exports === "object") {
exports = module.exports = cookiesExport;
}
})(typeof window === 'undefined' ? this : window);
// But always support CommonJS module 1.1.1 spec (`exports` cannot be a function)
exports.Cookies = cookiesExport;
} else {
global.Cookies = cookiesExport;
}
})(typeof window === "undefined" ? this : window);

@ -4,17 +4,22 @@
* Adapted from: https://github.com/fred-wang/mathml.css */
(function () {
window.addEventListener("load", function() {
window.addEventListener("load", function () {
var box, div, link, namespaceURI;
// First check whether the page contains any <math> element.
namespaceURI = "http://www.w3.org/1998/Math/MathML";
// Create a div to test mspace, using Kuma's "offscreen" CSS
document.body.insertAdjacentHTML("afterbegin", "<div style='border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px;'><math xmlns='" + namespaceURI + "'><mspace height='23px' width='77px'></mspace></math></div>");
document.body.insertAdjacentHTML(
"afterbegin",
"<div style='border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px;'><math xmlns='" +
namespaceURI +
"'><mspace height='23px' width='77px'></mspace></math></div>",
);
div = document.body.firstChild;
box = div.firstChild.firstChild.getBoundingClientRect();
document.body.removeChild(div);
if (Math.abs(box.height - 23) > 1 || Math.abs(box.width - 77) > 1) {
if (Math.abs(box.height - 23) > 1 || Math.abs(box.width - 77) > 1) {
window.supportsMathML = false;
}
});
}());
})();

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

@ -1,195 +0,0 @@
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()
(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
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:']

@ -0,0 +1,243 @@
app.views.Content = class Content extends app.View {
static el = "._content";
static loadingClass = "_content-loading";
static events = { click: "onClick" };
static shortcuts = {
altUp: "scrollStepUp",
altDown: "scrollStepDown",
pageUp: "scrollPageUp",
pageDown: "scrollPageDown",
pageTop: "scrollToTop",
pageBottom: "scrollToBottom",
altF: "onAltF",
};
static routes = {
before: "beforeRoute",
after: "afterRoute",
};
init() {
this.scrollEl = app.isMobile()
? document.scrollingElement || document.body
: 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", () => 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)
) {
this.find("a:not(:empty)")?.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) {
return url?.startsWith("http:") || url?.startsWith("https:");
}
};

@ -1,166 +0,0 @@
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

@ -0,0 +1,245 @@
app.views.EntryPage = class EntryPage extends app.View {
static className = "_page";
static errorClass = "_page-error";
static events = { click: "onClick" };
static shortcuts = {
altC: "onAltC",
altO: "onAltO",
};
static routes = { before: "beforeRoute" };
static 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 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">${EntryPage.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?.filePath?.();
this.entry = context.entry;
if (!isSameFile) {
this.restore() || this.load();
}
}
load() {
this.loading();
this.xhr = this.entry.loadFile(
(response) => this.onSuccess(response),
() => 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));
}
};

@ -1,92 +0,0 @@
class app.views.OfflinePage extends app.View
@className: '_static'
@events:
click: 'onClick'
change: 'onChange'
deactivate: ->
if super
@empty()
return
render: ->
if app.cookieBlocked
@html @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
renderDoc: (doc, status) ->
app.templates.render('offlineDoc', doc, status)
getTitle: ->
'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
docByEl: (el) ->
el = el.parentNode until slug = el.getAttribute('data-slug')
app.docs.findBy('slug', slug)
docEl: (doc) ->
@find("[data-slug='#{doc.slug}']")
onRoute: (context) ->
@render()
return
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
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
onInstallError: (doc) ->
return unless @activated
if el = @docEl(doc)
el.lastElementChild.textContent = 'Error'
return
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
onChange: (event) ->
if event.target.name is 'autoUpdate'
app.settings.set 'manualUpdate', !event.target.checked
return

@ -0,0 +1,145 @@
app.views.OfflinePage = class OfflinePage extends app.View {
static className = "_static";
static events = {
click: "onClick",
change: "onChange",
};
deactivate() {
if (super.deactivate(...arguments)) {
this.empty();
}
}
render() {
if (app.cookieBlocked) {
this.html(this.tmpl("offlineError", "cookie_blocked"));
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 app.docs.all()) {
html += this.renderDoc(doc, statuses[doc.slug]);
}
this.html(this.tmpl("offlinePage", html));
this.refreshLinks();
}
});
}
renderDoc(doc, status) {
return app.templates.render("offlineDoc", doc, status);
}
getTitle() {
return "Offline";
}
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) {
let slug;
while (!(slug = el.getAttribute("data-slug"))) {
el = el.parentNode;
}
return app.docs.findBy("slug", slug);
}
docEl(doc) {
return this.find(`[data-slug='${doc.slug}']`);
}
onRoute(context) {
this.render();
}
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) {
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) {
let el;
if (!this.activated) {
return;
}
if ((el = this.docEl(doc))) {
el.lastElementChild.textContent = "Error";
}
}
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 === "autoUpdate") {
app.settings.set("manualUpdate", !event.target.checked);
}
}
};

@ -1,43 +0,0 @@
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()
'androidWarning'
else if @isHidden()
'splash'
else if 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

@ -0,0 +1,46 @@
app.views.RootPage = class RootPage extends app.View {
static 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"
: this.isHidden()
? "splash"
: app.isMobile()
? "mobileIntro"
: "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();
}
}
};

@ -1,116 +0,0 @@
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

@ -0,0 +1,143 @@
app.views.SettingsPage = class SettingsPage extends app.View {
static className = "_static";
static 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 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();
}
};

@ -1,26 +0,0 @@
class app.views.StaticPage extends app.View
@className: '_static'
@titles:
about: 'About'
news: 'News'
help: 'User Guide'
notFound: '404'
deactivate: ->
if super
@empty()
@page = null
return
render: (page) ->
@page = page
@html @tmpl("#{@page}Page")
return
getTitle: ->
@constructor.titles[@page]
onRoute: (context) ->
@render context.page or 'notFound'
return

@ -0,0 +1,30 @@
app.views.StaticPage = class StaticPage extends app.View {
static className = "_static";
static titles = {
about: "About",
news: "News",
help: "User Guide",
notFound: "404",
};
deactivate() {
if (super.deactivate(...arguments)) {
this.empty();
this.page = null;
}
}
render(page) {
this.page = page;
this.html(this.tmpl(`${this.page}Page`));
}
getTitle() {
return this.constructor.titles[this.page];
}
onRoute(context) {
this.render(context.page || "notFound");
}
};

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save