mirror of https://github.com/freeCodeCamp/devdocs
Merge pull request #1441 from freeCodeCamp/decaffeinate
Migrate CoffeeScript to JavaScriptpull/2073/head
commit
92b009800d
@ -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 =
|
||||
'&': '&'
|
||||
'<': '<'
|
||||
'>': '>'
|
||||
'"': '"'
|
||||
"'": '''
|
||||
'/': '/'
|
||||
|
||||
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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"/": "/",
|
||||
};
|
||||
|
||||
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} · <a href="/##{location.pathname}" target="_top" class="_error-link">Reload</a>
|
||||
· <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">
|
||||
— <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} · <a href="/#${location.pathname}" target="_top" class="_error-link">Reload</a>
|
||||
· <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">
|
||||
— <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>→</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>→</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>→</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>→</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–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–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> 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">↓</code>
|
||||
<code class="_shortcut-code">↑</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">→</code>
|
||||
<code class="_shortcut-code">←</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} + ←</code>
|
||||
<code class="_shortcut-code">#{navKey} + →</code>
|
||||
<dd class="_shortcuts-dd">Go back/forward
|
||||
<dt class="_shortcuts-dt">
|
||||
#{if arrowScroll
|
||||
'<code class="_shortcut-code">↓</code> ' +
|
||||
'<code class="_shortcut-code">↑</code>'
|
||||
else
|
||||
'<code class="_shortcut-code">alt + ↓</code> ' +
|
||||
'<code class="_shortcut-code">alt + ↑</code>' +
|
||||
'<br>' +
|
||||
'<code class="_shortcut-code">shift + ↓</code> ' +
|
||||
'<code class="_shortcut-code">shift + ↑</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} + ↑</code>
|
||||
<code class="_shortcut-code">#{ctrlKey} + ↓</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> 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">↓</code>
|
||||
<code class="_shortcut-code">↑</code>
|
||||
<dd class="_shortcuts-dd">Move selection
|
||||
<dt class="_shortcuts-dt">
|
||||
${arrowScroll ? '<code class="_shortcut-code">shift</code> + ' : ""}
|
||||
<code class="_shortcut-code">→</code>
|
||||
<code class="_shortcut-code">←</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} + ←</code>
|
||||
<code class="_shortcut-code">${navKey} + →</code>
|
||||
<dd class="_shortcuts-dd">Go back/forward
|
||||
<dt class="_shortcuts-dt">
|
||||
${
|
||||
arrowScroll
|
||||
? '<code class="_shortcut-code">↓</code> ' +
|
||||
'<code class="_shortcut-code">↑</code>'
|
||||
: '<code class="_shortcut-code">alt + ↓</code> ' +
|
||||
'<code class="_shortcut-code">alt + ↑</code>' +
|
||||
"<br>" +
|
||||
'<code class="_shortcut-code">shift + ↓</code> ' +
|
||||
'<code class="_shortcut-code">shift + ↑</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} + ↑</code>
|
||||
<code class="_shortcut-code">${ctrlKey} + ↓</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} <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‑to‑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
|
||||
} <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‑to‑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 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 <name></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 — 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 — <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 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 <name></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 — 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 — <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">↑</code><code class="_label">↓</code><code class="_label">←</code><code class="_label">→</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">↑</code><code class="_label">↓</code><code class="_label">←</code><code class="_label">→</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">↓</code> <code class="_label">↑</code> <code class="_label">←</code> <code class="_label">→</code> to navigate the sidebar.<br>
|
||||
Hit <code class="_label">space / shift space</code>#{if app.settings.get('arrowScroll') then ' or <code class="_label">↓/↑</code>' else ', <code class="_label">alt ↓/↑</code> or <code class="_label">shift ↓/↑</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">↓</code> <code class="_label">↑</code> <code class="_label">←</code> <code class="_label">→</code> to navigate the sidebar.<br>
|
||||
Hit <code class="_label">space / shift space</code>${
|
||||
app.settings.get("arrowScroll")
|
||||
? ' or <code class="_label">↓/↑</code>'
|
||||
: ', <code class="_label">alt ↓/↑</code> or <code class="_label">shift ↓/↑</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) {}
|
||||
|
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…
Reference in new issue